Skip to content

Payload uploads vanish after a deploy: move media to an object store

staticDir writes to a filesystem that disappears on serverless. Add a storage adapter, keep the database rows, and migrate the files you already have.

Payload blog4 min readships at docs/solutions/blog-payload/media-on-an-object-store.md

Tags: payload · uploads · storage · s3 · vercel · serverless

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:

  1. Upload an image in the deployed /cms.
  2. Check the object appears in the bucket, at the prefix you configured.
  3. Load the public page; the image URL should be the store's domain, not your app's /media/... path.
  4. Redeploy, then reload the page. This is the step that catches the bug. The image must still be there.
  5. 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.