Skip to content

Serving R2 objects publicly: custom domains, r2.dev and cache headers

r2.dev is rate-limited and not for production. A custom domain puts Cloudflare's cache in front of the bucket, but only if you wrote Cache-Control at upload time.

Cloudflare R25 min readships at docs/solutions/r2/custom-domains-and-cache-headers.md

Tags: r2 · cloudflare · cdn · caching · custom-domain · cache-control · public-access

You have a bucket of product images. They are public by nature (no login, no per-user rules) so signing a URL for each view is pure overhead. You enable public access, get a https://pub-<hash>.r2.dev/... URL, ship it, and a few weeks later images start intermittently failing to load under load.

Two separate mistakes are usually in play: serving production traffic from r2.dev, and storing objects with no cache headers so nothing can be cached anyway.

r2.dev is a development convenience

Cloudflare says this plainly and it is worth repeating: the r2.dev subdomain is rate-limited and not intended for production. It exists so you can confirm an object is publicly readable without doing DNS work.

What you lose by using it:

  • Requests are throttled, unpredictably, under load.
  • No control over cache behaviour, headers, or rules.
  • Every request is a paid class B operation against your bucket, because there is no meaningful cache in front.
  • The hostname is not yours, so you can never move off it without breaking every URL already in the wild, in emails, in other people's pages, in search results.

That last point is the expensive one. Public URLs are forever.

Use a custom domain

R2 → your bucket → Settings → Public access → Connect Domain, on a zone in the same Cloudflare account. Cloudflare creates the DNS record and issues the certificate.

What changes, immediately:

  • Cloudflare's CDN is now in front of the bucket. A cache hit is served from an edge location and never touches R2: no class B operation, no bucket read.
  • You get Cache Rules, Transform Rules, WAF, bot protection: normal Cloudflare features, on your own hostname.
  • The URL is yours (files.example.com), so the bucket behind it can change later without breaking a single link.
  • Egress is still free, and now most of it never happens at all.

Then set the base URL and let the app build public URLs from it:

export function publicUrl(key: string): string | null {
  const base = optionalEnv("R2_PUBLIC_BASE_URL");
  if (!base) return null;
  return `${base.replace(/\/+$/, "")}/${key}`;
}

Returning null when it is unset is deliberate. A bucket with no connected domain is private, and private is the correct default for anything a user uploaded. Code that needs a public URL has to handle its absence rather than silently producing a 404.

The part everyone misses: cache headers are set at write time

Connecting a domain does not make things cacheable. R2 serves whatever Cache-Control the object was stored with, and an object written without one is served without one. Cloudflare then applies conservative defaults, most requests miss, and you have a CDN in front of a bucket that is doing nothing for you.

The header is a property of the object, set when it is uploaded:

export async function putObject({
  key,
  body,
  contentType,
  cacheControl = "public, max-age=31536000, immutable",
}: PutObjectInput): Promise<{ key: string }> {
  await s3().send(
    new PutObjectCommand({
      Bucket: bucket(),
      Key: key,
      Body: body,
      ContentType: contentType,
      CacheControl: cacheControl,
    }),
  );

  return { key };
}

A year with immutable is aggressive and it is correct because the keys contain a uuid. The content at a given key never changes, so there is nothing to revalidate. immutable additionally tells the browser not to revalidate even on a reload, which is the difference between a fast repeat visit and a wave of 304s.

If your keys are not content-addressed (logos/company-logo.png, overwritten whenever marketing changes it) a year is a year of stale logos. Either version the key (logos/company-logo.v3.png, the better answer) or use a short max-age with revalidation:

public, max-age=300, stale-while-revalidate=86400

Fixing headers on objects already stored

There is no bulk "set headers" operation. You copy each object onto itself with new metadata:

import { CopyObjectCommand } from "@aws-sdk/client-s3";

await s3().send(
  new CopyObjectCommand({
    Bucket: bucket(),
    Key: key,
    CopySource: `${bucket()}/${encodeURIComponent(key)}`,
    CacheControl: "public, max-age=31536000, immutable",
    ContentType: contentType,
    MetadataDirective: "REPLACE",
  }),
);

MetadataDirective: "REPLACE" is required, without it the copy keeps the old metadata and nothing changes. Note that ContentType must be restated too, or you will replace a correct type with a default.

For a whole bucket this is a class A operation per object, so it is worth doing once, deliberately, rather than discovering it twice.

If you cannot reprocess the objects, a Cloudflare Cache Rule on the custom domain can override edge TTL for a path pattern. That fixes the CDN but not the browser cache, so it is a mitigation rather than the fix.

Private and public do not share a bucket

Once a domain is connected, every object in that bucket is publicly readable by anyone who can construct the key. There is no per-object exception, because R2 has no per-object ACLs.

So: two buckets. uploads, private, read through getSignedDownloadUrl(). public-assets, domain-connected, read through publicUrl(). A uuid in the key is not access control: it is a speed bump, and keys leak through referrer headers, screenshots and support tickets.

Checklist before you call it public

  • [ ] Custom domain connected, not r2.dev.
  • [ ] R2_PUBLIC_BASE_URL set in every environment that needs it.
  • [ ] The bucket contains only objects that are public by nature.
  • [ ] Every write sets CacheControl, and the value matches whether the key is immutable.
  • [ ] Existing objects backfilled, or a Cache Rule in place as a stopgap.
  • [ ] curl -I https://files.example.com/<key> shows your cache-control, and a second request shows cf-cache-status: HIT.

That last command is the whole test. If the second request says MISS or DYNAMIC, the CDN is not caching and you are paying for reads you thought were free.