Danila (Dayfing)
Back to writing
2,687 words13 min

Static site caching with Nginx and Cloudflare: headers and purge

Give fingerprinted assets Cache-Control: public, max-age=31536000, immutable, and give HTML no-cache for browsers plus a short, separate edge TTL for Cloudflare. Make HTML eligible with a Cache Rule, keep Browser Cache TTL on Respect Existing Headers, and purge HTML as the last step of every deploy. Browsers then revalidate a small document on each visit, the edge answers most of those checks, and a release is visible as soon as the purge call returns.

Two cache policies for two kinds of files

A static build produces two kinds of output. Fingerprinted files, such as /_astro/index.3f9c1a.js, carry a content hash in the name. When the content changes, the URL changes, so a copy of the old URL can stay in any cache for a year. HTML, feeds, sitemaps and robots.txt keep stable URLs, so any cached copy of them can go stale.

Hashed files therefore get the longest lifetime and never need purging. Stable URLs get a browser policy that revalidates and an edge policy that you can purge. Most of the speed comes from the first group: a repeat visitor fetches a small HTML document, often as a 304, and reuses every script, stylesheet and font. That makes caching one of the cheapest gains for Time to First Byte and Largest Contentful Paint, as the Core Web Vitals guide explains.

Two deployment rules keep this safe. Upload new hashed files before HTML switches to the new release. Keep the previous release's hashed files while HTML that references them can still sit in a cache, because a tab opened before the deploy will request the old names.

Browser TTL and edge TTL are separate clocks

The browser cache belongs to the visitor, and nothing you do later can remove an entry from it. The edge cache belongs to Cloudflare, and the purge API empties it within seconds. So make the browser TTL long only for URLs whose content never changes. For HTML, no-cache lets the browser store the page but requires a conditional request before each reuse.

The edge TTL can come from s-maxage, from Cloudflare-CDN-Cache-Control or CDN-Cache-Control, or from a Cache Rule. Because you can purge it, the edge TTL for HTML is a safety net, not the update mechanism. If a purge fails, the worst-case edge staleness is the freshness lifetime plus the stale-while-revalidate window: with max-age=300, stale-while-revalidate=60, that is 360 seconds.

Check one zone setting first. Browser Cache TTL defaults to four hours on every plan, and Cloudflare overrides origin lifetimes lower than that value. HTML meant to be revalidated could then stay in browsers for hours, beyond the reach of any purge. Set Browser Cache TTL to Respect Existing Headers, as the edge and browser TTL documentation describes.

How Cloudflare reads s-maxage, stale-while-revalidate and stale-if-error

Browsers ignore s-maxage, so Cache-Control: public, max-age=0, s-maxage=300 looks like the obvious HTML header, and Cloudflare does use it as the edge TTL. The catch is in RFC 9111: s-maxage also carries the semantics of proxy-revalidate, so a shared cache must not serve the response stale without revalidating first.

On Free, Pro and Business plans, Origin Cache Control is always on, and Cloudflare applies that rule. Its revalidation documentation lists s-maxage, must-revalidate, proxy-revalidate and no-cache as directives that disable stale serving. Next to stale-while-revalidate, they turn UPDATING into EXPIRED, and the visitor waits for the origin. The same directives make Cloudflare ignore stale-if-error. A header such as max-age=0, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400 gets the 300-second edge TTL and nothing more.

When stale-while-revalidate applies, revalidation is asynchronous: the first request after expiry receives the stale copy with cf-cache-status: UPDATING while Cloudflare refreshes it in the background. stale-if-error applies only to 5xx responses from the origin. Always Online disables both directives, and TTL values must be integers.

To give browsers and the edge different policies without s-maxage, use a targeted header. Cloudflare evaluates Cloudflare-CDN-Cache-Control, then CDN-Cache-Control, then Cache-Control. When a CDN header is present, Cache-Control reaches the browser unchanged and does not affect the edge, and Cloudflare does not forward Cloudflare-CDN-Cache-Control. For HTML:

