Cloudflare CDN Cache Headers for Self-Hosted S3: Setup, Verification, and a Next.js Bug Fix
If you've got Garage or another S3-compatible store running behind Cloudflare or Bunny, getting the CDN pointed at your origin is the easy part. The part that actually determines whether it works is cache headers, cache-busting, and confirming the CDN is honoring both. Get those wrong and you end up with a CDN that's technically in front of your origin but still forwarding most requests straight through to it. This guide covers the two header rules that matter, how to verify hit rates instead of assuming them, and a Next.js plus Payload bug that will quietly 404 your images on client-side renders even when the server-rendered page looks fine.
I hit the Next.js bug on a live multi-tenant marketplace app: Garage as the origin, Cloudflare as delivery, Payload normalizing media URLs server-side. Server-rendered pages worked. Client-side route transitions threw 404s on the exact same images. That mismatch is the signature of this bug, and it's worth knowing before you spend an afternoon debugging the CDN config instead of the URL helper.
The immutable directive matters more than the long max-age. Without it, some CDNs and browsers still issue conditional revalidation requests back to origin on a schedule, even when the object hasn't changed. immutable tells them not to bother checking at all.
Pair that with cache-busting via content hash in the filename, not the query string:
When a tenant replaces a product image, write a new object with a new hash in the name and update the reference in your database. Don't overwrite the old path. The new URL is a guaranteed cache miss on first load by virtue of being a new URL, and the old cached version simply expires on its own a year later, with nobody waiting on it. This is what lets you skip purge calls and invalidation APIs entirely. If your CDN setup involves manually purging cache on every image update, the filename scheme is the thing to fix, not the purge tooling.
Resize and convert to WebP or AVIF once, at upload time, and store each variant as its own object with its own hashed filename. Dynamic resizing on every request means every cache hit still triggers work on your origin, which defeats most of the point of caching in the first place.
Verifying the CDN Is Actually Caching
CF-Cache-Status: HIT or MISS
Headers being correct in your code and the CDN actually respecting them in production are two separate things, and the only way to know which one you're dealing with is to check.
Look for the cache status header: CF-Cache-Status on Cloudflare, a similarly named header on Bunny or whichever CDN you're using. The first request after a deploy should show MISS. Every request after that, for the same URL, should show HIT. Run the curl command twice in a row and compare.
If you're seeing MISS repeatedly on a URL that should be immutable, the problem is almost always one of three things: the cache key configuration on the CDN includes query parameters that vary between requests, the Cache-Control header isn't actually reaching the CDN from your origin response, or the object path itself is changing between requests in a way you didn't intend (a common cause: presigned URL parameters leaking into what should be a stable public path).
A second, faster check: hit the same image URL several times and watch response time. A cache hit served from a nearby edge node should be noticeably faster than a cache miss that has to round-trip to a small origin VPS. If response times stay flat and slow across repeated requests, you're not actually caching, regardless of what the headers say.
The Next.js Plus Payload Bug: CDN URLs Stripped on the Client
Images load correctly on first page load, then 404 on client-side route transitions or refetches, even though nothing about the image URL changed between renders.
This shows up specifically in Next.js apps where a CMS layer, Payload in my case, normalizes raw S3 keys into CDN URLs server-side before sending them to the client. A helper function checks whether a given URL is "already a CDN URL" by comparing its origin against an environment variable, typically something shaped like NEXT_PUBLIC_CDN_URL.
The break happens because NEXT_PUBLIC_* variables are inlined into client bundles at build time, not read at runtime. If that variable is missing, misconfigured, or simply absent from a particular client bundle, which happens more often than you'd expect in monorepos or multi-app Next.js setups, the helper's CDN check silently returns false for a URL that actually is a CDN URL. The function then treats your correct, absolute CDN URL as something that still needs normalizing, strips the origin off it, and hands back a relative path. That relative path resolves against your frontend's own domain instead of the CDN, and the request 404s. Because this only happens inside the URL-normalizing helper, and the helper only runs identically on server and client, the bug only surfaces on client-side renders, hydration, and refetches, not on the initial server-rendered HTML.
The fix adds a hardcoded hostname check that runs only in the browser (typeof window !== 'undefined'), as a backstop for exactly the case where the environment variable didn't make it into that bundle. It's deliberately redundant with the env-var check above it. The server-side path keeps using the env variable as the source of truth, since server code always has access to runtime environment variables regardless of what got bundled for the client. The client-side path gets a fallback that doesn't depend on bundling at all.
If you're running multiple CDN domains across environments, staging versus production, a single hardcoded string won't cover it. Maintain a small allowlist array of known CDN hostnames instead, or move the hostname into a build-time constant that gets inlined regardless of NEXT_PUBLIC_ prefix rules, depending on how your build pipeline is set up.
To catch this before it reaches production, check the Network tab for image requests after a client-side route transition or a React Query refetch specifically, not just on initial page load. A URL that should read cdn.yourdomain.com/... showing up instead as a relative path, or as your app's own domain, is the exact signature of this bug.
FAQ
Why does Cache-Control: immutable matter if I already set a long max-age?
A long max-age tells caches how long to consider an object fresh, but some CDNs and browsers still send conditional revalidation requests (If-Modified-Since, ETag checks) back to origin on their own schedule regardless of max-age. immutable tells them the object will never change, so there's nothing to revalidate, and they can stop checking entirely.
My CF-Cache-Status shows DYNAMIC instead of or . What does that mean?
usually means Cloudflare decided the response shouldn't be cached at all, often because the response is missing a cacheable header, or because the request includes a or header that signals personalized content. Check that your origin is actually sending the header on the response for that specific path.
Should I use presigned URLs for public product images?
No. Presigned URLs carry signature and expiry parameters in the query string, which interferes with cache key matching and means the same underlying image generates a different cache key every time the signature regenerates. Use stable public paths for anything meant to be cached and reserve presigned URLs for private, permission-gated files that should bypass the CDN entirely.
Does this bug only affect Payload, or any CMS with a similar URL helper?
It affects any setup where a server-side helper rewrites storage paths to CDN URLs and a parallel client-side check relies on an environment variable that may not be present in every client bundle. Payload is the CMS where I hit it, but the underlying cause (NEXT_PUBLIC_* variables not guaranteed to be present in every bundle) is a Next.js bundling behavior, not a Payload-specific one.
Do I need this fix if I'm not using Next.js?
No. This specific failure mode comes from how NEXT_PUBLIC_* environment variables get inlined at build time into client JavaScript bundles. Other frameworks handle client-side environment variables differently, so the bug as described is Next.js-specific, though the general lesson, don't let a client-side check depend on a build-time variable that might not be present, applies more broadly.
Conclusion
A CDN in front of self-hosted S3 only does its job if the application sends the right cache headers, busts cache through filenames instead of overwrites, and you actually verify hit rates instead of assuming them from the architecture diagram alone. The Next.js plus Payload bug above is a good example of why verification matters: the architecture was correct, the CDN was correctly configured, and images still broke, because a client-side helper quietly fell back to the wrong behavior. Check your CF-Cache-Status headers, check your Network tab after client-side transitions, and you'll catch this class of problem before a tenant does.
Let me know in the comments if you run into a variant of this, and subscribe for more practical development guides.