Rolling Back a Bad Asset Deploy on CloudFront

A broken JavaScript bundle shipped. Users see a blank page or a runtime error. The fix is already staged but the current release is live and CloudFront is serving it globally. This guide walks through the fastest safe rollback path: restore the prior index.html from S3, invalidate that one path, and rely on the fact that all prior hashed assets are still in S3 and still cached at the edge.

Why CloudFront Rollbacks Are Simpler With Fingerprinted Assets

The key insight behind a surgical rollback is that content hashing makes each release’s assets independent objects in S3. When you deployed main.a1b2c3d4.js in the bad release and main.9e2f1a3b.js was the prior good release, both files exist in S3 and both may still be cached at CloudFront edge POPs. Rolling back means pointing index.html back to the old hash — the file it references is already present.

Contrast this with non-fingerprinted deployments, where main.js is overwritten in-place. There, rolling back requires re-uploading the old binary and then invalidating /* to evict all stale copies — a much noisier operation that triggers a cache-miss storm.

Fingerprinting also protects against the most common rollback hazard: a mismatch between HTML and assets. When index.html references main.9e2f1a3b.js, that exact file is served from S3 (or from an edge POP that still has it cached). There is no window where users get new HTML with old files or old HTML with new files.

Review cache key architecture for the underlying reason: each unique filename is a distinct cache entry. The old hash URL and the new hash URL coexist in the edge cache simultaneously. Only index.html — which is not fingerprinted — needs any attention at all.

Choosing a Rollback Strategy

Dimension Nuclear cache wipe (/*) Surgical HTML rollback (/index.html only)
What gets invalidated Every object in the distribution HTML entry points only
Cache-miss storm risk High — all POPs must refetch everything Minimal — only HTML POPs refetch
Invalidation path cost 1 path (/* counts as one) 1–3 paths
Time to user recovery 5–30 s invalidation + cache warm-up 5–30 s invalidation; assets already warm
Works with fingerprinted assets Yes, but wastes edge cache Yes, optimal
Works with unhashed assets Required if assets were overwritten No — same URL, old content still cached
Risk of partial-state exposure Moderate during warm-up Very low — old hashed assets serve immediately

Use the surgical approach whenever your assets are fingerprinted. Use the nuclear approach only when assets are not fingerprinted (same URL, new content) or when a security-sensitive payload must be purged immediately from all edge caches.

CloudFront surgical rollback flow Timeline showing five steps: bad deploy detected, prior S3 version identified, index.html re-uploaded, single-path invalidation issued, and users receiving old working HTML that references prior hashed assets still cached at the edge. 1 Bad deploy detected errors in logs 2 Find prior S3 version list-object-versions or git tag 3 Re-upload index.html aws s3 cp no-cache headers 4 Invalidate /index.html 1 path, 5–30 s propagates all POPs 5 Users get prior HTML references old hash assets still cached Fingerprinted assets (main.9e2f1a3b.js) remain in S3 and in edge caches throughout. No asset invalidation needed — only /index.html is purged from edge POPs.
Surgical rollback sequence: restore the prior index.html in S3, invalidate one path, and rely on hashed assets that are still present at the edge.

Step-by-Step Rollback Procedure

The complete rollback script below handles all five steps: identifying the prior S3 version, restoring it, re-uploading with correct cache headers, invalidating index.html, and polling for completion.

#!/usr/bin/env bash
set -euo pipefail

# -----------------------------------------------------------------------
# CloudFront rollback: restore prior index.html and invalidate the path.
# Prerequisites: AWS CLI v2, correct IAM permissions, DIST_ID and BUCKET set.
# -----------------------------------------------------------------------

DIST_ID="${DIST_ID:?Set DIST_ID to your CloudFront distribution ID}"
BUCKET="${BUCKET:?Set BUCKET to your S3 bucket name}"
HTML_KEY="index.html"
PRIOR_HTML="/tmp/prior-index.html"

echo "=== Step 1: List recent S3 versions of ${HTML_KEY} ==="
aws s3api list-object-versions \
  --bucket "$BUCKET" \
  --prefix "$HTML_KEY" \
  --query "Versions[*].{VersionId:VersionId,LastModified:LastModified,IsLatest:IsLatest}" \
  --output table

# Set PRIOR_VERSION_ID to the version you want to restore.
# Example: export PRIOR_VERSION_ID="abc123XYZ..."
PRIOR_VERSION_ID="${PRIOR_VERSION_ID:?Export PRIOR_VERSION_ID after reviewing the table above}"

echo "=== Step 2: Download prior version to local disk ==="
aws s3api get-object \
  --bucket "$BUCKET" \
  --key "$HTML_KEY" \
  --version-id "$PRIOR_VERSION_ID" \
  "$PRIOR_HTML"

echo "=== Step 3: Re-upload with no-cache headers ==="
aws s3 cp "$PRIOR_HTML" "s3://${BUCKET}/${HTML_KEY}" \
  --cache-control "no-cache, must-revalidate" \
  --content-type "text/html" \
  --metadata-directive REPLACE

echo "=== Step 4: Create invalidation for /index.html only ==="
INVALIDATION_ID=$(aws cloudfront create-invalidation \
  --distribution-id "$DIST_ID" \
  --paths "/index.html" \
  --query "Invalidation.Id" \
  --output text)
echo "Invalidation ID: $INVALIDATION_ID"

echo "=== Step 5: Wait for invalidation to complete ==="
while true; do
  STATUS=$(aws cloudfront get-invalidation \
    --distribution-id "$DIST_ID" \
    --id "$INVALIDATION_ID" \
    --query "Invalidation.Status" \
    --output text)
  echo "Status: $STATUS"
  if [ "$STATUS" = "Completed" ]; then
    echo "Rollback complete. Invalidation propagated to all edge POPs."
    break
  fi
  sleep 5
done

echo "=== Verify ==="
echo "Run: curl -sI https://your-distribution.cloudfront.net/index.html | grep -i 'x-cache'"
echo "Expected: x-cache: Miss from cloudfront (first hit), then Hit from cloudfront"

Using S3 Versioning

S3 object versioning must be enabled on the bucket before you deploy. If you have not enabled it, you cannot use list-object-versions to restore a prior HTML file. Enable it with:

aws s3api put-bucket-versioning \
  --bucket "$BUCKET" \
  --versioning-configuration Status=Enabled

Once enabled, every s3 cp or s3 sync creates a new version. To restore without downloading locally:

# Restore a specific version in-place (no local file needed)
aws s3api copy-object \
  --copy-source "${BUCKET}/${HTML_KEY}?versionId=${PRIOR_VERSION_ID}" \
  --bucket "$BUCKET" \
  --key "$HTML_KEY" \
  --cache-control "no-cache, must-revalidate" \
  --content-type "text/html" \
  --metadata-directive REPLACE

If S3 versioning is not enabled, roll back from your Git repository instead — check out the prior index.html from the previous release tag and re-upload it.

Verification

After the invalidation completes, confirm that users receive the prior HTML:

# Check x-cache header — should be Miss on first request after invalidation
curl -sI "https://your-distribution.cloudfront.net/index.html" \
  | grep -iE "x-cache|age|cache-control|etag"
# Expected on first request:
# x-cache: Miss from cloudfront
# age: 0
# cache-control: no-cache, must-revalidate

# Confirm the HTML references the old asset hash (not the broken release hash)
curl -s "https://your-distribution.cloudfront.net/index.html" \
  | grep -o 'main\.[a-f0-9]\{8\}\.js'
# Expected: main.9e2f1a3b.js  (the prior-release hash)

Verify the old hashed asset itself is still reachable (it was never invalidated):

curl -sI "https://your-distribution.cloudfront.net/assets/main.9e2f1a3b.js" \
  | grep -iE "x-cache|age"
# Expected: x-cache: Hit from cloudfront (still warm in edge cache)
# age: some positive number — it never left the cache

If the old asset is not in the edge cache (age: 0, x-cache: Miss), CloudFront will fetch it from S3. As long as the file still exists in S3, the rollback is safe — the edge simply refills its cache from the origin on the next request.

When to Reconsider the Surgical Approach

Non-fingerprinted assets were overwritten in S3. If you deployed new content to main.js (no hash in the filename), the old file is gone from S3. A surgical rollback cannot work because the URL you are rolling back to points to the overwritten file. You must re-upload the old binary to main.js and then invalidate /* to evict stale copies. This is a strong argument for switching to content-addressed filenames — the content-hashing vs semantic-versioning reference explains the trade-offs.

A security-sensitive payload was exposed. If a release accidentally shipped credentials, tokens, or private data in a JavaScript bundle, a surgical rollback that only changes index.html leaves the compromised bundle cached at edge POPs until its TTL expires. In this case, you must also invalidate the specific hashed asset path to evict it from the edge, even though hashed assets normally never need invalidation.

S3 versioning was not enabled. If you do not have a prior version of index.html in S3, you need to reconstruct it from Git or an artifact store. The CI/CD rollback guide covers how to re-run the build from a prior commit and redeploy from the artifact.

Your HTML files are themselves fingerprinted. Some frameworks (Next.js App Router, for example) fingerprint even the HTML file names. In that case, the deployment places the new HTML at a new URL and the root route (/) redirects to it. The rollback procedure is the same conceptually — restore the redirect to point at the prior HTML URL — but the mechanism differs by framework.

The rollback is for a Cloudflare-hosted site. The procedure described here is specific to S3 + CloudFront. For Cloudflare, see rolling back a bad asset deploy on Cloudflare, which uses cache-tag purge rather than path invalidation.