Generate Report →

Edge Caching & Cloudflare WAF Security Rules for Static Sites

Tune Cache-Control headers per asset type, write Cloudflare WAF custom rules that block probe traffic, and rate-limit scrapers without touching real bots.

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:

  1. 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.
  2. Deployments invalidate automatically. Cloudflare Pages purges its edge cache when you deploy — you do not manage purges for normal releases.
  3. The browser cache is beyond your reach after the fact. Whatever max-age you 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-origin

Recommended values by asset class:

Asset typeBrowser TTLWhy
Fingerprinted CSS/JS1 year, immutableURL changes with content; stale is impossible
Web fonts1 year, immutableLarge, rarely change, hurt repeat-visit LCP
Images (stable URLs)1 day + stale-while-revalidateBalance freshness vs re-download cost
HTML0, must-revalidateDeploys must be visible immediately
sitemap.xml / feeds1 hourCrawlers 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 Challenge

Rule 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 hour

cf.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

  1. Add the _headers file with the five policies above; deploy and verify with curl -I that fingerprinted assets return immutable.
  2. Create the three custom WAF rules; watch Security → Events for 48 hours to confirm zero legitimate matches.
  3. Add the rate-limit rule with the not cf.client.bot guard.
  4. 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.

Run this article on your site

Harden my Cloudflare Pages static site: write a _headers file setting 'public, max-age=31536000, immutable' on fingerprinted CSS/JS/font assets and 'max-age=0, must-revalidate' on HTML, then create Cloudflare WAF custom rules that block requests to WordPress probe paths (/wp-login.php, /xmlrpc.php, /wp-admin) and add a rate-limiting rule that challenges IPs exceeding 100 requests in 10 seconds while exempting verified bots via cf.client.bot.

Paste into Claude Code, ChatGPT, Cursor or Gemini. It executes the steps above against your own site.

Frequently Asked Questions

What Cache-Control headers should a static site use?

Fingerprinted assets (styles.a1b2c3.css) should use public, max-age=31536000, immutable because their content can never change under that URL. HTML should use a short edge TTL with revalidation — for example max-age=0, must-revalidate at the browser plus a few minutes of edge caching — so deploys propagate quickly.

Does a static site on Cloudflare Pages even need WAF rules?

The site cannot be SQL-injected, but WAF rules still cut log noise, block credential-probe floods against paths like /wp-login.php, stop aggressive scrapers from consuming Function invocations, and keep exploit scanners from inflating your analytics. Think of it as traffic hygiene rather than breach prevention.

How do I block bad bots without blocking Googlebot or GPTBot?

Cloudflare maintains a verified bot list exposed in rule expressions as cf.client.bot. Write rate limits and blocks that exempt verified crawlers — for example, challenge requests matching scanner paths only when cf.client.bot is false — so search and AI crawlers you rely on are never collateral damage.

What is the difference between the _headers file and Cloudflare Cache Rules?

The _headers file ships with your Pages deployment and sets response headers per path pattern — versioned, code-reviewed, and portable. Cache Rules are zone-level dashboard configuration that can override edge TTLs across the whole domain. Prefer _headers for per-asset policy and Cache Rules for zone-wide behavior.

Why is my updated page still showing the old version after deploying?

Usually a long browser max-age on HTML rather than an edge problem — Cloudflare Pages invalidates its own edge cache on each deployment, but it cannot reach into a visitor's browser cache. Keep browser TTL on HTML at zero with revalidation and let the edge absorb the load instead.

Continue the track — Static Architecture & Performance