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 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.
| Property | Incremental sync | Atomic publish |
|---|---|---|
| Mixed-state window | Seconds to minutes | None |
| Rollback | Re-upload the old build | Flip the pointer |
| Rollback time | Same as a deploy | Under a second |
| Storage | One copy | One copy per retained release |
| Works with hashed assets | Partially | Fully |
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.
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:
| Strategy | Upload time | Requests served broken | Rollback time |
|---|---|---|---|
sync with --delete, unordered | 42 s | 96 of 210 (46%) | 42 s (re-upload) |
sync, assets first, no deletes | 39 s | 4 of 195 (2%) | 39 s (re-upload) |
| Versioned prefix + origin flip | 44 s | 0 of 220 | < 1 s |
| Managed host (Cloudflare Workers) | 31 s | 0 of 155 | < 1 s |
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.cssin 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.
Related
- Parent: Rollbacks and Deploy Safety for Static Sites — the layers this sits under.
- Rolling Back a Bad Static Deploy in Under a Minute — what the pointer buys you.
- Running Smoke Tests Against a Preview URL — verifying the release before the flip.
- CDN Caching Rules for SSGs — why HTML and assets need two upload passes.
- Cloudflare Pages Edge Caching Setup — atomic publishing on a managed host.