Rollbacks and Deploy Safety for Static Sites

Static hosting removes a lot of failure modes — no runtime, no database, no server to fall over — and leaves one: you can publish a broken build. A missing partial, a content file that fails a schema, an asset pipeline that silently produced nothing. The site is up, fast, cached at the edge, and wrong.

The defence is not more careful deploying; it is making a bad deploy cheap to undo. This guide covers atomic deploys, immutable artifacts, smoke tests that run against the deployed URL, and the one-command rollback that turns an incident into an inconvenience. It sits under Production-Ready Deployment & CI/CD Workflows, alongside Preview Environments for Pull Requests — previews catch problems before merge, this catches the ones that get through.

Four layers of deploy safety Four stacked layers, each catching what the previous one missed. Build gates catch broken content before deploy. Atomic upload prevents partial states. Smoke tests against the deployed URL catch broken output. A pointer-based rollback restores the previous build in seconds when everything else fails. Each layer catches what the one before it missed 1 · Build gates — link check, schema, word count, Lighthouse budget catches broken content before anything is published 2 · Atomic upload — complete build to a new immutable location, then flip removes the half-deployed window entirely 3 · Smoke tests — against the deployed URL, not the build directory catches output that built fine and serves wrong 4 · Rollback — repoint at the previous deployment, under 60 seconds the layer that makes every other decision reversible
The layers are ordered by cost: gates are free and run on every commit, rollback is the expensive one you hope never to use — and the only one that works when the failure is something nobody predicted.

Atomic Deploys Are Not Optional

A deploy that copies files into a live directory produces a window — sometimes seconds, sometimes minutes — where the site is a mixture of two builds. New HTML references hashed assets that have not uploaded yet; readers get unstyled pages and 404s on scripts. It is the single most common cause of "the site broke for five minutes after a deploy".

Every modern static host solves this the same way: upload the complete build to a new immutable location, verify it, then flip a pointer.

HostAtomic by defaultPrevious builds addressableRollback mechanism
NetlifyYesYes, indefinitelyPublish an earlier deploy
Cloudflare Pages / WorkersYesYes, by deployment IDPromote a previous deployment
VercelYesYes, by deployment URLPromote to production
S3 + CloudFront syncNoOnly via versioningRe-upload, then invalidate
rsync to a VMNoOnly what you keptRestore from a copy

The last two rows are where teams get hurt. If you must deploy to object storage or a VM, build atomicity yourself: upload to a versioned prefix, then move a symlink or update the origin path.

# Atomic deploy to a VM origin: upload beside, then swap a symlink
STAMP=$(git rev-parse --short HEAD)
rsync -a --delete dist/ deploy@origin:/var/www/releases/"$STAMP"/
ssh deploy@origin "ln -sfn /var/www/releases/$STAMP /var/www/current.new && \
                   mv -Tf /var/www/current.new /var/www/current"

mv -Tf on a symlink is atomic on Linux, so no request ever sees a partial state, and every previous release remains on disk ready to be swapped back.

Keep Build Artifacts Immutable and Addressable

A rollback is only fast if the previous output still exists somewhere you can point at. That means two disciplines.

Stamp every build. Put the commit SHA in the output — a meta tag, a /version.json, a comment in the HTML — so you can tell from a browser which build is live:

<meta name="build" content="a91f3c2" data-built="2026-08-01T09:14:22Z">

This is the first thing you check during an incident, and without it you spend the first five minutes arguing about whether the deploy actually went out.

Retain deployments. Thirty days is a reasonable default for a content site. Static output is small, and the cost of retention is far below the cost of discovering that the last known-good build was garbage-collected an hour ago.

Immutability matters for assets too: hashed filenames mean an old HTML page and a new one can coexist during a cache transition without fighting over /style.css. That is the same policy described in CDN Caching Rules for SSGs, and it is what makes a rollback safe rather than merely fast — rolling back HTML that references hashed assets works because those assets were never overwritten.

Smoke Test the Deployment, Not the Build

