Cloudflare CDN in Practice: Cache Rules, API Purge, and Safe Deploys for Dynamic Sites
If your app feels “fast locally, slow globally,” a CDN is usually the quickest win. Cloudflare can cache static assets (and even some HTML) close to users, reduce origin load, and add a layer of resilience. This hands-on guide shows how to set up practical caching for a typical web app (static assets + dynamic API), how to purge cache safely during deploys, and how to verify it all works.
What you’ll build
- A caching strategy that keeps
/api/*dynamic while aggressively caching static assets. - Cache rules you can reason about (and debug) using response headers.
- A deploy step that purges cache by host (or by URL) using Cloudflare’s API.
- A simple “cache-busting” pattern so you rarely need full purges.
1) Understand Cloudflare caching in one minute
Cloudflare sits in front of your origin. When a request comes in:
- If the response is cacheable and Cloudflare has a fresh copy, it returns it immediately (fast).
- Otherwise, it forwards to origin, returns the response, and may store it depending on rules/headers.
Two key ideas:
- Cache eligibility is driven by HTTP headers like
Cache-Controlplus your Cloudflare rules. - Cache key determines what makes two requests “different” (URL, query string, sometimes cookies/headers).
2) A practical caching policy for most web apps
A solid baseline:
- Static assets (JS/CSS/images/fonts): cache “forever” with versioned filenames (
app.3f2a1c.js). - HTML pages: usually cache lightly (or not at all) unless you’re doing static/SSR with known patterns.
- APIs: don’t cache by default (unless you intentionally add caching per endpoint).
Why versioned filenames? If the filename changes when content changes, you don’t need to purge: users will request the new URL, Cloudflare caches it, done.
3) Configure origin headers (the part you control everywhere)
Even with Cloudflare, good origin headers make your behavior predictable. Here’s an example using an Express server that serves built assets from /public and an API under /api.
import express from "express"; import path from "path"; const app = express(); const publicDir = path.join(process.cwd(), "public"); // Cache static assets for 1 year, mark immutable. // Works best when filenames are content-hashed. app.use( "/assets", express.static(path.join(publicDir, "assets"), { setHeaders(res) { res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); }, }) ); // Never cache API responses by default. app.use("/api", (req, res, next) => { res.setHeader("Cache-Control", "no-store"); next(); }); app.get("/api/time", (req, res) => { res.json({ now: new Date().toISOString() }); }); // For HTML, choose conservative caching. // If SSR/HTML changes often, use no-store. // If mostly static, consider short TTL + stale-while-revalidate. app.get("/", (req, res) => { res.setHeader("Cache-Control", "public, max-age=0, must-revalidate"); res.sendFile(path.join(publicDir, "index.html")); }); app.listen(3000, () => console.log("Listening on :3000"));
Tip: If you’re using Nginx, the same idea applies: long cache for /assets, no-store for /api.
4) Cloudflare Cache Rules: keep it simple
In Cloudflare’s dashboard, you can set Cache Rules (names may vary by plan/UI). The goal:
- Cache
/assets/*aggressively. - Bypass cache for
/api/*.
Conceptually, the rules look like this:
- Rule A (API bypass): If URL path starts with
/api/→Cache = Bypass - Rule B (assets cache): If URL path starts with
/assets/→Cache = EligibleandEdge TTLhigh
Even if your origin headers are correct, explicit rules help prevent surprises (like caching something you didn’t mean to).
5) Verify caching with response headers (no guessing)
Cloudflare adds useful headers. You’ll commonly check:
CF-Cache-Status:HIT,MISS,BYPASS,REVALIDATED, etc.- Your
Cache-Controlheader from origin
Use curl and run twice:
# First request (likely MISS) curl -I https://your-domain.com/assets/app.3f2a1c.js # Second request (should become HIT if cacheable) curl -I https://your-domain.com/assets/app.3f2a1c.js # API should be BYPASS (or MISS but never HIT), depending on config curl -I https://your-domain.com/api/time
Look for CF-Cache-Status: HIT on assets, and BYPASS (or consistent non-HIT) on API responses.
6) Don’t purge everything: use cache-busting first
The safest deploy strategy is: never purge for static assets because filenames are hashed. Most bundlers can do this:
- Vite/Webpack/Rollup: enable content hashing in production builds
- Ensure HTML references the hashed filenames (handled by build tooling)
When you still might need purges:
- Your HTML is cached and needs immediate refresh.
- You changed non-hashed assets (like
/favicon.icoor/robots.txt). - You cache certain API GET responses on purpose.
7) Purge cache via Cloudflare API (hands-on)
Below is a working Node.js script that purges either:
- Everything (
purge_everything) — avoid unless you truly need it - A list of URLs — preferred for targeted invalidation
Requirements:
CLOUDFLARE_API_TOKENwith permission to purge cache for the zoneCLOUDFLARE_ZONE_IDfor your domain’s zone
/** * purge-cloudflare.js * Usage: * node purge-cloudflare.js --urls https://example.com/ https://example.com/favicon.ico * node purge-cloudflare.js --everything */ const args = process.argv.slice(2); function getArg(flag) { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : null; } const token = process.env.CLOUDFLARE_API_TOKEN; const zoneId = process.env.CLOUDFLARE_ZONE_ID; if (!token || !zoneId) { console.error("Missing CLOUDFLARE_API_TOKEN or CLOUDFLARE_ZONE_ID"); process.exit(1); } const isEverything = args.includes("--everything"); const urlsIndex = args.indexOf("--urls"); const urls = urlsIndex >= 0 ? args.slice(urlsIndex + 1).filter(Boolean) : []; if (!isEverything && urls.length === 0) { console.error("Provide --everything or --urls <url1> <url2> ..."); process.exit(1); } async function purge() { const endpoint = `https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`; const body = isEverything ? { purge_everything: true } : { files: urls }; const res = await fetch(endpoint, { method: "POST", headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok || !data.success) { console.error("Purge failed:", JSON.stringify(data, null, 2)); process.exit(1); } console.log("Purge success:", JSON.stringify(data.result, null, 2)); } purge().catch((err) => { console.error(err); process.exit(1); });
8) Add purge to CI/CD after deploy (example: GitHub Actions)
Here’s a minimal job step that purges only your HTML entrypoint and a couple of known “non-hashed” files. This keeps most cached assets intact.
# .github/workflows/deploy.yml (snippet) - name: Purge Cloudflare cache (targeted) env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }} run: | node purge-cloudflare.js --urls \ https://example.com/ \ https://example.com/favicon.ico \ https://example.com/robots.txt
Deploy order tip: Upload new assets first, then update HTML, then purge the HTML URL. That prevents users from getting “new HTML pointing to old assets” (or vice versa).
9) Common gotchas (and quick fixes)
-
“My API is cached!”
Ensure/api/*is set toCache Bypassin Cloudflare rules and your origin setsCache-Control: no-store. -
“Assets never become HIT.”
Confirm your origin returnsCache-Control: publicand a non-zeromax-age. Also check if you’re accidentally settingSet-Cookieon asset responses (often makes them uncacheable). -
“HTML updates are delayed.”
Either avoid caching HTML, or use a short TTL and targeted purge of/and key routes during deploy. -
“Query strings create too many cache entries.”
If you use tracking params (utm_*), consider normalizing them at the app/router level or via rules so they don’t fragment cache.
10) A sensible checklist to ship today
/assets/*served withCache-Control: public, max-age=31536000, immutableand content-hashed filenames/api/*served withCache-Control: no-storeand Cloudflare rule = bypass- Verify with
curl -IandCF-Cache-Status - CI deploy step purges only
/and a small set of known non-hashed URLs
Once this baseline is in place, you can get fancier (HTML caching for marketing pages, caching certain GET endpoints, or adding edge logic). But for most junior/mid developers, this setup delivers the biggest speedup with the least risk—and it’s easy to debug when something goes weird.
Leave a Reply