A three-hundred-page site sits on an ordinary VPS behind a hosting panel. No git on the server, no CI agent, no release switcher. Publishing one article touches thirty files: pages in four languages, an image, section indexes, feeds, five sitemaps. Every file goes up as its own scp. A connection drop on the fifteenth one is a normal working day, not an exotic edge case.
Atomicity for that set is not for sale. A different property is: "it failed, run it again, and the result is correct". The gap between those two goals shapes the entire design.
Two primitives, and both need to be known precisely
rename(2) replaces the target atomically: no process ever observes a moment when the file is missing. Hence the standard move — upload to a temporary file, then rename:
scp file host:/path/page.html.tmp.$RUN_ID
ssh "mv /path/page.html.tmp.$RUN_ID \
/path/page.html"
The constraint that breaks a tidy implementation: rename returns EXDEV when the paths sit on different mounted filesystems — even when it is the same filesystem mounted at two points. A shared staging directory such as /tmp/upload looks cleaner than a temporary file next to the target, and silently turns the rename into a copy. A copy is not atomic.
mkdir(2) supplies the other half: the directory is either created by you or already exists (EEXIST). An atomic test-and-set, and the only mutex available over bare ssh without installing anything on the receiving end.
Mutual exclusion is not a formality here. Deploy state lives in a file that gets read, modified and written back. Two concurrent runs produce a lost update, and it looks harmless: the first deploy's files stay in place and remain reachable at their addresses. It disappears only from the indexes and the sitemap — and it never comes back, because the next run takes the already-corrupted state as its baseline.
The file is atomic, the set is not
Thirty sequential uploads cannot be folded into one operation. The first of two decisions is to deploy in dependency order, so nothing appears before whatever references it: assets first, then pages, then indexes and feeds, and only then the sitemap.
A mid-way drop leaves an under-deployed site rather than a site with links into nothing. Check it against a list of addresses, not by eye: leave the image out of the check and you easily end up with a page returning 200 whose og:image returns 404. That defect lived on four of my section pages for exactly that reason — the check looked at documents and not at assets.
The state record is written last
The second decision goes by "the manifest is the commit marker", and the obvious reading of that phrase is wrong.
Wrong: nothing is visible until the state is recorded. Indexes, hubs and the sitemap are rendered from the locally modified state and ship to the server ahead of it. By the final stage the live site already shows the new material in full, while the record on the server is still the old one.
Right: the state record is a durable fact — "the transaction reached the end" — and the input for the next run. The invariant that actually does the work reads:
Take the reverse order. The record is committed first, the upload fails. The next run sees the object in the record and refuses to deploy it as new — leaving update mode, which assumes a previous version is alive. Every subsequent index render picks the object out of the record and links to a page that does not exist. The sitemap announces an address that returns 404 to search engines.
With the correct order, a failure leaves the system in the "not deployed" state: the object is absent from the record, a repeat run takes the normal path and rewrites everything. Where the target does have a real switching primitive, the same job is done by canary releases and progressive delivery — here the order of operations plays that role.
Write the failure windows down
| Failure during | Site | Record | Recovery |
|---|---|---|---|
| page upload | pages live, no index links to them | old | repeat the run as new |
| index upload | indexes link, sitemap stale | old | same |
| verification | visually consistent | old | same |
| after the commit | consistent | new | nothing |
The error message should name the state outright. "Pages may be live but unindexed" beats "deploy failed": the operator reads it at the worst possible moment and should not have to reconstruct the failure model from source. It is the same question as defining "recovered" during an incident — name the state, or everyone fills it in for themselves.
Verification comes before the commit
Every affected address is checked for 200 before the state is recorded. One non-200 and there is no commit; the system stays in the "not deployed" state. The reverse order turns a gate into a report of what already happened: the same defects surface, with nothing left to fix.
A response code confirms availability, not correctness. Checks are perfectly capable of granting confidence where none is warranted — a green CSP header is one example.
Why not a symlink, and why not rsync
Symlink switching needs control over the directory layout, which panel-managed hosting does not give. A detail surfaces along the way: the familiar command does not do what people expect of it. An strace measurement on uutils coreutils 0.8.0:
$ ln -sfn <new> current
unlink("current") = 0
symlink("<new>", "current") = 0
Two operations, and between them the path does not exist. A request landing in that window gets a 404. The GNU coreutils documentation describes -f literally as removing the existing destination and promises no atomicity. The atomic variant performs a single rename:
ln -s <new> current.tmp \
&& mv -T current.tmp current
rsync --delay-updates is the right tool when a deploy means synchronising a whole directory. Its man page is honest about the guarantee: files accumulate in a holding directory and are renamed into place at the end "in rapid succession", which makes the update "a little more atomic". Not a transaction — a shorter window.
What this design honestly does not give
There is no automatic rollback: uploaded files are not reverted, and rolling back is a manual job against backups. kill -9 leaves the lock hanging — release lives in the process cleanup block, and the lock has no TTL. And rename(2) guarantees that nobody sees an intermediate state, but not that the rename survives a power loss on its way to disk: that needs an fsync of the directory. Atomicity and durability are separate properties.
None of these limits gets in the way of the work. Pretending they are absent does.