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";
  }
}
Releases Are Directories; Current Is a Pointer Each release is extracted into its own timestamped directory. A symlink named current points at one of them and is what the web server serves. Moving the symlink is a single atomic filesystem operation, so no request ever sees a partially copied directory. releases/2026-07-29-c47b912 releases/2026-07-30-a91f3cd releases/2026-07-31-8d31f0a current → one atomic rename web server document root never sees a partial copy Rollback is the same operation pointed at the row above — which is why the previous releases must not be deleted eagerly.

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.

Why Copying Into Place Is Not a Deploy Copying files directly into the document root leaves a window of several seconds during which some files are new and some are old, and requests served in that window can mix them. A symlink swap has no such window because the pointer changes in a single operation. COPY INTO PLACE 4–20 seconds where old and new files coexist — requests can mix them consistent again SYMLINK SWAP consistent — the window is a single rename syscall The window in the first row is short, intermittent and impossible to reproduce, which is what makes it expensive to debug.

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 -1

To 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"
How Many Releases to Keep on Disk The current release must obviously stay. The previous two are rollback targets and must stay. Releases older than that are only useful for open browser sessions still requesting their hashed assets, and can be pruned after a day. Beyond a week, nothing references them. KEEP OR PRUNE current previous 2 — rollback targets up to 1 day old prune serving must stay open sessions still fetch these nothing references them Pruning too eagerly breaks the third column: a tab opened before the deploy asks for chunk names that no longer exist. Keeping five releases costs a few hundred megabytes and removes the entire class of problem.

Common pitfalls

  • ln -sf without -n on an existing symlink-to-directory. Without -n, ln follows the existing symlink and creates the new link inside the old release directory instead of replacing it. Always use ln -sfn plus the mv -Tf rename for true atomicity.
  • Caching index.html like a hashed asset. The HTML entry point must be revalidated (Cache-Control: no-cache) or a visitor keeps an old index.html pointing at deleted asset hashes. Only content-hashed files under /assets/ get immutable.
  • Pruning too aggressively. KEEP=1 deletes 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.

Why atomicity matters more for static sites than it seems

A static frontend looks like the easiest thing to deploy — copy some files and you are done — which is why the failure mode is so persistent. Copying files into a live document root is not atomic: for the seconds the copy takes, some files are new and some are old, and any request served in that window can receive a mixture.

With content-hashed filenames the mixture is usually harmless because the old files still exist. Without them it produces the classic symptom: a user loads the new HTML and the old JavaScript, the bundle does not match what the document expects, and the page renders blank. The window is short, the failure is intermittent, and it is essentially impossible to reproduce deliberately — which means it gets attributed to browsers, caches, or bad luck, and persists for years.

A symlink swap eliminates the window rather than shortening it. The rename is a single filesystem operation; a request either resolves through the old target or the new one, never through a directory being written.

The rollback property that comes free

Because each release is a complete directory that is never modified in place, rollback is the same operation pointed at a previous target. There is nothing to rebuild, nothing to re-upload, and nothing that can partially succeed. On a filesystem origin this is genuinely a sub-second operation, which puts it in a different category from every deployment strategy that requires shipping bytes.

That property depends on not deleting old releases eagerly. Keeping five is a reasonable default: it covers the rollback window, costs a few hundred megabytes, and leaves headroom for the case where the release you want to return to is not the immediately previous one.

What still needs care

Two details are easy to miss. First, the web server must resolve the symlink per request rather than caching the resolved path at start-up; some configurations cache the document root’s inode, and the swap then has no effect until a reload. Testing the swap end to end — not just checking that the link moved — is the only way to catch this.

Second, shared mutable state does not participate in the atomicity. Uploaded files, generated caches and anything else written into the release directory at run time will be lost on the next deploy, because the new release is a different directory. Those belong outside the release tree, symlinked in, which is a decision worth making before the first time a deploy silently discards user uploads.

← Back to Blue-Green Deployments for Full-Stack Apps