Algoritmi AI SandboxPractice environment with sample data. Nothing here reaches the production platform.

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:

FileWhat it shows
apps/example-app/app/_lib/storage.tsThe storage helper. Copy it unchanged
apps/example-app/app/_lib/gallery.tsWhere images go in the bucket, checking a key, checking that a file really is an image
apps/example-app/app/gallery/actions.tsUpload, delete and email as server actions
apps/example-app/deploy.config.ymlThe 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:

  1. Copy the helper apps/example-app/app/_lib/storage.ts to apps/<your-app>/app/_lib/storage.ts.
  2. Add the dependencies to your app's package.json, at the versions Example App uses, then run pnpm install from the repo root:
package.jsonjson
{
"dependencies": {
  "@aws-sdk/client-s3": "^3.1079.0",
  "@aws-sdk/s3-request-presigner": "^3.1079.0"
}
}
  1. Map the five values in apps/<your-app>/deploy.config.yml. If the file already has a secrets: block, add the lines to it:
deploy.config.ymlyaml
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"
  1. Forward the secret in both deploy-app-<your-app>-staging.yml and deploy-app-<your-app>-production.yml: add BUCKET_SECRET_ACCESS_KEY to their secrets: block, as Example App's workflows do.
  2. 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

In a server action or server componenttypescript
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.ts does 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>, not next/image: a presigned link expires, and next/image would 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.bodySizeLimit raised in next.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.

  1. Open the client's Railway project (e.g. shimomoto-vibe-coding-platform).
  2. Switch the environment dropdown to staging (or production).
  3. On the project canvas, click CreateBucket.
  4. 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, and railway bucket rename does not change it later. Always take BUCKET_NAME from the Credentials tab.
  5. Wait for the bucket to finish deploying.
  6. Open the bucket → Credentials tab. You will need these values for GitHub:
Railway Credentials fieldPlatform env varNotes
ENDPOINTBUCKET_ENDPOINTe.g. https://t3.storageapi.dev
ACCESS_KEY_IDBUCKET_ACCESS_KEY_IDS3 access key
SECRET_ACCESS_KEYBUCKET_SECRET_ACCESS_KEYS3 secret key
BUCKETBUCKET_NAMES3 API bucket name (includes hash suffix). Not RAILWAY_BUCKET_NAME
REGIONBUCKET_REGIONUsually 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 keyTypeSource
BUCKET_ENDPOINTVariableRailway ENDPOINT
BUCKET_ACCESS_KEY_IDVariableRailway ACCESS_KEY_ID
BUCKET_SECRET_ACCESS_KEYSecretRailway SECRET_ACCESS_KEY
BUCKET_NAMEVariableRailway BUCKET
BUCKET_REGIONVariableRailway 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)

VariablePurpose
BUCKET_ENDPOINTS3-compatible API endpoint
BUCKET_ACCESS_KEY_IDCredentials
BUCKET_SECRET_ACCESS_KEYCredentials
BUCKET_NAMEBucket to read and write
BUCKET_REGIONOptional; auto if unset

If one of the first four is missing, getStorage() returns null. See also Environment Variables.

Troubleshooting bucket config

SymptomFix
The page says storage is not set upCheck 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 productionForward BUCKET_SECRET_ACCESS_KEY in the production deploy workflow
SignatureDoesNotMatchCompare BUCKET_ENDPOINT and the keys with the bucket's Credentials tab
NoSuchBucketBUCKET_NAME must be BUCKET from the Credentials tab, not the display name
Works in staging, not productionProduction 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 causes SignatureDoesNotMatch), 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

Add photo uploads to an app
Claude prompt
Add photo uploads to my meal-planner app the same way Example App's gallery does. Copy apps/example-app/app/_lib/storage.ts, add the @aws-sdk dependencies, map the five BUCKET_* values in deploy.config.yml, and forward BUCKET_SECRET_ACCESS_KEY in both deploy workflows. Store photos under the "meal-planner/photos/" prefix, check each file really is an image, and show the photos with presigned links.

Quiz

Quiz

A user needs to download a file stored in the bucket. What's the right approach?