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_URLset 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 yourcache-control, and a second request showscf-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.