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.comThe 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:
The output after a clean deploy:
✓ Cloudflare cache purged
✓ Sitemap clean — 61/61 URLs return 200And 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 sitemapThe cache headers
While I was in there, I fixed the cache duration too. The original settings were conservative to the point of uselessness:
| Setting | Before | After |
|---|---|---|
Browser cache (max-age) | 1 day | 7 days |
CDN cache (s-maxage) | 1 hour | 1 day |
| Stale-while-revalidate | 1 day | 7 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:
- Don’t Assume AI Fixes Things Properly: the same pattern, where tooling looks like it works but is pointed at the wrong thing
- AI Trusts Your Docs. That’s the Problem.: why “looks right” isn’t “works right”. The deploy script said success for weeks
- Skills vs Scripts: the sitemap parse above, after it broke — a script has no judgment, which is the point and the risk