File Storage
Store and show files in your app with the platform's S3-compatible Railway bucket.
When your app needs to keep files people upload (photos, PDFs, exports), store them in the platform's Railway Storage Bucket. Files are private: the browser gets a presigned URL, a link to one file that stops working after a while.
Storage is turned on per app. An app receives the bucket's keys only when its deploy.config.yml asks for them, so an app that stores nothing never holds a key that can read every other app's files.
See It Working First
Example App is the reference. Open /gallery on it: you can upload an image, see everyone's uploads, delete one, or email a link to it. Everything on this page is already done in its code, so copy from there:
| File | What it shows |
|---|---|
apps/example-app/app/_lib/storage.ts | The storage helper. Copy it unchanged |
apps/example-app/app/_lib/gallery.ts | Where images go in the bucket, checking a key, checking that a file really is an image |
apps/example-app/app/gallery/actions.ts | Upload, delete and email as server actions |
apps/example-app/deploy.config.yml | The five lines that give the app its bucket keys |
Turn Storage On for Your App
The quickest way is to ask Claude (see the prompt below). This is what it does:
- Copy the helper
apps/example-app/app/_lib/storage.tstoapps/<your-app>/app/_lib/storage.ts. - Add the dependencies to your app's
package.json, at the versions Example App uses, then runpnpm installfrom the repo root:
{
"dependencies": {
"@aws-sdk/client-s3": "^3.1079.0",
"@aws-sdk/s3-request-presigner": "^3.1079.0"
}
}- Map the five values in
apps/<your-app>/deploy.config.yml. If the file already has asecrets:block, add the lines to it:
secrets:
BUCKET_ENDPOINT: "BUCKET_ENDPOINT"
BUCKET_ACCESS_KEY_ID: "BUCKET_ACCESS_KEY_ID"
BUCKET_SECRET_ACCESS_KEY: "BUCKET_SECRET_ACCESS_KEY"
BUCKET_NAME: "BUCKET_NAME"
BUCKET_REGION: "BUCKET_REGION"- Forward the secret in both
deploy-app-<your-app>-staging.ymlanddeploy-app-<your-app>-production.yml: addBUCKET_SECRET_ACCESS_KEYto theirsecrets:block, as Example App's workflows do. - Open a PR. The PR check fails if your code uses storage without the map, the map is there without code that uses it, or a deploy workflow does not forward the secret.
Using the Helper
import { getDownloadUrl, getStorage, uploadFile } from '@/app/_lib/storage';
const storage = getStorage();
if (!storage) {
// No bucket in this environment: say so, and keep the page working.
return;
}
// Keys start with your app's name.
await uploadFile(storage, 'my-app/receipts/0001.jpg', bytes, 'image/jpeg');
// A link the browser can open for one hour.
const url = await getDownloadUrl(storage, 'my-app/receipts/0001.jpg', 60 * 60);getStorage() returns null when the bucket is not set up, so a page can explain that instead of crashing. The helper also has listFiles, fileExists and deleteFile; Example App's gallery uses all of them.
Rules Worth Knowing
- Start every key with your app's name (
my-app/...). One bucket serves every app, and the prefix is what keeps them apart. - Never trust a key or a file from the browser. Check a key against the pattern your app uses before touching the bucket, and read a file's first bytes to see what it really is. Example App's
gallery.tsdoes both. - No personal data in keys. Make keys on the server; do not use the uploaded file's name or someone's email address.
- Show images with
<img>, notnext/image: a presigned link expires, andnext/imagewould fetch it on the server. - A presigned link works for whoever has it until it expires. Keep links on a page short (an hour). Seven days is the longest possible.
- Uploads over 1 MB need
serverActions.bodySizeLimitraised innext.config.ts, as Example App does for its 5 MB limit.
Create a Railway Storage Bucket
Do this once per client. Platform apps share one bucket per Railway environment: staging and production each get their own bucket with separate credentials.
1. Create the bucket in Railway
Do this twice — once with staging selected, once with production selected in the Railway project.
- Open the client's Railway project (e.g.
shimomoto-vibe-coding-platform). - Switch the environment dropdown to staging (or production).
- On the project canvas, click Create → Bucket.
- Choose a region (you cannot change it later). Set a display name, e.g.
shimomoto-storage. That name is only a label — the S3 bucket name is different, andrailway bucket renamedoes not change it later. Always takeBUCKET_NAMEfrom the Credentials tab. - Wait for the bucket to finish deploying.
- Open the bucket → Credentials tab. You will need these values for GitHub:
| Railway Credentials field | Platform env var | Notes |
|---|---|---|
ENDPOINT | BUCKET_ENDPOINT | e.g. https://t3.storageapi.dev |
ACCESS_KEY_ID | BUCKET_ACCESS_KEY_ID | S3 access key |
SECRET_ACCESS_KEY | BUCKET_SECRET_ACCESS_KEY | S3 secret key |
BUCKET | BUCKET_NAME | S3 API bucket name (includes hash suffix). Not RAILWAY_BUCKET_NAME |
REGION | BUCKET_REGION | Usually auto |
Railway docs: Storage Buckets.
2. Add values to GitHub Environments
In the client repo (algoritmi-tech/<client>-vibe-coding-platform), go to Settings → Environments.
Add the keys on both staging and production — values come from the bucket you created in that Railway environment:
| GitHub key | Type | Source |
|---|---|---|
BUCKET_ENDPOINT | Variable | Railway ENDPOINT |
BUCKET_ACCESS_KEY_ID | Variable | Railway ACCESS_KEY_ID |
BUCKET_SECRET_ACCESS_KEY | Secret | Railway SECRET_ACCESS_KEY |
BUCKET_NAME | Variable | Railway BUCKET |
BUCKET_REGION | Variable | Railway REGION |
Only BUCKET_SECRET_ACCESS_KEY is a Secret. The other four must be Variables.
This is a functional requirement, not a preference. GitHub Variables reach the deploy workflow on their own; a Secret arrives only when the app's workflow forwards it, and apps forward only BUCKET_SECRET_ACCESS_KEY. Store BUCKET_ACCESS_KEY_ID as a Secret and every app receives it empty, so getStorage() returns null — a confusing failure, because the GitHub side looks correct.
3. Redeploy the apps that use storage
CI writes the values at deploy time, so adding them in GitHub changes nothing until each app that maps them is deployed again. List those apps with grep -l BUCKET_SECRET_ACCESS_KEY apps/*/deploy.config.yml, run their deploy workflows (or push to main), then confirm Railway → service → Variables shows all five BUCKET_*.
Environment Variables (runtime)
| Variable | Purpose |
|---|---|
BUCKET_ENDPOINT | S3-compatible API endpoint |
BUCKET_ACCESS_KEY_ID | Credentials |
BUCKET_SECRET_ACCESS_KEY | Credentials |
BUCKET_NAME | Bucket to read and write |
BUCKET_REGION | Optional; auto if unset |
If one of the first four is missing, getStorage() returns null. See also Environment Variables.
Troubleshooting bucket config
| Symptom | Fix |
|---|---|
| The page says storage is not set up | Check that the app's deploy.config.yml maps all five BUCKET_* and both deploy workflows forward the secret, then redeploy |
| Works in the PR preview, not in production | Forward BUCKET_SECRET_ACCESS_KEY in the production deploy workflow |
SignatureDoesNotMatch | Compare BUCKET_ENDPOINT and the keys with the bucket's Credentials tab |
NoSuchBucket | BUCKET_NAME must be BUCKET from the Credentials tab, not the display name |
| Works in staging, not production | Production needs its own bucket and its own production keys |
Gotchas (Worth Reading Once)
- The helper already handles Railway's quirks. It strips a trailing slash from
BUCKET_ENDPOINT(a trailing slash causesSignatureDoesNotMatch), uses path-style requests, and sends checksum headers only when an operation needs one. Copy it unchanged rather than writing your own client. - Adding the map is not enough on its own — the secret must also be forwarded in both deploy workflows. Without it, the preview works (previews receive every secret) and production does not.
- Deploys never remove variables. When an app stops using storage, delete its
BUCKET_*variables in Railway as well as the map.
Ask Claude
Quiz
A user needs to download a file stored in the bucket. What's the right approach?