Cloud Architecture·8 min read·

Blue-Green on One VPS with Two systemd Units

Run app-blue and app-green on one VPS, health-check the idle color, then flip nginx so a bad release never shares a process with the live one.

NB

Netbay Infrastructure Team

Netbay Engineering

On this page

Blue-green on one VPS is two systemd units, two loopback ports, and one nginx upstream file that points at exactly one of them. Blue is live on 127.0.0.1:3000. Green is idle, or is being warmed, on 127.0.0.1:3001. A deploy starts the idle color with the new release, waits for /healthz, rewrites the upstream, reloads nginx, and only then stops the old color. If health fails, you never touch nginx. There is no in-place restart of the live process, and there is no second machine. Shared state stays on disk and in Postgres, which both colors already treat as backing services.

Two colors, one live upstream

The units are twins. They differ in Environment PORT, WorkingDirectory, and unit name. They share User=app, the env file for secrets, and the data directories. They must not share a WorkingDirectory of /srv/app/current, because then a symlink flip mutates the live color. Give each color its own release path: /srv/app/blue and /srv/app/green. The deploy copies into the idle path only.

Nginx should include a tiny file you generate, not a hand-edited server block, so the switch is one mv plus reload. reload, not restart: restart drops connections; reload drains them.

Blue-green cut on one VPS nginx :443 includes live.conf BLUE LIVE app-blue.service 127.0.0.1:3000 GREEN IDLE app-green.service 127.0.0.1:3001 STATE Postgres + uploads shared, not colored health-check idle, rewrite live.conf, nginx -s reload

Unit files that do not step on each other

Keep the unit files in /etc/systemd/system. The only drift between colors should be the port and the working directory. MemoryMax applies to each color so running both during a cut does not OOM the node. On a 4 GB plan, size so two copies of the app plus Postgres still fit; if they do not, stop a worker-heavy sidecar during the cut, not the database.

ini
# /etc/systemd/system/app-blue.service
[Unit]
Description=App blue
After=network.target postgresql.service

[Service]
User=app
WorkingDirectory=/srv/app/blue
EnvironmentFile=/etc/app/env
Environment=PORT=3000
Environment=BIND=127.0.0.1
Environment=COLOR=blue
ExecStart=/usr/bin/node dist/server.js
Restart=on-failure
MemoryMax=768M
TimeoutStopSec=20

[Install]
WantedBy=multi-user.target

Copy to app-green.service, set PORT=3001, COLOR=green, WorkingDirectory=/srv/app/green. The nginx live file is three lines:

nginx
# /etc/nginx/conf.d/live.conf  (generated, not edited)
upstream app_live {
    server 127.0.0.1:3000;  # blue
}

The switch script writes the other port to a temp file, tests nginx config, moves the file, reloads. Health against the idle color happens first. Migrations against Postgres happen before the idle color starts if the new code needs new columns, and they must be backward compatible with the live color still running. If a migration is not backward compatible, this pattern cannot save you; you need a two-step schema.

The cut script is the architecture

bash
#!/usr/bin/env bash
# /usr/local/bin/app-cut
set -euo pipefail
LIVE=$(systemctl is-active app-blue && echo blue || echo green)
IDLE=$( [ "$LIVE" = blue ] && echo green || echo blue )
IDLE_PORT=$( [ "$IDLE" = green ] && echo 3001 || echo 3000 )
LIVE_PORT=$( [ "$LIVE" = blue ] && echo 3000 || echo 3001 )

systemctl start app-$IDLE
for i in 1 2 3 4 5 6 7 8 9 10; do
  curl -fsS --max-time 2 http://127.0.0.1:$IDLE_PORT/healthz && break
  sleep 1
  [ "$i" -eq 10 ] && { echo idle unhealthy; systemctl stop app-$IDLE; exit 1; }
done

printf 'upstream app_live { server 127.0.0.1:%s; }\n' "$IDLE_PORT"   > /etc/nginx/conf.d/live.conf.new
nginx -t
mv /etc/nginx/conf.d/live.conf.new /etc/nginx/conf.d/live.conf
nginx -s reload
systemctl stop app-$LIVE
echo "live is now $IDLE on $IDLE_PORT (was $LIVE on $LIVE_PORT)"

Run the script from CI over SSH after copying the artifact into /srv/app/$IDLE. Keep the previous color unpacked on disk so a second cut is a rollback: start the old unit, health-check, flip nginx back. Do not delete the old tree until the new color has been live for a soak interval you have written down. Two colors on one Intel Xeon Platinum VPS with High-Speed SSD in Lucknow DC01 is enough for most products that still fit in 4–8 GB. When it is not, the next step is a second VPS, not a rewrite of the cut.

What the health check must prove

/healthz on the idle color is not a 200 that the process started. It must open Postgres, ping Redis, and exercise one code path the new release actually changed. A migrate-then-cut that only checks process.uptime will ship a binary that cannot talk to the new schema. Fail the cut if the idle color is not ready in ten seconds; a slow boot is a signal the release is compiling on start, which twelve-factor already forbade.

Drain is the other half. After nginx reloads, the old color still has in-flight requests. TimeoutStopSec=20 plus a proxy_read_timeout of 10s means those requests finish or fail before SIGKILL. Do not systemctl stop the old color in the same second as the reload. A one-second sleep is crude and usually enough; a loop that waits until ss shows no established sockets on the old port is better. Record the live color in /var/lib/app/live-color so the next human or the next CI job does not guess. Guessing is how you deploy green on top of green and take the site down with a start that is actually a restart of the live process.

You can practice this cut on a Netbay Ubuntu 24.04 instance in under an hour — netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance