A static site on a CDN is already fast, so most owners never touch the caching or firewall layers. That leaves real performance on the table — HTML being revalidated more often than it needs to be, fonts re-downloaded on every visit — and it leaves the logs full of garbage: thousands of daily probes for /wp-login.php on a site that has never run WordPress.
Both problems are solved with configuration, not code. Cloudflare gives static sites two levers: precise Cache-Control policy via the _headers file, and a Web Application Firewall whose free tier is more capable than most people assume.
Here is how to set both up properly, with the actual header values and rule expressions.
How Edge Caching Actually Works for a Static Site
Cloudflare operates a large global network of points of presence; each one caches your assets independently after its first request for them (a MISS that fetches from Pages storage, then HITs thereafter). Three consequences follow:
- Cache state is per-location. A visitor in Tokyo can HIT while a visitor in São Paulo MISSes the same URL. Global “warm-up” happens organically through traffic.
- Deployments invalidate automatically. Cloudflare Pages purges its edge cache when you deploy — you do not manage purges for normal releases.
- The browser cache is beyond your reach after the fact. Whatever
max-ageyou send, you cannot recall. This asymmetry drives the whole policy below: be aggressive at the edge, conservative in the browser — except for assets whose URL changes when their content does.
That exception is the key insight of static-site caching. Hugo’s asset pipeline fingerprints files (main.min.abc123.css), so the URL is a content hash. A fingerprinted URL can safely be cached forever, everywhere, because any change produces a different URL.
The _headers File: Per-Asset Cache Policy in Version Control
Cloudflare Pages reads a _headers file from your build output (keep it in Hugo’s static/ directory):
# static/_headers
# Fingerprinted assets: cache forever, everywhere
/css/*
Cache-Control: public, max-age=31536000, immutable
/js/*
Cache-Control: public, max-age=31536000, immutable
/fonts/*
Cache-Control: public, max-age=31536000, immutable
# Images: long browser cache, not immutable (URLs may be reused)
/images/*
Cache-Control: public, max-age=86400, stale-while-revalidate=604800
# HTML: browser always revalidates; edge absorbs the load
/*
Cache-Control: public, max-age=0, must-revalidate
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-originRecommended values by asset class:
| Asset type | Browser TTL | Why |
|---|---|---|
| Fingerprinted CSS/JS | 1 year, immutable | URL changes with content; stale is impossible |
| Web fonts | 1 year, immutable | Large, rarely change, hurt repeat-visit LCP |
| Images (stable URLs) | 1 day + stale-while-revalidate | Balance freshness vs re-download cost |
| HTML | 0, must-revalidate | Deploys must be visible immediately |
sitemap.xml / feeds | 1 hour | Crawlers poll these; modest caching is safe |
The immutable directive is underused: it tells the browser not even to send a conditional revalidation request on reload, eliminating a wave of 304 round trips on repeat visits.
WAF for a Site With No Application to Attack
A pre-rendered site has no database or login, so why run a firewall? Because your traffic still includes exploit scanners, scrapers, and floods, and each has a cost even against static files: polluted analytics, wasted Pages Function invocations, and — for the aggressive scrapers — bandwidth spent serving bots that give nothing back. The zero-attack-surface argument, which we make in full in the zero-database security model for static sites, covers breaches; the WAF covers nuisance.
The Cloudflare free plan includes basic managed protections (DDoS mitigation, some high-severity managed rules) and a small budget of custom WAF rules per zone — check your dashboard for the current allowance, since it has changed over time. However many you get, a static site needs very few. Spend them like this:
Rule 1 — Block CMS probe paths (this site has no CMS):
(http.request.uri.path in {"/wp-login.php" "/xmlrpc.php" "/wp-admin/" "/.env" "/phpmyadmin/"})
Action: Block
Rule 2 — Challenge suspicious methods on a read-only site:
(http.request.method in {"PUT" "DELETE" "TRACE"})
Action: Managed Challenge
Rule 3 — Geo-fence your admin/preview hostname (if any):
(http.host eq "preview.example.com" and ip.geoip.country ne "RO")
Action: Managed ChallengeRule 1 is the one that earns its keep. WordPress-probe paths are a standing feature of the traffic to any public hostname, including sites that have never run WordPress, and blocking them at the edge means those requests never reach your Function quotas or your analytics. Before you enable the block, spend a day watching Security → Events to see what your own zone is actually receiving — the volume is usually the surprise, and it is your zone’s number, not a figure borrowed from someone else’s.
Rate Limiting Without Friendly Fire
The free plan includes a rate-limiting rule. The classic mistake is writing it too broadly and throttling Googlebot or the AI crawlers you want indexing you. Cloudflare’s verified-bot flag solves this:
Rate limit — scrapers, not crawlers:
Expression: (not cf.client.bot)
Threshold: 100 requests / 10 seconds per IP
Action: Managed Challenge for 1 hourcf.client.bot is true for crawlers on Cloudflare’s verified list — Googlebot and Bingbot among them, alongside the AI crawlers Cloudflare has verified — and verification is done by IP and reverse DNS rather than by spoofable user-agent strings. The roster changes as new crawlers appear, so confirm which bots are currently verified before you rely on the flag. Legitimate crawlers sail through; the anonymous scraper is challenged. Which AI crawlers you admit in the first place is a separate decision, made in robots.txt rather than the WAF — we cover those directives in writing robots.txt rules for AI crawlers.
A 30-Minute Setup Checklist
- Add the
_headersfile with the five policies above; deploy and verify withcurl -Ithat fingerprinted assets returnimmutable. - Create the three custom WAF rules; watch Security → Events for 48 hours to confirm zero legitimate matches.
- Add the rate-limit rule with the
not cf.client.botguard. - Re-test a deploy: confirm updated HTML appears immediately (edge purge) while CSS URLs changed via fingerprinting.
None of this requires a paid plan; all of it is portable configuration living in your repository or in a handful of dashboard rules. If your site is already on Pages — and if not, start with our Cloudflare Pages free-tier hosting walkthrough — this is the highest-leverage half hour you can spend on infrastructure this quarter.
MarketLens