Rolling Back a Content-Hashed Release

A new deploy breaks production. You need to roll back — now. If you shipped with semantic versioning and a shared filename like main.js, you face a painful CDN purge cycle while users land on a half-broken state. If you shipped with content hashing, the situation is far simpler: your old asset files are still sitting intact on the CDN, cached at their original URLs, completely unmodified. Rolling back is almost entirely a manifest swap.

This page walks through why content-hashed deployments are inherently rollback-friendly, exactly which files need to change during a rollback, a step-by-step shell procedure, verification commands, and the edge cases that can trip you up. For context on why content hashing beats semantic versioning for cache control, see Content Hashing vs Semantic Versioning.

Why Content-Hashed Assets Are Inherently Rollback-Friendly

When your build tool writes app.3f8d2a1c.js, the hash is derived from the file’s content. That filename is effectively immutable: as long as the source does not change, the hash does not change. When you deploy v3 of your app, v2’s app.7b4e9c2f.js still exists on your CDN origin. No new deploy touched it. No cache-control header expired it. The CDN served it from cache at its old URL for the past week, and it will continue to do so.

This is the property that makes rollback cheap: your CDN cache is not the problem. The problem is only the HTML entry point or asset manifest — the thin routing layer that tells browsers which hashed filename to request. When you rolled from v2 to v3, you updated index.html from <script src="/assets/app.7b4e9c2f.js"> to <script src="/assets/app.3f8d2a1c.js">. To roll back, you restore the prior index.html. The v2 asset file has been there the whole time.

This contrasts sharply with semantic-versioning rollbacks, where a CDN purge is mandatory — but more on that in the comparison table below.

One prerequisite makes this work: your build artifact retention strategy must keep prior build outputs. If your CI pipeline deletes old dist/ folders after deploy, you lose the prior index.html and manifest. See the section on build artifact retention below.

For more on how deterministic build outputs ensure the same source always produces the same hash — so prior artifacts remain valid — see that dedicated guide.

The Rollback Decision Matrix

Not every rollback is identical. The scope of what went wrong determines what you need to restore.

Scenario What broke What to restore CDN purge needed?
Bad JS/CSS logic shipped Application code index.html + manifest only No
Bad image or font shipped Static asset file index.html + manifest only No
HTML template itself corrupted Entry point markup index.html only No (HTML is not long-cached)
Entire build artifact lost Origin files deleted Full redeploy from prior artifact No (if assets survive on CDN)
Assets deleted from CDN origin Origin + edge gone Full redeploy from prior artifact No (they are already gone)
Semantic versioning deployment Shared filenames overwritten New files + CDN purge Yes

The middle rows require a full artifact redeploy — not just a manifest swap — but still no CDN purge. If the hashed asset files exist somewhere (your artifact store, a prior deployment’s origin bucket), they can be re-uploaded and will resume serving from the CDN immediately. The CDN was never the bottleneck.

Build Artifact Retention Strategy

Rollback requires that you have prior build outputs available. The minimum viable strategy is keeping N previous builds in your artifact store.

A workable convention: tag each build with a timestamp and short Git SHA, store the full dist/ output as a compressed archive, and keep the last five. For AWS S3-backed deployments:

s3://your-deploy-bucket/releases/
  20240614T143022-a1b2c3d/   ← v3 (current, broken)
  20240613T091500-7e4f8a2/   ← v2 (rollback target)
  20240611T162344-9d3c1b5/   ← v3 archive
  20240609T080112-2f6a8e4/   ← v4 archive
  20240607T113055-4c9b7d1/   ← v5 archive

Each release folder contains the full dist/ output: index.html, asset-manifest.json, and all hashed asset files. When you deploy, you sync a release folder to your serving origin. The hashed files from older releases remain in place — you do not delete them on deploy.

Under cache-key architecture, each hashed URL is its own cache key. Old keys are never invalidated by new deploys, so there is no cost to leaving old assets on the origin.

Rollback Procedure: Step by Step

The following shell script identifies available releases, deploys a specific prior release’s HTML and manifest, and leaves all existing hashed assets untouched on the origin.

#!/usr/bin/env bash
# rollback.sh — deploy a prior release's HTML/manifest without touching hashed assets
#
# Usage:
#   ./rollback.sh list                          → list available releases
#   ./rollback.sh deploy 20240613T091500-7e4f8a2  → roll back to that release
#
# Prerequisites: AWS CLI configured, S3_BUCKET and CLOUDFRONT_ORIGIN_ID env vars set

set -euo pipefail

S3_BUCKET="${S3_BUCKET:?Set S3_BUCKET to your deploy bucket name}"
RELEASES_PREFIX="releases"
ORIGIN_PREFIX="www"

list_releases() {
  echo "Available releases (newest first):"
  aws s3 ls "s3://${S3_BUCKET}/${RELEASES_PREFIX}/" \
    | awk '{print $NF}' \
    | sed 's|/$||' \
    | sort -r
}

