Your 'Atomic' Deploys Probably Aren't Atomic
Source: Dev.to

If you’re using symlink swaps for zero‑downtime deployments, you’ve probably written something like this:
ln -sfn releases/20260112 current
Looks atomic. It’s not.
The Bug
Run that command through strace:
symlink("releases/20260112", "current") = -1 EEXIST
unlink("current") = 0
symlink("releases/20260112", "current") = 0
Between unlink and the second symlink the current symlink doesn’t exist. Under load, some requests hit that gap and get ENOENT. Your “zero‑downtime” deploy just caused downtime.
The Linux Fix
The well‑documented solution is:
ln -s releases/20260112 .tmp/current.$$
mv -T .tmp/current.$$ current
Create a temporary symlink, then use mv -T to atomically replace the target. The -T flag forces mv to call rename(2), which is atomic on POSIX filesystems.
The macOS Problem
BSD mv lacks -T and follows symlinks differently—if current points to a directory, mv .tmp/current.$$ current moves the temp symlink into that directory instead of replacing it. The Capistrano community has long known this and works around it with Ruby, but most tools either accept the race condition on macOS or require Linux for development.
A Different Approach
A portable solution works on both Linux and macOS by using Python’s os.replace(), which directly invokes rename(2) on all POSIX systems.
detect_platform() {
if mv --version 2>/dev/null | grep -q 'GNU'; then
printf 'linux'
else
printf 'bsd'
fi
}
activate_release() {
local tmp_link=".tmp/current.$$"
ln -s "releases/$release_id" "$tmp_link"
if [[ "$(detect_platform)" == "linux" ]]; then
mv -T "$tmp_link" "current"
else
python3 -c "import os; os.replace('$tmp_link', 'current')"
fi
}
Platform detection checks for GNU coreutils (mv --version) rather than relying on uname, handling edge cases like Homebrew GNU coreutils on macOS.
Proving the Race Condition
A simple script demonstrates the bug:
#!/bin/bash
mkdir -p releases/v1 releases/v2
echo "v1" > releases/v1/version
echo "v2" > releases/v2/version
ln -s releases/v1 current
# Reader loop in background
(
for i in {1..10000}; do
cat current/version 2>/dev/null || echo "ENOENT"
done
) > reads.log &
reader_pid=$!
# Writer loop – rapidly swap symlink
for i in {1..1000}; do
ln -sfn releases/v1 current
ln -sfn releases/v2 current
done
wait $reader_pid
errors=$(grep -c ENOENT reads.log || true)
echo "Errors: $errors / 10000 reads"
On a typical system you’ll see 10–50 ENOENT errors per run. Using the atomic approach eliminates them.
The Full Script
The author wrapped the logic into a single‑file deployment script that provides:
- Atomic symlink swap on Linux and macOS
- Directory‑based locking with stale PID detection
- Automatic rollback on
SIGINT/SIGTERM - State‑machine cleanup that knows whether to rollback or just remove temporary files
The script has no dependencies beyond Bash and Python 3.
GitHub:
What It Doesn’t Do
- Shared directories – No Capistrano‑style shared folder symlinking
- Remote deployment – Wrap it in
ssh/rsyncyourself - Release pruning – Add a cron job if needed
- Service restarts – Use your init system
One thing, done right.
FAQ
Why not just use Capistrano/Deployer?
They’re great if you’re already in Ruby/PHP. This script can be dropped into any CI pipeline.
Why not containers?
Not everyone runs Kubernetes. VMs, bare metal, and edge devices still exist.
Python is a dependency.
Python 3 ships with macOS and virtually every Linux distro. It’s as ubiquitous as Bash.
What about renameat2() with RENAME_EXCHANGE?
That’s Linux 3.15+ with glibc 2.28+. It provides a true atomic swap but isn’t portable.
Does this work on NFS?
No. rename(2) atomicity guarantees don’t hold on network filesystems.
Further Reading
- Atomic symlinks – Deep dive on the problem
- Things UNIX can do atomically – The
mv -Tinsight - Capistrano issue #346 – Original bug report from 2013