A build directory that passes every check can still serve wrong: a host redirect rule swallowing a path, a base URL configured for the wrong environment, an asset excluded by a deploy ignore file. Smoke tests run against the deployed URL and answer one question — is this site usable?

// scripts/smoke.mjs <base-url> — exits non-zero on the first failure
const base = process.argv[2].replace(/\/$/, '');
const checks = [
  { path: '/', must: ['<title', 'site-header', 'href="/guides/'] },
  { path: '/guides/', must: ['<h1', 'article'] },
  { path: '/guides/deploying/', must: ['<h1', 'site-footer'] },
  { path: '/sitemap.xml', must: ['<urlset', '<loc>'] },
];

let failed = 0;
for (const { path, must } of checks) {
  const res = await fetch(base + path);
  const body = await res.text();
  if (res.status !== 200) { console.error(`FAIL ${path} → ${res.status}`); failed++; continue; }
  for (const needle of must) {
    if (!body.includes(needle)) { console.error(`FAIL ${path} missing ${needle}`); failed++; }
  }
  // Every hashed asset the page references must actually exist
  for (const [, href] of body.matchAll(/(?:href|src)="(\/_assets\/[^"]+)"/g)) {
    const asset = await fetch(base + href, { method: 'HEAD' });
    if (!asset.ok) { console.error(`FAIL ${path} → missing asset ${href}`); failed++; }
  }
}
console.log(failed ? `smoke: ${failed} failure(s)` : 'smoke: OK');
process.exit(failed ? 1 : 0);

Run it twice: once against the preview URL before promotion, and once against production immediately after. The second run is what turns a silent bad deploy into an alert, and it is the trigger for an automatic rollback if you choose to wire one — see Running Smoke Tests Against a Preview URL for the full check set.

Make Rollback a Single Command

The rollback procedure must be short enough to execute correctly at 2 a.m. by someone who did not write it. That means one command, documented where people will look, with no build step.

# Cloudflare Workers/Pages — list and promote a previous deployment
npx wrangler deployments list
npx wrangler rollback <deployment-id>

# Netlify
netlify api listSiteDeploys --data '{"site_id":"SITE"}' | jq -r '.[0:5][] | "\(.id) \(.created_at) \(.title)"'
netlify api restoreSiteDeploy --data '{"site_id":"SITE","deploy_id":"DEPLOY"}'
Incident timeline with and without a rehearsed rollback Two timelines. Without a rehearsed rollback: detection at 4 minutes, diagnosis at 14, a fix commit at 26, a rebuild at 31 and recovery at 34 minutes. With a rehearsed rollback: detection at 4 minutes, decision at 5, pointer flip at 6 and recovery at 6 minutes, with diagnosis happening afterwards at leisure. Fix forward vs roll back, same incident Fix forward detect 4m diagnose 10m write + review fix 12m rebuild 5m 34m Roll back detect 4m decide flip 6m — site restored diagnose calmly, off the clock Detection time is identical in both — the difference is entirely what happens after the decision Rolling back does not skip the diagnosis; it moves it out of the incident
The argument for rolling back first is not that diagnosis is unimportant — it is that diagnosis is slower than a pointer flip, and readers are looking at the broken site the whole time.

Rehearse it. A rollback nobody has performed is a hypothesis. Once a quarter, roll production back one deployment on purpose, confirm the site is correct, and roll forward again — the whole exercise takes five minutes and is the difference between a documented procedure and a working one.

Decide What "Safe to Deploy" Means

A deploy policy that treats every change identically is either too slow for a typo fix or too loose for a template change. Classify changes and let the classification decide the ceremony.

Content-only changes — prose edits, a new article, a corrected number. These touch one page's output. A build gate plus a smoke test is enough, and they can deploy on merge, continuously, at any hour.

Template and layout changes — a partial, a stylesheet, a component. These touch every page that uses them, so a single broken partial is a site-wide incident. These deserve a preview review, a visual check on at least one page per template, and deployment during hours when someone is watching.

Structural changes — routing, URL scheme, generator upgrade, dependency bumps that affect the build. These can break things no page-level check will notice. Stage them behind a canary, deploy them early in the week, and never batch them with content.

