# 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.

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:

```ts
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.

```bash
bun add @payloadcms/storage-s3
```

```ts
// 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:

```ts
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:

```bash
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:

```sql
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
