Answers · Keys & Services

How do I store uploaded files on S3 instead of local disk?

Set S3_BUCKET, S3_REGION, AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, plus S3_ENDPOINT for a non-AWS provider. config/storage.yml already defines the service, so production uses object storage instead of the container's disk. Existing files on local disk do not move by themselves.

The variables

Variable Notes
S3_BUCKET The bucket name.
S3_REGION auto works for several S3-compatible providers.
S3_ENDPOINT Only for non-AWS. Cloudflare R2, Backblaze B2, MinIO all use this.
AWS_ACCESS_KEY_ID Named for the protocol, not the vendor.
AWS_SECRET_ACCESS_KEY Same.
bin/kamal app exec 'bin/rails runner "puts ActiveStorage::Blob.service.name"'

That prints which service is live, which is the only check worth trusting. Setting the variables and assuming is how people discover in a month that everything still went to disk.

Why this matters more on a container deploy

The container filesystem is replaced on every deploy. Uploads stored on local disk survive only because they sit on a mounted volume declared in config/deploy.yml. That works, but it ties your files to one server, it puts them in the same backup problem as the database, and it makes moving hosts a copy operation.

Object storage removes all three. It is also the thing that has to be true before a second app server is possible at all.

Migrating what is already there

New uploads go to the new service. Old ones do not move, and Active Storage will keep looking for them where they were. Rails ships a mirror service for exactly this transition: write to both, let it run until the old files are copied, then switch reads.

Do not skip straight to switching. A deploy that changes the service with files still on disk turns every existing attachment into a broken link.

Keep the bucket private

Active Storage serves files through your app by default, with signed URLs. A public bucket short-circuits that and makes every file world-readable by URL, which is rarely what you want for customer uploads. Leave public access blocked and let the app do the authorizing.

What stays local

Nothing else. The SQLite databases live on the volume regardless, and they should: they need local disk latency and they are not what object storage is for.

Related questions

Do I need API keys to run a Rails starter kit locally?

No. Billing, email, bot checking and error reporting each sit behind an adapter that returns a real client when its credential is present and a deterministic local fake when it is not. A fresh clone runs bin/setup and bin/dev with no keys, and checkout, sign-in and gated features all work end to end.

How do I stop bot signups on a Rails sign-up form?

Set TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY. app/adapters/bot_check.rb returns a real Cloudflare Turnstile verifier when both are present and a pass-everything fake when they are not, so the form works in development with no account and starts blocking bots in production without a code change.

How do I tell which integrations are live in production?

Ask the adapters at runtime rather than reading environment variables. Each one chooses a real client or a local fake based on whether its credential is present, so querying the adapter tells you what the app is actually doing. A variable that is set but misspelled looks configured and behaves like a fake.