Rolling Back a Bad Asset Deploy on Cloudflare

You deployed a broken build and users are seeing a white screen, a JavaScript error on every page, or a CSS regression. The natural instinct is to hit “Purge Everything” in the Cloudflare dashboard. That instinct is wrong. Understanding exactly what to purge—and what not to touch—is the difference between a two-minute recovery and a ten-minute origin stampede.

Diagnosis: What Is Actually Broken?

Before touching Cloudflare, identify the failure type. The rollback procedure differs depending on whether the bad content is in a hashed asset or an HTML entry point.

# Check the browser console error to identify the broken file
# Then inspect what Cloudflare is serving for that file:
curl -sI "https://www.example.com/assets/app.a1b2c3d4.js" \
  | grep -i "cf-cache-status\|content-length\|last-modified"

# Check whether the HTML is referencing the broken hash
curl -s "https://www.example.com/" \
  | grep -o 'src="[^"]*\.js"'

The two failure patterns are:

Pattern Root cause What Cloudflare serves
Broken JS/CSS at a new hashed URL Bad code shipped in new build The file Cloudflare fetched from origin after deploy
Broken JS/CSS at an old hashed URL Prior deploy left stale content OR origin deleted old files prematurely MISS (file gone from origin) or stale HIT
Page blank or missing styles HTML references wrong or missing asset hash Stale or fresh HTML pointing to non-existent file

In the overwhelming majority of cases, the problem is option three: the HTML entry point was cached (or re-fetched) with a reference to a broken asset filename. The hashed asset files themselves are correct for what they are—they just contain broken code.

The Correct Rollback Approach

The key insight is that content-hashed assets are immutable by design. When you redeploy the prior working build, the prior working asset files return to the origin with their original hashed filenames—app.9f3a2b11.js, not app.a1b2c3d4.js. The broken files at the new hash simply become dead URLs; nobody asks for them once the HTML stops referencing them.

Your only job is to make the HTML entry point(s) reference the old hashes again, and then ensure Cloudflare stops serving the stale (broken) HTML.

Cloudflare rollback sequence Timeline diagram showing bad deploy arriving, the HTML entry point being updated at origin with old hashes, a targeted URL purge removing stale HTML from the Cloudflare edge, and users receiving the prior working HTML without any purge of asset files. Bad deploy broken JS ships HTML → broken hash users see errors Redeploy prior build tag origin HTML now has old working hashes Purge HTML URL purge via API Cloudflare edge drops stale HTML Verify curl -I check cf-cache-status: MISS users see fix Do NOT purge hashed asset URLs Old filenames stay cached — they become unreferenced, not harmful
Rollback sequence: redeploy the prior build at origin, purge only the HTML entry-point URLs, verify with curl. Never purge the hashed asset files.

Step-by-Step Rollback Procedure

Step 1: Redeploy the Prior Working Build at the Origin

Return the origin to the last known-good state. How you do this depends on your deployment platform:

Git tag / CI/CD rollback:

# Trigger a redeploy of the last known-good tag in your CI/CD system
# Example for a GitHub Actions workflow dispatch:
ROLLBACK_TAG="v2.14.0"  # the last working release tag

curl -s -X POST \
  "https://api.github.com/repos/your-org/your-repo/actions/workflows/deploy.yml/dispatches" \
  -H "Authorization: Bearer ${GITHUB_TOKEN}" \
  -H "Content-Type: application/json" \
  --data "{\"ref\": \"${ROLLBACK_TAG}\"}"

Manual file replacement (S3 / R2 origin):

# Download and re-upload the prior build artifact
# Assumes your CI stored the prior build artifact as a zip

ROLLBACK_VERSION="v2.14.0"
BUCKET="your-origin-bucket"

# Pull the artifact from your artifact store (adjust command to your system)
aws s3 cp "s3://${BUCKET}-artifacts/${ROLLBACK_VERSION}/build.tar.gz" /tmp/rollback.tar.gz
tar -xzf /tmp/rollback.tar.gz -C /tmp/rollback-build/

# Sync only the HTML files first (the assets from the old build already exist at origin
# because hashed filenames match the old content — they were never deleted)
aws s3 sync /tmp/rollback-build/dist/ "s3://${BUCKET}/" \
  --exclude "*" \
  --include "*.html" \
  --cache-control "no-cache"

