A blue-green deployment keeps two copies of your application running side by side. One copy (for example blue) serves production traffic while you deploy and test the new release on the idle copy (green). When green is healthy, you point the load balancer at it in a single atomic step, and if something goes wrong you point it back. In this tutorial you will build that setup on a single Ubuntu 24.04 server with Nginx as the traffic switch, two systemd-managed app instances and a small deploy script that health-checks, switches and rolls back.

The same pattern scales to two servers or two container stacks: only the upstream addresses change.

Prerequisites

To follow this tutorial you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 1 GB of RAM.
  • A non-root user with sudo privileges.
  • UFW allowing SSH and HTTP (sudo ufw allow OpenSSH and sudo ufw allow 'Nginx HTTP' after Nginx is installed).
  • Optionally, a domain name pointing to the server. The examples use your_domain; replace it with your domain or the server IP.

The demo application is a static site served by Python's built-in HTTP server so you can focus on the deployment mechanics. Replace the ExecStart line with your real app (Node.js, Gunicorn, a Go binary) later; everything else stays the same.

Step 1 - Installing Nginx

Nginx will be the only public entry point. Install it and confirm it is running:

sudo apt update
sudo apt install nginx
sudo ufw allow 'Nginx HTTP'
systemctl status nginx --no-pager
● nginx.service - A high performance web server and a reverse proxy server
     Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled; preset: enabled)
     Active: active (running) since ...

Step 2 - Creating the blue and green releases

Each color gets its own directory with the application code. Create a system user to run the app and the directory layout:

sudo useradd --system --no-create-home --shell /usr/sbin/nologin myapp
sudo mkdir -p /opt/myapp/blue /opt/myapp/green /etc/myapp

Create a first release in the blue directory. The health file is the endpoint the deploy script will check before switching traffic:

