---
title: "Cloudflare CDN Cache Headers for Self-Hosted S3 Guide"
slug: "cloudflare-cdn-cache-headers-self-hosted-s3-nextjs-payload-bug"
published: "2026-06-18"
updated: "2026-07-20"
validated: "2026-06-17"
categories:
  - "Cloudflare"
tags:
  - "Cloudflare CDN cache headers"
  - "Cache-Control immutable"
  - "CF-Cache-Status"
  - "filename-based cache-busting"
  - "self-hosted S3"
  - "Next.js Payload bug"
  - "client-side 404 images"
  - "content-hash filenames"
  - "WebP AVIF conversion"
  - "presigned URL cache key"
  - "edge caching verification"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "cloudflare"
  - "next.js"
  - "payload"
  - "s3-compatible storage"
  - "curl"
status: "stable"
llm-purpose: "Cloudflare CDN cache headers: set immutable Cache-Control, use hashed filenames, verify CF-Cache-Status, and apply a Next.js+Payload fix to prevent…"
llm-prereqs:
  - "Access to Cloudflare"
  - "Access to Next.js"
  - "Access to Payload"
  - "Access to S3-compatible storage"
  - "Access to curl"
llm-outputs:
  - "Completed outcome: Cloudflare CDN cache headers: set immutable Cache-Control, use hashed filenames, verify CF-Cache-Status, and apply a Next.js+Payload fix to prevent…"
---

