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, edge-cache tuning, purge automation in CI, and the verification that proves it worked — the Cloudflare-specific piece of Production-Ready Deployment & CI/CD Workflows.
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. Each rule is a path glob followed by indented header lines. Long-cache hashed assets, and give HTML a short shared-cache TTL with background revalidation:
/assets/*
Cache-Control: public, max-age=31536000, immutable
/*.html
Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400
s-maxage controls the edge cache while max-age=0 keeps the browser revalidating, and stale-while-revalidate lets the edge serve a slightly stale page while it refreshes in the background. This is safe only because static generators emit content-hashed asset filenames — a new build produces new URLs — so caching the old ones forever can never serve a wrong asset. Author the file in your input directory and let your generator copy it through to the output; the Automating Eleventy Deployments with Cloudflare Pages guide shows the passthrough-copy step in full.
Tuning the Two Tiers
The two tiers fail for opposite reasons, so they get opposite policies. Assets are immutable because their URL changes whenever their content does; HTML is mutable because the same URL must always point at the latest build. Switching from Pages' conservative defaults to an explicit immutable rule on assets produces a sharp repeat-visit improvement:
| Scenario | Repeat-visit requests to origin | Repeat-visit load |
|---|---|---|
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 win 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.
Cache Invalidation in CI
A new Pages deploy invalidates the files that changed, so you rarely purge manually. When you must — for example clearing an external Cloudflare zone cache that fronts Pages — trigger it only on production merges and scope it as tightly as you can:
- 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"]}'
Scope the API token to Zone → Cache Purge only, and prefer a files array over purge_everything whenever you can compute the changed URLs — a full purge cold-starts the cache and spikes origin load. Wire this into your build job alongside GitHub Actions for Automated SSG Builds so the purge runs only after a successful production deploy.
Verifying the Edge Cache
Local dev does not reproduce edge headers, so confirm caching against the deployed URL by reading the response headers:
curl -s -I https://example.com/index.html | grep -iE 'cf-cache-status|cache-control|age'
cf-cache-status: HIT means it served from the edge; MISS or DYNAMIC means it reached origin. The age header climbs toward your s-maxage on repeat requests, which is your proof the edge is holding the page. Run the same check on an asset URL and confirm it carries the one-year immutable value. Benchmark the resulting TTFB against other hosts using the comparison 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:
- Astro:
dist/—astro buildemits fingerprinted assets out of the box. - Eleventy / Jekyll:
_site/— the shared default for both. - Hugo:
public/— enableminifyso asset hashing stays stable across builds.
For Hugo specifically, you can push further than static hosting and put dynamic logic at the edge with Pages Functions and Workers — that path 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-revalidate. - Missing
_redirects: without it, unmatched routes 404 instead of hitting your fallback. Ship it next to_headersin the output directory. - Purging on every commit: a full purge cold-starts the cache and spikes origin load. Limit purges to production merges and prefer a
filesarray. _headersin the wrong place: the file must be at the root of the published output. A malformed glob is dropped silently, so verify withcurlafter deploy.- Over-broad API token: an account-wide token in CI is a liability. Scope it to
Zone → Cache Purgeand nothing more.
Key Takeaways
- One
_headersfile controls everything:immutableyear-long caching for hashed assets, shorts-maxageplusstale-while-revalidatefor HTML. - The repeat-visit win comes entirely from
immutableassets, which let the browser skip revalidation. - Let per-deploy invalidation do the work; reach for the purge API only on production merges, scoped to changed files.
- Verify with
curl -Iand readcf-cache-status—HITis edge,MISS/DYNAMICis origin.
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.
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.
- Netlify vs Vercel Deployment Strategies — benchmark Cloudflare's edge against other hosts.