The critical point: the hashed asset files from the old build are already at the origin (or on Cloudflare’s edge) because they were uploaded during that previous deploy. Their filenames are determined by content; the same code produces the same hash. The broken build uploaded new filenames; the old filenames are still present. You do not need to re-upload the old assets.

If your deploy pipeline deletes old files during sync (for example, aws s3 sync --delete), you may need to restore the old asset files. This is why rolling back content-hashed releases typically recommends a keep-all-versions origin bucket.

Step 2: Purge the HTML Entry-Point URL on Cloudflare

Once the origin is serving the prior-build HTML, tell Cloudflare to evict any cached copy of the HTML entry point so the next user request fetches the fixed version:

ZONE_ID="your_zone_id_here"
API_TOKEN="your_api_token_here"

# Purge all HTML entry points that were affected
curl -s -X POST \
  "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{
    "files": [
      "https://www.example.com/",
      "https://www.example.com/about/",
      "https://www.example.com/pricing/"
    ]
  }'

If you have a short edge TTL on HTML (30 seconds or less, as the Cloudflare Cache Rules guide recommends), you can skip this step and simply wait for the TTL to elapse. The purge call is optional when the HTML TTL is already short—it just eliminates the wait.

Step 3: Do Not Purge the Hashed Asset Files

This is the step most engineers get wrong under pressure. The broken asset files—for example, app.a1b2c3d4.js with the bad code—should not be purged. Here is why:

  1. After the HTML rollback, no page references app.a1b2c3d4.js anymore. Users’ browsers will never request it.
  2. Purging it wastes an API call and evicts a file that has zero active references.
  3. Worse, if any browser still has a cached (stale) copy of the broken HTML referencing the broken hash, purging the asset causes a 404 for those users until the HTML also expires. Leaving the broken asset cached (even though unreferenced) means a user who somehow still has the broken HTML can still load the asset without a 404.

The old working asset files—app.9f3a2b11.js—are still on Cloudflare’s edge (they were never purged; they just weren’t referenced) or will refetch from origin on the next miss. No purge of asset files is needed.

Step 4: Verify the Rollback on Cloudflare

Confirm the fix reached the edge before declaring the incident resolved:

# 1. Verify the HTML entry point shows MISS (edge fetching fresh from origin)
curl -sI "https://www.example.com/" \
  | grep -i "cf-cache-status"
# Expected: cf-cache-status: MISS  (first request after purge)

# 2. Confirm the HTML body now references the old (working) asset hash
curl -s "https://www.example.com/" \
  | grep -o 'src="[^"]*\.js"'
# Expected: src="/assets/app.9f3a2b11.js"  (the working hash, not the broken one)

# 3. Confirm the working asset file is accessible
curl -sI "https://www.example.com/assets/app.9f3a2b11.js" \
  | grep -i "cf-cache-status\|http"
# Expected: HTTP/2 200, cf-cache-status: HIT or MISS (both fine — file exists)

# 4. Second HTML request should be HIT with the correct content
curl -sI "https://www.example.com/" \
  | grep -i "cf-cache-status"
# Expected: cf-cache-status: HIT

If step 2 still shows the broken hash after the purge, the origin has not yet returned the old HTML. Re-check that the redeploy completed. If the origin is correct but Cloudflare is still serving stale HTML, wait 5–10 seconds and check again—purge propagation is near-instantaneous but not literally instantaneous.

What to Do If You Cannot Immediately Redeploy

Sometimes the rollback pipeline is slow, broken, or unavailable. In that window, users continue hitting the broken build. Two Cloudflare-level mitigations can reduce the blast radius:

Option A: Enable Cloudflare’s “Always Online” mode (not applicable here — that is for origin downtime, not bad code).

Option B: Use a Cache Rule to bypass caching for the HTML route temporarily. This forces every user to hit your origin directly for HTML. If your origin is still running an old server process or container from the prior deploy, this can serve working HTML while the new artifact deploys:

ZONE_ID="your_zone_id_here"
API_TOKEN="your_api_token_here"

# Temporarily bypass cache for the root HTML route
curl -s -X POST \
  "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/rulesets/phases/http_request_cache_settings/entrypoint/rules" \
  -H "Authorization: Bearer ${API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{
    "description": "Emergency: bypass HTML cache during rollback",
    "expression": "(http.request.uri.path matches \"(\\\\.html$|/$)\")",
    "action": "set_cache_settings",
    "action_parameters": {
      "cache": false
    }
  }'

