Zero-Downtime Deploys on a Single VPS: Blue-Green and Canary Releases with Nginx

Deploying a new version of your application should not mean telling users “be right back”. On a single VPS you cannot spin up a second datacenter, but you can still get zero-downtime releases — because Nginx is a perfectly good traffic switch. With two application directories, two upstream pools, and an atomic reload, you can run blue-green swaps and canary releases without Kubernetes or a load balancer in front. The only prerequisite is a VPS with enough RAM to hold two copies of the app, so check the plans on our VPS comparison table if you need headroom.

Blue-Green with Two Upstreams

Blue-green means running the old version (blue) and the new version (green) side by side, then switching traffic at the proxy. With Nginx this is two upstream blocks pointing at different ports or sockets, and a default that selects one:

upstream app_blue {
  server 127.0.0.1:8001;
}
upstream app_green {
  server 127.0.0.1:8002;
}

# Point this at the active color
upstream app_active {
  server 127.0.0.1:8001;
}

server {
  listen 80;
  server_name app.example.com;
  location / {
    proxy_pass http://app_active;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  }
}

To deploy version 2: start it on port 8002, run health checks against 127.0.0.1:8002, then flip app_active to server 127.0.0.1:8002; and reload: nginx -s reload. The reload is atomic — Nginx finishes in-flight requests with the old config and applies the new one to everything after, so no connection is dropped. If the new version misbehaves, flip the pointer back and reload again. Rollback is a config edit, not a redeploy.

Canary Releases with Weighted Upstreams

Blue-green is binary: all or nothing. A canary is graduated — send 5% of traffic to the new version, watch error rates, then ramp to 25%, 50%, 100%. Nginx does this with upstream weights:

upstream app_canary {
  server 127.0.0.1:8001 weight=95;  # blue: 95%
  server 127.0.0.1:8002 weight=5;   # green: 5%
}



Each reload with a new weight is a canary step. Watch three things between steps: HTTP 5xx rate in access.log, application error logs, and latency percentiles. If the canary's error rate matches or beats blue's, keep ramping; the moment it spikes, reload with weight=0 on green and investigate. For a cleaner signal, give the canary its own log file (access_log /var/log/nginx/canary.log; in a server block that only green sees) so you can compare populations instead of averages.

Header and Cookie-Based Routing: Canary for Specific Users

Weighted routing is random; sometimes you want deterministic canaries — internal testers always see green, everyone else stays on blue. A map block decides the upstream per request:

map $http_cookie $backend {
  default        app_blue;
  "~*canary=1"   app_green;
}

server {
  location / {
    proxy_pass http://$backend;
  }
}



Testers visit app.example.com with the canary=1 cookie; everyone else stays on blue. The same pattern works with a header ($http_x_canary) for API clients or CI smoke tests. This is the classic staging-inside-production trick: the canary is fully deployed and serving real requests, but only for a population you control.

A Minimal Deploy Script

The whole cycle fits in a short script: deploy to the inactive color, health-check it, switch, verify, and keep the previous version around for rollback.

#!/usr/bin/env bash
set -euo pipefail
APP_DIR=/srv/app
NEW_PORT=8002   # alternate between 8001/8002 per release

# 1. Ship code to the inactive color
rsync -a --delete ./build/ $APP_DIR/green/

# 2. Start the green app (systemd unit on port 8002)
systemctl restart app-green

# 3. Health-check before touching Nginx
for i in {1..10}; do
  curl -fsS http://127.0.0.1:8002/healthz && break || sleep 1
done

# 4. Switch traffic and reload atomically
sed -i 's/server 127.0.0.1:8001;/server 127.0.0.1:8002;/' \
  /etc/nginx/conf.d/app.conf
nginx -t && nginx -s reload

# 5. Post-deploy smoke test through the public entrypoint
curl -fsS https://app.example.com/healthz



The two colors can be two systemd units (app-blue.service, app-green.service), two directories under /srv/app/, or two containers — Nginx does not care, it only balances between the ports. Just ensure the app reads its config from the environment so both colors run with identical settings except the version.

Where This Breaks (Know the Limits)

  • Stateful sessions: sticky sessions and in-memory sessions break when traffic moves between colors. Keep sessions in Redis or the database, or pin by cookie.
  • Long-lived WebSockets: nginx -s reload does not kill existing connections, so in-flight WebSocket upgrades survive — but new connections during the reload window may hit either color. Version the WebSocket protocol defensively.
  • Database migrations: the old version still runs during the switch, so schema changes must be backward-compatible (expand-migrate-contract). This is the real constraint on single-VPS blue-green, not Nginx.
  • One box, one point of failure: this gives you zero-downtime deploys, not high availability. A hardware failure still takes the site down until you restore from backup.

For most small applications this pattern removes the most stressful part of shipping: the moment where a bad release takes the site down for everyone. Nginx gives you the switch for free; the discipline is in keeping both colors deployable and health-checked at all times. If you are about to size a server for running two app instances plus a database, our memory and storage comparison helps you pick a tier that fits, and the provider ranking is a good starting point for hosts with fast NVMe for the rsync step.

Leave a Reply