Cache-Control: no-cache
Cloudflare-CDN-Cache-Control: max-age=300, stale-while-revalidate=60, stale-if-error=86400

The browser revalidates every time. Cloudflare keeps the page for five minutes, serves it stale for up to a minute while refreshing, and serves the last good copy for a day if the origin fails. The CDN-Cache-Control documentation covers the precedence rules.

Cache Rules: respect the origin or override it

Cloudflare decides default eligibility by file extension and does not cache HTML or JSON by default. A URL such as /writing/post/ has no extension, so without a rule every page request is DYNAMIC and goes to the origin.

A Cache Rule with Eligible for cache has three Edge TTL modes. respect_origin follows your headers and falls back to defaults, such as 120 minutes for a 200 without headers. bypass_by_default follows headers and skips caching when they are missing. override_origin ignores headers and forces a TTL, with a plan minimum of 2 hours on Free, 1 hour on Pro and 1 second on Business and Enterprise. Browser TTL can respect the origin, override it or bypass.

When the origin sends deliberate headers, respect them. This ruleset for the http_request_cache_settings phase makes the hostname eligible and then excludes dynamic paths. The last matching rule wins for cache settings, so the bypass rule comes last.

{
  "rules": [
    {
      "description": "Cache the site according to origin headers",
      "expression": "http.host eq \"example.com\"",
      "action": "set_cache_settings",
      "action_parameters": {
        "cache": true,
        "edge_ttl": { "mode": "respect_origin" },
        "browser_ttl": { "mode": "respect_origin" }
      }
    },
    {
      "description": "Never cache API routes and previews",
      "expression": "http.host eq \"example.com\" and (starts_with(http.request.uri.path, \"/api/\") or starts_with(http.request.uri.path, \"/preview/\"))",
      "action": "set_cache_settings",
      "action_parameters": { "cache": false }
    }
  ]
}

Do not add http.request.method eq "GET" to a caching rule. Cloudflare warns that single-file purge may fail when a rule matches only GET, because purge requests use another method internally. The full option list is in Cache Rules settings.

Nginx headers without the add_header inheritance trap

Nginx inherits add_header directives from the enclosing level only if the current level defines none. Once a location adds its own Cache-Control, every header from the server level silently disappears there, security headers included.

server {
    add_header X-Content-Type-Options "nosniff" always;

    location ^~ /_astro/ {
        # X-Content-Type-Options is no longer sent for this location.
        add_header Cache-Control "public, max-age=31536000, immutable";
    }
}

The portable fix is a snippet with the shared headers, included in every block that has its own add_header. On Nginx 1.29.3 and later, including the 1.30 stable branch, add_header_inherit merge appends inherited headers instead. With merge, keep Cache-Control out of the server block, or locations will send it twice.

According to the headers module documentation, add_header without always applies only to 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308 responses. Use always for security headers and for no-store on error pages, never for long-lived cache headers, or a 404 for a mistyped asset name becomes immutable for a year. Also avoid mixing expires with add_header Cache-Control, which produces two header fields. A server block for a static build, with TLS and logging omitted:

server {
    listen 443 ssl;
    http2 on;
    server_name example.com;
    root /srv/example.com/current;
    index index.html;

    gzip on;
    gzip_vary on;
    gzip_static on;
    gzip_types text/css application/javascript application/json
               application/xml application/rss+xml image/svg+xml text/plain;

    location ^~ /_astro/ {
        include snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=31536000, immutable";
        try_files $uri =404;
    }

    location ~* \.(?:avif|webp|png|jpe?g|gif|svg|ico|woff2)$ {
        include snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=86400";
        try_files $uri =404;
    }

    location ~* \.(?:xml|txt)$ {
        include snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=300";
        add_header Cloudflare-CDN-Cache-Control "max-age=3600, stale-if-error=86400";
        add_header Cache-Tag "html";
        try_files $uri =404;
    }

    location / {
        include snippets/security-headers.conf;
        add_header Cache-Control "no-cache";
        add_header Cloudflare-CDN-Cache-Control "max-age=300, stale-while-revalidate=60, stale-if-error=86400";
        add_header Cache-Tag "html";
        try_files $uri $uri/ =404;
    }

    error_page 404 /404.html;
    location = /404.html {
        internal;
        include snippets/security-headers.conf;
        add_header Cache-Control "no-store" always;
    }
}

