About railway-storage
railway-storage is a reference skill for using Railway's private S3-compatible storage with the AWS SDK. It covers configuration, uploads, file serving, and common errors, with guidance for proxy endpoints or presigned URLs when files must be accessed publicly.
Key features
- 01Configures the S3 client with Railway variables
- 02Explains lazy client initialization
- 03Uploads and reads bucket objects
- 04Serves private files through a proxy endpoint
- 05Describes presigned URLs for file access
- 06Covers common configuration errors
Use cases
- 01Configure an application to use a Railway storage bucket
- 02Serve stored files through an API route
- 03Troubleshoot upload or endpoint configuration issues
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ blink-new/ claude --skill railway-storageAfter installing, type/railway-storagein Claude Code to confirm it loaded.
Details
From the project READMERailway S3-Compatible Storage
Railway provides S3-compatible storage buckets that work with standard AWS SDK. However, there are critical differences from AWS S3.
Critical: Private Buckets Only
Railway buckets are private by default and do not support public buckets. The ACL: "public-read" setting is ignored.
To serve files publicly:
- Proxy endpoint (recommended) - API route that fetches from S3 and serves to client
- Presigned URLs - Generate time-limited signed URLs for direct access
Environment Variables
Railway auto-injects these when you link a storage bucket to your service. Use these exact names:
AWS_ENDPOINT_URL=https://storage.railway.app
AWS_DEFAULT_REGION=auto
AWS_S3_BUCKET_NAME=your-bucket-name
AWS_ACCESS_KEY_ID=tid_xxx
AWS_SECRET_ACCESS_KEY=tsec_xxx
Important: Railway uses AWS_* prefixed names by default. Do NOT use S3_* prefixes as they won't match Railway's injected variables.
S3 Client Setup
Use lazy initialization to avoid build-time errors (env vars unavailable during Docker builds):
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
let s3Client: S3Client | null = null;
function getS3Client(): S3Client {
if (!s3Client) {
s3Client = new S3Client({
endpoint: process.env.AWS_ENDPOINT_URL,
region: process.env.AWS_DEFAULT_REGION ?? "auto",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
forcePathStyle: true, // Required for Railway
});
}
return s3Client;
}
Key points:
forcePathStyle: trueis required- Never access
process.envat module level - Region is typically
"auto"
Upload Implementation
export async function uploadToS3(key: string, body: Buffer, contentType: string): Promise<string> {
const client = getS3Client();
const bucket = process.env.S3_BUCKET_NAME!;
await client.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentType: contentType,
// Note: ACL is ignored - Railway buckets are always private
}));
// Return proxy URL (not direct S3 URL)
return `/uploads/${key}`;
}
Proxy Endpoint Pattern
Create an API route to serve files from S3:
// src/app/api/uploads/[...path]/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params;
const key = path.join("/");
const result = await getS3Object(key);
if (!result) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
return new NextResponse(result.body, {
headers: {
"Content-Type": result.contentType,
"Content-Length": result.contentLength.toString(),
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
Helper to read from S3:
export async function getS3Object(key: string) {
const client = getS3Client();
const response = await client.send(
new GetObjectCommand({ Bucket: process.env.AWS_S3_BUCKET_NAME!, Key: key })
);
if (!response.Body) return null;
return {
body: response.Body.transformToWebStream(),
contentType: response.ContentType || "application/octet-stream",
contentLength: response.ContentLength || 0,
};
}
Next.js Rewrite Rule
// next.config.ts
const nextConfig: NextConfig = {
async rewrites() {
return [{ source: "/uploads/:path*", destination: "/api/uploads/:path*" }];
},
};
Dependencies
bun add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
Common Issues
| Issue | Cause | Solution |
|---|---|---|
| Files upload but return 403 | Railway ignores ACL | Use proxy endpoint |
| Build fails with missing env vars | S3 client at module level | Use lazy initialization |
| "Invalid endpoint" error | Missing forcePathStyle | Add forcePathStyle: true |
| Images don't update after upload | Browser/React Query caching | Add invalidateQueries() |
URL Format
Store proxy URLs in database, not direct S3 URLs:
- Correct:
/uploads/{teamId}/avatar/{filename} - Wrong:
https://storage.railway.app/bucket/{key}