Purging Cloudflare Cache via API on Deploy
Automating Cloudflare cache purge inside your deploy pipeline guarantees that visitors see the freshly shipped build the instant your CI run finishes, without waiting for edge TTLs to expire or manually clicking “Purge Everything” in the dashboard. This guide walks through tagging assets with Cache-Tag headers, purging by tag (or by surgical URL list) from a GitHub Actions step, surviving 429 rate limits with batching, and falling back to a full purge only when you genuinely must. After reading it you will have a deploy job that invalidates exactly the changed objects and verifies the result with curl.
A targeted purge-on-deploy is the safest companion to aggressive Cache Purging & Invalidation policies, and it lets you keep long edge TTLs without serving stale HTML. It pairs naturally with sane Cache-Control headers for static and dynamic content and with disciplined cache key design so that a single tag maps cleanly to a logical group of objects.
Key objectives:
- Mint a scoped API token with only
Cache Purgepermission and locate your zone id. - Emit
Cache-Tagresponse headers so assets can be purged in logical groups. - Run a purge-by-tag (or purge-by-URL) call from a GitHub Actions deploy step, with batching and retry.
- Verify invalidation by watching
cf-cache-statusflip fromHITtoMISSand back.
Prerequisites and environment setup
You need an active zone on Cloudflare and shell access to a runner (local or CI) with curl and jq installed. The examples assume curl 7.68+ and jq 1.6+; check with curl --version and jq --version.
Mint a scoped API token
Do not use a Global API Key. Create a dedicated token under My Profile → API Tokens → Create Custom Token with a single permission: Zone → Cache Purge → Purge, scoped to the one zone you deploy. This least-privilege token cannot read DNS, edit firewall rules, or touch other zones, so leaking it in CI logs is far less damaging.
Verify the token before wiring it into a pipeline:
curl -s -X GET "https://api.cloudflare.com/client/v4/user/tokens/verify" \
-H "Authorization: Bearer $CF_API_TOKEN" | jq '.result.status, .success'
Expected output:
"active"
true
Find your zone id
The zone id is a 32-character hex string shown on the zone Overview page, or fetched by name:
curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=example.com" \
-H "Authorization: Bearer $CF_API_TOKEN" | jq -r '.result[0].id'
Store both values as CI secrets named CF_API_TOKEN and CF_ZONE_ID. Never commit them.
Step 1: Tag assets with Cache-Tag headers
Purge-by-tag only works if your origin (or an edge rule) attaches a Cache-Tag response header to objects as they are cached. A tag is an arbitrary label; comma-separated values let one object carry several tags. Tags are stripped from the response sent to browsers, so they never leak to clients.
Emit them from your origin. An Nginx example that tags everything under /blog/ and stamps a release id:
location /blog/ {
add_header Cache-Tag "blog, release-$release_id" always;
add_header Cache-Control "public, max-age=86400, s-maxage=86400";
}
Or attach them at the edge in a Worker, which is handy when you cannot change the origin:
export default {
async fetch(request, env, ctx) {
const response = await fetch(request);
const res = new Response(response.body, response);
const path = new URL(request.url).pathname;
if (path.startsWith("/assets/")) res.headers.set("Cache-Tag", "assets");
return res;
}
};
Side effect / plan note: the Cache-Tag header is honored for cache segmentation and purge only on Enterprise zones. Free, Pro, and Business plans ignore it for purge-by-tag and must purge by URL instead. Confirm your plan before designing the pipeline around tags; the troubleshooting section covers the symptom when this is missed.
Step 2: Purge by tag from the command line
The purge endpoint is POST /zones/{zone_id}/purge_cache. A tag purge sends a tags array:
curl -s -X POST \
"https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{"tags":["blog","release-2026-06-20"]}' | jq '.success, .errors'
Expected output on success:
true
[]
A single request accepts at most 30 tags. If your deploy touched more logical groups than that, batch them. The following snippet reads tags from a file (one per line) and chunks them into groups of 30:
#!/usr/bin/env bash
set -euo pipefail
mapfile -t TAGS < tags.txt
for ((i=0; i<${#TAGS[@]}; i+=30)); do
batch=$(printf '%s\n' "${TAGS[@]:i:30}" | jq -R . | jq -s -c .)
curl -s -X POST \
"https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"tags\":$batch}" | jq -e '.success' >/dev/null
echo "purged batch $((i/30 + 1))"
done
Purge by URL list instead
On non-Enterprise plans, purge the exact URLs you changed. The files array accepts up to 30 URLs per request and supports per-URL headers for cache-key variants:
curl -s -X POST \
"https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{"files":[
"https://example.com/index.html",
"https://example.com/app.css"
]}' | jq '.success'
Building the URL list from a git diff keeps the purge surgical:
git diff --name-only HEAD~1 HEAD -- 'public/' \
| sed 's#^public#https://example.com#' > changed_urls.txt
The purge-everything fallback
Reserve this for emergencies: a CDN-wide config change, a poisoned cache key, or a release that invalidated everything. It evicts every cached object zone-wide and momentarily spikes origin load as the cache refills.
curl -s -X POST \
"https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}' | jq '.success'
You cannot combine purge_everything with tags or files in one call; the API returns error 1006 if you try.
Step 3: Wire it into GitHub Actions
Add a purge step that runs only after your deploy step succeeds. This job purges by tag, retries on 429 with exponential backoff, and fails the build if Cloudflare rejects the request.
name: deploy
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and upload
run: ./scripts/deploy.sh
- name: Purge Cloudflare cache by tag
env:
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
run: |
set -euo pipefail
payload='{"tags":["blog","assets","release-'"$GITHUB_SHA"'"]}'
for attempt in 1 2 3 4 5; do
code=$(curl -s -o resp.json -w '%{http_code}' -X POST \
"https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data "$payload")
if [ "$code" = "200" ]; then
echo "purge ok"; jq '.result' resp.json; exit 0
fi
if [ "$code" = "429" ]; then
sleep $((attempt * attempt)); continue
fi
echo "purge failed ($code)"; cat resp.json; exit 1
done
echo "exhausted retries on 429"; exit 1
Expected behavior: on a clean run the step logs purge ok and the build is green. A scope or payload error fails fast with the Cloudflare error body printed; a rate limit retries with 1s, 4s, 9s, 16s, 25s backoff before giving up.
Verification
Confirm the purge actually evicted the object by watching the cf-cache-status header. Immediately after the purge, the first request must be a MISS (Cloudflare refetched from origin), and a second request should return HIT.
curl -sI "https://example.com/blog/" | grep -i cf-cache-status
# first call after purge:
# cf-cache-status: MISS
curl -sI "https://example.com/blog/" | grep -i cf-cache-status
# second call:
# cf-cache-status: HIT
For a purge-by-URL change, diff the ETag or Last-Modified header before and after to prove the new build is being served:
curl -sI "https://example.com/app.css" | grep -iE 'etag|age|cf-cache-status'
A freshly purged object reports age: 0 and cf-cache-status: MISS on its first request. If age keeps climbing and status stays HIT, the purge did not land — proceed to troubleshooting.
Troubleshooting
429 Too Many Requests
Diagnosis: the API returns HTTP 429 with "errors":[{"code":10000}] or an empty body. Cloudflare rate-limits purge calls (roughly 1000–2000 per minute zone-wide, lower for tag purges). Parallel CI matrix jobs purging simultaneously are the usual cause.
Fix: serialize purges into one step, batch tags into groups of 30, and back off exponentially as shown in the Actions job. Coalesce many tags into a single call rather than one call per asset.
Token scope / authentication errors
Diagnosis: HTTP 403 with code: 9109 (“Unauthorized to access requested resource”) or code: 10000 (“Authentication error”). Verify the token resolves and is bound to the right zone:
curl -s "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_API_TOKEN" -X POST \
-H "Content-Type: application/json" --data '{"tags":["smoke-test"]}' \
| jq '.errors'
Fix: ensure the token has Cache Purge → Purge and that its zone resource scope includes CF_ZONE_ID. A token scoped to a different zone authenticates but cannot purge yours. Re-check that the secret was not truncated when pasted into CI.
Tags ignored — nothing gets purged
Diagnosis: the API returns success: true but cf-cache-status stays HIT and content does not change. The most common root cause is plan tier: Cache-Tag purge requires Enterprise.
Fix: confirm the plan with curl -s ".../zones/$CF_ZONE_ID" -H "Authorization: Bearer $CF_API_TOKEN" | jq '.result.plan.name'. On Free/Pro/Business, switch the pipeline to purge by URL files instead. Also confirm the origin actually emits Cache-Tag on the cached response — inspect it at the edge with a Worker log or origin access log, since the header is stripped before it reaches the browser.
Purge succeeds but a CDN in front still serves stale
Diagnosis: cf-cache-status: MISS on Cloudflare yet the body is old. A second CDN, a reverse proxy, or aggressive browser caching sits in the chain.
Fix: check age and cache-control on the response; if max-age is high and the asset is fingerprinted, that is expected for the browser layer. For shared caches, ensure each layer is purged or that you rely on immutable, hashed filenames so a new deploy produces new URLs that no cache holds yet.
Wrong host or scheme in the URL list
Diagnosis: files purge returns success: true but the wrong objects clear. URLs must match the exact cached scheme, host, and path including query string variants that participate in the cache key.
Fix: purge both http:// and https:// if both are cached, and include each query-string variant separately. Aligning your purge URLs with your cache key configuration eliminates this mismatch entirely.
Frequently Asked Questions
Do I need an Enterprise plan to purge on deploy?
No. Every plan supports purge by URL files and purge_everything. Only purge by Cache-Tag (and by prefix or hostname) requires Enterprise, so on lower tiers build a URL list from your git diff instead.
How many tags or URLs can one purge call carry?
Thirty per request for both tags and files. Batch anything larger into chunks of 30 and issue sequential calls, ideally with a short backoff between batches to stay under the rate limit.
Will purge-by-tag remove the Cache-Tag header from what users see?
No — Cloudflare strips Cache-Tag from the client-facing response automatically. It is only visible at the origin and in edge logs, so it never exposes your internal tagging scheme to visitors.
Should I just call purge_everything on every deploy? Avoid it. A zone-wide purge empties the entire cache and forces every subsequent request to your origin until the cache refills, spiking load and latency. Targeted tag or URL purges keep your Cache-Control TTLs working for everything you did not change.
How fast does a purge propagate globally?
Purges typically complete across all POPs within a few seconds. If a single edge location still serves stale content after that window, retry the request to land on a different cache shard and confirm with cf-cache-status.
Related
- Cache Purging & Invalidation
- Customizing Cache Keys to Improve Hit Ratio
- Setting Cache-Control Headers for Static and Dynamic Content
Back to Cache Purging & Invalidation