The cache purge had been run, and staging was still serving the old build. That combination is what made me stop for a moment. If the cleaning tool has already been used and nothing changed, there are only two possibilities left: the layer being cleaned isn't the stale one, or the tool never actually cleaned anything. That night the answer turned out to be both at once.
What was measurable that night
On the night of 1 May 2026 I pushed three prod-prep commits, and Railway finished its auto-deploy without complaint. Then I pulled the staging headers as they came:
curl -sI https://staging.clientsite.com/cf-cache-status: HIT
age: 1082HIT means that response never touched the origin. age tells you how long that copy has been sitting at the edge, in seconds. Divided by 60, 1082 seconds is 18.03 minutes. So for more than 18 minutes after the deploy, staging was still serving the old build, even though the cache-purge GitHub Action had been run.
The origin was fresh, the stale copy sat in front of it
The next step separates those two possibilities. If I can punch through Cloudflare and get the new build, the origin is correct and the edge is what's holding the old version. The cheapest way through is a query string that has never entered anyone's cache key:
STAMP=$(date +%s)
curl -sI "https://staging.clientsite.com/?_cb=$STAMP" | grep -i cf-cache-status
curl -s "https://staging.clientsite.com/?_cb=$STAMP" | grep -o 'old-build-marker\|new-build-marker'The result was cf-cache-status: MISS and the new build. I didn't stop at how the page looked, I grepped the raw HTML: the banner-component marker from the old build was gone, and the new page marker was present. The origin and Railway were serving fresh. Cloudflare was holding stale.
A bypass claim that didn't match the headers
I need to write this part plainly, because one sentence sent my work in the wrong direction for a while. The client's DevOps contact had stated earlier that the staging cache was bypassed. If that were true, a HIT with an age that high could not appear on the homepage.
So the Cloudflare cache for that staging subdomain was not fully bypassed, contrary to the earlier statement. My guess at the time, and it stays a guess because I'm not the one holding that Cloudflare dashboard, is that API routes or specific paths were bypassed while the homepage and the HTML pages stayed cached at the edge. A full bypass would have prevented the HIT I measured.
The cleaning tool itself exits with code 1
The second cause only showed up after I opened the workflow file on 1 May 2026 and read the order of its API calls. It comes down to two steps:
1. GET /zones?name=$DOMAIN -> fetch ZONE_ID
2. POST /zones/$ZONE_ID/purge_cache -> body {"purge_everything": true}The first step decides everything. Cloudflare zones are registered against the root or apex domain, not against a subdomain. So the moment what gets passed to name= is the staging subdomain, what comes back is an empty array:
$DOMAIN = staging.clientsite.com -> empty array -> ZONE_ID=null -> exit 1
$DOMAIN = clientsite.com -> zone found -> purge runsZONE_ID becomes null, the workflow exits with code 1, and the log writes "Zone ID not found for domain: ...". This is not a silent failure. The message is visible in the Actions log, it's just that the Actions tab isn't where people go after pressing a button that appeared to trigger fine.
What makes this mistake easy is that the intuition behind it is reasonable. If the thing you want cleared is the staging cache, typing the staging address feels natural. But the purge operates at zone level, and subdomains under that zone get flushed as part of the root-zone purge. So the correct input is the root domain, and typing the subdomain makes the pipeline either fail or no-op.
The working rule I use now
The client side invited me to the GitHub repo that holds the cache-clearing pipeline, and my push plus Actions access came through on 1 May 2026. The workflow lives at .github/workflows/purge-cache.yml and is triggered manually through workflow_dispatch from the Actions tab. The path is straightforward: open the repo, go to the Actions tab, pick the purge-cache workflow, hit Run workflow, enter the root domain, run it.
For a routine post-deploy flush I run that action myself with the root domain. There's no need to escalate to the client side unless the action itself errors. And as long as the bypass hasn't been extended to cover HTML routes, a manual purge after every deploy is required rather than optional.
Three steps before blaming anything
Out of this I settled on a check that is only three steps long. First, curl -sI the path being complained about and read cf-cache-status. If it's HIT with a high age, Cloudflare is the issue. Second, run a bypass test with ?_cb=$(date +%s). If the new content appears, the origin is fine and only the edge is stale, so just run the purge. Third, if the bypass test also shows old content, the Railway origin hasn't deployed yet, so wait for the build or check the Railway dashboard.
What changed from my old habit is the order of suspicion. When someone reports "I edited X but staging still shows the old thing", a Cloudflare cache HIT is in fact the most likely culprit for HTML pages, not the last one on the list. Verify it before ruling it out.
I still keep the other suspects on record, filed as less likely but possible: a Sanity singleton that hasn't been re-seeded, a 301 redirect cached in the browser, and a Next.js ISR cache that was never revalidated because revalidateTag is missing on the mutating server action.
What purge_everything costs
The most uncomfortable part of this solution is the shape of the purge. purge_everything: true wipes the entire zone cache, every host and every path, not just the staging ones. While production doesn't share that zone the blast radius is limited, and the moment production moves in, this turns dangerous.
There's a caveat line in my own notes that still assumes the staging cache is bypassed and that this workflow is therefore effectively a no-op, and that line contradicts the measurement I wrote in another part of the same notes. I'm leaving it here as a reminder that an old assumption doesn't correct itself the moment the evidence changes.
My expectation for what comes next: once production launches on that same root domain, running this workflow to fix a staging issue will also flush the production cache. What follows is a cache-miss flood to the Railway origin, with a possible brief slowdown or a rate-limit hit if production carries meaningful traffic.
Three mitigation paths are on my list to consider before production ships. First, a second workflow that purges by host or prefix only, for example a body of {"hosts":["staging.clientsite.com"]} or {"prefixes":[...]}, keeping the nuclear option separate. Second, splitting staging onto an entirely different Cloudflare zone. Third, and this is the bare minimum, treating this workflow as a production-impacting action once production is live, so it never gets run casually.
For production itself, the direction I'm taking isn't purging but short TTLs shipped from the application. Price-sensitive routes send Cache-Control: public, s-maxage=60, stale-while-revalidate=300 from Next.js middleware or route config, and Cloudflare respects it without extra configuration. Some routes have already shipped with s-maxage=15.
What I took away
Two causes stacked, and from the outside both looked like the same one. The stale layer wasn't the layer I believed had been bypassed, and the tool meant to clear it stopped at its first API call because it was handed a hostname that was never registered as a zone.
If a purge pipeline looks like it ran and nothing changed, open its log before blaming the cache. The failure is often already written there in a full sentence. And if that pipeline takes a domain name as input, remember that Cloudflare registers zones at the apex. The root domain is what you type, and the subdomain you wanted cleared comes along with it.
I updated the project notes that same night, after the deploy-cache mismatch incident corrected the bypass claim I'd been carrying.