v3 to v4
v4 supports Payload 4. It follows the new storage adapter API, so most changes come from Payload itself. Do steps 1 to 4 first. The later steps only apply to some apps.Requires Payload 4 and Node.js 24.15 or later. Upgrade Node and Payload first. Follow the Payload 4 migration
guide for the changes in Payload itself.
1. Update Node and the packages
Payload 4 needs Node.js 24.15 or later. Then install v4 of the plugin and the matching cloud storage plugin:2. Register the adapter under storage
Payload 4 does not accept storage adapters in plugins. Move bunnyStorage to the top-level storage array:
payload.config.ts
npx @payloadcms/codemod --transform migrate-storage-adapters-to-config codemod only knows the official adapters, so move bunnyStorage by hand.
3. Update the renamed options
Some options are renamed or merged. The plugin throws at startup and names the new key for each old one:collections.<slug>, even with enabled: false.
Check these details too:
expiresInfunction.expiresAttook a UNIX timestamp, butexpiresInreads a number as seconds. Return aDateinstead, for exampleexpiresIn: () => eventEndsAt ?? 3600. A number over 10 years throws an error. See Signed URLs.redirect.status. It accepts302or307only.tus.access. It still replaces the default check. CalldefaultAccess()to keep it, for exampleaccess: async ({ data, defaultAccess }) => data.filesize < 5e9 && (await defaultAccess()). See TUS uploads.getBunnyStorageForCollection(). It returnss3: trueorfalseand the region inregion. Readregion, nots3.region.
These changes do not throw an error. Check them if they apply to your app:
- The
prefixargument of aurlTransformfunction is now only the document’s ownprefixfield. In v3 it also contained the collectionprefix. If your function builds a path fromprefix, add the collection prefix yourself. - A collection
thumbnailnow keeps the globalthumbnail.urlTransformunless it sets its own. In v3, a collection that only setsizeNamelost the global transform function. - A collection set to
falseis now left out, together with its hiddenprefixfield, so the SQL schema changes. In v3,falseturned the collection on. To switch the plugin with an environment variable, useenabled. - A collection
stream.tus: trueorstream.tus: { ... }now turns TUS on for that collection, even when the globalstream.tusis off. In v3, it was ignored. - When a collection sets
generateFileURLandsignedUrls.userIpis set, the plugin now keeps thegenerateFileURLresult. In v3, it replaced it with an IP-locked signed URL. - Cache purge now purges the plain CDN URL with a trailing
*. This also clears cached copies with query strings, such as signed or transformed URLs.
4. Add the prefix column (SQL databases)
Every upload collection that uses the plugin now has a hidden prefix field. Payload adds it even when the collection has no static prefix, and even when you set enabled: false. This keeps the schema the same in every environment.
On Postgres, SQLite and D1, prefix is a new column on each upload collection’s table. Payload also adds a hidden _objectKey field. Create and run a migration:
prefix, or an empty string, so existing rows keep resolving to the same files.
Payload 4 has more schema changes of its own, such as versions and job fields. One migrate:create run covers them all.
5. Redeploy the Edge Script
If you use client uploads with an Edge Script (HTTP API zones), redeploy it after the upgrade:bin scripts with cli.commands. The plugin registers bunny:deploy-edge-script there for you, so the command itself does not change. It is not available when you set cli: false.
6. Custom upload clients
This step only applies if you wrote your own upload client. The admin UI needs no changes. The plugin’sPOST /api/storage-bunny/storage/upload endpoint is removed. Client uploads now use Payload’s shared POST /api/upload-instructions endpoint:
- Send
{ collectionSlug, filename, filesize, mimeType }toPOST /api/upload-instructions. PUTthe file torequest.urlwithrequest.headersfrom the response.- Send the returned
fileobject unchanged as thefilefield of the document request. It replacesclientUploadContext.
tus-auth no longer returns clientUploadContext. When you send head, it returns filename and signedReceipt. Send the file as { filename, mimeType, size, uploadReference: { signedReceipt } } with the document.
7. Read errors from errors
Client upload errors now use Payload’s standard error format:
{ "error": "..." }. Read errors[0].message instead. Some status codes changed too, because Payload now runs its own checks first. For example, a file over upload.limits.fileSize gets 400, and a file type the collection does not accept gets a validation error. Check response.ok rather than exact codes. The tus-auth endpoint already used this format.
8. Client upload keys have a UUID folder
Client uploads now store each file under its own random folder:<prefix>/<uuid>/<filename>. The stored prefix includes the UUID, for example uploads/tenants/acme/3f2a….
If your access rules or queries match the stored prefix with equals, switch to like:
9. clientUploads.access only narrows
Payload now checks that the user is signed in and has create or update access to the collection before your clientUploads.access function runs. In v3, a custom access function replaced that check. Now it can only make it stricter.
If your access function let in users without collection create or update access, for example anonymous visitors, give them that access in the collection’s own access config instead. For example, clientUploads: { access: () => true } no longer allows public uploads.
10. Files at the zone root
This step only applies if you upgrade from v3.1.0 or earlier. v3.1.1 and later already read these files under the collection prefix. If you have not moved them yet, follow v3.1.0 to v3.1.1 first.11. Stream cleanup schedule
If you setstream.cleanup.schedule.cron, check that it uses Payload 4’s stricter cron syntax. A step must follow * or a range, so write */10, not /10 or 5/10. The default 0 2 * * * is valid.
12. Move TUS labels to Payload’s i18n
The plugin’s i18n option is removed. Override the plugin labels in Payload’s own i18n config, under the @seshuk/payload-storage-bunny key:
payload.config.ts
tusUploadChecking, tusUploadFileSize and tusUploadFinalizing are gone, and the status texts are new. See Translations for the current keys.
13. Remove v2 leftovers
- The
@seshuk/payload-storage-bunny/migrationssubpath is removed. If you still have v2 data, upgrade to v3 and run the data migration on Payload 3 first. - The renamed v2 keys, such as
apiKey,adminThumbnailorstream.tus.uploadTimeout, still throw a startup error with the new name. Remove any that are left.
v3.1.1 to v3.1.2
v3.1.2 contains security fixes for Stream TUS uploads. It needs no option changes.tus-authchecks the file like a storage client upload. It rejects a type outside the collection’supload.mimeTypesor the StreammimeTypes, a restricted type and a file overupload.limits.fileSize, even whencheckAccessallows the request. The saved document gets the signed file size and MIME type.- A
videoTokenis bound to the signed-in user and expires after 24 hours. Tokens from v3.1.1 stop working, so an upload that was paused before the update starts again. The same happens to an upload that stays paused for more than 24 hours. - Payload rejects a
videoIdthat another document in a Stream collection of the same library already uses.
tus-auth from your own client, request a new videoToken for each upload and save the document within 24 hours.
v3.1.0 to v3.1.1
v3.1.1 needs no option changes. Most apps are not affected. It only changes documents that have an empty or missingprefix in a collection that sets a prefix. v3.1.0 stored the files of such documents at the root of the storage zone. v3.1.1 reads, deletes and stores them under the collection prefix. Documents with a non-empty prefix keep the same file paths.
To check a collection, query it for documents without a prefix:
<file> to <collection prefix>/<file> in the storage zone. Otherwise their URLs return 404. You can move files with any S3 client on an S3 zone, or download and re-upload them in the Bunny dashboard.
v3.0.x to v3.1.0
v3.1.0 needs no option changes, but it has new install and upload requirements. Most apps only need steps 1 and 2. v3.1.0 requires Payload 3.90.2 or later. If you are on v3.0.1 with an older Payload version, upgrade Payload first. Staying on v3.0.1 is not recommended, because it lacks the security fixes in v3.1.0.1. Install the cloud storage plugin
@payloadcms/plugin-cloud-storage is now a peer dependency. Add it next to the plugin, in the version that matches your Payload version:
2. Redeploy the Edge Script
If you use client uploads with an Edge Script (HTTP API zones), redeploy it:3. Stricter client uploads
The/api/storage-bunny/storage/upload endpoint now has these requirements:
- Without a custom
clientUploads.accessfunction, the user needscreateorupdateaccess to the collection. filesizeis required, next tocollectionSlug,filenameandmimeType.- HTML, JavaScript, SVG and XML files are rejected, unless the collection sets
upload.allowRestrictedFileTypes: true. - The upload URL only accepts the declared size and MIME type.
- It returns
409when a file already exists at the path.
409 is rare in practice.
4. Custom upload clients
This step only applies if you wrote your own upload client. The admin UI needs no changes.- Client uploads. Send the
headersfrom the upload endpoint response with thePUT. Send theclientUploadContextfrom the same response unchanged with the document. Payload now requires its signed receipt, and the plugin takes the prefix, file size and MIME type from it. - TUS and Stream.
tus-authreturns avideoTokenwith everyvideoId. Send it asbunnyData.stream.videoTokenwhen you save a document with avideoId, and sendvideoIdwithvideoTokento resume an upload. Without a valid token, Payload ignores thevideoIdon save, andtus-authcreates a new video. - Stream client uploads. Send the file head as
headtotus-auth, and send the returnedclientUploadContextunchanged with the document. - Read-only fields. The injected
prefixfield andbunnyData.stream.resolutionsno longer accept values from the REST or GraphQL API. Server code that uses the Local API is not affected.
5. Check static plus mint-time prefixes
This step only applies to collections that set both a staticprefix and a clientUploads.prefix callback that returns a path outside the static prefix. For example, the static prefix uploads and the callback result tenants/acme.
v3.0.x uploaded such files to tenants/acme/<file>, but stored uploads/tenants/acme on the document. So URLs and deletes pointed to a path with no file. v3.1.0 uploads new files to uploads/tenants/acme/<file>, which matches the stored prefix.
To fix files uploaded with v3.0.x, move each file (and its image sizes) from <callback result>/<file> to <static prefix>/<callback result>/<file>. You can move files with any S3 client on an S3 zone, or download and re-upload them in the Bunny dashboard. The stored documents need no change. Collections that use only one of the two prefixes are not affected.
v2.x to v3.0
v3 has two independent parts: a set of config changes (which throw at startup until you rename the keys) and a data migration for the stored Stream metadata. Do both.Requires Payload 3.90.2 or later and Node.js 22 or later. Upgrade Payload (and Node, if needed) first if you are
below either.
Config changes (breaking)
Several config keys were renamed or removed in v3. Most throw at boot with a targeted message, so a v2 config crashes on startup until you update it. The last three rows are the exception — they produce no boot error (they are a naming convention, silently ignored, or always overridden), but you should still clean them up:
Per-service keys are unchanged:
storage.apiKey and stream.apiKey stay as they are.
New in v3 (optional, nothing to migrate — adopt when you need them):
- Client uploads — browser-direct uploads, configured under
storage.clientUploads(storage.clientUploads: trueshorthand, or the full options). There is nomodeoption — the transport is chosen automatically (presigned S3 when the zone setsstorage.s3, otherwise a deployed Edge Script). - Per-collection zones and libraries — point a collection at its own storage zone / stream library with full options; the top-level
storage/streambecome optional. storage.s3— S3-compatible zone access.stream.referer— Referer header for MP4-fallback requests behind BlockNoneReferrer libraries.- Per-collection
stream.tus: false— disable TUS for one collection. - Stream webhook (
stream.webhook) — v2 had no webhook; MP4-fallback resolutions were fetched lazily on the first serve (v3 still does this as a fallback). The optional webhook pre-populatesbunnyData.stream.resolutionsthe moment encoding finishes instead. Setwebhook.secretto the library’s Read-Only API key (Bunny signs each callback with theX-BunnyStream-SignatureHMAC header) and point your Bunny library’s webhook at/api/storage-bunny/stream/webhook.
What changed (data)
v3 stores Bunny Stream metadata in a singlebunnyData group field instead of the flat bunnyVideoId and bunnyVideoMeta fields.
type and libraryId are computed on read (not stored). videoId is stored and indexed, so queries such as where: { 'bunnyData.stream.videoId': { equals } } keep working.
Migrate your data
v3.x ships a migration helper at@seshuk/payload-storage-bunny/migrations. It supports MongoDB, Postgres and SQLite and is idempotent — re-running it only touches documents that have not been migrated yet.
1
Upgrade the plugin to v3
2
Create a migration file
3
Call the helper from it
4
Run the migration
5
Remove the old fields
The legacy data lives in the You can run this as a one-off from any script with a Payload instance, or fold it into the migration once you’re confident:
bunnyVideoId and bunnyVideoMeta fields (MongoDB) — the bunny_video_id and bunny_video_meta columns on SQL. You do not declare these in your own config: the v2 plugin added them for you, and v3 no longer declares them, so after migrating they are leftover data with no field definition behind them.Once you have verified your app reads video metadata from bunnyData, delete the leftovers by passing drop: true. This unsets the fields (MongoDB) or drops the columns (Postgres/SQLite) for every managed collection:Database notes
- MongoDB
- Postgres
- SQLite
MongoDB is schemaless, so there is no ordering constraint.If your project doesn’t use
payload migrate, call migrateBunnyData({ payload }) once from any script that has a Payload instance.