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.
TL;DR (Recommended)
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_ENDPOINTS3_REGIONS3_ACCESS_KEYS3_SECRET_KEYS3_BUCKETPUBLIC_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:
cloudflareawsbackblazetigrisdigitaloceanminiowasabicustom
Provider resolution order:
- User override from
UPLOAD_PROVIDER_OVERRIDES[userId] - System default from
UPLOAD_PROVIDER_DEFAULT - Environment fallback from
BETTER_UPLOAD_PROVIDER - 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:
bucketNamein the user/default settings objectBETTER_UPLOAD_<PROVIDER>_BUCKETBETTER_UPLOAD_BUCKETS3_BUCKET
Production Validation (Must Pass)
- Upload an image from user flow.
- Upload/generate a CMS post cover from admin.
- Trigger an AI generation task and open returned file URL.
If all 3 succeed, your storage integration is basically healthy.
Common Misconfigurations
403or signature mismatch: endpoint/region/key do not match same provider account.- Upload succeeded but URL is broken:
PUBLIC_S3_URL_BASEis wrong or bucket is not public via CDN. - Works locally but fails in production: web/api env variables are inconsistent.
- 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