The answer. Put a custom domain on the R2 bucket from day one, pin it as a Worker var, and treat both as immutable. The moment a content pipeline writes <img src="https://media.example.com/..."> into an article body, that URL is data, not config: it lives in your database, in the customer's CMS, and in whatever third party re-hosts the body. You can't rotate it with a redeploy. A rename or an outage of that hostname breaks in-body images across every article you have ever published.
The pattern.
# wrangler.toml — the bucket and its public base travel together
[env.prod]
vars = { MEDIA_PUBLIC_BASE = "https://media.example.com" } # chosen ONCE
[[env.prod.r2_buckets]]
binding = "MEDIA"
bucket_name = "my-media" # custom domain attached to this bucket in the dashboard
// Refuse to write content until BOTH halves are bound.
if (!env.MEDIA || !env.MEDIA_PUBLIC_BASE) {
return { skipped: "image_sink_media_unbound" }; // inert, not broken
}
await env.MEDIA.put(key, bytes, { httpMetadata: { contentType } });
const url = `${env.MEDIA_PUBLIC_BASE}/${key}`; // this string is now permanent
The receipt. HarperFlow's API Worker started writing generated featured, OG and body images to R2 for the care-package delivery sink (2026-08-09) and the Shopify sink (2026-08-12). Shopify re-hosts the featured image but not body <img> tags, so every live Shopify article hotlinks our media hostname directly from the public storefront. The comment above the binding in wrangler.toml reads "chosen ONCE ... must NEVER change" for exactly that reason. The binding and the var together lift an inert-guard in dispatch: if either is missing, image generation is skipped rather than producing broken links. We didn't count how many articles carry baked URLs; the number only goes up.
Watch out.
- The
r2_bucketsblock is config-as-code, not a secret. Add it only after the bucket exists, or the deploy fails. - Don't put the default
r2.devURL in content; it is rate-limited and meant for development. - Set cache headers on the objects. The domain is forever; the bytes don't have to be.
Related: wrangler-kv-r2-default-local-use-remote · webflow-homepage-via-transform-rule-rewrite