Everything works locally. You deploy, upload an image in /cms, and it renders.
The next day the image is a broken link, and so is every other one uploaded
after the last deploy. The document row is still there, with a filename and a
size, pointing at a file nothing can find.
Why it happens
Payload's default upload config writes files to disk:
upload: {
staticDir: "public/media",
},
On your laptop that directory persists. On Vercel (and on Lambda, Cloud Run, Fly machines, most container platforms) it does not:
- the filesystem is read-only except for
/tmp; - each instance has its own copy, so an upload handled by instance A is invisible to instance B;
- every deploy replaces the image entirely, and idle instances are recycled.
Note the shape of the failure: the database row survives, because that went to Postgres. Only the bytes are gone. That is why it looks like a rendering bug rather than a storage bug, and why it is often noticed a week late.
The fix: a storage adapter
Payload has adapters that swap the filesystem for an object store, keeping the same collection config and the same admin UI.
bun add @payloadcms/storage-s3
// payload.config.ts
import { s3Storage } from "@payloadcms/storage-s3";
export default buildConfig({
// ...
plugins: [
s3Storage({
collections: {
media: { prefix: "media" },
},
bucket: process.env.S3_BUCKET ?? "",
config: {
endpoint: process.env.S3_ENDPOINT, // omit for AWS S3
region: process.env.S3_REGION ?? "auto",
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "",
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
},
forcePathStyle: true, // required by R2 and MinIO
},
}),
],
});
Any S3-compatible store works: AWS S3, Cloudflare R2, Backblaze B2, MinIO, DigitalOcean Spaces. R2 is a common choice because it has no egress charges, which matters for a media library served straight to browsers.
There are dedicated adapters for other backends (@payloadcms/storage-vercel-blob, @payloadcms/storage-uploadthing,
@payloadcms/storage-azure) with the same shape.
Add the new variables to your environment file and to the deployment, and remove
staticDir from the collection: the adapter takes over.
Keep generating sizes
The adapter changes where files live, not what is generated. Keep imageSizes
so the site never serves a full-resolution original:
upload: {
mimeTypes: ["image/*"],
imageSizes: [
{ name: "thumbnail", width: 480, height: 270, position: "centre" },
{ name: "card", width: 960, height: 540, position: "centre" },
{ name: "hero", width: 1920 },
],
adminThumbnail: "thumbnail",
},
sharp resizes on upload; each size is stored as its own object. Restricting
mimeTypes is worth doing on its own merits: an upload field that accepts
anything is an upload field that accepts an HTML file with a script in it.
Serving the files
Two options, and the difference is about caching, not correctness:
Public bucket. Files are served straight from the store's CDN. Simplest, fastest, and correct for a blog: the images are public anyway once the post is.
Private bucket with signed URLs. The adapter proxies through your app so access rules apply. Necessary for gated content; it puts every image request through a function, so use it only when you need it.
If you use next/image with a public bucket, add the host to
images.remotePatterns in next.config.ts or the optimizer refuses it.
Migrating the files you already have
The rows in media are fine; the bytes need moving. If you still have them
locally:
aws s3 sync public/media s3://your-bucket/media --acl public-read
Then deploy the adapter. Payload builds URLs from the filename and the prefix, so existing rows resolve to the new location without a data migration.
If the files are already lost (the usual case) the rows are the useful part. Find them, then re-upload:
select id, filename, created_at
from payload.media
order by created_at desc;
Anything uploaded to a deployed instance is gone. Anything on a developer's machine can be re-synced.
Confirming it actually works
The failure mode is delayed, so test it in a way that reproduces the delay:
- Upload an image in the deployed
/cms. - Check the object appears in the bucket, at the prefix you configured.
- Load the public page; the image URL should be the store's domain, not your
app's
/media/...path. - Redeploy, then reload the page. This is the step that catches the bug. The image must still be there.
- Wait for the instance to go cold (or force a new deploy) and load it again.
Add it to your pre-launch checklist, because it is the one storage bug that passes every test you would normally write.