A blue-green deployment keeps two copies of your application: one (say blue) serves all traffic while the other (green) sits idle. A new release is started in the idle copy, checked, and only then does the reverse proxy switch traffic to it. The previous version keeps running, so rolling back is just switching again. In this tutorial you will build this on a single Ubuntu 24.04 server with Docker containers and Nginx, and write one small script that deploys, health checks, switches and rolls back.

Prerequisites

To follow this tutorial you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, and a non-root user with sudo privileges.
  • Docker Engine installed from Docker's official repository.
  • Nginx installed (sudo apt install nginx), and UFW allowing Nginx Full if the firewall is enabled.
  • Your application packaged as a container image that listens on one port and exposes a health endpoint returning HTTP 200 when it is ready. The tutorial uses the public traefik/whoami image, which listens on port 80 and has a /health endpoint, so you can follow along before using your own image.
  • Optional: a domain name pointing to your_server_ip if you want to add HTTPS at the end.

How the setup works

ComponentBlueGreen
Container nameapp-blueapp-green
Published port (localhost only)127.0.0.1:8081127.0.0.1:8082

Nginx proxies to an upstream called app_backend defined in /etc/nginx/conf.d/app-upstream.conf. That file contains a single server line pointing to the active port, so it is also the record of which color is live. Switching means rewriting that line and running systemctl reload nginx: Nginx starts new workers with the new upstream while the old workers finish the requests they are already handling, so no request is dropped.

Step 1 - Starting the blue version

Start the first release as the blue container. The port is bound to 127.0.0.1 so the container is reachable only through Nginx, and --hostname makes whoami print which color answered:

docker run -d --name app-blue --hostname app-blue --restart unless-stopped \
  -p 127.0.0.1:8081:80 traefik/whoami:v1.10.3

Check that it answers on its health endpoint and on /:

curl -i http://127.0.0.1:8081/health
curl -s http://127.0.0.1:8081/ | grep Hostname
HTTP/1.1 200 OK
...
Hostname: app-blue

Step 2 - Configuring Nginx

Create the upstream file. It starts pointing at blue:

sudo nano /etc/nginx/conf.d/app-upstream.conf
upstream app_backend {
    server 127.0.0.1:8081;
    keepalive 16;
}

Ubuntu's /etc/nginx/nginx.conf includes every *.conf file in conf.d inside the http block, so the upstream is available to all sites.

Now create the site that uses it. Replace your_domain with your domain, or leave _ to accept any host name:

sudo nano /etc/nginx/sites-available/app
server {
    listen 80;
    server_name your_domain;

    location / {
        proxy_pass http://app_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;
    }
}

proxy_http_version 1.1 and the empty Connection header let Nginx reuse connections to the container (the keepalive setting). The X-Upstream response header shows which port served each request, which is handy for verifying switches.

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

sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/app
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Request the page through Nginx:

curl -si http://localhost/ | grep -E 'X-Upstream|Hostname'
X-Upstream: 127.0.0.1:8081
Hostname: app-blue

Step 3 - Writing the deploy and rollback script

The deploy procedure is always the same, so it belongs in a script. It reads the active color from the upstream file, starts the new image in the idle color, waits for the health check and only then switches Nginx. If the new container never becomes healthy, traffic stays where it is.

Create the script:

sudo nano /usr/local/sbin/bluegreen
#!/usr/bin/env bash
set -euo pipefail

UPSTREAM_CONF=/etc/nginx/conf.d/app-upstream.conf
APP_PORT=80              # port the application listens on inside the container
HEALTH_PATH=/health      # returns 200 when the application is ready
BLUE_PORT=8081
GREEN_PORT=8082

usage() {
    echo "Usage: $0 deploy IMAGE | rollback | status" >&2
    exit 2
}

detect_colors() {
    if grep -q "127.0.0.1:${BLUE_PORT};" "$UPSTREAM_CONF"; then
        active=blue;  idle=green; idle_port=$GREEN_PORT
    else
        active=green; idle=blue;  idle_port=$BLUE_PORT
    fi
}

wait_healthy() {
    local port=$1
    for _ in $(seq 1 30); do
        if curl -fsS -o /dev/null "http://127.0.0.1:${port}${HEALTH_PATH}"; then
            return 0
        fi
        sleep 2
    done
    return 1
}

switch_to() {
    local color=$1 port=$2
    cp "$UPSTREAM_CONF" "${UPSTREAM_CONF}.bak"
    printf 'upstream app_backend {\n    server 127.0.0.1:%s;\n    keepalive 16;\n}\n' "$port" > "$UPSTREAM_CONF"
    if ! nginx -t -q; then
        mv "${UPSTREAM_CONF}.bak" "$UPSTREAM_CONF"
        echo "nginx -t failed, configuration restored" >&2
        exit 1
    fi
    systemctl reload nginx
    echo "Traffic switched to ${color} (port ${port})"
}

