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/vibe-coding-guide/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. 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.devno 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_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 vars reach a called workflow like deploy-reusable.yml automatically; secrets only arrive if the calling workflow forwards them explicitly, and the app-portal and vibe-coding-guide deploy workflows forward BUCKET_SECRET_ACCESS_KEY alone.

Store BUCKET_ACCESS_KEY_ID as a Secret and those two apps receive it empty, so createStorageClientFromEnv() throws Missing storage environment variables — a confusing failure, because the GitHub side looks correctly configured.

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/...
database-accessor/exports/...

This way one bucket cleanly serves many apps.

Gotchas (Worth Reading Once)

  • Strip the trailing slash from BUCKET_ENDPOINT — a trailing slash causes SignatureDoesNotMatch. database-accessor does this (rawEndpoint.replace(/\/$/, '')); the reference client does not, so keep the variable clean or add the strip yourself.
  • forcePathStyle can be either. Railway reports urlStyle: virtual-host, which matches the reference client's false. The backing store accepts path-style too — database-accessor runs with true and works. Flipping this will not fix a signing error, so look at the endpoint and credentials instead.
  • Checksum headers are not configured anywhere in this monorepo, and uploads succeed without touching them on @aws-sdk/client-s3 3.1079.0. If you ever get a 403 on upload that nothing else explains, setting checksum calculation to 'WHEN_REQUIRED' is the knob to try — but do not add it pre-emptively.

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 vibe-coding-guide, 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?