The Visual Website Builder
You Actually Own. React + Next.js, low-code, self-hosted. Your data, your infrastructure, every feature included.
© ChaiBuilder. All rights reserved.

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.
Create a bucket on your provider and an access key pair scoped to it. Note down:
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.
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.media attaches the adapter to the media upload collection. Add other
upload collections here the same way if you create them.prefix namespaces objects per app, so a shared bucket won't collide across projects.region falls back to auto, which is what Cloudflare R2 expects. Set S3_REGION to a
real region on AWS S3 and other providers.endpoint is left undefined when S3_ENDPOINT is unset, so the AWS SDK infers the AWS
S3 endpoint from the region.forcePathStyle. R2, MinIO, and Spaces work with the default
virtual-hosted addressing. If your provider needs path-style URLs, add
forcePathStyle: true to the config block yourself.Restart the app, open the media manager in the builder, and upload an image. Then:
| 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 |
AccessDenied / signature errors - key pair lacks bucket permissions, wrong region, or
wrong endpoint..env in production).