VeroXM Docs

Storage & Email Integrations

Two categories of third-party integration sit underneath VeroXM: where uploaded media actually lives (local disk, S3, or Cloudflare R2), and how transactional email actually sends (a platform-wide Resend key, or a per-Tenant override). This page covers both in depth — configuration, the exact object-key layout, and the advanced deployment patterns each one enables.

Storage providers

Every Project stores its media through one of three interchangeable storage providers, selected by a disk value — local, s3, or r2 — read at upload time. All three implement the same interface (save, delete, get a public URL), so nothing above the storage layer — the media library, thumbnail/WebP generation, the Content API's asset fields — needs to know or care which one a Project uses.

disk is set at creation, not a dashboard toggle

A Project's disk is chosen when the Project is created and there is currently no dashboard control or API route to change it afterward — switching a live Project from local to s3, for example, isn't a supported migration today. Cloning a Project carries its source Project's disk forward unchanged. In practice this means the storage backend is effectively a deployment-level decision (set once, via environment variables, before Projects are created) rather than a per-Project setting an operator switches later.

Object key layout

Both S3 and R2 write objects using the same key convention, one that deliberately mirrors VeroXM's original storage layout so existing tooling and CDN path rules keep working regardless of which provider is behind them:

Object keys
public/{projectUuid}/{filename}                    — original upload
public/{projectUuid}/thumbnails/{filename}         — generated thumbnail
public/{projectUuid}/{filename-with-.webp}         — WebP variant of the original
public/{projectUuid}/thumbnails/{filename-with-.webp} — WebP variant of the thumbnail

S3

apps/api/.env
AWS_REGION="us-east-1"
AWS_S3_BUCKET="your-bucket-name"
AWS_ACCESS_KEY_ID="..."
AWS_SECRET_ACCESS_KEY="..."
AWS_S3_URL_BASE="https://your-bucket-name.s3.amazonaws.com"   # optional

Every object the S3 provider writes is uploaded with ACL: 'public-read' set per object, so media is publicly reachable immediately with no separate bucket-policy step. The public URL for an object defaults to https://{bucket}.s3.amazonaws.com/{key} unless AWS_S3_URL_BASE overrides the base.

Advanced use case — fronting S3 with a CDN. Setting AWS_S3_URL_BASE to a CloudFront (or other CDN) distribution domain instead of the raw S3 endpoint means every media URL VeroXM generates — in the dashboard, in Content API responses, in the media library — already points at the CDN, with no rewrite layer in front of VeroXM needed. Combine this with the CDN & Media purge settings so publishing new media and purging stale CDN edges stay in sync.

R2

apps/api/.env
R2_ACCOUNT_ID="..."
R2_BUCKET="your-bucket-name"
R2_ACCESS_KEY_ID="..."
R2_SECRET_ACCESS_KEY="..."
R2_PUBLIC_URL_BASE="https://media.example.com"   # required

R2 speaks the S3 API, but Cloudflare doesn't support per-object ACLs the way S3 does — a bucket is either entirely public (via a connected custom domain, or an r2.dev subdomain) or entirely private, with no per-upload override. That's why R2_PUBLIC_URL_BASE is required for the R2 provider rather than an optional override the way AWS_S3_URL_BASE is for S3: without it, VeroXM has no way to know what public URL an uploaded object will actually be reachable at. The R2 provider connects using region: 'auto' and an endpoint built directly from R2_ACCOUNT_ID, rather than a region you configure.

Advanced use case — Cloudflare-native media pipeline. Because R2 has no egress fees between Cloudflare products, pairing it with Cloudflare as your CDN (using R2_PUBLIC_URL_BASE pointed at your connected custom domain) and the existing Cloudflare cache-purge integration gives you a storage-to-edge pipeline that stays entirely inside one provider, with predictable cost as media volume grows.

Email (Resend)

Every outbound transactional email — invites, password resets, approval-workflow notifications, webhook-triggered mail — goes through a single MailService.send() call, which resolves which Resend credentials to actually send with before dispatching.

Credential resolution

When a send is tied to a Tenant, VeroXM looks for that Tenant's own mail configuration first; only when no override exists — or the request has no Tenant context at all — does it fall back to the platform-wide default:

Resolution order
1. Tenant-specific override (if tenantId is passed AND a TenantMailConfig
   row exists for it AND it's enabled AND it has an API key set)
2. Platform-wide default — RESEND_API_KEY / RESEND_FROM_EMAIL (apps/api/.env)

This is the same per-Tenant override introduced briefly on the Tenants page — this section covers what actually happens underneath it. Each distinct Resend API key gets its own cached client, so a Tenant override never shares a connection (or, more importantly, a rate limit) with the platform default.

apps/api/.env
RESEND_API_KEY="re_..."
RESEND_FROM_EMAIL="notifications@yourdomain.com"

Sends never throw

MailService.send() is deliberately non-fatal — a failed send (bad key, Resend outage, invalid recipient) never throws and never interrupts the calling code path. It always resolves to a result object, and every failure is logged at ERROR level with a stable mail.send_failed prefix so it can be found and alerted on without every caller needing its own try/catch:

Return shape
{ ok: true }
{ ok: false, error: "..." }

This matters operationally: an invite, a password reset, or an approval notification finishing its own database work is never rolled back or blocked just because the email leg failed — the person doing the inviting sees the invite succeed, and a misconfigured Tenant mail key surfaces in the logs rather than as a broken workflow.

Advanced use case: white-labeling for agencies

An agency running several client organizations as separate Tenants on one VeroXM deployment can give each client Tenant its own TenantMailConfig — its own Resend API key and from address — so invites, password resets, and approval notifications for that Tenant's users arrive from that client's own domain instead of the agency's platform-wide default. Tenants without their own configuration keep using the platform default automatically, so this can be adopted Tenant by Tenant rather than all at once.