tenancy option. Multi-tenant storage is a recipe built from two general-purpose pieces you already have: the per-document storage prefix, and Payload’s own access control. Nothing here is coupled to @payloadcms/plugin-multi-tenant internals — you pass your own tenant value in, so an upstream rename can’t break your uploads.
The idea: put each tenant’s files under their own path prefix (tenants/<id>/...), and pick how the CDN enforces isolation.
Two shapes of multi-tenancy
- Shared zone, prefix per tenant (this page’s main recipe) — every tenant’s files live in one Bunny zone/library, separated by path prefix and isolated by signed URLs (plus, for Stream, Payload access control). Best when tenants map to documents inside shared collections.
- A zone or library per tenant — when tenants map to separate collections, each collection can point at its OWN Bunny storage zone and stream library via a full per-collection config. The config includes
apiKey, which flags it as a full replacement: all ofapiKey/hostname/zoneName(storage) orapiKey/hostname/libraryId(stream) are required, and nothing is inherited from any global config. Each tenant then needs its owntokenSecurityKeyfor signed URLs and its ownwebhook.secret:
storage/stream can be omitted entirely. The rest of this page covers the shared-zone, prefix-per-tenant recipe.
Plugin order
RegisterbunnyStorage before multiTenantPlugin. The multi-tenant plugin expects to be placed after other plugins and warns in the console when it can’t find its collections.
payload.config.ts
Server-side uploads: prefix by tenant
Files uploaded through Payload (admin panel, Local API, REST) get their path from the document’sprefix. A short beforeChange hook on your upload collection writes it from the tenant field.
One plugin setting is required: give the collection a static prefix in the plugin config. That option is what creates the hidden per-document prefix field (with the static value as its default) — without it, the value your hook writes is dropped on save.
collections/Media.ts
Client uploads: prefix at mint time
Browser-direct uploads (clientUploads) never run your beforeChange before the file lands — the path has to be decided when the upload URL is minted. Use storage.clientUploads.prefix, resolved server-side at mint time:
getTenantFromCookie helper (with plugin-multi-tenant, users carry a tenants array, not a single tenant field) — never from the client-supplied body, which the caller controls. The callback runs after the storage.clientUploads.access check, so an unauthorized caller never reaches it.
The mint-time prefix decides where the file lands in the zone, and the plugin persists that same minted prefix onto the document automatically — it copies it from the client upload context on the create request that follows the upload. Deletes, reads, and URL generation then resolve to the minted path with no matching beforeChange hook of your own. When you use a clientUploads.prefix function, the plugin also injects the hidden per-document prefix field for you, so a static collection prefix isn’t required for client-only uploads.
Isolation: how the CDN enforces it
Prefixing organizes files; it does not by itself keep one tenant from reading another’s. Serve prefixed files as signed direct CDN URLs:disablePayloadAccessControl: true, each document’s URL is generated from its stored prefix and signed. Each URL is a time-limited token bound to that exact file — a token minted for tenant A’s file can’t fetch tenant B’s — and files stream straight from the CDN edge. See Signed URLs.
Or serve through Payload access control
If you prefer to gate files behind Payload’s own read rules instead of signing direct CDN URLs, leavedisablePayloadAccessControl at its default (access control on). The static handler resolves each /api/<collection>/file/... request to the document’s own stored prefix — so a file under tenants/<id>/media is served correctly in both proxy and redirect modes. Your collection’s read access (constrained by the tenant where) then keeps one tenant from reading another’s files.
Stream has no folders
Bunny Stream videos are library-scoped GUIDs; there are no per-tenant folders, so the prefix recipe doesn’t apply to Stream. Isolate Stream two ways: keep Payload access control on (collection read rules gate the video — no prefix is involved, so unlike Storage this works), or enable signed URLs (stream.tokenSecurityKey), which scope the token to the individual video. Gate uploads with stream.tus.checkAccess(req, body) against your own tenant model.
To keep each tenant’s videos tidy in the Bunny dashboard, you can drop them into a per-tenant Bunny collection — see Stream collections. Note that collections are organization only and do not isolate tenants; the two options above are what enforce isolation.
Optional building blocks
@payloadcms/plugin-multi-tenant/utilities exports a couple of helpers if you need the current tenant outside a document context — for example inside storage.clientUploads.prefix:
getUserTenantIDs(user)— every tenant ID the user belongs to.getTenantFromCookie(headers, idType)— the tenant currently selected in the admin UI.
payload-tenant cookie name, the tenant field slug when customized) directly — pass those values in through your own config instead.
Caveats
- Filename collisions. Two tenants uploading
logo.pngnever overwrite each other — the prefixes alone keep the storage paths distinct. Note that Payload’s filename dedupe is collection-wide, not per prefix: the secondlogo.pngin the collection is renamed (logo-1.png) regardless of tenant. - Custom tenant field name. The recipe reads
data.tenant. If you setmultiTenantPlugin({ tenantField: { name: '...' } }), use that name in thebeforeChangehook.