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.
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.
| Host | Atomic by default | Previous builds addressable | Rollback mechanism |
|---|---|---|---|
| Netlify | Yes | Yes, indefinitely | Publish an earlier deploy |
| Cloudflare Pages / Workers | Yes | Yes, by deployment ID | Promote a previous deployment |
| Vercel | Yes | Yes, by deployment URL | Promote to production |
S3 + CloudFront sync | No | Only via versioning | Re-upload, then invalidate |
rsync to a VM | No | Only what you kept | Restore 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"}'
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 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 syncinto 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.
Related
- Parent: Production-Ready Deployment & CI/CD Workflows — where deploy safety fits the pipeline.
- Rolling Back a Bad Static Deploy in Under a Minute — the runbook, per host.
- Running Smoke Tests Against a Preview URL — the check set in full.
- Atomic Deploys vs Incremental Uploads — why the upload strategy decides your worst case.
- Preview Environments for Pull Requests — catching problems before they are deployed at all.
- CDN Caching Rules for SSGs — immutable assets are what make a rollback safe.