deploy_release() {
  local release_id="$1"
  local release_path="${RELEASES_PREFIX}/${release_id}"

  echo "Verifying release exists: ${release_id}"
  aws s3 ls "s3://${S3_BUCKET}/${release_path}/" > /dev/null \
    || { echo "ERROR: Release not found: ${release_id}"; exit 1; }

  echo "Deploying hashed assets (skips files already present on origin)..."
  aws s3 sync \
    "s3://${S3_BUCKET}/${release_path}/assets/" \
    "s3://${S3_BUCKET}/${ORIGIN_PREFIX}/assets/" \
    --cache-control "public, max-age=31536000, immutable" \
    --exclude "*.html" \
    --exclude "asset-manifest.json"

  echo "Deploying manifest..."
  aws s3 cp \
    "s3://${S3_BUCKET}/${release_path}/asset-manifest.json" \
    "s3://${S3_BUCKET}/${ORIGIN_PREFIX}/asset-manifest.json" \
    --cache-control "no-cache, no-store, must-revalidate"

  echo "Deploying entry point HTML (atomic last step)..."
  aws s3 cp \
    "s3://${S3_BUCKET}/${release_path}/index.html" \
    "s3://${S3_BUCKET}/${ORIGIN_PREFIX}/index.html" \
    --cache-control "no-cache, no-store, must-revalidate"

  echo "Rollback complete. Active release: ${release_id}"
}

case "${1:-}" in
  list)   list_releases ;;
  deploy) deploy_release "${2:?Provide a release ID from 'list'}" ;;
  *)      echo "Usage: $0 list | deploy <release-id>"; exit 1 ;;
esac

Three things to note about this script:

  1. Hashed assets are synced first, HTML last. This avoids a window where index.html references assets that are not yet on the origin.
  2. --cache-control "public, max-age=31536000, immutable" on assets, no-cache on HTML. The manifest and entry point must always be fresh; the hashed files can be cached aggressively.
  3. The sync for assets is a no-op when the files already exist. Because S3 sync skips identical objects, re-uploading assets that are already on the origin is fast and safe.

Rollback Flow Diagram

v2 Deploy (broken) Detect Issue alert / monitor Scope? HTML only or full? HTML only full redeploy Swap index.html Sync prior assets CDN serves old hashed URLs no purge needed Verify curl / smoke

Verification: Confirming the Rollback Took Effect

After restoring the prior index.html, confirm two things: the entry point references the expected prior hashes, and the CDN is serving those asset URLs with HTTP 200.

#!/usr/bin/env bash
# verify-rollback.sh — confirm prior hashed assets are responding correctly
# Usage: SITE_URL=https://www.example.com ./verify-rollback.sh

set -euo pipefail

SITE_URL="${SITE_URL:?Set SITE_URL to your site's base URL}"

echo "=== Step 1: Fetch entry point and extract asset URLs ==="
HTML=$(curl -sf "${SITE_URL}/")

JS_SRC=$(echo "$HTML" | grep -oE '/assets/[a-zA-Z0-9._-]+\.[a-f0-9]{8,16}\.js' | head -1)
CSS_SRC=$(echo "$HTML" | grep -oE '/assets/[a-zA-Z0-9._-]+\.[a-f0-9]{8,16}\.css' | head -1)

echo "JS bundle : ${JS_SRC:-NOT FOUND}"
echo "CSS bundle: ${CSS_SRC:-NOT FOUND}"

echo ""
echo "=== Step 2: Verify hashed assets return HTTP 200 ==="

check_asset() {
  local path="$1"
  local label="$2"
  local status
  status=$(curl -so /dev/null -w "%{http_code}" "${SITE_URL}${path}")
  if [ "$status" = "200" ]; then
    echo "OK  ${label}: ${path}${status}"
  else
    echo "FAIL ${label}: ${path}${status}"
    return 1
  fi
}

[ -n "${JS_SRC:-}" ]  && check_asset "$JS_SRC"  "JS"
[ -n "${CSS_SRC:-}" ] && check_asset "$CSS_SRC" "CSS"

echo ""
echo "=== Step 3: Confirm HTML is not cached (must-revalidate) ==="
CACHE_CONTROL=$(curl -sI "${SITE_URL}/" | grep -i "cache-control" | tr -d '\r')
echo "Cache-Control on /: ${CACHE_CONTROL}"

echo ""
echo "Rollback verification complete."

Run this immediately after deploying the prior index.html. If a prior asset URL returns 404, see the edge cases section below.

Edge Cases

What if old hashed assets were deleted from the CDN origin?

If your deploy pipeline runs aws s3 rm --recursive or equivalent on the entire origin prefix before uploading, old hashed files will be gone. In this case, the rollback also requires re-uploading the prior asset files — not just the HTML. This is why the rollback script above syncs assets before swapping the HTML. If you discover assets are missing, retrieve the prior build artifact archive and run the full sync step. The CDN edge cache may still hold copies of popular files even if the origin deleted them — but do not rely on this, as TTL expiry could leave you with a cold origin miss.

