Zero-Downtime Deploys on a Single VPS: systemd, Blue-Green, and Rollback

A plain systemctl restart app drops every in-flight request, closes established connections, and leaves a visible gap in your error log. On a single VPS you cannot spin up a second machine to absorb the switch — but you can get close to zero downtime with two systemd features you already have: socket activation, which keeps the listening socket alive across restarts, and a blue-green release layout that swaps a symlink instead of stopping the service. This guide builds both, plus a rollback path that takes one command.

The approach assumes your application is a long-running daemon that binds to a TCP or Unix socket and supports graceful shutdown (finishing current requests before exiting). If your app is a PHP-FPM or Apache worker pool, the same symlink trick applies to the document root. Budget matters too — the release directories need a little extra disk, so compare VPS plans on our comparison table and pick one with SSD or NVMe storage and headroom for two full copies of the app.

Socket Activation: Restarts Without Dropped Connections

With socket activation, systemd owns the listening socket and hands the file descriptor to your service on startup. When you restart the service, the socket stays open in systemd; new connections queue in the kernel backlog for the split second the app is down. For a typical web API that means restarts drop from “connection refused” to a few milliseconds of queueing. The units:

# /etc/systemd/system/myapp.socket
[Socket]
ListenStream=/run/myapp.sock
SocketMode=0660
SocketUser=www-data

[Install]
WantedBy=sockets.target
# /etc/systemd/system/myapp.service
[Service]
User=www-data
ExecStart=/srv/app/current/bin/myapp
Restart=on-failure
# systemd passes the socket as FD 3 (LISTEN_FDS=1)

[Install]
WantedBy=multi-user.target

Enable the socket, not the service: systemctl enable --now myapp.socket. Test that a restart no longer interrupts clients:

while true; do curl -s --unix-socket /run/myapp.sock http://localhost/healthz; sleep 0.2; done
# in a second terminal:
systemctl restart myapp
# the loop above never prints a connection error

Gunicorn, uWSGI, and most Node/Python frameworks support inheriting the systemd socket natively — gunicorn --bind unix:/run/myapp.sock or the LISTEN_FDS env var in Go’s systemd package. If your app insists on opening its own port, you can also use ListenStream=127.0.0.1:8080 and let the socket unit own the TCP port instead.

Blue-Green Release Directories

Socket activation fixes restarts, but deploying a broken build still takes the site down until you notice. Blue-green solves that by keeping two complete copies of the app and switching /srv/app/current atomically:

/srv/app/
├── current -> releases/2026-08-05-1530   # symlink, switched atomically
├── blue/    -> releases/2026-08-05-1530
├── green/   -> releases/2026-08-05-1400
└── releases/
    ├── 2026-08-05-1400/                   # old version (green)
    └── 2026-08-05-1530/                   # new version (blue)

The deploy script rsyncs the new build into the inactive slot, points current at it, restarts, and health-checks before declaring victory:

#!/bin/bash
set -euo pipefail
RELEASE="/srv/app/releases/$(date +%Y-%m-%d-%H%M)"
rsync -a --delete /tmp/build/ "$RELEASE/"
ln -sfn "$RELEASE" /srv/app/current          # atomic switch
systemctl restart myapp
for i in $(seq 1 10); do
  curl -fsS http://127.0.0.1:8080/healthz && exit 0
  sleep 1
done
ln -sfn /srv/app/blue /srv/app/current      # rollback
systemctl restart myapp
exit 1

Because ln -sfn is atomic, the symlink flip never leaves a half-written state, and the old release directory stays intact until you prune it. If you would rather not run this yourself on a busy production box, Cloudways managed VPS hosting handles deployments with one-click Git pulls and instant rollback — the same blue-green idea, without you writing the script.

Health Checks and Instant Rollback

The health endpoint is the contract your deploy script depends on. Make it cheap (no database query if possible) but honest: it should fail when the app cannot serve traffic. A 200 within the retry window means the new version is live; anything else triggers the rollback branch, which flips the symlink back and restarts. Rollback stays instant because the previous release directory was never deleted.

Keep a rotation policy so disk does not fill up: delete releases older than 5–10 deploys with a cron job or systemd timer: find /srv/app/releases -mindepth 1 -maxdepth 1 -mtime +14 -exec rm -rf {} +. Combined with socket activation, the whole cycle — deploy, verify, roll back — never interrupts an active connection.

What This Does Not Solve

Schema migrations still need care: if the new code assumes a migrated database but the old code is still running during rollback, you get two versions fighting over one schema. Run backward-compatible migrations first, or gate them behind a feature flag. Long-lived WebSocket connections also survive the socket handover but not an app process exit — accept that and reconnect clients on the front end. And if you run multiple VPSes behind a load balancer, drain connections before restarting each node; on a single VPS this guide is the whole story. Start with socket activation alone if a full blue-green layout feels heavy — it already eliminates the visible restart gap, and you can add release directories later. For the hardware to run two app copies comfortably, see the full specs and pricing on our comparison table.

Leave a Reply