Remove this rule immediately after the rollback completes. Cache bypass means every HTML request hits your origin, removing the CDN’s protection against traffic spikes.

Why You Do Not Purge Hashed Assets

This section addresses the emergency temptation directly, because the pressure to “purge everything and start fresh” is strong during an incident.

Hashed assets have names derived from their content. app.a1b2c3d4.js will always be the same file—its hash is its identity. Purging it from Cloudflare’s cache does not fix the bad code inside it. The next request will fetch it from origin and re-cache the same broken file. Nothing is improved.

Purging hashed assets is only appropriate in one narrow scenario: you shipped a file with a security vulnerability and need to ensure no browser can load it from any cache layer. In that case, purge the specific filename AND simultaneously update every HTML reference away from it—which means shipping a new build anyway. The rolling back a content-hashed release guide covers that scenario.

For ordinary functional regressions (broken UI, runtime JavaScript errors), the correct sequence is always: fix the origin HTML → purge the HTML URL → done.

The cache-key architecture guide explains in detail why filename-based cache keys make this rollback pattern safe and predictable.

CI/CD Integration: Automate the Rollback Purge

Add a rollback purge step to your CI/CD rollback job so it runs automatically whenever a rollback deploy completes. This ensures the HTML purge is never forgotten under pressure:

#!/usr/bin/env bash
# rollback-purge.sh — run at the end of a rollback deploy pipeline
set -euo pipefail

ZONE_ID="${CF_ZONE_ID:?CF_ZONE_ID must be set}"
API_TOKEN="${CF_API_TOKEN:?CF_API_TOKEN must be set}"
BASE_URL="${SITE_BASE_URL:?SITE_BASE_URL must be set}"  # e.g. https://www.example.com

# List of HTML entry points to purge after rollback
# Keep this in sync with your site's route structure
HTML_ROUTES=(
  "/"
  "/about/"
  "/pricing/"
  "/blog/"
  "/contact/"
)

# Build the JSON files array
FILES_JSON=$(printf '%s\n' "${HTML_ROUTES[@]}" \
  | jq -Rn '[inputs | "'"${BASE_URL}"'" + .]')

echo "Purging HTML entry points on Cloudflare after rollback..."
RESPONSE=$(curl -s -X POST \
  "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data "{\"files\": ${FILES_JSON}}")

SUCCESS=$(echo "${RESPONSE}" | jq -r '.success')
if [ "${SUCCESS}" != "true" ]; then
  echo "Purge failed: ${RESPONSE}"
  exit 1
fi

echo "Purge complete. Verifying..."
sleep 3

# Verify the root HTML returns MISS (fresh from origin)
CF_STATUS=$(curl -sI "${BASE_URL}/" | grep -i "cf-cache-status" | awk '{print $2}' | tr -d '\r')
echo "cf-cache-status after purge: ${CF_STATUS}"

if [ "${CF_STATUS}" = "MISS" ] || [ "${CF_STATUS}" = "EXPIRED" ]; then
  echo "Rollback purge verified successfully."
else
  echo "Unexpected cf-cache-status: ${CF_STATUS} — investigate manually."
  exit 1
fi

Store CF_ZONE_ID, CF_API_TOKEN, and SITE_BASE_URL as CI/CD secrets. The CI/CD asset pipeline integration guide covers how to structure these secrets in GitHub Actions.

When to Reconsider

Purge the HTML TTL instead of running an API call when your HTML edge TTL is already 30 seconds or less. In low-severity incidents you can wait 30 seconds for the stale HTML to expire naturally without running any Cloudflare API call. Reserve the purge API for high-severity incidents where every second of exposure matters.

Purge a broader scope (prefix purge or Cache-Tag purge) only when your HTML route list is too large to enumerate in the single-file URL purge. On Enterprise plans, a single Cache-Tag purge call handles thousands of HTML routes. The URL vs Cache-Tag comparison covers when to upgrade from URL purge to Cache-Tag purge for routine deploys and rollbacks.

Review your origin’s delete-on-deploy behavior if old asset files become 404 after a rollback. Some deploy pipelines delete all files not present in the new build (--delete flag in S3 sync). For rollback safety, retain old hashed asset files at origin for at least one week. The deterministic build outputs guide explains why retaining old file versions simplifies rollback across all CDN providers.