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 aspublicUrl per bucket.
Setup
-
In
apps/backend/package.json, ensure"tooling-cdn-cloudflare-r2": "workspace:*"is listed underdependencies. - Run workspace install updates:
-
In the Cloudflare dashboard:
- Create an R2 bucket (name must match your logical bucket constant, e.g.
profile-pictures). - Create S3-compatible API credentials (Access Key ID + Secret Access Key).
- Enable public access or attach a custom domain to the bucket — note the public base URL for
publicUrl.
- Create an R2 bucket (name must match your logical bucket constant, e.g.
-
Configure
tools.cdninproduction.tsandstaging.ts:
- Set environment variables on your deployment platform:
-
Align
CDN_BUCKET_PROFILE_PICTURESinapps/backend/src/constants/cdn.tswith the R2 bucket name andbuckets[].namein config. - 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 nopublicUrl. 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 mapstools.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-r2on upload/delete/getFileUrl
Adding a new bucket
- Create the R2 bucket and public delivery URL in Cloudflare.
- Add a constant in
apps/backend/src/constants/cdn.ts. - Add a
buckets[]entry and env var forpublicUrlin production/staging config. - Call
tools.cdn.uploadFile(file, YOUR_BUCKET_CONSTANT)from your feature code. - Run
make test module=backend.
Gotchas
publicUrlmust match how Cloudflare exposes the bucket — mismatched domains return 404 in the browser even when upload succeeds.buckets[].namemust match the R2 bucket name and thebucketargument passed totools.cdnmethods.- 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?
- Local CDN — dev/test noop client
- CDN overview — contract and extension patterns
- Blog system — article and media CDN lifecycle
- Cloudflare deployment — DNS, TLS, and edge CDN
- Configuration — swapping pluggable tools
- Tooling system — loaders and workspace packages
