Server Setups/Operations
Zero-downtime deploys without Kubernetes
Symlink releases, a socket-activated restart and a rollback that takes one command and two seconds.
// read first
The version of this most people reach for is a container orchestrator. For a single service on a single host, the orchestrator is more moving parts than the thing it deploys.
What follows is the Capistrano-shaped layout — timestamped releases, an atomic symlink swap, a current pointer — with a modern restart strategy bolted on.
The layout
/home/deploy/app
├── current -> releases/20260701T090000 # atomic symlink
├── releases/
│ ├── 20260628T140000/
│ ├── 20260630T101500/
│ └── 20260701T090000/
├── shared/
│ ├── .env # symlinked into each release
│ └── uploads/ # survives deploys
└── repo/ # bare git mirrorEverything mutable lives in shared and is symlinked into the release. A release directory is therefore disposable, which is what makes rollback trivial.
The deploy script
#!/usr/bin/env bash
set -Eeuo pipefail
APP=/home/deploy/app
REL="$APP/releases/$(date -u +%Y%m%dT%H%M%S)"
git --git-dir="$APP/repo" fetch --prune origin main
mkdir -p "$REL"
git --git-dir="$APP/repo" archive origin/main | tar -x -C "$REL"
ln -sfn "$APP/shared/.env" "$REL/.env"
ln -sfn "$APP/shared/uploads" "$REL/uploads"
cd "$REL"
npm ci --omit=dev
npm run build
# Atomic: ln -sfn on the same filesystem is a rename(2).
ln -sfn "$REL" "$APP/current.new"
mv -Tf "$APP/current.new" "$APP/current"
sudo systemctl reload-or-restart app
"$APP/bin/healthcheck" || { echo "health check failed"; exit 1; }
ls -1dt "$APP"/releases/* | tail -n +6 | xargs -r rm -rf#!/usr/bin/env bash
# Poll until the new process answers, or fail after 30s.
for i in $(seq 1 30); do
if curl -fsS --max-time 2 http://127.0.0.1:3000/healthz >/dev/null; then
echo "healthy after ${i}s"
exit 0
fi
sleep 1
done
exit 1#!/usr/bin/env bash
set -Eeuo pipefail
APP=/home/deploy/app
# Second-newest release, i.e. the one before the current pointer.
PREV=$(ls -1dt "$APP"/releases/* | sed -n 2p)
ln -sfn "$PREV" "$APP/current.new"
mv -Tf "$APP/current.new" "$APP/current"
sudo systemctl restart app
echo "rolled back to $PREV"Why not blue/green?
Because with a symlink swap and a two-second restart, the outage window is smaller than the health-check interval of most load balancers. Blue/green earns its complexity when you have more than one host to coordinate; below that it is ceremony.
The right amount of infrastructure is the least you can operate confidently at 3am, half-awake, from a phone.
Environment
| Variable | Description | Required |
|---|---|---|
APP_RELEASE20260701T090000 | Injected by the deploy script. Surfaced in error reports to identify the build. | Optional |
HEALTHCHECK_PATH/healthz | Endpoint the deploy script polls. Must not touch the database. | Required |
// related
From the rest of the site.
A Core Web Vitals Budget You Can Actually Ship
Concrete numbers, the handful of techniques that move them, and how to stop a fast site from slowly getting slow.
