BunshipBunship

S3-Compatible Storage

Practical storage setup for Bunship: exact env values, R2-first setup, per-user provider routing, and production validation steps.

If storage is wrong, Bunship will fail in visible places: avatar upload, CMS cover upload, and AI output URLs.

This page focuses on the fastest production-safe setup.

Use Cloudflare R2 first unless your team already runs on AWS.

  • lower egress risk in common CDN setups
  • fully S3-compatible with Bunship
  • quickest path to stable uploads

What Bunship Needs

For the default env-only setup, Bunship needs these 6 variables to run uploads end-to-end:

  • S3_ENDPOINT
  • S3_REGION
  • S3_ACCESS_KEY
  • S3_SECRET_KEY
  • S3_BUCKET
  • PUBLIC_S3_URL_BASE

Use PUBLIC_S3_URL_BASE as the public URL prefix used by the app. NEXT_PUBLIC_S3_URL_BASE is still supported as a legacy fallback; do not use S3_URL_BASE.

These are runtime variables, not Docker build variables. The Web/API container needs one copy. If AI tasks run on Trigger.dev cloud, the Trigger.dev project environment also needs S3_* and PUBLIC_S3_URL_BASE; otherwise tasks can upload files but return broken URLs.

For the complete environment variable reference, including optional provider routing variables, see Environment Config.

Quick Setup: Cloudflare R2

1. Create bucket

Create one bucket, for example: bunship-prod.

2. Create API token

Create an R2 API token with read/write access to that bucket.

3. Get endpoint and public URL base

  • Endpoint format: https://<ACCOUNT_ID>.r2.cloudflarestorage.com
  • Public URL base:
    • your CDN/custom domain, or
    • R2 public domain (if enabled)

4. Fill Web/API runtime environment variables

S3_ENDPOINT="https://<ACCOUNT_ID>.r2.cloudflarestorage.com"
S3_REGION="auto"
S3_ACCESS_KEY="<R2_ACCESS_KEY_ID>"
S3_SECRET_KEY="<R2_SECRET_ACCESS_KEY>"
S3_BUCKET="bunship-prod"
PUBLIC_S3_URL_BASE="https://cdn.yourdomain.com"

If AI tasks run on Trigger.dev, add the same S3_* and PUBLIC_S3_URL_BASE values to the Trigger.dev project's prod Environment Variables, or sync them through .github/workflows/deploy-trigger.yml + apps/ship-api/trigger.config.ts.

5. Restart web and api services

After env changes, restart both services so upload clients reload config.

Server-Side Upload Provider Routing

Bunship currently supports provider routing as a server-side settings capability. The upload API reads UPLOAD_PROVIDER_OVERRIDES[userId] and UPLOAD_PROVIDER_DEFAULT from the settings table, then chooses the Better Upload provider for that request.

This is useful when an administrator needs tenant-specific buckets, regional storage, or a gradual migration from one S3-compatible provider to another. The repo does not currently include a self-service UI where end users switch providers themselves.

Supported upload providers:

  • cloudflare
  • aws
  • backblaze
  • tigris
  • digitalocean
  • minio
  • wasabi
  • custom

Provider resolution order:

  1. User override from UPLOAD_PROVIDER_OVERRIDES[userId]
  2. System default from UPLOAD_PROVIDER_DEFAULT
  3. Environment fallback from BETTER_UPLOAD_PROVIDER
  4. Final fallback: cloudflare

Settings-based routing applies to the Better Upload server routes (/s3/upload and /admin/s3/upload). Existing presigned STS helpers and AI output storage still depend on the shared S3_* and PUBLIC_S3_URL_BASE environment variables.

Default provider in settings

UPLOAD_PROVIDER_DEFAULT can be a provider string:

"cloudflare"

Or an object with an explicit bucket and client config:

{
  "provider": "aws",
  "bucketName": "prod-assets",
  "client": {
    "accessKeyId": "...",
    "secretAccessKey": "...",
    "region": "us-east-1"
  }
}

Per-user overrides

UPLOAD_PROVIDER_OVERRIDES is a map keyed by user ID:

{
  "user_123": "cloudflare",
  "user_456": {
    "provider": "aws",
    "bucketName": "team-a-assets"
  },
  "user_789": {
    "provider": "custom",
    "bucketName": "oss-bucket",
    "client": {
      "endpoint": "https://oss-cn-hangzhou.aliyuncs.com",
      "accessKeyId": "...",
      "secretAccessKey": "...",
      "region": "cn-hangzhou",
      "forcePathStyle": false
    }
  }
}

client is passed to the Better Upload provider factory and is merged over the environment fallback. Empty strings and null values are ignored, so a user override can set only provider and bucketName while credentials continue to come from env.

Bucket resolution order:

  1. bucketName in the user/default settings object
  2. BETTER_UPLOAD_<PROVIDER>_BUCKET
  3. BETTER_UPLOAD_BUCKET
  4. S3_BUCKET

Production Validation (Must Pass)

  1. Upload an image from user flow.
  2. Upload/generate a CMS post cover from admin.
  3. Trigger an AI generation task and open returned file URL.

If all 3 succeed, your storage integration is basically healthy.

Common Misconfigurations

  1. 403 or signature mismatch: endpoint/region/key do not match same provider account.
  2. Upload succeeded but URL is broken: PUBLIC_S3_URL_BASE is wrong or bucket is not public via CDN.
  3. Works locally but fails in production: web/api env variables are inconsistent.
  4. Sudden cost spike: no CDN caching policy + large media payloads.

When to Choose AWS S3 Instead

Choose S3 directly if you already have:

  • existing AWS networking and IAM policy setup
  • mandatory AWS compliance requirements
  • centralized AWS cost and logging workflows

Next Steps