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.

Cloudflare Pages deploy and edge-cache flow A Git push triggers a Pages build that emits hashed assets and a _headers file; the edge applies a one-year immutable cache to assets and a short stale-while-revalidate cache to HTML, and an optional scoped purge runs only on production merges. From Git push to two cache lifetimes at the edge Git push commit to branch Pages build hashed assets + _headers Global edge 300+ PoPs Scoped purge production merges files array only Hashed assets app.abc123.js max-age 1yr · immutable never revalidated HTML documents /index.html · /guide/ s-maxage 300 + SWR fresh, revalidated
A Pages build emits hashed assets and a `_headers` file; the edge caches assets immutably for a year and HTML briefly with background revalidation, while purges stay scoped to production merges.

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:

ScenarioRepeat-visit requests to originRepeat-visit load
Pages defaults (no _headers)28 conditional revalidations590 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 build emits fingerprinted assets out of the box.
  • Eleventy / Jekyll: _site/ — the shared default for both.
  • Hugo: public/ — enable minify so 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-age on HTML: serves stale pages that reference assets which no longer exist, and breaks rollbacks. Use short s-maxage plus stale-while-revalidate.
  • Missing _redirects: without it, unmatched routes 404 instead of hitting your fallback. Ship it next to _headers in 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 files array.
  • _headers in the wrong place: the file must be at the root of the published output. A malformed glob is dropped silently, so verify with curl after deploy.
  • Over-broad API token: an account-wide token in CI is a liability. Scope it to Zone → Cache Purge and nothing more.

Key Takeaways

  • One _headers file controls everything: immutable year-long caching for hashed assets, short s-maxage plus stale-while-revalidate for HTML.
  • The repeat-visit win comes entirely from immutable assets, 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 -I and read cf-cache-statusHIT is edge, MISS/DYNAMIC is 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.

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.

Static Site Generators in Production