Skip to main content

Cloudflare R2 Setup Guide

Updated: 2026-09-14 — post/story media use the private bucket + API presigned URLs; Worker binding PRIVATE_BUCKET documented; 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.

VariableDevelopment (typical)Production (R2)Notes
Storage__ProviderMinIOR2Required switch
Minio__Endpointlocalhost:9000 or minio:9000unused when R2Keep in .env only if you might switch back
Minio__AccessKey / Minio__SecretKeyMinIO root user/passunused when R2—
Minio__PublicUrlhttp://localhost:9000unused when R2Browser URL for MinIO
R2__AccountIdempty / unsetCloudflare Account IDProd only
R2__AccessKeyempty / unsetR2 API token access keyProd only
R2__SecretKeyempty / unsetR2 API token secretProd only — never commit
R2__PublicUrlempty / unsethttps://r2.yourdomain.comWorker custom domain
VITE_STORAGE_PUBLIC_URLhttp://localhost:9000 (build-time)https://r2.yourdomain.comRebuild frontend/admin images after change
CSP / Traefikallow localhost MinIOallow R2__PublicUrl and https://*.r2.cloudflarestorage.com (presigned post/story)See Environment
No new “feature flag” env

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​

  1. Log in to Cloudflare Dashboard
  2. Your Account ID is in the right sidebar of the overview page, or in the URL: https://dash.cloudflare.com/<ACCOUNT_ID>
  3. Save this — you need it as R2__AccountId

Step 2: Create Buckets​

  1. Go to R2 Object Storage in the left sidebar
  2. If first time, accept terms and add a payment method
  3. Create these 6 buckets (click Create bucket for each):
BucketPurposePublic via Worker?
publicAvatars, covers, comments, chat thumbs, blog images, ticket processedYes (referer / path rules)
privatePost + story processed media (security #17)No — API presigned URLs only
tempTemporary uploads awaiting processingYes (referer-restricted)
filesBlog file downloads, attachmentsYes (open)
supportLegacy / support pathsYes (referer-restricted)
backupsDatabase & system backupsNo — Worker always 403

Region: Choose the closest to your users. "Automatic" or "Asia-Pacific (APAC)" for Southeast Asia.

Auto-creation

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.

Private bucket is required

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​

  1. Go to R2 Object Storage main page
  2. Click Manage R2 API Tokens (top right)
  3. Click Create API Token
  4. Configure:
    • Token name: e.g., BellamyBook Production
    • Permissions: Object Read & Write
    • Buckets: Apply to all buckets (include private)
  5. Click Create API Token
  6. Copy both values immediately (the secret is only shown once!):
    • Access Key ID → R2__AccessKey
    • Secret Access Key → R2__SecretKey

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​

  1. Go to Workers & Pages → create (or open) Worker named r2-router
  2. Paste the code from CICD/cloudflare/r2-router.worker.js (repo root)
  3. 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 / support paths
  • Open paths — public/blogs/featured/* and public/blogs/content/* (SEO)
  • No-Referer allowed for avatars/, covers/, comments/, chat/, defaults (native mobile apps)
  • Video Range / 206 + CORS

4b. Bind R2 Buckets​

Variable nameR2 bucket
PUBLIC_BUCKETpublic
PRIVATE_BUCKETprivate
TEMP_BUCKETtemp
FILES_BUCKETfiles
SUPPORT_BUCKETsupport
BACKUPS_BUCKETbackups

4c. Add Custom Domain​

  1. Worker → Settings → Domains & Routes → Custom domain
  2. Enter: r2.yourdomain.com (e.g. r2.bellamybook.com)

4d. Disable r2.dev Subdomain​

For each of public, temp, files, support, private:

  1. Bucket → Settings → Public access
  2. 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:

  1. Create R2 bucket private (if missing)
  2. Redeploy Worker from CICD/cloudflare/r2-router.worker.js
  3. Add binding PRIVATE_BUCKET → private
  4. Disallow r2.dev on private
  5. Deploy backend with AWS SDK presign + private URL presigner (not MinIO PresignedGetObjectAsync)
  6. Confirm VITE_STORAGE_PUBLIC_URL=https://r2.bellamybook.com on frontend/admin builds
  7. CORS on bucket private (see Step 5)
  8. R2 API token must include Object Read & Write on private (not only public)

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​

BucketWorkerApp read path
publicReferer/Origin + open blog paths + no-referer for avatar/cover/comment/chatDirect CDN URL
privateAlways 403Presigned S3 URL from API
tempReferer-restrictedPresigned / short-lived CDN
filesOpenCDN / optional presign
supportReferer-restrictedCDN
backupsAlways 403Backend 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​

ResourceFree TierPaid
Storage10 GB / month$0.015 / GB
Writes (Class A)1M / month$4.50 / million
Reads (Class B)10M / month$0.36 / million
EgressAlways freeAlways free
Worker requests100K / day$5/month for 10M

Next Steps​