--- title: "Blue-Green Deployments with Docker and Nginx on Ubuntu 24.04" description: "Run two Docker copies of your app behind Nginx, deploy each release to the idle one, health check it and switch traffic with a reload for zero-downtime updates." category: "CI/CD DevOps" language: en source: https://cubepath.com/docs/ci-cd-devops/blue-green-deployments-with-docker-and-nginx updated: 2026-09-25 --- # Blue-Green Deployments with Docker and Nginx on Ubuntu 24.04 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 | Component | Blue | Green | |---|---|---| | Container name | `app-blue` | `app-green` | | Published port (localhost only) | `127.0.0.1:8081` | `127.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: ```bash 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 `/`: ```bash curl -i http://127.0.0.1:8081/health curl -s http://127.0.0.1:8081/ | grep Hostname ``` ```output HTTP/1.1 200 OK ... Hostname: app-blue ``` ## Step 2 - Configuring Nginx Create the upstream file. It starts pointing at blue: ```bash sudo nano /etc/nginx/conf.d/app-upstream.conf ``` ```nginx 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: ```bash sudo nano /etc/nginx/sites-available/app ``` ```nginx 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: ```bash 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: ```bash curl -si http://localhost/ | grep -E 'X-Upstream|Hostname' ``` ```output 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: ```bash sudo nano /usr/local/sbin/bluegreen ``` ```bash #!/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: ```bash sudo chmod 755 /usr/local/sbin/bluegreen sudo bluegreen status ``` ```output 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: ```bash 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: ```bash sudo bluegreen deploy traefik/whoami:v1.11.0 ``` ```output 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: ```output 300 200 ``` Confirm that Nginx now sends traffic to green: ```bash curl -si http://localhost/ | grep -E 'X-Upstream|Hostname' ``` ```output 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: ```bash sudo bluegreen rollback ``` ```output Traffic switched to blue (port 8081) ``` ```bash curl -s http://localhost/ | grep Hostname ``` ```output 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: ```bash 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.