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.
Environment Variables
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 tous-east-1.
If any required variable is missing, createStorageClientFromEnv() throws: Missing storage environment variables.
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?