Strategist · Designer · Operator

Launch
with Ben



Build Notes

Automate Cloudflare Cache Purge + Warm After Deploy (Bash)

Manual Cloudflare purges and cold first hits cost me 30 minutes a day. A 50-line bash script now purges, warms every sitemap URL, and QAs the deploy.

The short version

My deploy script's cache purge printed a green checkmark for weeks while clearing the wrong site, because the zone ID pointed at another project. A 50-line bash script now purges, warms every sitemap URL and reports the status codes.

What you will learn

  • How to check which site your Cloudflare zone ID really points to
  • How to warm every sitemap URL in parallel with xargs -P
  • Why grep -o matters for a one-line minified sitemap
  • How the warm-up status codes double as a sitemap check

Who it is for

Someone who deploys several times a day behind Cloudflare and purges the cache by hand.

Skip it if

You are not on Cloudflare, or you deploy rarely enough that a manual purge costs you little.

You leave with

The sitemap parse and parallel warm commands to build this step into your own deploy.


I work on 2-6 projects at any given time. Multiple deploys a day. And after every single one, the same ritual: open Cloudflare, find the zone, purge cache, go back to the site, hard refresh, check the page, move on. Sometimes I’d forget. Sometimes I’d purge, switch to another project, come back five minutes later to a stale page. Purge again.

Each cycle: 1-2 minutes. Across a day of shipping, that’s 15-30 minutes of my life, spent clicking “Purge Everything” and waiting for first-hit loads to crawl back.

The worst part wasn’t the time. It was the background anxiety. Knowing that every visitor hitting my site after a deploy was getting a slow, uncached response until someone happened to load that page.

The bug I didn’t catch for weeks

My deploy script had a Cloudflare cache purge step. It ran after every deploy. It printed ✓ Cloudflare cache purged. Green checkmark and everything.

It was purging the wrong site.

The CF_ZONE_ID in my .env pointed to salespathpro.com. A completely different project. Every deploy of launchwithben.com was dutifully clearing the cache of a site I wasn’t even working on. Meanwhile, launchwithben.com’s Cloudflare cache was never being purged at all.

I only caught it when I stopped trusting the output and checked what it actually did:

# What I assumed was happening
curl -s "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['name'])"

# Output: salespathpro.com
# ...not launchwithben.com

The deploy script said “success” because the API call succeeded. It purged a cache. Just not mine. The tooling worked perfectly. It was pointed at the wrong project, and the green checkmark hid that for weeks.

The fix that did more than fix

After correcting the zone ID, I could have stopped there. But the manual warm-up problem remained. Every page is slow on its first hit after a purge, until a visitor loads it and primes the CDN cache.

Then the obvious thought: why not hit every page myself, right after purging?

And the follow-up: if I’m already hitting every page, I might as well check the status codes.

One script. Three jobs. Cache purge, cache warm, sitemap QA.

Before: Manual CF purge (sometimes wrong zone), cold pages after deploy, sitemap errors discovered days later by crawlers

After: Automated purge → parallel warm of every sitemap URL → status code report. Zero manual steps. Sitemap QA is free.

The script

50 lines of bash. Runs standalone or as part of deploy.sh.

# Fetch and parse sitemap URLs
# grep -o handles minified single-line XML (Astro's sitemap is one line)
URLS=$(curl -s "$SITEMAP_URL" --max-time 15 | grep -o '<loc>[^<]*</loc>' | sed 's/<loc>//;s/<\/loc>//' | sort)
URL_COUNT=$(echo "$URLS" | wc -l | tr -d ' ')

if [ "$URL_COUNT" -eq 0 ] || [ -z "$URLS" ]; then
    log "  ✗ No URLs found in sitemap"
    exit 1
fi

# Warm in parallel, capture status codes for sitemap QA
RESULTS=$(echo "$URLS" | xargs -P "$CONCURRENCY" -I {} \
    curl -s -o /dev/null -w "%{http_code} {}\n" --max-time "$TIMEOUT" "{}")

⚠️ grep -o there, not grep, and that one flag is the whole difference. This post originally shipped a line-based parse: grep '<loc>' piped into sed 's/.*<loc>//'. It worked until the front end moved to Astro, which emits its sitemap as one minified line — at which point the pipeline matched that single line, sed stripped to the first </loc>, and the script warmed exactly one URL of fifty while reporting success. That failure has its own post: skills vs scripts, where it is the worked example of a script with no judgment running happily against input that no longer matches it.

Note what the zero-URL guard above does not do. It catches an empty sitemap, which is the failure everybody writes a guard for. It cannot catch the one that actually happened, because one URL is not zero URLs. A count that is merely lower than it should be passes every check that asks “did we get anything?”

xargs -P is the other key. The -P flag runs 6 requests in parallel instead of one at a time. 61 URLs warm in about 10 seconds instead of 60+.

Each response gets classified:

deploy.sh → cache.sh → CF API purge → parse sitemap.xml → parallel warm (6 concurrent) → classify each response
200 = cached + healthy
301/302 = redirect in sitemap (fix it)
404 = dead URL in sitemap (remove it)
5xx = server error (check logs)

The output after a clean deploy:

✓ Cloudflare cache purged
✓ Sitemap clean — 61/61 URLs return 200

And when something’s wrong:

! Sitemap issues — 59 OK, 2 problems out of 61 URLs
⚠  301 /projects/old-slug — redirect in sitemap (should be final URL)
✗  404 /learn/deleted-post — dead URL in sitemap

The cache headers

While I was in there, I fixed the cache duration too. The original settings were conservative to the point of uselessness:

SettingBeforeAfter
Browser cache (max-age)1 day7 days
CDN cache (s-maxage)1 hour1 day
Stale-while-revalidate1 day7 days

stale-while-revalidate is the important one. It tells Cloudflare to serve the cached version immediately and refresh in the background. Visitors never see a slow page. They get the stale version instantly while the CDN fetches a fresh one behind the scenes.

With the cache warm running after every deploy, the CDN has a fresh copy of every page. With 7-day stale-while-revalidate, even if the cache expires between deploys, visitors still get instant responses.

The insight

The cache warm and the sitemap QA are the same operation. You’re hitting every URL. The status code is already in the response. Checking it costs exactly zero extra work.

This is the same pattern as conversion tracking that rides on existing analytics calls, or deploy preflights that reuse the test suite. The best monitoring is the kind that piggybacks on something you’re already doing.

The Deploy Automation Rule

Do

  • If you do something manually after every deploy, it belongs in your deploy script
  • If you’re hitting URLs anyway (warm, verify, smoke test), check the status codes
  • If your deploy script says “success,” verify it’s actually doing what you think it is
  • If a step takes 1-2 minutes and you deploy multiple times a day, automate it today. Not “eventually”

Usage

./cache.sh              # purge + warm + QA
./cache.sh --warm-only  # just warm (skip purge)
./cache.sh --purge-only # just purge (skip warm)

Or do nothing. deploy.sh calls it automatically after every deploy. The 30 minutes a day is gone. The sitemap is verified on every ship. And I’ll never purge the wrong site’s cache again.


Related:

Ben Peetermans

Ben Peetermans

I build MVPs and run ad campaigns with AI, and I write these notes from real projects. Twenty years shipping for the web. If you want someone who works with these tools every day to build yours, that is what I do.