Cloudflare Pages Edge Caching Setup
Cloudflare Pages serves your static output from Cloudflare's global edge network, and a _headers file is how you take control of the cache. The entire win comes from one split that recurs on every host: hash-fingerprinted assets cached for a year, HTML kept fresh. This guide walks through the _headers syntax, the two-tier Cache-Control policy, purge automation in CI, and the cf-cache-status verification that proves it worked — the Cloudflare-specific piece of Production-Ready Deployment & CI/CD Workflows. If you are weighing Cloudflare against other platforms first, the trade-offs sit in Netlify vs Vercel Deployment Strategies; this page assumes you have already chosen Pages and want the cache dialed in.
How Pages Reads _headers
Cloudflare Pages processes a _headers file found at the root of your published output directory automatically — no dashboard configuration, no build plugin, no Cache Rule to author in the Cloudflare UI. Each rule is a path pattern on its own line followed by one or more indented header lines. The parser matches every rule against the request path and merges the headers from all matching rules, so more specific rules layer on top of broader ones. A minimal two-tier file looks like this:
/assets/*
Cache-Control: public, max-age=31536000, immutable
/*.html
Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Three syntax rules save the most debugging time. First, the file is applied to the deployed output only — it does not affect wrangler pages dev or a local preview server, so never conclude your policy is broken because the local response lacks the headers. Second, patterns support a trailing splat (/assets/*) and named placeholders (/blog/:slug), and later matching rules override an earlier header of the same name rather than appending a second copy — order your file broad-to-specific and let the specific rule win. Third, you can strip a header that an upstream default added by prefixing its name with ! (for example ! X-Powered-By) on an indented line. A rule with a malformed pattern is dropped silently during the build, and the path quietly falls back to Pages' conservative defaults — always confirm the live response rather than trusting the file.
Author the file in your source tree and let the generator copy it verbatim into the output; a framework that hashes filenames but leaves _headers behind produces a site with no long-cache policy at all. The Automating Eleventy Deployments with Cloudflare Pages guide shows the passthrough-copy step that carries _headers and _redirects through to _site/ in full.
The Two-Tier Cache-Control Policy
Assets and HTML fail for opposite reasons, so they get opposite policies. A hashed asset's URL changes whenever its bytes change, so the old URL can be cached forever with zero risk of serving stale content — that is exactly what immutable promises the browser, letting it skip even the conditional If-None-Match revalidation round trip. HTML is the reverse: the same URL (/guide/) must always resolve to the newest build, so it can never be pinned. The directives divide the responsibility cleanly:
max-age=0governs the browser's private cache. Zero forces the browser to revalidate the HTML on every navigation, so a fresh deploy is visible immediately on reload.s-maxage=300governs the shared edge cache and overridesmax-ageat Cloudflare's PoPs. For five minutes the edge answers directly, absorbing traffic spikes without touching your build output.stale-while-revalidate=86400lets the edge serve the slightly stale page instantly while it fetches a fresh copy in the background, so no visitor ever waits on a revalidation.immutableon assets tells the browser the response body will never change under this URL, eliminating revalidation entirely for the one-year window.
Switching from Pages' defaults to an explicit immutable rule on assets produces a sharp repeat-visit improvement, measured here with Chrome DevTools over a warm cache on a 12-asset page:
| Scenario | Repeat-visit requests to origin | Repeat-visit load (Fast 3G) |
|---|---|---|
Pages defaults (no _headers) | 28 conditional revalidations | 590 ms |
Two-tier policy (immutable assets) | 1 (HTML revalidation only) | 160 ms |
The first visit is identical in both rows; the entire delta is on repeat navigation, where immutable lets the browser skip revalidation for every hashed asset. The HTML tier trades a few hundred milliseconds of edge freshness for instant rollbacks — the moment you re-point to a previous deploy, the short s-maxage means the edge picks it up within the window rather than serving the rolled-back release for hours. That fingerprinting guarantee only holds if your build reliably emits content-hashed filenames; the mechanics of stable hashing across incremental runs are covered in Incremental Builds and Build Caching for SSGs.
One route deserves its own rule: anything personalized or authenticated must opt out of shared caching with Cache-Control: private, no-store, or the edge will hand one visitor's response to the next. Static marketing and docs sites rarely have such routes, but a mixed app on Pages Functions does.
Cache Invalidation in CI
A new Pages deploy invalidates the files that changed, so you rarely purge manually — this is the single biggest difference from running your own CDN, and leaning on it keeps origin load flat. You reach for the purge API only when an external Cloudflare zone cache fronts Pages, or when a _headers change alters the policy on URLs whose bytes did not change. When you do, trigger it only on production merges and scope it as tightly as the deploy lets you:
- name: Purge Cloudflare cache
if: github.ref == 'refs/heads/main'
run: |
curl -X POST \
"https://api.cloudflare.com/client/v4/zones/${{ secrets.CF_ZONE_ID }}/purge_cache" \
-H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \
-H "Content-Type: application/json" \
--data '{"files":["https://example.com/index.html","https://example.com/sitemap.xml"]}'
Prefer a files array over purge_everything whenever you can compute the changed URLs — a full purge cold-starts every PoP and spikes origin load right when a release is under scrutiny. The files form accepts up to 30 URLs per call on the standard plan, so batch large releases or, on plans that support them, tag responses with a Cache-Tag header and purge by tag to clear a whole content type in one request. Scope the API token itself to Zone → Cache Purge on the single zone and nothing more; an account-wide token sitting in CI is a standing liability. Wire the step into your build job alongside GitHub Actions for Automated SSG Builds so the purge runs only after a successful production deploy, never on a feature branch.
Preview deployments need the opposite treatment. Each pull request gets its own *.pages.dev URL, and you do not want those indexed or cached like production — send X-Robots-Tag: noindex and a short TTL on preview branches. The full preview workflow, including per-PR URLs and teardown, lives in Preview Environments for Pull Requests.
Verifying the Edge Cache
Local dev does not reproduce edge headers, so confirm caching against the deployed URL by reading the response headers directly:
curl -s -I https://example.com/index.html | grep -iE 'cf-cache-status|cache-control|age'
cf-cache-status is the ground truth for whether the edge or your origin answered. HIT means the edge served it from cache; MISS means it was cacheable but not yet cached, so it reached origin and is now stored; EXPIRED and REVALIDATED mean the cached copy aged out and was refreshed; STALE means stale-while-revalidate served the old copy during a background refresh; and DYNAMIC or BYPASS mean the response was treated as uncacheable and always hits origin. The age header climbs toward your s-maxage on repeat requests, which is your proof the edge is holding the page rather than re-fetching it. On an asset URL, confirm the response carries the full one-year immutable value and that a second request returns instantly from browser cache with no network round trip at all.
For a load-time comparison rather than a header check, run WebPageTest or a scripted curl -w '%{time_total}' from a cold and then warm PoP; benchmark the resulting TTFB against other hosts using the numbers in Netlify vs Vercel Deployment Strategies.
Framework Output Directories & Routing
Point the build at the right output directory and keep a _redirects file alongside _headers so unmatched routes hit your fallback instead of a bare 404. The output directory is where both control files must land, and it differs per generator:
- Astro:
dist/—astro buildemits fingerprinted assets underdist/_astro/out of the box, so a/_astro/*immutable rule covers them. - Eleventy / Jekyll:
_site/— the shared default for both; add_headersand_redirectsto your passthrough copy orincludelist so they survive the build. - Hugo:
public/— enableminifyand fingerprinting viaresources.Fingerprintso asset hashes stay stable across builds and the immutable rule never serves a wrong file.
A companion _redirects file at the same root handles SPA fallbacks and moved URLs with the same first-match ordering as _headers. For Hugo specifically, you can push past static hosting and put dynamic logic at the edge with Pages Functions and Workers — that path, including how caching interacts with a Worker in front of your assets, is covered in Deploying Hugo to Cloudflare Pages and Workers.
Common Pitfalls
- Long
max-ageon HTML: serves stale pages that reference assets which no longer exist, and breaks rollbacks. Use shorts-maxageplusstale-while-revalidateand keepmax-age=0. - Missing
_redirects: without it, unmatched routes 404 instead of hitting your fallback. Ship it next to_headersin the output directory. _headersnever copied to output: the generator hashes filenames but leaves the control file in the source tree, so the live site runs on defaults. Verify it appears in the deploy's file list.- Purging on every commit: a full
purge_everythingcold-starts the cache and spikes origin load. Limit purges to production merges and prefer afilesarray or cache tags. _headersin the wrong place or malformed: the file must be at the root of the published output, and a malformed pattern is dropped silently. Always confirm the live response withcurlafter deploy.- Caching a personalized route: an authenticated or per-user response under a shared
s-maxageleaks one visitor's page to the next. Mark those routesprivate, no-store. - Over-broad API token: an account-wide token in CI is a liability. Scope it to
Zone → Cache Purgeon one zone and nothing more.
Getting the Two Layers to Agree
The practical failure looks like this: _headers says max-age=0, must-revalidate for HTML, the team expects the edge to revalidate on every request, and instead the edge serves a cached copy for hours because a cache rule says so. Neither layer is wrong — they were configured to do different things.
The reliable way to keep them consistent is to write both from the same policy and verify both after every change. For HTML, that means must-revalidate in _headers and a short edge TTL with revalidation in the cache rule. For hashed assets it means a year and immutable in both, since nothing about them can change without the filename changing.
The verification is two curl commands and takes ten seconds: read Cache-Control from the response to confirm the browser contract, and cf-cache-status plus age to confirm the edge one. Doing that immediately after a configuration change is what stops a caching decision from becoming a mystery three months later, when the person who made it is no longer looking at it.
Document the final policy where the configuration lives, not in a wiki. Two comments at the top of _headers — one naming the intent for hashed assets, one for HTML — cost nothing and prevent the next person from 'fixing' a deliberate choice. Caching decisions are unusually easy to reverse by accident, because the wrong setting looks perfectly reasonable in isolation and the symptom appears days later.
One operational note: cache rules are account-level configuration while _headers travels with the repository, so the two have different review paths. A change to the file is visible in a pull request; a change in the dashboard is visible to whoever was watching. Where a policy matters, prefer the file, and where the dashboard is unavoidable, record the intended setting in the repository so a drift is detectable.
Key Takeaways
- One
_headersfile at the output root controls everything:immutableyear-long caching for hashed assets, shorts-maxageplusstale-while-revalidatefor HTML. max-agegoverns the browser,s-maxagegoverns the edge and overrides it there — that split is what lets HTML stay fresh while assets cache for a year.- The repeat-visit win comes entirely from
immutableassets, which let the browser skip revalidation and turned 28 revalidations into 1 in the measured page. - Let per-deploy invalidation do the work; reach for the purge API only on production merges, scoped to changed files or cache tags.
- Verify with
curl -Iand readcf-cache-status—HIT/STALEis edge,MISS/DYNAMICis origin — and watchageclimb towards-maxage.
FAQ
How do I confirm content is being served from Cloudflare's edge?
Read the cf-cache-status response header. HIT means the edge served it, MISS or DYNAMIC means it reached the origin. Run curl -I against an HTML route and an asset and watch the age header climb toward your s-maxage on repeat requests.
What is the recommended cache policy for SSG HTML on Pages?
Keep HTML short-lived with max-age=0 so the browser revalidates, an s-maxage around 300 seconds so the edge caches it briefly, and stale-while-revalidate so the edge can serve a slightly stale page while it refreshes in the background.
Does Cloudflare Pages cache assets automatically?
It does, but conservatively. Add a _headers rule applying public, max-age=31536000, immutable to your hashed asset paths so browsers skip revalidation entirely and serve those files straight from local cache for a year.
How do I invalidate specific paths after a deploy?
Usually you do not need to, because a new Pages deploy invalidates the files that changed. When you must clear an external zone cache, call the purge API with a files array of the exact URLs rather than purging everything, and only on production merges.
Why are my _headers rules being ignored?
The file must sit at the root of the published output directory and the globs must be valid. A malformed rule is dropped silently and the path falls back to defaults, so check the deploy log for parse warnings and verify the live response with curl.
What is the difference between max-age and s-maxage on Cloudflare Pages?
max-age governs the browser's private cache; s-maxage governs the shared edge cache and overrides max-age at Cloudflare's PoPs. Setting max-age=0 with s-maxage=300 means the browser always revalidates while the edge still absorbs traffic for five minutes.
Related
- Parent: Production-Ready Deployment & CI/CD Workflows — where edge caching fits the deploy lifecycle.
- Automating Eleventy Deployments with Cloudflare Pages — the end-to-end Git-connected setup.
- Deploying Hugo to Cloudflare Pages and Workers — pushing dynamic logic to the edge.
- GitHub Actions for Automated SSG Builds — wiring the build and purge into CI.
- Preview Environments for Pull Requests — per-PR URLs with noindex and short-TTL caching.
- Incremental Builds and Build Caching for SSGs — stable content hashing that makes immutable caching safe.
- Netlify vs Vercel Deployment Strategies — benchmark Cloudflare's edge against other hosts.