Storage Configuration
For uploaded images and files to persist in production, the media collection must write
to an object store rather than the local filesystem. ChaiBuilder uses Payload's official
S3 storage plugin (@payloadcms/storage-s3), which works with any S3-compatible
backend - AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces, Wasabi, and more.
Why this is required: on serverless and containerised hosts the local filesystem is ephemeral - files written at runtime disappear on redeploy and aren't shared across instances. Object storage keeps uploads durable and centralized. See Overview.
1. Provision a bucket
Create a bucket on your provider and an access key pair scoped to it. Note down:
- Bucket name
- Region
- Access key ID and secret access key
- Endpoint - the S3 API endpoint. For AWS this is inferred from the region; for S3-compatible providers (R2, MinIO, Spaces) it's an explicit URL.
2. Set environment variables
Add your credentials to the project's environment. ChaiBuilder's Payload config reads these:
# --- S3-compatible object storage (media uploads) ---
BUCKET_NAME=your-bucket
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
S3_REGION=auto # e.g. us-east-1, or "auto" for Cloudflare R2
S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com # provider S3 API endpoint
BUCKET_NAME, AWS_ACCESS_KEY_ID, and AWS_SECRET_ACCESS_KEY are the three that switch
storage on. S3_REGION defaults to auto when unset, and S3_ENDPOINT is optional - leave
it out on AWS S3, set it for R2, MinIO, and Spaces.
The plugin is only registered when all three required variables and
CHAIBUILDER_APP_KEY are present, since the storage prefix is derived from the app key. Miss
any one of them and the app quietly stays on local disk.
The S3 plugin block in
payload.config.tsand your project's.env.exampleare the source of truth for these names.
3. Wire the plugin (already in the starter)
The ChaiBuilder starter already registers the S3 plugin in payload.config.ts, pointing the
media collection at your bucket. It looks like this:
import { s3Storage } from '@payloadcms/storage-s3'
export default buildConfig({
// …
plugins: [
// …
s3Storage({
collections: {
media: { prefix: getAppStoragePrefix() }, // keeps each app's files namespaced
},
bucket: process.env.BUCKET_NAME!,
config: {
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
region: process.env.S3_REGION || 'auto',
endpoint: process.env.S3_ENDPOINT || undefined,
},
}),
],
})
Notes:
collections.mediaattaches the adapter to themediaupload collection. Add other upload collections here the same way if you create them.prefixnamespaces objects per app, so a shared bucket won't collide across projects.regionfalls back toauto, which is what Cloudflare R2 expects. SetS3_REGIONto a real region on AWS S3 and other providers.endpointis left undefined whenS3_ENDPOINTis unset, so the AWS SDK infers the AWS S3 endpoint from the region.- The starter does not set
forcePathStyle. R2, MinIO, and Spaces work with the default virtual-hosted addressing. If your provider needs path-style URLs, addforcePathStyle: trueto theconfigblock yourself.
4. Restart and verify
Restart the app, open the media manager in the builder, and upload an image. Then:
- Confirm the image renders in the builder and on a published page.
- Check your bucket - the object should appear under the configured prefix.
- Redeploy and confirm existing images still load (the point of object storage).
Provider quick reference
| Provider | S3_ENDPOINT |
S3_REGION |
Notes |
|---|---|---|---|
| AWS S3 | omit (inferred from region) | e.g. us-east-1 |
must set a real region |
| Cloudflare R2 | https://<account>.r2.cloudflarestorage.com |
auto (the default) |
|
| MinIO | your MinIO URL | any non-empty value | may need forcePathStyle: true in config |
| DigitalOcean Spaces | https://<region>.digitaloceanspaces.com |
region slug |
Troubleshooting
- Uploads succeed but images 404 after deploy - you're still on the local filesystem; the S3 env vars aren't set or aren't reaching the runtime.
AccessDenied/ signature errors - key pair lacks bucket permissions, wrong region, or wrong endpoint.- Works locally, fails on the host - env vars not configured in the deployment platform's
settings (they aren't read from a local
.envin production).
Related
- Overview - how media and uploads work.
- Environment & License - where these vars sit alongside the rest of your configuration.
- Deploy to Cloudflare - pairs naturally with R2.

