Purging Cloudflare's Cache Automatically on Every Netlify Deploy
This site sits on Netlify behind Cloudflare, and after every deploy Cloudflare kept serving stale HTML. Here's why the Netlify-CDN-Cache-Control header was a dead end for static files, and the 26-line build plugin that purges Cloudflare's edge cache the moment a deploy goes live — including the CJS/ESM gotcha that broke the first build.

This blog is a fully static Nuxt site. Netlify builds it (NITRO_PRESET=netlify-static), Cloudflare sits in front as the DNS and CDN layer. It's a boring, cheap, fast setup — right up until I told Cloudflare to cache HTML at its edge and then shipped a deploy.
The page I had just changed kept coming back in its old version. Not for everyone, not everywhere — just wherever a Cloudflare PoP still held the previous copy. Netlify had the new HTML. Cloudflare didn't care.
Here's the whole journey: why caching HTML at Cloudflare's edge is worth doing in the first place, the header-based fix that turned out to be a documented no-op, and the tiny Netlify build plugin that actually solved it — plus the module-format gotcha that broke the very first deploy of that plugin.
The Setup: Two CDNs, One Source of Truth
If you put Cloudflare in front of Netlify, you have two CDNs stacked on top of each other:
- Netlify's CDN serves your deployed files. For static sites it's the source of truth: every deploy atomically swaps the content, and Netlify invalidates its own edge automatically.
- Cloudflare's edge sits in front of that. By default it caches static assets (images, CSS, JS) but not HTML.
The default is safe precisely because of the staleness problem. But it also means every HTML request travels through Cloudflare to Netlify's origin. For a content site, having the HTML itself served from Cloudflare's edge — the PoP closest to the visitor — is an easy TTFB win, and TTFB is the floor under every other loading metric (I've written about that chain before).
So I added a Cloudflare Cache Rule: cache HTML at the edge, 10 minutes TTL. Deliberately short, because I already knew what was coming.
The Problem: Atomic Deploys Meet a CDN That Wasn't Invited
Netlify deploys are atomic. The moment a deploy goes live, every file on Netlify's CDN is the new version — their platform handles that invalidation for you, and it's one of the genuinely great things about the product.
Cloudflare knows nothing about any of this. It caches whatever it fetched, for however long the Cache Rule says, and serves it until the TTL expires. Deploy at 14:00, and a visitor hitting a warm Cloudflare PoP at 14:07 gets the 13:58 version of the page. With a 10-minute TTL that's an annoyance. With the hour-plus TTL I actually wanted, it's broken.
The classic tension: long TTL = fast but stale, short TTL = fresh but pointless. A 10-minute edge TTL barely earns its keep — most visits on a personal blog are more than 10 minutes apart, so the cache is cold more often than not.
The way out is equally classic: cache long, purge on change. The only question is where the purge hook lives.
The Dead End: Netlify-CDN-Cache-Control
My first instinct was to solve it with headers, because that had just worked so nicely elsewhere. Netlify supports a Netlify-CDN-Cache-Control header, including a durable directive that keeps content warm across their whole edge network from a single origin fetch. I shipped this in public/_headers:
/*
Netlify-CDN-Cache-Control: public, durable, s-maxage=31536000
The reasoning felt sound: deploys auto-invalidate Netlify's cache, so a year-long s-maxage is safe, and durable means one origin fetch fills all PoPs.
Then I actually read the Netlify docs instead of skimming them, and reverted the whole thing a few commits later. Two findings:
- Static asset responses on Netlify are already edge-cached fresh for up to a year, invalidated on every deploy. The platform does this whether you set headers or not.
Netlify-CDN-Cache-Control(anddurable) only applies to responses generated by Functions. For plain static files it's a no-op. The header I was setting did precisely nothing.
The tell that sent me down this path in the first place — fwd=stale entries in response debugging — turned out to be Netlify's internal shard revalidation, not an origin round trip that a header could eliminate. I was optimizing a layer that was already optimal.
More importantly: even if the header had worked, it was aimed at the wrong CDN. My staleness lived in Cloudflare's cache, and no Netlify response header reaches into Cloudflare's edge and evicts anything. The fix had to talk to Cloudflare directly.
The Fix: A Netlify Build Plugin That Calls the Purge API
Cloudflare has a straightforward cache purge endpoint: one POST to /zones/{zone}/purge_cache with purge_everything: true and the zone's edge cache is empty. Netlify build plugins have an onSuccess event that runs after the deploy is live — which is exactly the ordering you want. Purge earlier (say, onPostBuild) and Cloudflare could re-fill its cache from the old deploy in the gap before the new one goes live.
Wire the two together and the whole solution is one small file. Local plugins live in your repo and get registered in netlify.toml:
[[plugins]]
package = "./netlify/plugins/purge-cloudflare"
The plugin needs a one-line netlify/plugins/purge-cloudflare/manifest.yml:
name: purge-cloudflare
And the plugin itself, netlify/plugins/purge-cloudflare/index.js:
// Purge Cloudflare's edge cache after a successful deploy — CF caches our
// HTML (Cache Rule) and would otherwise serve stale pages for up to the
// rule's edge TTL. onSuccess runs after the deploy is live.
module.exports = {
onSuccess: async () => {
const zone = process.env.CLOUDFLARE_ZONE_ID
const token = process.env.CLOUDFLARE_PURGE_TOKEN
if (!zone || !token) {
console.log('purge-cloudflare: CLOUDFLARE_ZONE_ID / CLOUDFLARE_PURGE_TOKEN not set, skipping')
return
}
const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${zone}/purge_cache`, {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({ purge_everything: true }),
})
const body = await res.json().catch(() => ({}))
if (!res.ok || !body.success) {
// warn, don't fail the deploy — stale cache expires via TTL anyway
console.warn('purge-cloudflare: purge failed', res.status, JSON.stringify(body.errors ?? body))
return
}
console.log('purge-cloudflare: Cloudflare cache purged')
},
}
That's the entire integration. No npm package, no dependency, no webhook service in between. A few decisions in there are worth spelling out:
- It no-ops gracefully. If the env vars aren't set, it logs and returns. The plugin could be merged and deployed before the credentials existed in the Netlify UI, and preview or fork builds without secrets don't explode.
- A failed purge never fails the deploy. If the Cloudflare API is down or the token expired, the worst case is the old behaviour: stale HTML until the TTL runs out. That is strictly not worth a red deploy, so the plugin warns and moves on. Using
utils.status.showorutils.build.failPluginwould be the by-the-book option; aconsole.warnin the deploy log is honestly all this needs. purge_everythinginstead of purging by URL. On a site this size, surgically purging changed URLs is complexity with no payoff — Netlify's CDN still serves everything instantly, so Cloudflare just re-fetches a handful of pages from a warm origin. On a large site with heavy traffic you'd purge by prefix or tag instead.- Native
fetch, zero dependencies. The build runs on Node 22 (pinned innetlify.toml), sofetchis just there.
Scope the Token Like You Mean It
The token this thing uses can be — and therefore should be — almost powerless. In the Cloudflare dashboard, create an API token with exactly one permission: Zone → Cache Purge → Purge, restricted to the one zone it needs. If that token leaks out of a build log or a compromised dependency, the blast radius is "someone can empty my cache," which is an inconvenience, not an incident. Given what supply-chain attacks on build pipelines look like these days, any credential that lives in CI deserves this treatment.
Both values (CLOUDFLARE_ZONE_ID, CLOUDFLARE_PURGE_TOKEN) go into the Netlify UI as environment variables — never into the repo.
The Gotcha: ESM Exports in a CJS Repo
The first version of the plugin used an ESM named export, because that's how I write everything these days:
export const onSuccess = async () => { /* ... */ }
It looked right, it matched examples floating around — and it broke the very next build. @netlify/build loads local plugins through Node's module system, and Node decides how to parse a .js file based on the nearest package.json. This repo has no "type": "module", so my index.js was parsed as CommonJS, and export is a syntax error in CommonJS. The deploy that was supposed to verify the purge plugin died in the plugin loader instead.
The fix was the twenty-second kind: same logic, CJS shape.
module.exports = {
onSuccess: async () => { /* ... */ },
}
Alternatively, naming the file index.mjs (and pointing the manifest at it) or adding "type": "module" to the repo would both have worked — but flipping the module type of an entire working project to accommodate a 26-line plugin is the tail wagging the dog. Match the plugin to the repo, not the repo to the plugin.
The broader lesson is one I keep re-learning: in a CJS repo, every .js file is CJS, including the ones that feel like standalone scripts. Build plugins, config files, little tools in subdirectories — Node doesn't care how modern the code inside looks, it cares what the nearest package.json says.
The Result
The deploy log now ends with a line I actually look for:
purge-cloudflare: Cloudflare cache purged
Every deploy leaves Cloudflare's edge empty, so the very next visitor to any page gets the fresh version — and every visitor after them gets it from Cloudflare's edge cache. The staleness ceiling dropped from "edge TTL" to "seconds after deploy goes live," which means the TTL is no longer a freshness knob at all. It's purely a fallback for the day the purge fails, and it can be raised accordingly.
That's the part I like most about this pattern: it converts a trade-off (fresh or fast) into two independent controls. The purge handles freshness. The TTL handles resilience. Twenty-six lines, no dependencies, and the two CDNs finally agree on what "current" means.
If you're running a similar stack — Netlify or Vercel behind Cloudflare, static or headless storefront — and fighting stale pages or slow TTFB, this pattern transfers directly. Happy to take a look at your setup.
Useful References
- Netlify Build Plugins — local plugins,
manifest.yml, and the event lifecycle (onSuccessruns after the deploy is live) - Cloudflare Purge Cache API —
purge_everything, plus purge by URL, prefix, or tag - Netlify caching overview — why static responses are already edge-cached and deploy-invalidated, and why
Netlify-CDN-Cache-Controlapplies to Function responses - Cloudflare API token permissions — scoping a token to Cache Purge on a single zone
Enjoyed this?
Get new posts as they land.
Keep reading

Debugging LCP on a Real Site - A Case Study on marcofaul.de
A first-person walkthrough of fixing LCP on this very site - a JS reveal animation gating first paint, a font preload that held rendering hostage, a 5.8s image CDN cold start, and the measurement noise that almost sent me chasing ghosts.

Core Web Vitals 2026 - How to Master INP (Interaction to Next Paint)
INP is the Core Web Vital most sites fail. Learn what INP measures, the 200ms threshold, and concrete JavaScript techniques to make your store feel instant.

Shopware 6 Developer Performance Guide - Essential Improvements and Fixes
Performance optimization is crucial for any e-commerce platform. Here are the most impactful performance improvements and fixes for Shopware 6 developers.