# 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.

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:

```ts
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:

```ts
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:

```ts
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