gzip_static requires a build with --with-http_gzip_static_module, which nginx -V shows. The origin should accept connections only from Cloudflare, so nobody can bypass the cache; the Linux VPS hardening guide covers that firewall setup.

ETag, Last-Modified and revalidation

With the default etag on, Nginx sends ETag and Last-Modified for static files and answers conditional requests with 304. Its ETag is built from the modification time and size, not from a content hash. A deploy that rewrites every file therefore changes every ETag, and the first revalidation after a release returns a full 200. Several origin servers need identical file times, for example via rsync -a, or revalidations will depend on which server answers.

While its copy is fresh, Cloudflare answers browser revalidations itself. After expiry, it sends a conditional request to Nginx, and a 304 renews the TTL without a body transfer. When Cloudflare changes the encoding, it weakens a strong ETag to W/"...", which is harmless because If-None-Match uses weak comparison. Enable Respect Strong ETags only when a client needs byte-exact validators.

Compression: gzip at the origin, Brotli and Zstandard at the edge

Cloudflare asks the origin for accept-encoding: br, gzip and can re-encode what it receives. Toward visitors it serves gzip, Brotli or Zstandard, based on Accept-Encoding, the plan and Compression Rules. By default, Free zones prefer Zstandard, Pro and Business prefer Brotli, and Enterprise uses gzip. Only 200, 403 and 404 responses are compressed, as the compression documentation explains.

For an origin behind Cloudflare, gzip is enough. Precompress text during the build for gzip_static, or let gzip on work on the fly. gzip_types defaults to text/html only, so list the other text types, and keep gzip_vary on. Brotli and Zstandard are not in the nginx.org module set and need third-party modules. Avoid Cache-Control: no-transform on text, because it stops Cloudflare from compressing an uncompressed response.

Cookies, query strings and the cache key

The default cache key contains the scheme, host, path and full query string, plus a few request headers such as Origin. Cookies are not part of it, so a cached URL must never vary by cookie. Put signed-in pages, carts, an API or an agent backend behind a bypass rule with private or no-store, or on a separate hostname. The production AI agent architecture guide shows what such a dynamic request path usually contains.

A Set-Cookie response header defeats caching. With Eligible for cache, Cloudflare keeps the cookie and does not store the response, so every request is a MISS. Check that no module or load balancer adds cookies to static files, or strip them with a Cache Response Rule.

Every distinct query string is a separate entry, so links with ?utm_source= start as misses. That costs hit ratio, not correctness. The Ignore Query String caching level applies only to static file extensions, and custom query string keys depend on the plan. Sort query string is available on all plans.

Purge strategies and purging from CI

Since April 2025 every plan has all purge methods:

  • Single URL removes exact URLs, up to 100 per request (500 on Enterprise), when the build knows what changed.
  • Prefix removes everything under a path such as example.com/writing/, including query string variants.
  • Tag removes every object whose response carried a matching Cache-Tag header, which Cloudflare strips before the visitor sees it. Tag HTML, feeds and sitemaps with html, and one call purges every stable URL while hashed assets stay cached.
  • Hostname removes everything for one host.
  • Everything empties the zone and sends all traffic to the origin until the cache refills, so keep it as the fallback.

Hostname, tag, prefix and purge-everything calls share an account limit: 5 requests per minute on Free, 5 per second on Pro, 10 per second on Business and 50 per second on Enterprise, per the purge documentation. Use an API token limited to Cache Purge on one zone, stored as a CI secret:

