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.

Environment Variables

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 us-east-1.

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

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?