· 5 min read

Blue-green on a 5-euro VPS

Two nginx upstreams and a one-line config swap give you blue-green on any box, without a load balancer or second server.

By Marten

Blue-green does not require a second server. It does not require a load balancer. It does not require a service mesh. On a single VPS, you can run two versions of your app side by side, switch traffic between them with a single nginx config edit, and roll back by switching the config back.

This is what mine looks like. I’ve been running it on a 5-euro Hetzner box for eighteen months.

The setup

Two copies of the app, on different ports.

/srv/www/example.com/
├── blue/     (port 9001)
└── green/    (port 9002)

Each directory is a full deployment. Each has its own PHP-FPM pool (or Node process, or whatever your runtime is), bound to its own port. They share a database. They share the uploads directory. They are otherwise isolated.

A systemd unit per pool:

# /etc/systemd/system/example-blue.service
[Unit]
Description=Example app (blue)
After=network.target

[Service]
Type=simple
User=www-data
WorkingDirectory=/srv/www/example.com/blue
ExecStart=/usr/bin/php -S 127.0.0.1:9001 -t public
Restart=on-failure

[Install]
WantedBy=multi-user.target

Same file for green, swap blue→green and 9001→9002. systemctl enable --now example-blue example-green and both are running.

The switch

nginx has a single upstream block that points at whichever one is live:

# /etc/nginx/conf.d/example-upstream.conf
upstream example_app {
    server 127.0.0.1:9001;  # blue is live
}

And a site config that uses it:

# /etc/nginx/sites-enabled/example.com
server {
    listen 443 ssl http2;
    server_name example.com;

    location / {
        proxy_pass http://example_app;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

To switch from blue to green, I edit the upstream file and reload nginx:

sed -i 's/9001/9002/' /etc/nginx/conf.d/example-upstream.conf
nginx -t && systemctl reload nginx

That is the deploy. nginx reload takes about 500ms. Existing connections drain on the old pool. New connections go to the new pool. Zero dropped requests.

The full deploy flow

On a deploy, I push code to the idle pool (whichever is not currently serving), restart that pool, verify it works, then switch.

#!/usr/bin/env bash
set -euo pipefail

# Which one is currently live? Read the upstream file.
CURRENT_PORT=$(grep -oP 'server 127\.0\.0\.1:\K\d+' /etc/nginx/conf.d/example-upstream.conf)
if [ "$CURRENT_PORT" = "9001" ]; then
    LIVE=blue
    IDLE=green
    IDLE_PORT=9002
else
    LIVE=green
    IDLE=blue
    IDLE_PORT=9001
fi

echo "live: $LIVE, deploying to: $IDLE"

# Push new code to the idle directory
rsync -a --delete /tmp/build/ "/srv/www/example.com/$IDLE/"

# Link shared state
ln -sf /srv/www/example.com/shared/.env "/srv/www/example.com/$IDLE/.env"
ln -sf /srv/www/example.com/shared/uploads "/srv/www/example.com/$IDLE/public/uploads"

# Restart the idle pool against the new code
systemctl restart "example-$IDLE"

# Give it a moment to start up
sleep 2

# Health-check the idle pool directly on its port
expected_sha=$(cd /tmp/build && git rev-parse --short HEAD)
actual_sha=$(curl -fsS "http://127.0.0.1:$IDLE_PORT/_health" | jq -r .commit)
if [ "$actual_sha" != "$expected_sha" ]; then
    echo "health check failed: expected $expected_sha, got $actual_sha"
    exit 1
fi

# Swap the upstream
sed -i "s/:[0-9]\+;/:$IDLE_PORT;/" /etc/nginx/conf.d/example-upstream.conf
nginx -t && systemctl reload nginx

echo "$IDLE is now live (was $LIVE)"

Rollback is the same script, except instead of pushing code first, you just swap the upstream back:

sed -i "s/:[0-9]\+;/:$LIVE_PORT;/" /etc/nginx/conf.d/example-upstream.conf
nginx -t && systemctl reload nginx

That is a 3-second rollback to the previous version. The previous version is still running, on its own port, waiting.

Symlink deploys (the atomic-swap-a-symlink pattern) are simpler and cover most cases. What blue-green adds:

One, you can run the new version in isolation and test it on the idle port before switching traffic. With symlinks, the moment you swap, you have committed. With blue-green, you can curl the idle pool, test a few pages, even open it in a browser (if you expose the port to yourself), and only switch when you are sure.

Two, a more complete rollback. Symlink rollback reverts the code. Blue-green rollback also keeps the old process running with its own memory, connections, and state. If the bug you deployed is a memory leak that manifests after an hour, symlink rollback puts clean code on the old process. Blue-green rollback puts you back on the old process entirely, including its not-yet-leaking state.

Three, warm-up is free. If your app takes 20 seconds to get its opcache warm, the idle pool has already been warmed up by the time you switch. Users never see a cold start.

What this does not buy you

It does not help with database changes. Both pools hit the same database. A breaking migration breaks both sides.

It does not help if your app state lives in memory. Two pools, two memory spaces. A user whose session cookies are tied to specific in-memory state on the blue pool will lose that state when they get routed to green. Use an external session store (Redis, database, cookie-encoded) and this is not an issue.

It does not help if the bug is data-shaped. If the reason your deploy breaks is that a user record has a value your new code does not handle, rolling back to the old pool has the same bug as soon as that user tries again on the old pool.

Why it runs on a 5-euro VPS

Two PHP processes holding the same app, mostly idle, cost almost nothing in memory if you size the pools correctly. I run pm = static with pm.max_children = 4 per pool, which means 8 processes total. Each process is about 50MB. Total: 400MB. On a 2GB VPS, that is fine.

The CPU cost of running the idle pool is essentially zero, because it has no traffic. It just sits there.

The bandwidth cost is zero — everything is on localhost.

The operational cost is two systemd units to maintain instead of one. That is the whole overhead.

The one thing I would do differently

Back when I set this up, I used sed to edit the upstream file. It works, but one day I will typo the sed command and take the site down. I now keep two upstream files and change which one is included:

# /etc/nginx/conf.d/example-upstream.conf
include /etc/nginx/conf.d/example-upstream-active.conf;

Where example-upstream-active.conf is a symlink to either example-upstream-blue.conf or example-upstream-green.conf. Switching is now an atomic symlink swap, which cannot fail in mid-edit. The sed approach worked fine for a year but I no longer like it.

That is the only refinement in eighteen months. The rest has just run.