**Summary Triples**
- (Immutable cache header, set, Cache-Control: public, max-age=31536000, immutable on any object that will not change)
- (Cache-busting strategy, use, filename-based cache-busting (content-hash filenames) instead of query-string versioning for CDN-friendly caching)
- (Edge caching verification, verify, check CF-Cache-Status and response headers (e.g., via curl -I) to ensure the CDN returns HIT rather than MISS/BYPASS)
- (Client-side 404 signature, symptom, server-rendered pages show images but client-side route transitions 404 on the same image URLs — indicates URL normalization mismatch)
- (Payload + Next.js bug, cause, Payload may normalize or rewrite media URLs server-side producing different URLs than the client uses, causing CDN/origin 404s on client navigation)
- (Presigned URLs, avoid for public delivery, don't rely on ephemeral presigned query-strings as cache keys for public CDN-delivered assets; use stable CDN-hosted or content-hash URLs instead)
- (Quick fix approach, ensure, media URLs are identical on server and client (return absolute CDN URLs or canonicalized paths with hashed filenames))
- (Cloudflare settings, consider, configure cache key and query-string handling so cache key matches the stable asset URL (or remove query strings from cache key for hashed assets))

### {GOAL}
Cloudflare CDN cache headers: set immutable Cache-Control, use hashed filenames, verify CF-Cache-Status, and apply a Next.js+Payload fix to prevent…

### {PREREQS}
- Access to Cloudflare
- Access to Next.js
- Access to Payload
- Access to S3-compatible storage
- Access to curl

### {STEPS}
1. Set immutable Cache-Control headers
2. Use filename-based cache-busting
3. Generate and store image variants
4. Verify edge caching with curl
5. Measure response times for hits
6. Patch Next.js + Payload URL helper
7. Test client-side transitions

<!-- llm:goal="Cloudflare CDN cache headers: set immutable Cache-Control, use hashed filenames, verify CF-Cache-Status, and apply a Next.js+Payload fix to prevent…" -->
<!-- llm:prereq="Access to Cloudflare" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to Payload" -->
<!-- llm:prereq="Access to S3-compatible storage" -->
<!-- llm:prereq="Access to curl" -->
<!-- llm:output="Completed outcome: Cloudflare CDN cache headers: set immutable Cache-Control, use hashed filenames, verify CF-Cache-Status, and apply a Next.js+Payload fix to prevent…" -->

# Cloudflare CDN Cache Headers for Self-Hosted S3 Guide
> Cloudflare CDN cache headers: set immutable Cache-Control, use hashed filenames, verify CF-Cache-Status, and apply a Next.js+Payload fix to prevent…
Matija Žiberna · 2026-06-18

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.

If you haven't yet set up the storage-versus-delivery split itself, [I cover that architecture and the marketplace traffic numbers behind it separately](/blog/self-hosting-s3-storage-5-eur-vps-garage). This piece assumes that part is done and picks up from there.

## Cache Headers and Cache-Busting

Two rules cover almost everything a CDN needs from your application to do its job.

Set immutable cache headers on every object that won't change:

```http
Cache-Control: public, max-age=31536000, immutable
```

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:

```text
bad:  /products/shop-123/candle.webp
good: /products/shop-123/candle-a83f91-800.webp
```

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.

```bash
curl -I https://cdn.yourdomain.com/products/shop-123/candle-a83f91-800.webp
```

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.

```typescript
// File: src/payload/utilities/images/getMediaUrl.ts

function isCdnUrl(url: string): boolean {
  try {
    const parsed = new URL(url);

    // Client-side fallback: match the known CDN hostname directly,
    // so this still works if the env var didn't make it into the bundle
    if (typeof window !== 'undefined' && parsed.hostname === 'cdn.yourdomain.com') {
      return true;
    }

    // Server-side: check against the runtime env variable
    return isCanonicalCdnOrigin(parsed.origin);
  } catch {
    return false;
  }
}

export const getMediaUrl = (url: string | null | undefined): string => {
  if (!url) return "";

  if (isCdnUrl(url)) {
    return url;
  }

  const normalizedUrl = normaliseImagePath(url);
  const cdnBase = getCanonicalCdnBase();

  if (cdnBase && shouldResolveRelativePathViaCdn(normalizedUrl)) {
    return `${cdnBase}${normalizedUrl}`;
  }

  return normalizedUrl;
};
```

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 `HIT` or `MISS`. What does that mean?**
`DYNAMIC` usually means Cloudflare decided the response shouldn't be cached at all, often because the response is missing a cacheable `Cache-Control` header, or because the request includes a `Set-Cookie` or `Authorization` header that signals personalized content. Check that your origin is actually sending the `Cache-Control` 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.

Thanks,
Matija

## LLM Response Snippet
```json
{
  "goal": "Cloudflare CDN cache headers: set immutable Cache-Control, use hashed filenames, verify CF-Cache-Status, and apply a Next.js+Payload fix to prevent…",
  "responses": [
    {
      "question": "How do I set immutable cache headers on S3 so Cloudflare caches my assets long-term?",
      "answer": "1) During your build/upload pipeline, set object metadata Cache-Control: public, max-age=31536000, immutable (for objects that never change). 2) If using a self-hosted S3 (Garage/MinIO), set the header via your upload tool or SDK (e.g., aws s3 cp --metadata-directive REPLACE --cache-control 'public, max-age=31536000, immutable'). 3) Verify with curl -I <asset-url> that the header is present. 4) If Cloudflare still bypasses, check Cloudflare cache rules and ensure requests aren't being forced to origin by Cloudflare Workers, Page Rules, or cache-control response overrides."
    },
    {
      "question": "How can I implement filename-based cache-busting for images and static assets?",
      "answer": "1) Configure your build (Next.js/static bundler) to emit content-hash filenames (e.g., image.abcdef123.jpg). 2) Reference those hashed filenames directly in your CMS or static HTML — avoid query-string versioning for CDN caching. 3) Upload hashed files to your S3 origin with immutable Cache-Control. 4) When you change the content, produce a new filename so the CDN treats it as a new object and won't require cache purges."
    },
    {
      "question": "How do I verify Cloudflare is serving assets from its edge rather than my origin?",
      "answer": "1) Run curl -I https://cdn.example.com/path/to/asset.jpg and inspect CF-Cache-Status (HIT = edge, MISS/BYPASS = origin). 2) Check Age header — non-zero Age indicates time served from edge. 3) Use repeated requests from different geographic locations or Cloudflare diagnostic tools to confirm global edge hits. 4) If results show MISS, validate Cache-Control on origin and Cloudflare cache rules (e.g., cache everything, ignore query strings where appropriate)."
    },
    {
      "question": "What causes the Next.js + Payload client-side 404s on images and how do I fix it quickly?",
      "answer": "1) Symptom: SSR pages show images but client-side route transitions 404 on same image URLs — indicates server and client are using different URLs. 2) Root cause: Payload may normalize/transform media URLs during SSR (e.g., returning presigned or origin-only URLs) while the client runtime uses a different resolver/relative path. 3) Quick fixes: a) Ensure Payload returns absolute CDN-hosted URLs (origin replaced with your CDN hostname) for media so SSR and client use identical addresses; or b) Stop using presigned query-string URLs for publicly cached assets — serve stable hashed filenames without ephemeral queries; or c) Adjust Next.js image loader or runtime URL resolver to canonicalize URLs the same way as SSR. 4) Verify by comparing the image src in the SSR HTML and the client-side DOM after navigation; use curl to fetch the URLs and confirm both return the same headers."
    },
    {
      "question": "How do I handle presigned URLs and caching when using a CDN?",
      "answer": "1) For public, long-lived assets: avoid presigned URLs with unique query strings because they make each request unique to the cache key. Instead, publish assets to stable paths or CDN-hosted URLs with content-hash filenames. 2) If presigned URLs are required for access control, consider caching at the origin or using short-circuiting CDN auth at the edge (Cloudflare Workers or tokens) while keeping the public cache key stable. 3) Alternatively, configure Cloudflare Cache Key settings to ignore query strings if you can guarantee identical content for different signatures (rare and risky)."
    },
    {
      "question": "Which Cloudflare settings are worth checking if my assets still bypass the CDN?",
      "answer": "1) Cache Rules / Page Rules: ensure there isn't a rule forcing bypass for your asset paths. 2) Cache Key: review whether query strings are included in the cache key — for hashed filenames you can safely ignore query strings. 3) Origin response headers: ensure Cache-Control isn't set to private/no-cache/no-store. 4) Workers and transform rules: verify no edge code rewrites or attaches headers that force origin fetches. 5) Purge and re-test after changes, then validate CF-Cache-Status via curl."
    }
  ]
}
```