June 12, 2026
Zero-downtime deploys on a single VPS

When people hear “zero-downtime deploy” they usually picture Kubernetes rolling updates or a load balancer draining connections off an old pool before killing it. This site has none of that. It is a fully static Astro build served by nginx straight off disk on a single BinaryLane VPS, no backend process, no database, no systemd unit to restart. Which makes the whole problem smaller, but not zero. A static site can still go down mid-deploy if you copy files the naive way.
Here is what deploy/deploy.sh actually does today: build locally, then rsync the output straight into the live web root.
npm run build
rsync -az --delete --exclude='._*' --exclude='.DS_Store' --delete-excluded \
"$REPO/dist/" "$SSH_HOST:/var/www/erkshitiz/dist/"
That works, and for a low-traffic blog with a handful of html files it has never actually bitten me. But it is worth being honest about the risk it carries. rsync does not update a directory as one atomic unit. It walks the file list and writes files one at a time (each individual file write is safe, via a temp file and rename), but across hundreds of files that is hundreds of separate operations, not one. If a request lands on the server in the middle of that sync, it can see a half-updated tree: the new index.html referencing a hashed _astro/ chunk that has not been written yet, or an old page whose linked asset just got deleted by --delete. On a fast connection with a small site this window is tiny, but it is not nothing, and it gets worse as the site grows.
The fix is the same one every static host and CDN uses under the hood, just done by hand: never rsync into the directory nginx is actually serving. Build into a fresh, timestamped release directory, sync into that, and only when the sync is fully done do you point nginx at it, by flipping a symlink. A symlink swap on the same filesystem is a single rename() syscall, which is atomic at the filesystem level. Every request either gets the full old tree or the full new tree, never a mix.
RELEASE="/var/www/erkshitiz/releases/$(date +%Y%m%d%H%M%S)"
ssh "$SSH_HOST" "mkdir -p $RELEASE"
rsync -az "$REPO/dist/" "$SSH_HOST:$RELEASE/"
ssh "$SSH_HOST" "
ln -sfn $RELEASE /var/www/erkshitiz/current_tmp
mv -Tf /var/www/erkshitiz/current_tmp /var/www/erkshitiz/current
nginx -s reload
"
nginx.conf’s root would point at /var/www/erkshitiz/current instead of /var/www/erkshitiz/dist directly, and old release directories get pruned on a schedule so they do not pile up on disk. The reload at the end is not strictly required for the symlink swap itself, nginx resolves the path per request rather than caching the whole tree, but it is cheap insurance and worth keeping in the sequence.
To be clear about where this project actually stands: deploy.sh does not do any of that yet. It is the simpler, direct-rsync-into-the-live-root version, and I am writing this partly as a note to myself for when the traffic or the file count on this site grows enough to justify the extra moving parts (a releases directory, a pruning job, a symlink to keep track of).
The practical takeaway is that “static site” and “zero risk” are not the same thing. The risk is not in the code, it is in how the files land on disk relative to when nginx reads them. If your deploy script writes into the same path nginx is actively serving from, you have a race, no matter how simple the site is. Building into a new directory and flipping a pointer at the end removes that race entirely, and it costs one extra rsync target and a mv, not a load balancer.