#!/usr/bin/env bash
set -euo pipefail
: "${CF_API_TOKEN:?}" "${CF_ZONE_ID:?}" "${ORIGIN_HOST:?}"

# 1. Hashed assets first, without --delete, so old pages keep working.
rsync -a dist/_astro/ "deploy@${ORIGIN_HOST}:/srv/example.com/current/_astro/"

# 2. Everything else. Excluded paths are not deleted.
rsync -a --delete --exclude '/_astro/' dist/ "deploy@${ORIGIN_HOST}:/srv/example.com/current/"

# 3. Purge HTML, feeds and sitemaps at the edge.
curl -fsS --max-time 30 -X POST \
  "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"tags":["html"]}' </dev/null \
  | jq -e '.success == true' >/dev/null

Larger sites should upload into a release directory and switch a symlink atomically. A successful purge response means the request was accepted, so end the job by fetching a changed page and checking a build marker such as the commit hash.

Verifying with curl -I, cf-cache-status and Age

curl -sSI -H 'Accept-Encoding: zstd, br, gzip' https://example.com/writing/some-post/ \
  | grep -iE '^(cache-control|cf-cache-status|age|etag|content-encoding):'

Cloudflare converts HEAD to GET for cacheable requests, so curl -I fills the cache too. The first response should show MISS, the second HIT with Age, the seconds since the object was cached or revalidated. The cache responses documentation defines each status:

  • DYNAMIC: not eligible, usually because no rule covers HTML or Development Mode is on.
  • BYPASS: eligible, but no-store, private, Set-Cookie or Vary: * made the response uncacheable.
  • UPDATING: stale copy served during background revalidation.
  • EXPIRED: stale copy refetched synchronously. If you expected UPDATING, look for s-maxage, must-revalidate or no-cache.
  • REVALIDATED: the origin confirmed the copy with a 304 while the request waited.
  • STALE: the origin failed and the old copy was served.

After a purge, expect MISS, or EXPIRED with Tiered Cache and after purge everything. To test Nginx alone, query the origin from an allowed host with curl -sSI --resolve example.com:443:203.0.113.10 https://example.com/, then add -H 'If-None-Match: "<etag>"' and expect 304.

File type Example Cache-Control Edge policy After a deploy
Fingerprinted JS, CSS, fonts, images /_astro/app.3f9c1a.js public, max-age=31536000, immutable Respect origin Nothing
HTML pages /writing/post/ no-cache max-age=300, stale-while-revalidate=60, stale-if-error=86400 Purge tag html
Feeds, sitemaps, robots.txt /rss.xml public, max-age=300 max-age=3600, stale-if-error=86400 Purge tag html
Unhashed images and icons /favicon.ico public, max-age=86400 Respect origin Purge URL or rename
JSON updated between deploys /data/stats.json public, max-age=60 Respect origin or bypass Purge URL
404 page any missing URL no-store with always Not cached Nothing
API, previews, admin /api/ private, no-store Bypass rule Nothing

Edge values with max-age go into Cloudflare-CDN-Cache-Control. If you cache 404 responses at the edge, tag them html, or a page published later stays hidden behind a cached 404.

Deployment checklist

  • Asset names contain content hashes, and nothing with a stable URL is immutable.
  • Every Nginx block with add_header includes the security headers, and always appears only on security headers and error responses.
  • HTML sends no-cache plus Cloudflare-CDN-Cache-Control, without s-maxage next to stale-while-revalidate.
  • gzip_types covers the text types, and static responses carry no Set-Cookie.
  • Browser Cache TTL respects existing headers, and the bypass rule comes last without a GET-only condition.
  • CI uploads assets before HTML, keeps old hashed files, purges the html tag and fails when success is not true.
  • A page shows MISS, then HIT with Age, then UPDATING after the edge TTL.
  • The origin accepts traffic only from Cloudflare.

More