deploy() {
    local image=$1
    detect_colors
    echo "Active: ${active}. Deploying ${image} to ${idle}"

    docker pull "$image"
    docker rm -f "app-${idle}" >/dev/null 2>&1 || true
    docker run -d --name "app-${idle}" --hostname "app-${idle}" --restart unless-stopped \
        -p "127.0.0.1:${idle_port}:${APP_PORT}" "$image" >/dev/null

    if ! wait_healthy "$idle_port"; then
        echo "app-${idle} did not become healthy, traffic stays on ${active}" >&2
        docker logs --tail 20 "app-${idle}" >&2
        exit 1
    fi

    switch_to "$idle" "$idle_port"
    echo "app-${active} is still running for rollback"
}

rollback() {
    detect_colors
    if ! wait_healthy "$idle_port"; then
        echo "app-${idle} is not healthy, cannot roll back to it" >&2
        exit 1
    fi
    switch_to "$idle" "$idle_port"
}

status() {
    detect_colors
    echo "Active: ${active}"
    docker ps --filter name=app- --format '{{.Names}}\t{{.Image}}\t{{.Status}}'
}

case "${1:-}" in
    deploy)   [[ $# -eq 2 ]] || usage; deploy "$2" ;;
    rollback) rollback ;;
    status)   status ;;
    *)        usage ;;
esac

Set APP_PORT and HEALTH_PATH to match your own image. Make the script executable and check the current state:

sudo chmod 755 /usr/local/sbin/bluegreen
sudo bluegreen status
Active: blue
app-blue	traefik/whoami:v1.10.3	Up 5 minutes

Step 4 - Deploying a new version

Before deploying, start a request loop in a second terminal so you can see whether any request fails during the switch. It sends 300 requests over about 30 seconds and counts the status codes:

for i in $(seq 1 300); do curl -s -o /dev/null -w '%{http_code}\n' http://localhost/; sleep 0.1; done | sort | uniq -c

In the first terminal, deploy the new release:

sudo bluegreen deploy traefik/whoami:v1.11.0
Active: blue. Deploying traefik/whoami:v1.11.0 to green
v1.11.0: Pulling from traefik/whoami
...
Traffic switched to green (port 8082)
app-blue is still running for rollback

When the loop in the second terminal finishes, every request should have returned 200:

    300 200

Confirm that Nginx now sends traffic to green:

curl -si http://localhost/ | grep -E 'X-Upstream|Hostname'
X-Upstream: 127.0.0.1:8082
Hostname: app-green

The next deploy goes to blue, replacing the old container there, and so on.

Step 5 - Rolling back

If you find a problem with the new release, switch back to the previous one. The script checks that the idle container is still healthy before switching:

sudo bluegreen rollback
Traffic switched to blue (port 8081)
curl -s http://localhost/ | grep Hostname
Hostname: app-blue

The rollback takes as long as an Nginx reload, because nothing needs to be pulled or started.

Step 6 - Adding HTTPS (optional)

If server_name is a real domain, get a Let's Encrypt certificate with Certbot. It adds the TLS settings to the app server block and leaves the proxy_pass to app_backend untouched, so the script keeps working:

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d your_domain

What to keep in mind with your own application

  • Database migrations: for a while both versions run against the same database, and a rollback puts the old code back on the new schema. Make migrations backward compatible (add columns first, drop them in a later release).
  • State: sessions, uploads and caches must live outside the containers (a database, Redis, a volume shared by both colors), or users lose them on each switch.
  • Long-lived connections: WebSocket or streaming connections stay on the old container until they close. Keep the old color running until they drain; the script already does this by leaving it up.
  • Memory: during a deploy both versions run at the same time, so size the server for two copies of the application.

Troubleshooting

  • app-green did not become healthy: read the logs the script prints, and check that APP_PORT matches the port the application listens on inside the container and that HEALTH_PATH returns 200.
  • bind: address already in use from docker run: another process uses port 8081 or 8082. Find it with sudo ss -ltnp | grep -E '8081|8082' or change BLUE_PORT/GREEN_PORT in the script and in the upstream file.
  • 502 Bad Gateway from Nginx: the active container is down. Run sudo bluegreen status and docker logs app-blue (or app-green), then roll back or redeploy.

Conclusion

You now have two application slots behind Nginx, a script that deploys only healthy releases and switches traffic with a graceful reload, and a rollback that takes about a second. To go further, call sudo bluegreen deploy your_registry/your_app:tag from your CI pipeline over SSH after the image is pushed, or send a small percentage of traffic to the new version first with a canary release before switching completely.