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 full per-collection options. The options include
apiKey, which flags them as a full replacement: all ofapiKey/hostname/zoneName(storage) orapiKey/hostname/libraryId(stream) are required, and nothing is inherited from any global options. 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.
Registration
RegisterbunnyStorage under storage and multiTenantPlugin under plugins. Payload wires storage adapters before any plugin runs, so the order between them no longer matters.
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.
Every upload collection has a hidden per-document prefix field, so your hook can always write it. A static prefix in the plugin options is optional. It sets the field’s default and keeps every document prefix inside it.
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 reads the prefix from the signed receipt that Payload verifies 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.
Each client upload also gets its own UUID folder, so the stored prefix is tenants/<id>/media/<uuid>. Match tenant folders with like, not equals, in access rules and queries. See Querying by prefix.
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.access({ req, data }) 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. - SVG and XML files. The admin UI uploads SVG and XML files through the Payload server, not through client uploads, so
clientUploads.prefixnever runs for them. Keep thebeforeChangehook from the server-side recipe as well, so these files also land in the tenant folder. - Static and mint-time prefix together. If
clientUploads.prefixreturns a path outside the collection’s staticprefix, the plugin places it under the static prefix.tenantsplusacme/mediagivestenants/acme/media. - Custom tenant field name. The recipe reads
data.tenant. If you setmultiTenantPlugin({ tenantField: { name: '...' } }), use that name in thebeforeChangehook.