The fix for the future: never delete old hashed files during deploy. Sync new files in, but leave existing hashed paths untouched. Storage cost for a few extra megabytes of old JS/CSS is negligible.

What if the HTML manifest itself was corrupted?

A corrupted asset-manifest.json (truncated write, bad JSON, wrong encoding) will cause the application’s code-splitting runtime to fail when trying to resolve dynamic imports. The symptom is a clean HTTP 200 on index.html but JS errors in the console on chunk load. Restore the prior manifest file from the release archive using the aws s3 cp command in the rollback script. The manifest carries no-cache headers, so users will receive the corrected version on their next request.

What if the hash length changed between releases?

If v2 used 8-character hashes and v3 switched to 16-character hashes (common when migrating to monorepo tooling — see preventing hash collisions), the verification grep pattern in the script above covers both with [a-f0-9]{8,16}. No other change is needed for the rollback itself. The v2 index.html will reference 8-character filenames, and those files are on the origin.

What if users have a service worker caching index.html?

Service workers can intercept fetch requests and return a cached index.html even after you have deployed the rollback. If your service worker has a short TTL or uses a network-first strategy for HTML, users will receive the rollback within minutes. If the service worker uses cache-first on HTML, users may be stuck on the broken v3 index.html until the service worker cache expires. Consider forcing a service worker update using skipWaiting() on the new service worker that ships with the rollback, or serve a Clear-Site-Data: "cache" header on the HTML response temporarily.

Comparison: Content-Hashed Rollback vs Semantic-Versioning Rollback

Step Content-hashed rollback Semantic-versioning rollback
Restore old JS/CSS files to origin Usually a no-op (files never overwritten) Required — must re-upload overwritten files
Restore entry point HTML Yes — swap index.html Yes — swap index.html
CDN purge required No Yes — shared filenames are stale in edge cache
Time to full effect Seconds (HTML has no-cache) Minutes to hours (CDN purge propagation)
Risk of serving mismatched HTML + assets Very low High during purge window
Dependency on artifact retention Yes Yes

The semantic-versioning rollback is slower because the CDN has cached main.js and main.css under their fixed names. Deploying the prior version of those files overwrites the origin, but CDN edge nodes hold the broken version until the purge propagates globally — typically 30 seconds to several minutes per PoP, and up to 15 minutes if the CDN’s purge queue is busy.

For CI/CD-automated rollback workflows that hook directly into your pipeline, see rolling back fingerprinted assets in CI/CD.

When to Reconsider This Approach

Content-hashed rollbacks are fast and reliable, but a few conditions complicate them:

  • You do not keep prior build artifacts. If CI discards the old dist/ folder, you cannot recover the old index.html. Implement an artifact archiving step before this happens in production.
  • Your CDN has aggressive origin shield policies. Some origin shield configurations cache 404 responses for deleted assets. If you deleted v2 assets and then try to re-serve them after a CDN origin re-upload, you may need to purge only the specific 404 cache entries — not the full cache. Scope purges narrowly rather than purging everything.
  • Your HTML entry point is generated dynamically. If a server-side template inlines the asset manifest at request time, “swapping index.html” means deploying the prior server binary or template, not a static file copy. The rollback procedure above is for static-site and SPA deployments. Dynamic servers require a deployment rollback at the application layer.
  • Hash length is 8 characters and asset count exceeds 50,000 files. At that scale, the birthday paradox creates a small but non-trivial collision risk. Upgrade to 12–16 character hashes for large monorepos with thousands of output chunks.

Frequently Asked Questions

Do I need to purge the CDN when rolling back a content-hashed release?

No. Old hashed asset URLs remain valid indefinitely — they were never overwritten. Only the HTML entry point or manifest needs to be swapped back to the prior version. The CDN edge nodes continue serving the prior asset files from cache without any intervention.

How long do I need to keep prior build artifacts?

A minimum of three prior builds covers most incidents, since production issues are usually detected within hours. For regulated environments where audit trails matter, retaining 30 days of build archives is common. Storage cost is low because hashed asset files that are unchanged across releases are identical and can be deduplicated in your artifact store.

Can I roll back just one asset file, not the whole release?

No. Because index.html references a specific set of content hashes as an atomic unit — the hash of main.js must match the hash of the main.css it was built alongside — you cannot swap individual files without also updating the entry point. Rolling back to a complete prior release is the correct unit of rollback.

What cache TTL should I set on index.html to make rollbacks faster?

Set Cache-Control: no-cache, no-store, must-revalidate on your HTML entry point. This forces browsers and CDN edge nodes to revalidate on every request. Because the HTML is small (typically under 10 KB), the performance cost is negligible and the benefit — near-instant rollback propagation — is significant.

Does the rollback procedure differ for Vite vs Webpack builds?

No. The rollback procedure is tooling-agnostic. Both Vite and Webpack output an index.html referencing hashed filenames and an optional asset-manifest.json. The S3 sync and curl verification commands above work identically regardless of which bundler generated the output. For bundler-specific hash configuration, see how to configure content hashing in Vite production builds.