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:

package.jsonjson
{
"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.

  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.
  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://storage.railway.appno trailing slash
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 keyRecommended typeSource
BUCKET_ENDPOINTVariableRailway ENDPOINT
BUCKET_ACCESS_KEY_IDSecretRailway ACCESS_KEY_ID
BUCKET_SECRET_ACCESS_KEYSecretRailway SECRET_ACCESS_KEY
BUCKET_NAMEVariableRailway BUCKET
BUCKET_REGIONVariableRailway 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.

AppWorkflowBucket secrets forwarded
Database Accessordeploy-app-database-accessor-staging/production.ymlAll five BUCKET_* keys
App Portaldeploy-app-app-portal-staging/production.ymlBUCKET_SECRET_ACCESS_KEY (+ vars for the rest)
Vibe Coding Guidedeploy-app-vibe-coding-guide-staging/production.ymlBUCKET_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.

VariablePurpose
BUCKET_ENDPOINTS3-compatible API endpoint
BUCKET_ACCESS_KEY_IDCredentials
BUCKET_SECRET_ACCESS_KEYCredentials
BUCKET_NAMEBucket to read/write

Optional:

  • BUCKET_REGION — defaults to auto if unset (Railway buckets usually use auto).

If any required variable is missing, createStorageClientFromEnv() throws: Missing storage environment variables.

Troubleshooting bucket config

SymptomFix
Storage page says bucket vars missingSet all BUCKET_* keys on the correct GitHub Environment, redeploy
SignatureDoesNotMatchRemove trailing slash from BUCKET_ENDPOINT
Works in staging, not productionProduction needs its own bucket + separate GitHub production keys
Only BUCKET_SECRET_ACCESS_KEY empty in RailwayForward it in the app's deploy workflow secrets: block

Using the Client

Server-side usagetypescript
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 causes SignatureDoesNotMatch.
  • forcePathStyle: true is 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

Wire up file uploads in a new app
Claude prompt
Add file upload support to my meal-planner app. Copy storage.ts from staff-portal, add the @aws-sdk dependencies, and create a POST /api/upload route that stores images under the "meal-planner/photos/" prefix. Return a presigned download URL valid for 24 hours.

Quiz

Quiz

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