Skip to main content
Cloudflare R2 is the production CDN provider: credentialed S3 API writes to R2 and public delivery URLs built from your configured CDN base per bucket.

Why

R2 gives you S3-compatible object storage on Cloudflare without AWS lock-in. Pair it with a custom domain or R2 public bucket URL for browser delivery — configured as publicUrl per bucket.

Setup

  1. In apps/backend/package.json, ensure "tooling-cdn-cloudflare-r2": "workspace:*" is listed under dependencies.
  2. Run workspace install updates:
  1. In the Cloudflare dashboard:
    1. Create an R2 bucket (name must match your logical bucket constant, e.g. profile-pictures).
    2. Create S3-compatible API credentials (Access Key ID + Secret Access Key).
    3. Enable public access or attach a custom domain to the bucket — note the public base URL for publicUrl.
  2. Configure tools.cdn in production.ts and staging.ts:
  1. Set environment variables on your deployment platform:
  1. Align CDN_BUCKET_PROFILE_PICTURES in apps/backend/src/constants/cdn.ts with the R2 bucket name and buckets[].name in config.
  2. Verify:

Two URLs

PutObject does not return a fetchable URL. The client builds publicUrl + '/' + objectKey after upload.

Public access model

Objects on a public bucket are world-readable by anyone with the URL when the bucket is exposed via R2 public access or a custom domain. Feature scopes only gate issuing that URL; they do not protect the bytes. Private buckets have public access off and no publicUrl. Callers use getSignedDownloadUrl / getSignedUploadUrl (staging keys only: pending/…). Org files use sibling buckets organization-files-public and organization-files-private — see CDN overview. Presigned PutObject sets CacheControl: private, no-store for private objects and ContentDisposition (inline for the four image types; attachment otherwise). Owner decision (ALA-298 H5): S3/R2 GetObject ResponseOverrides cannot set X-Content-Type-Options. Private signed downloads therefore must not be raw R2 presigned GETs. They are served through the backend HMAC /cdn proxy (configured with CDN_SIGNED_URL_SECRET / signedUrlSecret), which sets nosniff, Content-Disposition, and Cache-Control: private, no-store. A Cloudflare Worker that adds the same headers is an acceptable future alternative for the same contract; until then the proxy is required for private org-file downloads. Public objects still use stable publicUrl links. Set X-Content-Type-Options: nosniff on the public CDN hostname with a Cloudflare response-header transform. A new zone is incomplete until CDN_SMOKE_PUBLIC_OBJECT_URL returns nosniff under make verify-production-smoke (apps/backend/scripts/assertCdnNosniff.mjs). Optionally set CDN_SMOKE_PRIVATE_SIGNED_OBJECT_URL to a signed private /cdn URL to assert the same headers on the proxy path. purgeFile calls the Cloudflare cache purge API for the exact public URL. Use a zone cache-purge token (Zone.Cache Purge only) for that one zone. Never log apiToken. Private objects have nothing cacheable; purge still runs and records that.

Blog operations and billing

Refract maps tools.cdn methods to S3-compatible R2 API calls: Blog article content save (writeArticleContent.ts, bucket articles): when content_path already exists, each successful save performs one PutObject + one DeleteObject — two Class A operations. First save on a new article is one PutObject only. Blog media: a new upload is one PutObject. replaceBlogMedia is upload + delete old object (two Class A ops). Reads on the marketing site and CMS previews drive GetObject / edge cache hits (Class B or egress — separate from write cost). We use new object keys instead of overwriting the same key so public MDX and images stay cache-friendly. In-place overwrite would halve write ops per save but risks stale cached content without a purge strategy — see Blog system — CDN lifecycle.
⚠️ Watch out: frequent explicit Saves during long editing sessions add Class A operations proportional to save count, not article size. Storage still accrues for any orphans left on disk until deleted.
Check current R2 pricing in the Cloudflare dashboard — request class definitions and per-million rates change over time.

Local observability

There is no R2 service in local Compose. Use:
  • Cloudflare dashboard — R2 bucket object list after uploads
  • Backend logs — feature logger cdn-cloudflare-r2 on upload/delete/getFileUrl

Adding a new bucket

  1. Create the R2 bucket and public delivery URL in Cloudflare.
  2. Add a constant in apps/backend/src/constants/cdn.ts.
  3. Add a buckets[] entry and env var for publicUrl in production/staging config.
  4. Call tools.cdn.uploadFile(file, YOUR_BUCKET_CONSTANT) from your feature code.
  5. Run make test module=backend.

Gotchas

  • publicUrl must match how Cloudflare exposes the bucket — mismatched domains return 404 in the browser even when upload succeeds.
  • buckets[].name must match the R2 bucket name and the bucket argument passed to tools.cdn methods.
  • Use S3-compatible credentials for R2 object APIs. Cache purge uses a separate zone-scoped purge token — do not reuse or log it.
  • Private buckets must not have a public hostname attached.
  • Custom domain TLS and DNS — see Cloudflare deployment.

What’s next?