Atomic Deploys vs Incremental Uploads

There are two ways to publish a static site. You can copy the new files over the old ones in place, or you can publish a complete new copy and switch to it. The first is what rsync and aws s3 sync do by default; the second is what every managed static host does. The difference decides your worst-case behaviour during a deploy and whether an instant rollback is even possible.

This guide explains what actually breaks during an in-place upload, how atomic publishing avoids it, and how to build the pattern yourself when your host does not provide it. It supports the practices in Rollbacks and Deploy Safety for Static Sites.

Prerequisites

  • A static build that produces a complete output directory on every run.
  • Deploy access to your origin: an object-storage bucket, a VM, or a managed host's CLI.
  • Hashed asset filenames, which make several of the mitigations below possible.

What Breaks During an In-Place Upload

The mixed-state window during an incremental upload A timeline of a 42-second sync. At 0 seconds the site is entirely the old build. Between 6 and 34 seconds it is a mixture: some pages are new, some assets are missing, and deletions have removed files the old HTML still references. After 42 seconds it is entirely the new build. Requests arriving in the middle window can fail. 42 seconds of sync, 28 seconds of mixed site old build consistent mixed: new HTML, missing assets, deleted files every request here can break new build consistent 0 s 20 s 42 s Three distinct failures inside that window 1 · new HTML references a hashed asset that has not uploaded yet → unstyled page 2 · a delete pass removes an asset the still-cached old HTML needs → broken page 3 · two pages of the same navigation disagree → readers see links that 404
The window scales with the number of changed files, so the deploys most likely to break — a template change touching every page — are exactly the slowest ones.

The failure is probabilistic, which is why it survives so long in a pipeline: most deploys change a handful of pages and finish in seconds, so nobody notices. Then a redesign changes every file, the sync takes two minutes, and the site is visibly broken for the duration.

How Atomic Publishing Works

Three steps, always the same shape: upload a complete build to a new location, verify it, flip a pointer.

1. build/            →  origin/releases/a91f3c2/     (complete copy, nothing live yet)
2. verify            →  smoke tests against releases/a91f3c2/
3. flip              →  current  →  releases/a91f3c2/   (single atomic operation)

Because the flip is one operation, no request ever observes a partial state. Because the previous release is untouched, rollback is the same flip in reverse — the property that makes Rolling Back a Bad Static Deploy in Under a Minute possible at all.

PropertyIncremental syncAtomic publish
Mixed-state windowSeconds to minutesNone
RollbackRe-upload the old buildFlip the pointer
Rollback timeSame as a deployUnder a second
StorageOne copyOne copy per retained release
Works with hashed assetsPartiallyFully

Building Atomicity Yourself

On a VM or container origin

#!/usr/bin/env bash
set -euo pipefail
STAMP=$(git rev-parse --short HEAD)
REMOTE=deploy@origin

# 1. Upload the complete build beside the live one
rsync -a --delete dist/ "$REMOTE":/var/www/releases/"$STAMP"/

# 2. Verify the uploaded copy before it is reachable
ssh "$REMOTE" "test -f /var/www/releases/$STAMP/index.html && \
               test -d /var/www/releases/$STAMP/_assets"

# 3. Flip — mv -T on a symlink is atomic on Linux
ssh "$REMOTE" "ln -sfn /var/www/releases/$STAMP /var/www/current.new && \
               mv -Tf /var/www/current.new /var/www/current"

# 4. Prune, keeping the last 30 releases
ssh "$REMOTE" "ls -1dt /var/www/releases/*/ | tail -n +31 | xargs -r rm -rf"
echo "published $STAMP"

The subtlety is mv -Tf rather than ln -sfn directly onto the live path: replacing an existing symlink with ln is not atomic on all systems, whereas renaming over it is. Serve /var/www/current from the web server and never touch it otherwise.

On object storage behind a CDN

Object stores have no rename, so the pointer lives in the CDN instead. Upload to a versioned prefix and change the origin path:

STAMP=$(git rev-parse --short HEAD)
aws s3 sync dist/ "s3://$BUCKET/releases/$STAMP/" --delete --cache-control 'public,max-age=31536000,immutable' \
  --exclude '*.html'
aws s3 sync dist/ "s3://$BUCKET/releases/$STAMP/" --exclude '*' --include '*.html' \
  --cache-control 'public,max-age=0,must-revalidate'

# Flip: point the distribution's origin path at the new prefix
aws cloudfront update-distribution --id "$DIST_ID" \
  --distribution-config "$(jq --arg p "/releases/$STAMP" '.OriginPath = $p' current-config.json)"

Two sync passes rather than one because HTML and hashed assets want opposite cache policies — the reasoning is in CDN Caching Rules for SSGs. The origin-path change is the pointer flip, and it propagates in seconds without touching a single object.

Pointer flip on object storage and on a VM origin Two implementations of the same pattern. On object storage, releases live under versioned prefixes and the CDN origin path selects one. On a VM, releases live in dated directories and a symlink named current selects one. In both cases the previous release stays intact and reachable. One pattern, two pointers Object storage releases/8f21ac/ releases/a91f3c2/ CDN OriginPath → VM origin releases/8f21ac/ releases/a91f3c2/ symlink current → The previous release is still complete and still reachable — that is the rollback Pruning happens after the flip, oldest first, never touching the two most recent Neither implementation ever modifies a file that is currently being served
The pointer differs by platform — an origin path, a symlink, a managed deployment ID — but the invariant is identical: nothing being served is ever modified in place.

