Show HN: Atomic symlink deployments that work on macOS URL
by mojoatomic | 2026-01-12 21:40:53+00:00
Most "atomic" deployment scripts aren't atomic.
If you're using the standard symlink swap pattern for zero-downtime deploys, you've probably seen this:

bash
ln -sfn releases/20260112 current
Looks atomic. It's not. Run it through strace:

symlink("releases/20260112", "current") = -1 EEXIST
unlink("current") = 0
symlink("releases/20260112", "current") = 0
There's a window where current doesn't exist. Under load, some requests get ENOENT.
The fix is well-documented for Linux:

bash
ln -s releases/20260112 .tmp/current.$$
mv -T .tmp/current.$$ current
The mv -T calls rename(2) which atomically replaces the symlink. Problem solved.
Except on macOS. BSD mv doesn't have -T, and it follows symlinks differently. The Capistrano/Deployer communities have known about this for over a decade. Most just accept the race condition on Mac, or tell you to develop on Linux.
I needed something that works on both (MSP managing mixed fleets - Linux servers, Mac dev machines, CI runners on both). The solution turned out to be Python's os.replace(), which calls rename(2) directly:

bash
if [[ "$platform" == "linux" ]]; then
ln -s "releases/$rel_id" "$tmp_link"
mv -T "$tmp_link" "current"
else
ln -s "releases/$rel_id" "$tmp_link"
python3 -c "import os; os.replace('$tmp_link', 'current')"
fi
I wrapped this in a deployment script with:
* Platform detection (GNU vs BSD coreutils)
* Directory-based locking with stale PID detection
* Automatic rollback on SIGINT/SIGTERM
* State machine cleanup (knows whether to rollback vs just clean up temp files)
Single bash script, no runtime dependencies beyond python3 (which is everywhere now).
GitHub: [https://github.com/mojoatomic/atomic-deployments.git]

Q: Why not just use Capistrano/Deployer/Shipit?
A: Those are great if you're already in that ecosystem. I needed a single script I could drop into any CI pipeline without pulling in Ruby/PHP/Node. Also, Deployer's own code shows it falls back to non-atomic on systems without mv -T.
Q: Why not use containers/Kubernetes?
A: Not everyone is on k8s. Lots of us still deploy to VMs, bare metal, edge devices. Symlink swaps are still the simplest zero-downtime pattern for those environments.
Q: Python dependency defeats the "no dependencies" claim
A: Fair. But python3 ships with macOS and virtually every Linux distro. It's as close to "always there" as you get. The alternative is writing a small C binary, which creates a different dependency problem.
Q: What about NFS/network filesystems?
A: Don't. rename(2) atomicity guarantees don't hold across network filesystems. This is for local filesystems only.
Q: What about the renameat2() RENAME_EXCHANGE flag?
A: That's Linux 3.15+ only and requires glibc 2.28+. It does a true atomic swap of two paths, which is even better. But it's not portable, so I stuck with the symlink + rename pattern that works everywhere.
Q: Does this handle shared directories (logs, uploads, etc.)?
A: Not in scope for this script. It just does the atomic swap. Capistrano-style shared directory symlinking is a separate concern.

Key technical points
1. ln -sfn is unlink + symlink, not atomic
2. mv -T on Linux calls rename(2) which IS atomic
3. BSD mv follows symlinks, breaking the pattern
4. Python's os.replace() calls rename(2) directly on all POSIX systems
5. Capistrano's workaround (create symlink in subdirectory, mv with relative path) works but requires their Ruby runtime
6. The script detects platform by checking if mv --version returns GNU, not by uname (more reliable for edge cases like GNU coreutils on Mac via Homebrew)