File Storage
Uploading and serving files using Railway's S3-compatible Storage Buckets.
When your app needs to store user-uploaded files (images, PDFs, exports), use Railway's S3-compatible Storage Buckets. Files are private by default and served to users via short-lived presigned URLs.
Reference Implementation
Don't reinvent the client. Copy this file verbatim into your app:
apps/staff-portal/app/_lib/storage.ts → apps/<your-app>/app/_lib/storage.ts
It exports two functions: createStorageClientFromEnv() (reads env vars, returns a configured S3 client) and getPresignedUrl(storage, key, expiresIn) (generates a time-limited download URL).
Add the Dependencies
In your app's package.json:
{
"dependencies": {
"@aws-sdk/client-s3": "^3.709.0",
"@aws-sdk/s3-request-presigner": "^3.709.0"
}
}Then from the monorepo root: pnpm install.
Create a Railway Storage Bucket
Platform apps share one bucket per Railway environment (staging and production each get their own bucket instance 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. - 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://storage.railway.app — no trailing slash |
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 | Recommended type | Source |
|---|---|---|
BUCKET_ENDPOINT | Variable | Railway ENDPOINT |
BUCKET_ACCESS_KEY_ID | Secret | Railway ACCESS_KEY_ID |
BUCKET_SECRET_ACCESS_KEY | Secret | Railway SECRET_ACCESS_KEY |
BUCKET_NAME | Variable | Railway BUCKET |
BUCKET_REGION | Variable | Railway REGION |
Use Variables for non-sensitive values (ENDPOINT, BUCKET, REGION). Use Secrets for ACCESS_KEY_ID and SECRET_ACCESS_KEY.
3. Redeploy apps that use storage
CI reads GitHub Environment vars/secrets during deploy and pushes BUCKET_* to Railway services via deploy-reusable.yml.
| App | Workflow | Bucket secrets forwarded |
|---|---|---|
| Database Accessor | deploy-app-database-accessor-staging/production.yml | All five BUCKET_* keys |
| App Portal | deploy-app-app-portal-staging/production.yml | BUCKET_SECRET_ACCESS_KEY (+ vars for the rest) |
| Vibe Coding Guide | deploy-app-vibe-coding-guide-staging/production.yml | BUCKET_SECRET_ACCESS_KEY (+ vars for the rest) |
After updating GitHub, run the relevant deploy workflow (or push to main). Confirm Railway → service → Variables shows BUCKET_ENDPOINT, BUCKET_NAME, etc.
Apps that require the bucket: database-accessor (storage browser), and any app you add storage.ts / upload routes to.
Environment Variables (runtime)
Your app needs all four of these. They're set per-environment in Railway via GitHub secrets — see Environment Variables.
| Variable | Purpose |
|---|---|
BUCKET_ENDPOINT | S3-compatible API endpoint |
BUCKET_ACCESS_KEY_ID | Credentials |
BUCKET_SECRET_ACCESS_KEY | Credentials |
BUCKET_NAME | Bucket to read/write |
Optional:
BUCKET_REGION— defaults toautoif unset (Railway buckets usually useauto).
If any required variable is missing, createStorageClientFromEnv() throws: Missing storage environment variables.
Troubleshooting bucket config
| Symptom | Fix |
|---|---|
| Storage page says bucket vars missing | Set all BUCKET_* keys on the correct GitHub Environment, redeploy |
SignatureDoesNotMatch | Remove trailing slash from BUCKET_ENDPOINT |
| Works in staging, not production | Production needs its own bucket + separate GitHub production keys |
Only BUCKET_SECRET_ACCESS_KEY empty in Railway | Forward it in the app's deploy workflow secrets: block |
Using the Client
import {
createStorageClientFromEnv,
getPresignedUrl,
} from '@/app/_lib/storage';
const storage = createStorageClientFromEnv();
// Generate a presigned URL valid for 7 days
const url = await getPresignedUrl(
storage,
'my-app/photos/avatar.png',
3600 * 24 * 7
);
// Return the URL to the browser — it can fetch the file directly.Bucket Organization
Prefix every key with your app name so files are grouped:
my-app/photos/...
checkin-board/attendances/...
staff-portal/documents/...
This way one bucket cleanly serves many apps.
Gotchas (Worth Reading Once)
These are baked into the reference storage.ts, but good to know so you don't "fix" them:
- Strip trailing slash from
BUCKET_ENDPOINT— a trailing slash causesSignatureDoesNotMatch. forcePathStyle: trueis required for Railway's S3-compatible API.- Checksum calculation must be set to
'WHEN_REQUIRED'— AWS SDK v3 ≥ 3.600 adds checksum headers by default and Railway's Tigris returns 403 otherwise.
Ask Claude
Quiz
A user needs to download a file stored in the bucket. What's the right approach?