# Classify from the diff and gate accordingly
- name: Classify change
  id: classify
  run: |
    if git diff --name-only origin/main | grep -qE '^(layouts|src/components|src/styles)/'; then
      echo "tier=template" >> "$GITHUB_OUTPUT"
    elif git diff --name-only origin/main | grep -qE '^(config|package.json|astro.config)'; then
      echo "tier=structural" >> "$GITHUB_OUTPUT"
    else
      echo "tier=content" >> "$GITHUB_OUTPUT"
    fi

Writing the tiers down is most of the value. Teams without them tend to apply template-level caution to typo fixes, which slows publishing until people start batching changes — and a batched deploy is exactly the thing that makes an incident hard to diagnose, because five changes went out at once.

The corollary is that small, frequent content deploys are a safety feature rather than a risk. When one commit is one deploy, the rollback target is obvious and the blast radius is a single page. This is the same argument for keeping build times low enough that continuous deployment is comfortable, which is why the work in Incremental Builds and Build Caching for SSGs pays off in reliability and not only in speed.

Detect Automatically, Decide Deliberately

Automatic rollback on a failed smoke test is attractive and worth a moment's thought. It works well when the check is unambiguous — the homepage returns 500, an asset is missing — and badly when it is a threshold, because a flaky Lighthouse run should not revert a correct deploy.

A good split:

  • Automatic rollback: hard failures only. Non-200 on a critical path, missing referenced asset, sitemap that does not parse.
  • Alert, do not act: performance budgets, visual diffs, link-check warnings. A human decides.
# .github/workflows/deploy.yml (excerpt)
- name: Deploy
  id: deploy
  run: npx wrangler deploy --json | tee deploy.json

- name: Smoke test production
  id: smoke
  run: node scripts/smoke.mjs https://example.com
  continue-on-error: true

- name: Roll back on hard failure
  if: steps.smoke.outcome == 'failure'
  run: |
    PREV=$(npx wrangler deployments list --json | jq -r '.[1].id')
    npx wrangler rollback "$PREV" --message "auto-rollback: smoke test failed"
    exit 1

The exit 1 at the end matters: the pipeline must end red even though the site is healthy, or the next person to look will believe the deploy succeeded.

Watch the First Ten Minutes

Most bad deploys announce themselves quickly if you are looking at the right things. Three signals cover almost everything on a static site:

404 rate at the edge. A structural break — a changed URL scheme, a missing redirect file — shows up within seconds as a spike. This is the single most valuable alert a static site can have.

Client-side error rate. A missing or mismatched script fires errors on every page view. Even a minimal error beacon catches this; the collection mechanics are the same as in Measuring CLS in the Field With web-vitals.js.

Cache hit ratio. A sudden drop usually means asset filenames changed in a way that invalidated everything, which is a performance incident even when the site is correct.

Watch them for ten minutes after every production deploy, and give the person who deployed a link to the dashboard rather than expecting them to find it. If nothing has moved in ten minutes, a static deploy is almost certainly fine — there is no slow-burn resource leak to worry about.

Staged Rollouts for Structural Changes

Content edits do not need a canary; a redesign, a routing change or a generator upgrade does. On a static host the mechanism is an edge routing rule rather than a deployment platform, which makes it cheap enough to use whenever the change touches every page.