echo '<h1>Version 1 (blue)</h1>' | sudo tee /opt/myapp/blue/index.html
echo 'ok' | sudo tee /opt/myapp/blue/health
sudo cp /opt/myapp/blue/* /opt/myapp/green/
sudo chown -R root:myapp /opt/myapp

Each instance reads its port and directory from an environment file. Create one per color:

sudo tee /etc/myapp/blue.env > /dev/null <<'EOF'
PORT=8081
APP_DIR=/opt/myapp/blue
EOF
sudo tee /etc/myapp/green.env > /dev/null <<'EOF'
PORT=8082
APP_DIR=/opt/myapp/green
EOF

Step 3 - Running both colors with a systemd template unit

A systemd template unit ([email protected]) lets you run myapp@blue and myapp@green from a single file. The %i specifier is replaced with the instance name, so each instance loads its own environment file.

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

[Service]
User=myapp
Group=myapp
EnvironmentFile=/etc/myapp/%i.env
ExecStart=/usr/bin/python3 -m http.server ${PORT} --bind 127.0.0.1 --directory ${APP_DIR}
Restart=on-failure

[Install]
WantedBy=multi-user.target

The instances only listen on 127.0.0.1, so users can reach them only through Nginx. Start both:

sudo systemctl daemon-reload
sudo systemctl enable --now myapp@blue myapp@green
curl -s http://127.0.0.1:8081/health
curl -s http://127.0.0.1:8082/health
ok
ok

Step 4 - Configuring Nginx to switch between colors

The trick is to keep the upstream definition in a small file that is swapped atomically. Create one upstream file per color:

sudo mkdir -p /etc/nginx/myapp
sudo nano /etc/nginx/myapp/blue.conf
upstream myapp_backend {
    server 127.0.0.1:8081;
    keepalive 16;
}
sudo nano /etc/nginx/myapp/green.conf
upstream myapp_backend {
    server 127.0.0.1:8082;
    keepalive 16;
}

Point a symbolic link called active.conf at blue. Nginx will only ever include this link:

sudo ln -sfn /etc/nginx/myapp/blue.conf /etc/nginx/myapp/active.conf

Now create the site. The X-Upstream header exposes which backend port served each request, which is handy while testing:

sudo nano /etc/nginx/sites-available/myapp
include /etc/nginx/myapp/active.conf;

server {
    listen 80;
    server_name your_domain;

    location / {
        proxy_pass http://myapp_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        add_header X-Upstream $upstream_addr always;
    }
}

Enable the site, disable the default one, test the configuration and reload:

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
curl -si http://localhost/ | grep -E 'X-Upstream|<h1>'
X-Upstream: 127.0.0.1:8081
<h1>Version 1 (blue)</h1>

Why this is zero-downtime: systemctl reload nginx starts new worker processes with the new upstream and lets the old workers finish the requests they are already handling before exiting. No connection is refused during the switch.

Step 5 - Writing the deploy script

The script does four things in order: detect the active color, verify the idle color is healthy, switch the symlink and reload Nginx, then verify the public endpoint. If the final check fails, it switches back automatically.

sudo nano /usr/local/bin/myapp-switch
#!/usr/bin/env bash
set -euo pipefail

NGINX_DIR=/etc/nginx/myapp
ACTIVE_LINK="$NGINX_DIR/active.conf"
PUBLIC_URL="http://127.0.0.1/health"

declare -A PORTS=([blue]=8081 [green]=8082)

current="$(basename "$(readlink -f "$ACTIVE_LINK")" .conf)"
if [[ "$current" == "blue" ]]; then target="green"; else target="blue"; fi

echo "Active: $current. Switching to: $target"

# 1. Health check the idle color directly, bypassing Nginx
for attempt in 1 2 3 4 5; do
    if curl -fsS --max-time 2 "http://127.0.0.1:${PORTS[$target]}/health" > /dev/null; then
        break
    fi
    if [[ "$attempt" -eq 5 ]]; then
        echo "ERROR: $target is not healthy, aborting. Traffic stays on $current." >&2
        exit 1
    fi
    sleep 2
done

switch_to() {
    ln -sfn "$NGINX_DIR/$1.conf" "$ACTIVE_LINK"
    nginx -t -q
    systemctl reload nginx
}

# 2. Switch traffic
switch_to "$target"
sleep 1

# 3. Verify through Nginx, roll back on failure
if ! curl -fsS --max-time 2 "$PUBLIC_URL" > /dev/null; then
    echo "ERROR: public check failed, rolling back to $current." >&2
    switch_to "$current"
    exit 1
fi

echo "Traffic is now on $target."

Make it executable:

sudo chmod 755 /usr/local/bin/myapp-switch

Because the script runs the same logic in both directions, a rollback is just another run of myapp-switch.

Step 6 - Deploying a new release

Now go through a real release cycle. Blue is live, so deploy version 2 to green:

echo '<h1>Version 2 (green)</h1>' | sudo tee /opt/myapp/green/index.html
sudo systemctl restart myapp@green

Test green directly before any user sees it. This is where you would run smoke tests or point a QA browser at the internal port:

curl -s http://127.0.0.1:8082/
<h1>Version 2 (green)</h1>

To prove that the switch drops no requests, open a second SSH session and run a loop that requests the site five times per second and prints only failures:

while true; do curl -fsS -o /dev/null http://127.0.0.1/ || echo "FAILED at $(date +%T)"; sleep 0.2; done

In the first session, switch traffic:

sudo myapp-switch
Active: blue. Switching to: green
Traffic is now on green.

The loop in the other session prints nothing, which means no request failed. Stop it with Ctrl+C and confirm the new version is live:

curl -si http://localhost/ | grep -E 'X-Upstream|<h1>'
X-Upstream: 127.0.0.1:8082
<h1>Version 2 (green)</h1>

To roll back, run sudo myapp-switch again and traffic returns to blue, which still runs version 1. Keep the previous color running until you are confident in the release; the next deployment will overwrite it.

Step 7 - Handling database changes

The switch is instant, but both versions share the same database. If version 2 renames a column that version 1 still reads, a rollback breaks. Use the expand and contract pattern so every schema is compatible with both the old and the new code:

  1. Expand: add new columns or tables without removing anything. Example: add email_address next to email.
  2. Deploy the new code, which writes to both columns and reads from the new one. Backfill old rows in batches.
  3. Switch traffic. Rollback is still safe because the old column is intact.
  4. Contract: once the old version is retired for good, drop the old column in a later release.

Never combine a destructive migration (DROP COLUMN, renames, type changes) with the release that stops using that column.

Sessions and uploaded files must also be shared: store sessions in Redis or the database, and files on shared storage or object storage, never on the local disk of one color.

Troubleshooting

nginx -t fails with duplicate upstream "myapp_backend". Another file in sites-enabled or conf.d also includes blue.conf or green.conf directly. Only active.conf must be included.

Traffic does not change after the switch. Check where the link points with readlink -f /etc/nginx/myapp/active.conf, and make sure you ran systemctl reload nginx, not only edited files. A browser may also be reusing a cached page; test with curl.

The idle color fails the health check. Look at its logs with sudo journalctl -u myapp@green -n 50. Typical causes are a port already in use or wrong file permissions in the release directory.

502 Bad Gateway after switching. The target instance stopped between the health check and the reload. Run sudo myapp-switch to roll back, then fix the instance.

Conclusion

You now have a working blue-green setup: two versions of the application run in parallel, Nginx switches between them with a graceful reload, and the deploy script refuses to switch to an unhealthy release and rolls back automatically. From here you can move each color to its own server by changing the upstream addresses, call myapp-switch from your CI pipeline over SSH, and add HTTPS to the Nginx site with Let's Encrypt.