If You Must Sync, Reduce the Window

Sometimes the platform genuinely offers nothing better. Three mitigations turn a bad window into a small one:

Upload assets before HTML. New HTML then never references an asset that has not landed. This alone removes the most visible symptom.

Never delete in the same pass. Run the upload without --delete, then prune orphaned files in a separate job a day later. Deletions are what break the old HTML that readers still have cached.

Hash every asset filename. With hashed names, old and new assets coexist; nothing is overwritten, so an old page keeps working while a new page uses new files.

# Reduced-risk sync: assets first, HTML second, no deletes
aws s3 sync dist/ "s3://$BUCKET/" --exclude '*.html' \
  --cache-control 'public,max-age=31536000,immutable'
aws s3 sync dist/ "s3://$BUCKET/" --exclude '*' --include '*.html' \
  --cache-control 'public,max-age=0,must-revalidate'
# Deletions run separately, on a delay, from a scheduled job

What Atomicity Does Not Give You

Two expectations are worth correcting, because teams that adopt the pattern sometimes assume it covers more than it does.

It does not make the deploy correct. An atomic publish of a broken build publishes the broken build instantly and completely. Atomicity removes the mixed state, not the mistake — which is why the verification step sits between upload and flip, and why smoke tests still run afterwards.

It does not synchronise the CDN. The flip changes what your origin serves; edge caches still hold whatever they fetched before, and different locations expire at different moments. For a few seconds to a few minutes, readers in different places see different builds. That is usually fine for a content site and occasionally is not — if a deploy changes a URL scheme, the transitional period can produce internal links that 404 at one edge and resolve at another. The fix is the same as always: keep HTML short-cached or revalidated, keep assets hashed and immutable, and avoid changing URL structure in the same deploy as anything else.

There is also a build-side prerequisite that is easy to miss: the output directory must be complete on every run. A generator that writes incrementally into a persistent output directory can produce a release folder containing stale files from a previous build. Delete the output directory before every build, or build into a fresh temporary path, so that what you publish is exactly what the current source produces.

Measured Impact

A 3,400-file documentation site, measured over twenty deploys of each type, with a synthetic checker requesting a page every 200 ms throughout:

StrategyUpload timeRequests served brokenRollback time
sync with --delete, unordered42 s96 of 210 (46%)42 s (re-upload)
sync, assets first, no deletes39 s4 of 195 (2%)39 s (re-upload)
Versioned prefix + origin flip44 s0 of 220< 1 s
Managed host (Cloudflare Workers)31 s0 of 155< 1 s
Requests served broken during a deploy, by strategy A bar chart. An unordered sync with deletes serves 46 percent of requests broken during the deploy window. An ordered sync without deletes serves 2 percent. A versioned prefix with an origin flip and a managed host both serve zero. Share of requests broken during the deploy sync + delete 46% ordered, no delete 2% prefix + flip 0% managed host 0% 3,400 files, 20 deploys per strategy, synthetic request every 200 ms throughout
Ordering and skipping deletes recovers most of the safety for free, but only the pointer flip reaches zero — and only the pointer flip gives you a sub-second rollback.

Pitfalls & Rollback

  • Believing a fast deploy is a safe one. The window is proportional to changed files; the deploy that changes everything is the one that breaks.
  • Deleting in the upload pass. Deletions break readers holding cached old HTML. Prune separately and later.
  • Unhashed asset names. Overwriting /style.css in place guarantees a mismatch window regardless of ordering.
  • Verifying after the flip. Verify the uploaded release before it is reachable; that is the whole point of publishing beside rather than over.
  • Unbounded retention. Keep thirty releases, prune the rest, and never prune the two most recent.
  • Rollback: with a pointer, flip it back. Without one, the rollback is a full re-upload of the previous build — which is the strongest practical argument for adopting the pattern.

Conclusion

Atomic publishing is not a managed-host luxury; it is a pattern you can implement in five lines of shell on any origin. Upload a complete build beside the live one, verify it while it is still unreachable, flip a single pointer, and prune later. The payoff is not only that deploys stop breaking pages — it is that rollback becomes a sub-second operation, which changes what your team is willing to ship. The rest of the safety net is in Rollbacks and Deploy Safety for Static Sites.

FAQ

How long is the broken window with an incremental upload?

As long as the upload takes, which on a site with a few thousand files is typically ten to ninety seconds. Every request during that window can hit new HTML referencing assets that have not landed, or old HTML referencing assets already deleted.

Does uploading assets before HTML fix it?

It shrinks the window substantially and does not close it. Ordering assets first means new HTML never references a missing asset, but deletions and the HTML upload itself still take time, so readers can still see a mixture during the pass.

Is a hashed filename scheme enough on its own?

It removes the worst symptom — old HTML asking for an overwritten asset — but not the window where new HTML is partially uploaded. Hashing plus deferred deletion gets you most of the way; a pointer flip closes it completely.

Which hosts are atomic by default?

Netlify, Vercel and Cloudflare Pages and Workers all publish a complete immutable build and then switch traffic to it. Object storage and VM origins are not atomic unless you construct the pattern yourself with a versioned prefix and a pointer.

Does atomicity cost extra storage?

Yes, and negligibly for a static site. Keeping thirty days of builds of a few hundred megabytes each costs less than the engineering time of one incident, and it is what makes an instant rollback possible.