Atomic Symlink Deploys for Static Frontends
You Googled this because a static deploy that copies files in place lets visitors load a half-updated site — new HTML referencing old, deleted asset hashes — and you want the instant, all-or-nothing cutover that blue-green deployments give server apps.
When to use this pattern
- You deploy a built static bundle (Vite, Next.js export, Astro) to a VM or bare-metal host you control, served by NGINX or Caddy.
- You need zero-downtime, atomic cutover and sub-second rollback without a managed platform.
- You want immutable, timestamped releases on disk so rollback never requires a rebuild.
Prerequisites
Complete working example
#!/usr/bin/env bash
# scripts/deploy-static.sh — atomic symlink deploy for a static frontend
set -euo pipefail
HOST="[email protected]"
BASE="/var/www/app" # holds releases/ and the current symlink
RELEASE="$BASE/releases/$(git rev-parse --short HEAD)-$(date -u +%Y%m%dT%H%M%SZ)"
KEEP=5 # releases retained for rollback
# 1. Create the new, immutable release directory and upload the build into it.
ssh "$HOST" "mkdir -p '$RELEASE'"
rsync -az --delete ./dist/ "$HOST:$RELEASE/" # --delete keeps the release exact
# 2. Atomically swap the 'current' symlink to the new release.
# ln -sfn writes to a temp name then renames — rename() is atomic on POSIX.
ssh "$HOST" "ln -sfn '$RELEASE' '$BASE/current.tmp' && mv -Tf '$BASE/current.tmp' '$BASE/current'"
# 3. Reload the web server so it re-resolves the document-root symlink (no dropped conns).
ssh "$HOST" "sudo nginx -s reload"
# 4. Prune old releases, keeping the newest \$KEEP for rollback.
ssh "$HOST" "ls -1dt '$BASE'/releases/*/ | tail -n +\$(( $KEEP + 1 )) | xargs -r rm -rf"
echo "Deployed $RELEASE and made it live."The NGINX server block points its root at the symlink, never at a release directory directly:
server {
listen 443 ssl;
server_name static.example.com;
root /var/www/app/current; # the symlink — swapped atomically on deploy
location / {
try_files $uri $uri/ /index.html; # SPA fallback
}
# Content-hashed assets are immutable — cache them hard.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}Step-by-step walkthrough
Release directory naming. Each release lives in releases/<sha>-<timestamp>/, immutable once uploaded. Keeping the SHA makes it obvious which commit is live; the timestamp guarantees uniqueness even for re-deploys of the same SHA.
rsync --delete. Uploads into the new directory only, so the live current release is untouched during transfer. --delete ensures the release is an exact mirror of dist/ with no stale leftover files.
Atomic symlink swap. ln -sfn followed by mv -Tf is the crux. Writing a temporary symlink and then mv-renaming it over current uses the POSIX rename() syscall, which is atomic — there is no instant where current is missing or points at a partial directory. Every request resolves to either the whole old release or the whole new one.
Web server reload. nginx -s reload re-resolves the root symlink with a graceful reload: in-flight requests finish on the old worker while new requests use the new root. No connections are dropped.
Pruning. Keeps the newest KEEP releases so rollback is always available, and removes older ones so disk usage stays bounded. Because content-hashed assets differ per build, old and new asset files coexist harmlessly during any CDN cache overlap.
Verification
# Confirm 'current' points at the new release
ssh [email protected] "readlink /var/www/app/current"
# → /var/www/app/releases/<sha>-<timestamp>
# Confirm the live site serves the new build hash
curl -s https://static.example.com/ | grep -o '/assets/index-[a-z0-9]*\.js' | head -1To roll back, repoint the symlink to the previous release and reload — no rebuild:
PREV=$(ssh "$HOST" "ls -1dt /var/www/app/releases/*/ | sed -n 2p")
ssh "$HOST" "ln -sfn '$PREV' /var/www/app/current.tmp && mv -Tf /var/www/app/current.tmp /var/www/app/current && sudo nginx -s reload"Common pitfalls
ln -sfwithout-non an existing symlink-to-directory. Without-n,lnfollows the existing symlink and creates the new link inside the old release directory instead of replacing it. Always useln -sfnplus themv -Tfrename for true atomicity.- Caching
index.htmllike a hashed asset. The HTML entry point must be revalidated (Cache-Control: no-cache) or a visitor keeps an oldindex.htmlpointing at deleted asset hashes. Only content-hashed files under/assets/getimmutable. - Pruning too aggressively.
KEEP=1deletes the release you would roll back to. Keep at least the last 3–5 so a bad deploy caught minutes later still has a warm target — the same warm-standby principle as server-side blue-green.
Related
- Blue-Green Deployments for Full-Stack Apps — the parent guide; this is its static-site equivalent.
- Deployment Strategies & Progressive Delivery — where atomic deploys sit among the strategies.
- Zero-Downtime Blue-Green Deploys with GitHub Actions — the containerized sibling workflow.
- Reducing Docker Image Size for Frontend Containers — when you containerize the static bundle instead.