railway-storage

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Railway S3-Compatible Storage

Railway S3兼容存储

Railway provides S3-compatible storage buckets that work with standard AWS SDK. However, there are critical differences from AWS S3.
Railway提供与标准AWS SDK兼容的存储桶,但与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:
  1. Proxy endpoint (recommended) - API route that fetches from S3 and serves to client
  2. Presigned URLs - Generate time-limited signed URLs for direct access
Railway存储桶默认是私有的,不支持公共存储桶。
ACL: "public-read"
设置会被忽略。
要公开提供文件服务:
  1. 代理端点(推荐)- 从S3获取文件并提供给客户端的API路由
  2. 预签名URL - 生成限时的签名URL以实现直接访问

Environment Variables

环境变量

Railway auto-injects these when you link a storage bucket to your service. Use these exact names:
bash
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.
当你将存储桶链接到服务时,Railway会自动注入以下环境变量,请使用这些确切的名称:
bash
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
重要提示: Railway默认使用
AWS_
前缀的变量名。请勿使用
S3_
前缀,因为它们与Railway注入的变量不匹配。

S3 Client Setup

S3客户端配置

Use lazy initialization to avoid build-time errors (env vars unavailable during Docker builds):
typescript
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: true
    is required
  • Never access
    process.env
    at module level
  • Region is typically
    "auto"
使用延迟初始化来避免构建时错误(Docker构建期间环境变量不可用):
typescript
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;
}
关键点:
  • 必须设置
    forcePathStyle: true
  • 切勿在模块级别访问
    process.env
  • 区域通常设置为
    "auto"

Upload Implementation

上传实现

typescript
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}`;
}
typescript
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,
    // 注意:ACL会被忽略 - Railway存储桶始终是私有的
  }));

  // 返回代理URL(而非直接S3 URL)
  return `/uploads/${key}`;
}

Proxy Endpoint Pattern

代理端点模式

Create an API route to serve files from S3:
typescript
// 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:
typescript
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,
  };
}
创建一个API路由来从S3提供文件服务:
typescript
// 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: "文件未找到" }, { status: 404 });
  }

  return new NextResponse(result.body, {
    headers: {
      "Content-Type": result.contentType,
      "Content-Length": result.contentLength.toString(),
      "Cache-Control": "public, max-age=31536000, immutable",
    },
  });
}
从S3读取文件的工具函数:
typescript
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.js 重写规则

typescript
// next.config.ts
const nextConfig: NextConfig = {
  async rewrites() {
    return [{ source: "/uploads/:path*", destination: "/api/uploads/:path*" }];
  },
};
typescript
// next.config.ts
const nextConfig: NextConfig = {
  async rewrites() {
    return [{ source: "/uploads/:path*", destination: "/api/uploads/:path*" }];
  },
};

Dependencies

依赖项

bash
bun add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
bash
bun add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

Common Issues

常见问题

IssueCauseSolution
Files upload but return 403Railway ignores ACLUse proxy endpoint
Build fails with missing env varsS3 client at module levelUse lazy initialization
"Invalid endpoint" errorMissing forcePathStyleAdd
forcePathStyle: true
Images don't update after uploadBrowser/React Query cachingAdd
invalidateQueries()
问题原因解决方案
文件上传成功但返回403Railway忽略ACL设置使用代理端点
构建失败,提示缺少环境变量S3客户端在模块级别初始化使用延迟初始化
"无效端点"错误未设置forcePathStyle添加
forcePathStyle: true
上传后图片未更新浏览器/React Query缓存调用
invalidateQueries()

URL Format

URL格式

Store proxy URLs in database, not direct S3 URLs:
  • Correct:
    /uploads/{teamId}/avatar/{filename}
  • Wrong:
    https://storage.railway.app/bucket/{key}
在数据库中存储代理URL,而非直接S3 URL:
  • 正确格式:
    /uploads/{teamId}/avatar/{filename}
  • 错误格式:
    https://storage.railway.app/bucket/{key}