Edge-routed canary for a static deployment Incoming requests reach an edge worker that sends five percent to the new deployment and ninety-five percent to the current one, with a sticky cookie so a reader stays on one version. Error rate and vitals from each group are compared before the split is widened to fifty and then a hundred percent. Five percent first, then decide with data Readers all traffic Edge worker sticky by cookie Current deployment · 95% known good, unchanged New deployment · 5% watched for 15 minutes Widen to 50% then 100% only when error rate and vitals match the control group Aborting is the same one-line change that started it — set the split back to zero
The sticky cookie matters: without it a reader flips between two versions as they navigate, which produces confusing bug reports and cache noise.
// Edge canary: 5% to the new deployment, sticky per reader
export default {
  async fetch(request, env) {
    const cookie = request.headers.get('cookie') || '';
    let bucket = cookie.match(/(?:^|;\s*)rel=(new|old)/)?.[1];
    if (!bucket) bucket = Math.random() < env.CANARY_SHARE ? 'new' : 'old';

    const origin = bucket === 'new' ? env.NEW_ORIGIN : env.CURRENT_ORIGIN;
    const url = new URL(request.url);
    const res = await fetch(new Request(new URL(url.pathname + url.search, origin), request));
    const out = new Response(res.body, res);
    out.headers.append('set-cookie', `rel=${bucket}; Path=/; Max-Age=3600; SameSite=Lax`);
    out.headers.set('x-release', bucket);
    return out;
  },
};

The x-release header is what makes the canary measurable: field data and edge logs can be grouped by it, so "is the new version worse?" becomes a query rather than an opinion. Fifteen minutes at five per cent is usually enough to catch a structural break; a week is enough to catch a subtle one, and there is no cost to leaving a canary running longer on a site with no server bill.

Abort by setting the share to zero. That is a configuration change with no deploy, which makes it the fastest reversal available — faster even than a rollback, because nothing has to be promoted.

Common Pitfalls

  • Sync-style deploys. aws s3 sync into a live bucket is not atomic and will serve mixed builds. Upload to a versioned prefix and flip.
  • Rollback that rebuilds. If restoring the previous version requires a build, you are minutes away from recovery instead of seconds. Keep artifacts.
  • No build stamp. Without a visible SHA in the output, the first minutes of an incident go to establishing what is even live.
  • Smoke tests against the build directory. They pass while production serves something else entirely. Test the URL.
  • Purging the whole cache on rollback. Immutable hashed assets never need purging; over-purging turns a rollback into a traffic spike against the origin.
  • Never rehearsing. The first real use of a rollback should not be the first use of a rollback.

Key Takeaways

  • Insist on atomic deploys; build them yourself with a versioned upload and a symlink swap if your host will not.
  • Stamp every build with its commit SHA and retain deployments for at least thirty days.
  • Smoke test the deployed URL — including that referenced assets exist — before and after promotion.
  • Make rollback one command with no build step, document it where people look, and rehearse it quarterly.
  • Automate rollback only for unambiguous failures; alert on everything else and let a human decide.
  • Watch 404 rate, client errors and cache hit ratio for ten minutes after each deploy.

FAQ

Why does a static site need a rollback plan at all?

Because a bad build is indistinguishable from a bad server. A template change that drops a navigation partial, a content edit that breaks a schema, or a deploy that half-uploads leaves readers with a broken site, and the only cure is putting the previous output back quickly. Static hosting makes that fast, but only if the previous output still exists.

What makes a deploy atomic?

Readers see either the whole old version or the whole new one, never a mixture. Hosts achieve this by uploading a complete build to a new immutable location and then flipping a pointer. Syncing files into a live directory is the opposite, and it produces windows where new HTML references assets that have not uploaded yet.

How fast should a rollback be?

Under a minute from decision to restored site. That is achievable on any host that keeps previous deployments addressable, because a rollback is a pointer change rather than a rebuild. If your rollback requires a rebuild, you do not have a rollback, you have a re-deploy.

What should smoke tests check after a deploy?

The handful of things whose failure means the site is unusable: the homepage and one page of each template return 200, the navigation and footer render, an internal link resolves, the sitemap parses, and the built asset referenced by the HTML actually exists. Five checks that take ten seconds catch most catastrophic deploys.

Do I need a canary or staged rollout for a static site?

Rarely for content changes, and it is worth it for structural ones. Routing a small percentage of traffic to the new deployment for a few minutes catches a broken template before it reaches everyone, and on a static host the mechanism is an edge routing rule rather than infrastructure.

How many previous builds should I retain?

Enough to cover the time it takes to notice a problem, which is usually longer than you think. Thirty days of deployments is a reasonable default; the storage cost of static output is negligible compared with the cost of not being able to go back.