Cloudflare R2 Setup Guide
Updated: 2026-09-14 — post/story media use the
privatebucket + API presigned URLs; Worker bindingPRIVATE_BUCKETdocumented; Dev vs Prod env diff below.
This guide walks you through setting up Cloudflare R2 as your storage provider. For a comparison between MinIO and R2, see Storage Configuration.
Canonical Worker source:
CICD/cloudflare/r2-router.worker.js
Deep historical notes:documents/_archive/backup-storage/R2_CLOUDFLARE_SETUP_GUIDE.md(may lag code — prefer this page + the Worker file).
Development vs Production (env diff)
Local development usually stays on MinIO (Docker). Production often switches to R2. Only these storage-related vars change — no extra app env vars beyond the table.
| Variable | Development (typical) | Production (R2) | Notes |
|---|---|---|---|
Storage__Provider | MinIO | R2 | Required switch |
Minio__Endpoint | localhost:9000 or minio:9000 | unused when R2 | Keep in .env only if you might switch back |
Minio__AccessKey / Minio__SecretKey | MinIO root user/pass | unused when R2 | — |
Minio__PublicUrl | http://localhost:9000 | unused when R2 | Browser URL for MinIO |
R2__AccountId | empty / unset | Cloudflare Account ID | Prod only |
R2__AccessKey | empty / unset | R2 API token access key | Prod only |
R2__SecretKey | empty / unset | R2 API token secret | Prod only — never commit |
R2__PublicUrl | empty / unset | https://r2.yourdomain.com | Worker custom domain |
VITE_STORAGE_PUBLIC_URL | http://localhost:9000 (build-time) | https://r2.yourdomain.com | Rebuild frontend/admin images after change |
| CSP / Traefik | allow localhost MinIO | allow R2__PublicUrl and https://*.r2.cloudflarestorage.com (presigned post/story) | See Environment |
Private post/story + presign is built into the API (from 2026-09). You do not add something like Media__UsePrivateBucket=true. Switching to R2 only needs the Storage__* / R2__* / VITE_STORAGE_PUBLIC_URL rows above, plus the private R2 bucket + Worker bind (ops).
Cloudflare ops (prod only, not env): create bucket private, deploy Worker from CICD/cloudflare/r2-router.worker.js, bind PRIVATE_BUCKET. Worker update is optional for post/story display after API deploy (presigned URLs hit *.r2.cloudflarestorage.com directly); it is recommended for mobile avatar/cover (no Referer).
Mobile app — storage comes from the server
The mobile app does not need R2__* / Minio__* in its own build. After the member enters your API URL (API_PUBLIC_URL), the app reads storagePublicUrl from bootstrap (same R2__PublicUrl or Minio__PublicUrl as the website). See Mobile App.
Prerequisites
- A Cloudflare account (free)
- A domain managed by Cloudflare (for custom domain setup)
- A credit card on file (required by R2, but free tier covers most usage)
Step 1: Find Your Account ID
- Log in to Cloudflare Dashboard
- Your Account ID is in the right sidebar of the overview page, or in the URL:
https://dash.cloudflare.com/<ACCOUNT_ID> - Save this — you need it as
R2__AccountId
Step 2: Create Buckets
- Go to R2 Object Storage in the left sidebar
- If first time, accept terms and add a payment method
- Create these 6 buckets (click Create bucket for each):
| Bucket | Purpose | Public via Worker? |
|---|---|---|
public | Avatars, covers, comments, chat thumbs, blog images, ticket processed | Yes (referer / path rules) |
private | Post + story processed media (security #17) | No — API presigned URLs only |
temp | Temporary uploads awaiting processing | Yes (referer-restricted) |
files | Blog file downloads, attachments | Yes (open) |
support | Legacy / support paths | Yes (referer-restricted) |
backups | Database & system backups | No — Worker always 403 |
Region: Choose the closest to your users. "Automatic" or "Asia-Pacific (APAC)" for Southeast Asia.
The backend auto-creates buckets if they don't exist. Create them manually first so you can bind the Worker and disable r2.dev cleanly.
After security fix #17, post/story objects are stored under private/….
Browsers must not load https://r2.yourdomain.com/private/… anonymously — the API returns S3 presigned URLs (*.r2.cloudflarestorage.com).
If the Worker has no /private/ route, those CDN URLs return 404 (gray images). Bind PRIVATE_BUCKET and keep Worker responses as 403 for /private/*.
Step 3: Create an R2 API Token
- Go to R2 Object Storage main page
- Click Manage R2 API Tokens (top right)
- Click Create API Token
- Configure:
- Token name: e.g.,
BellamyBook Production - Permissions: Object Read & Write
- Buckets: Apply to all buckets (include
private)
- Token name: e.g.,
- Click Create API Token
- Copy both values immediately (the secret is only shown once!):
- Access Key ID →
R2__AccessKey - Secret Access Key →
R2__SecretKey
- Access Key ID →
Step 4: Set Up Cloudflare Worker
A Cloudflare Worker routes all buckets under one custom domain and enforces access control.
4a. Create / update the Worker
- Go to Workers & Pages → create (or open) Worker named
r2-router - Paste the code from
CICD/cloudflare/r2-router.worker.js(repo root) - Save and Deploy
The Worker handles:
- Routing —
/public/*,/private/*,/temp/*,/files/*,/support/*,/backups/* /private/*and/backups/*→ always 403 (presign / backend only)- Referer / Origin checks for most
public/temp/supportpaths - Open paths —
public/blogs/featured/*andpublic/blogs/content/*(SEO) - No-Referer allowed for
avatars/,covers/,comments/,chat/, defaults (native mobile apps) - Video Range / 206 + CORS
4b. Bind R2 Buckets
| Variable name | R2 bucket |
|---|---|
PUBLIC_BUCKET | public |
PRIVATE_BUCKET | private |
TEMP_BUCKET | temp |
FILES_BUCKET | files |
SUPPORT_BUCKET | support |
BACKUPS_BUCKET | backups |
4c. Add Custom Domain
- Worker → Settings → Domains & Routes → Custom domain
- Enter:
r2.yourdomain.com(e.g.r2.bellamybook.com)
4d. Disable r2.dev Subdomain
For each of public, temp, files, support, private:
- Bucket → Settings → Public access
- R2.dev subdomain → Disallow Access
Leave backups private.
Step 5: Configure Your App
Backend
Storage__Provider=R2
R2__AccountId=your_account_id
R2__AccessKey=your_access_key
R2__SecretKey=your_secret_key
R2__PublicUrl=https://r2.yourdomain.com
R2 clients must use region auto. Object CRUD uses the MinIO SDK; presigned GET for private post/story URLs is generated with AWS SDK SigV4 (AWSSDK.S3). MinIO PresignedGetObjectAsync against R2 often returns XML AccessDenied in the browser even when the object exists.
On the R2 bucket private, add a CORS rule allowing GET/HEAD from your frontend (and admin) origins — required for some browsers/<video> loads. Example:
[
{
"AllowedOrigins": ["https://yourdomain.com", "https://admin.yourdomain.com"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "Content-Length", "Content-Range", "Accept-Ranges"],
"MaxAgeSeconds": 86400
}
]
Frontend & Admin
VITE_STORAGE_PUBLIC_URL=https://r2.yourdomain.com
Rebuild SPAs after changing Vite env (baked at build time).
Smoke tests
# Expected 403 without Referer (Worker referer rule) — OK for curl
curl -sI "https://r2.yourdomain.com/public/avatars/sm/SOME_ID" | head -5
# After Worker update: avatars/covers may return 200 even without Referer
# Private must never be anonymously readable
curl -sI "https://r2.yourdomain.com/private/posts/md/SOME_ID" | head -5
# Expect 403 (or 404 if binding missing — then add PRIVATE_BUCKET)
# From a logged-in session: feed JSON media URLs should look like
# https://<accountid>.r2.cloudflarestorage.com/private/posts/...?X-Amz-Signature=...
If you already followed the old guide
You likely have Worker + r2.bellamybook.com already. Do these extras:
- Create R2 bucket
private(if missing) - Redeploy Worker from
CICD/cloudflare/r2-router.worker.js - Add binding
PRIVATE_BUCKET→private - Disallow
r2.devonprivate - Deploy backend with AWS SDK presign + private URL presigner (not MinIO
PresignedGetObjectAsync) - Confirm
VITE_STORAGE_PUBLIC_URL=https://r2.bellamybook.comon frontend/admin builds - CORS on bucket
private(see Step 5) - R2 API token must include Object Read & Write on
private(not onlypublic)
Without step 5, feed may still return signed *.r2.cloudflarestorage.com URLs that open as XML AccessDenied. Worker steps 2–4 are recommended; post/story display after API deploy does not strictly require Worker update (presign bypasses the Worker).
Backup with R2
Backup is not affected by switching from MinIO to R2. The app uses a single object-storage client (IMinioService). When Storage:Provider=R2, backups go to the R2 backups bucket.
Backup__Storage__Type=MinIO
Backup__Storage__MinIO__Bucket=backups
(Type=MinIO is historical naming — it uses the same registered storage client.)
Bucket access summary
| Bucket | Worker | App read path |
|---|---|---|
public | Referer/Origin + open blog paths + no-referer for avatar/cover/comment/chat | Direct CDN URL |
private | Always 403 | Presigned S3 URL from API |
temp | Referer-restricted | Presigned / short-lived CDN |
files | Open | CDN / optional presign |
support | Referer-restricted | CDN |
backups | Always 403 | Backend only |
Migration from MinIO
rclone sync minio:public r2:public --progress
rclone sync minio:private r2:private --progress # if you already have private objects
rclone sync minio:temp r2:temp --progress
rclone sync minio:files r2:files --progress
rclone sync minio:support r2:support --progress
rclone sync minio:backups r2:backups --progress
Pricing
| Resource | Free Tier | Paid |
|---|---|---|
| Storage | 10 GB / month | $0.015 / GB |
| Writes (Class A) | 1M / month | $4.50 / million |
| Reads (Class B) | 10M / month | $0.36 / million |
| Egress | Always free | Always free |
| Worker requests | 100K / day | $5/month for 10M |