Blue-green deployment runs two copies of your application: blue (live) and green (the new version). You deploy and test green while blue serves users, then switch traffic to green in one step. If something’s wrong, switch back — rollback takes seconds.

You don’t need Kubernetes or a cloud load balancer for this. Nginx and a small script on a VPS are enough.

The idea

                ┌────────► blue  (v1.4, port 3001)   ← live
Users ─► Nginx ─┤
                └ ─ ─ ─ ─► green (v1.5, port 3002)   ← being tested

Deploy v1.5 to green, check it, point Nginx at green, reload. Blue keeps running until you’re confident, then becomes the target for the next release.

1. Two app instances

With systemd, a template unit lets you run the same app on two ports:

# /etc/systemd/system/[email protected]
[Unit]
Description=myapp (%i)
After=network.target

[Service]
User=deploy
WorkingDirectory=/srv/myapp/%i/current
EnvironmentFile=/srv/myapp/%i.env
ExecStart=/usr/bin/node server.js
Restart=always

[Install]
WantedBy=multi-user.target
# /srv/myapp/blue.env
PORT=3001
# /srv/myapp/green.env
PORT=3002
sudo systemctl enable --now myapp@blue myapp@green

With Docker Compose, run two services (app_blue, app_green) publishing 127.0.0.1:3001 and 127.0.0.1:3002.

2. Nginx points at the live color

Put the upstream in its own small file that the deploy script rewrites:

# /etc/nginx/conf.d/myapp-upstream.conf
upstream myapp_live {
    server 127.0.0.1:3001;    # blue
}
# site server block
location / {
    proxy_pass http://myapp_live;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

3. The deploy script

#!/bin/bash
set -euo pipefail
UPSTREAM=/etc/nginx/conf.d/myapp-upstream.conf

if grep -q 3001 "$UPSTREAM"; then LIVE=blue; IDLE=green; PORT=3002
else LIVE=green; IDLE=blue; PORT=3001; fi
echo "Live: $LIVE — deploying to $IDLE"

# 1. Deploy the new version to the idle color
RELEASE=/srv/myapp/$IDLE/releases/$(date +%Y%m%d%H%M%S)
mkdir -p "$RELEASE"
rsync -a --delete /srv/myapp/build/ "$RELEASE/"
ln -sfn "$RELEASE" /srv/myapp/$IDLE/current
sudo systemctl restart myapp@$IDLE

# 2. Health check the idle instance directly
for i in $(seq 1 30); do
  curl -fsS "http://127.0.0.1:$PORT/health" > /dev/null && break
  sleep 2
done
curl -fsS "http://127.0.0.1:$PORT/health" > /dev/null || { echo "Health check failed"; exit 1; }

# 3. Switch traffic
echo "upstream myapp_live { server 127.0.0.1:$PORT; }" | sudo tee "$UPSTREAM" > /dev/null
sudo nginx -t && sudo systemctl reload nginx
echo "Switched to $IDLE"

nginx -s reload is graceful: existing connections finish on the old workers while new requests go to the new upstream — users see no errors.

4. Rollback

Point the upstream back at the previous color and reload. Since blue is still running v1.4, rollback is instant:

echo "upstream myapp_live { server 127.0.0.1:3001; }" | sudo tee /etc/nginx/conf.d/myapp-upstream.conf
sudo nginx -t && sudo systemctl reload nginx

5. Test green before switching

Expose green on a private hostname for smoke tests:

server {
    server_name green.example.com;
    allow 203.0.113.0/24; deny all;
    location / { proxy_pass http://127.0.0.1:3002; }
}

Or run automated end-to-end tests against 127.0.0.1:3002 in the deploy script before switching.

The hard part: the database

Both versions share one database, and blue must keep working during and after the switch (for rollback). Use backward-compatible migrations — the expand/contract pattern:

  1. Expand: add new columns/tables; don’t remove or rename anything the old version uses. Deploy.
  2. Make the new code write to both old and new structures if needed.
  3. Contract: in a later release, after the old version is gone for good, drop unused columns.

Renaming a column in one step breaks the version that still expects the old name.

Other things to consider

  • Sessions: store them in Redis or the database, not process memory, so users stay logged in across the switch.
  • Background workers and cron: make sure only one color runs scheduled jobs.
  • Resources: you need RAM for two instances during deploys.
  • Static assets: if the app serves hashed assets, keep the old release’s assets available briefly for users with cached pages.

Blue-green vs other strategies

  • Rolling: replace instances one at a time — needs several instances.
  • Canary: send a small percentage of traffic to the new version first — see deployment strategies.
  • Recreate: stop old, start new — simple, with downtime.

See deployment strategies for a comparison, and automate the script from CI with GitHub Actions.

  • Deployment
  • Nginx
  • DevOps