Operations 34 min read

Nginx Blue-Green Deployment: Switching & Rollback Guide for Zero-Downtime Releases

This article details a practical blue-green deployment workflow using Nginx and Docker on a single Linux server, covering health checks, version verification, candidate validation, atomic Nginx upstream switching, rollback procedures, database compatibility, static asset versioning, and graceful shutdown of old instances.

Tech Ocean
Tech Ocean
Tech Ocean
Nginx Blue-Green Deployment: Switching & Rollback Guide for Zero-Downtime Releases

Blue and Green Are Two Production Versions

"Blue" and "green" are merely names for two deployment slots. In this example, the blue slot runs the old version listening on 127.0.0.1:8080; the green slot prepares the new version on 127.0.0.1:8081. Roles can swap on the next release. Unlike a test-vs-production split, both versions handle real production traffic and typically share the same production database, Redis, and file storage.

Therefore, two preconditions exist before switching: the new and old versions must understand the shared data, and the server must have capacity to run both applications simultaneously. CPU, memory, and database connection pools are sized for two instances, with special attention to the new version's startup resource peak. If a single machine cannot hold a second instance, the green instance can be placed on another machine and proxied by Nginx. Single-host blue-green reduces deployment interruption but does not provide high availability for server failures.

Blue and green production versions access the same production data through a single entry point; the switch changes which application version receives new requests
Blue and green production versions access the same production data through a single entry point; the switch changes which application version receives new requests

New Version Starts First, Old Version Keeps Working

Assume the old container is named app-blue and the new image is registry.example.com/team/myapp:release-r2. Image tags must be immutable or use digests to guarantee the exact code for rollback. The green slot is assumed free; when reusing a slot later, confirm it receives no traffic and has no in-flight requests before replacing the stopped container, while retaining the image and release records.

Start the new container in the green slot:

docker run -d \
  --name app-green \
  --restart unless-stopped \
  --env-file /etc/myapp/production.env \
  -p 127.0.0.1:8081:8080 \
  registry.example.com/team/myapp:release-r2
production.env

holds production configuration and credentials, maintained by the deploy account and not committed to the repository. New and old versions use compatible session formats and signing keys; sessions reside in shared production storage, not in individual process memory, to avoid login loss on switch. File uploads use shared production storage, not container-local temp directories. Only localhost ports are exposed; external requests enter via Nginx. The old container continues listening on 8080, untouched by this command.

A commonly missed prerequisite: the image starts an HTTP service. If the application automatically runs schema migrations, starts cron jobs, or consumes messages on startup, the new container may affect production even without web traffic. At the outset, separate HTTP, background tasks, and database migrations into independent entrypoints. The candidate version starts only HTTP; migrations run separately per the release plan; cron jobs and consumers have their own handoff processes. Framework-specific switches vary; a single universal environment variable cannot solve this.

Port Reachable Is Not Enough to Switch Traffic

The application should provide two read-only endpoints: /readyz: returns 200 if the application and its critical dependencies are ready, otherwise 503. /version: returns 200 with the current instance's release identifier (e.g., plain text release-r2) and no extra text.

These endpoints allow read-only probing from the server itself, without redirects to login or elsewhere. curl --fail alone is insufficient because it may exit successfully on 302 redirects; the curl manual documents this boundary, so explicit status-code checking is required.

Save the following two functions to /etc/myapp/release-checks.sh for use in Bash deploy scripts. curl_200 accepts only HTTP 200; check_release verifies both readiness and the expected version:

curl_200() {
  local response_file http_code
  response_file=$(mktemp) || return 1

  if ! http_code=$(curl --disable --silent --show-error \
      --connect-timeout 2 --max-time 5 \
      --output "$response_file" \
      --write-out '%{http_code}' "$@"); then
    rm -f "$response_file"
    return 1
  fi

  if [ "$http_code" != 200 ]; then
    printf 'Expected HTTP 200, got %s
' "$http_code" >&2
    rm -f "$response_file"
    return 1
  fi

  if ! cat "$response_file"; then
    rm -f "$response_file"
    return 1
  fi
  rm -f "$response_file"
}

check_release() {
  local expected_release=$1 base_url=$2 actual_release
  shift 2
  curl_200 "$@" "$base_url/readyz" >/dev/null || return 1
  actual_release=$(curl_200 "$@" "$base_url/version") || return 1
  [ "$actual_release" = "$expected_release" ]
}

In the deploy script, load the functions and check the green instance:

source /etc/myapp/release-checks.sh
check_release release-r2 http://127.0.0.1:8081 \
  --noproxy '*' -H 'Host: www.example.com' || exit 1

The --disable flag is placed as curl's first argument to prevent the deploy account's default curl config from enabling redirects that would alter probe behavior. exit 1 aborts the deploy script, skipping the switch. If the first check is not ready, wait and re-probe with a timeout; if it never becomes ready, let the blue version continue serving and investigate before retrying. The version identifier must also match; a 200 response does not guarantee the expected image. readyz is not a hard-coded OK. It checks database connections, required configuration, and critical dependencies; the probe remains read-only (no SMS, no order creation). Business acceptance follows health checks; acceptance accounts, permitted write objects, scopes, and cleanup procedures are agreed in advance. Docker's documentation also distinguishes "container running" from "service ready". Health checks are a gate into the deploy flow, not a substitute for business acceptance.

Nginx Selects Only One Version for New Requests

To make the switch atomic, place the upstream address in a separate file. First create the config directory: sudo mkdir -p /etc/nginx/myapp-upstreams Create /etc/nginx/myapp-upstreams/blue.conf with a single line: server 127.0.0.1:8080; Create green.conf in the same directory pointing to the new version: server 127.0.0.1:8081; Create a symlink active.conf initially pointing to blue.conf:

sudo ln -s \
  /etc/nginx/myapp-upstreams/blue.conf \
  /etc/nginx/myapp-upstreams/active.conf

This command is for first-time initialization. If an active symlink already exists, verify its target before proceeding; do not recreate or overwrite unknown files.

In Nginx's http block, configure the upstream and site (e.g., in a site file included by http):

upstream myapp_active {
  include /etc/nginx/myapp-upstreams/active.conf;
}

log_format release_log
  '$time_iso8601 $request_method $uri status=$status '
  'upstream=$upstream_addr '
  'upstream_status=$upstream_status '
  'rt=$request_time urt=$upstream_response_time';

server {
  listen 443 ssl;
  server_name www.example.com;

  ssl_certificate /etc/nginx/ssl/example.com.crt;
  ssl_certificate_key /etc/nginx/ssl/example.com.key;

  access_log /var/log/nginx/myapp-release.log release_log;

  location / {
    proxy_pass http://myapp_active;
    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 $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_connect_timeout 3s;
    proxy_read_timeout 30s;
    proxy_next_upstream off;
  }
}

Replace certificate paths with the real ones for your site. This minimal config assumes Nginx directly receives requests; if a CDN or load balancer sits in front, configure trusted proxies for the real client IP — do not blindly trust forwarded headers. readyz can be restricted via gateway rules. active.conf contains only one upstream address at a time. The old and new versions are not placed together in a load-balancing pool, and the old version is not set as an automatic fallback. When the new version errors, the deploy process decides whether to switch back, avoiding unevaluated cross-version retries. Duplicate write requests are handled by idempotency design.

Nginx's proxy documentation states that proxy_next_upstream off disables retry to the next upstream. The 30-second proxy_read_timeout is the idle timeout between two upstream reads, not a total business-request deadline; long-running tasks need a separate strategy per the actual protocol.

Business Acceptance via Candidate Entry

The green instance is not yet receiving production traffic; opening the normal site still shows the blue version. Acceptance success might only prove the old version works.

For API services, add a candidate HTTPS entry listening only on the loopback address, fixed to the green slot. Place the following server block in the same http config:

server {
  listen 127.0.0.1:8443 ssl;
  server_name www.example.com;

  ssl_certificate /etc/nginx/ssl/example.com.crt;
  ssl_certificate_key /etc/nginx/ssl/example.com.key;
  access_log /var/log/nginx/myapp-candidate.log release_log;

  location / {
    proxy_pass http://127.0.0.1:8081;
    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 $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_connect_timeout 3s;
    proxy_read_timeout 30s;
    proxy_next_upstream off;
    proxy_cache off;
  }
}

On first install, verify the active symlink still points to blue, run nginx -t, reload, then confirm the production entry still serves the old version. The candidate entry listens only on 127.0.0.1; acceptance personnel SSH into the server and probe, without exposing port 8443 to the public internet.

On the server, send requests to the candidate port:

check_release release-r2 https://www.example.com \
  --noproxy '*' \
  --connect-to www.example.com:443:127.0.0.1:8443 || exit 1

Curl's --connect-to changes only the connection target; the request still validates the certificate for www.example.com and retains the Host header. The candidate Nginx forwards headers so the application knows the original request used HTTPS; the application must configure trusted proxies to read these headers, otherwise it may redirect back to the production entry. Trust is limited to the actual Nginx proxy source, not arbitrary client headers. Candidate logs should show the upstream as 127.0.0.1:8081.

Login and business APIs use the same connection rule. For example, if the project provides /api/login and /api/me returning 200, the acceptance account's login JSON is stored in /etc/myapp/acceptance-login.json (path and fields replaced per real API):

umask 077

curl_200 \
  --noproxy '*' \
  --connect-to www.example.com:443:127.0.0.1:8443 \
  --header 'Content-Type: application/json' \
  --data-binary @/etc/myapp/acceptance-login.json \
  --cookie-jar /etc/myapp/acceptance.cookies \
  https://www.example.com/api/login || exit 1

curl_200 \
  --noproxy '*' \
  --connect-to www.example.com:443:127.0.0.1:8443 \
  --cookie /etc/myapp/acceptance.cookies \
  https://www.example.com/api/me || exit 1

The login request file and cookie file are maintained by the deploy account, readable/writable only by that account, and not committed to the repo. Beyond HTTP 200, verify the login result, identity, and business content; other endpoints are validated against their own success criteria. Redirects are not followed automatically; check the redirect target first to avoid subsequent requests silently falling back to blue.

This channel validates the candidate's HTTP APIs. Frontend interactions, OAuth callbacks, or third-party logins require a separate restricted browser acceptance entry, verifying that redirects stay within the candidate version. Until those are complete, interface probes cannot be considered full-site acceptance. In the next round when blue/green roles swap, the candidate entry points to whichever slot is idle.

Candidate acceptance failure retains blue; config check failure restores original symlink; after reload verify entry, on anomaly check old version readiness and data compatibility before deciding rollback
Candidate acceptance failure retains blue; config check failure restores original symlink; after reload verify entry, on anomaly check old version readiness and data compatibility before deciding rollback

Switch Entry, Then Verify Actual Version

Switching from blue to green replaces the active symlink with the green file. Using mv -T (GNU coreutils) creates a temporary symlink then atomically replaces it in the same directory, avoiding a momentary missing active file:

sudo ln -s \
  /etc/nginx/myapp-upstreams/green.conf \
  /etc/nginx/myapp-upstreams/active.conf.next &&

sudo mv -Tf \
  /etc/nginx/myapp-upstreams/active.conf.next \
  /etc/nginx/myapp-upstreams/active.conf &&

sudo nginx -t

Commands are chained with && so failure at any step halts the process. If the temporary symlink already exists, investigate whether a previous deploy was interrupted.

Only one deploy task operates this setup at a time. Script the flow and protect it with flock from slot selection through switch verification; any other process that modifies Nginx config or reloads it must respect the same lock. Locking a single command still allows cross-operation races.

Config check passes, then reload: sudo nginx -s reload If the config check fails, Nginx continues serving with the running old config; revert the active symlink to blue.conf, re-check, and do not reload. The on-disk config must also be restored to avoid leaving a broken entry for the next deploy.

The reload command returning success only means the signal was sent; check error logs and verify actual traffic. Nginx's reload documentation explains that the master process attempts to apply the new config; on failure it keeps the old config, on success it starts new workers and lets old workers gracefully exit.

Probe /version from the real domain using new connections, repeatedly confirming the expected release-r2. This endpoint returns Cache-Control: no-store, so Nginx and CDN do not cache it; also verify from the local server entry to avoid public probes hitting cache and never reaching the origin.

Example from the server via HTTPS entry:

check_release release-r2 https://www.example.com \
  --noproxy '*' \
  --resolve www.example.com:443:127.0.0.1 \
  -H 'Connection: close' || exit 1

Certificate validation still occurs; domain and certificate must match. The public entry is checked separately; local probes do not replace validation through the actual CDN or load-balancer chain.

During the switch, both versions coexist briefly. The application release identifier and the upstream address in Nginx logs are better indicators of where requests went than the symlink change alone.

Keep Old Version for Rollback Path

After switching to green, monitor error rate, latency, resource usage, and core business metrics for deviation from normal. The observation window is sized to traffic and business cycles, not a fixed minute count.

If a rollback is needed, first verify the blue instance is still ready and returns the expected old version identifier (e.g., release-r1) using the same function:

check_release release-r1 http://127.0.0.1:8080 \
  --noproxy '*' -H 'Host: www.example.com' || exit 1

If the old instance has stopped, restart it with the preserved exact old image, waiting for it to pass readiness and version checks. nginx -t only validates configuration; it does not confirm the old application can serve requests. The old instance may be stopped while the config still passes, leading to 502 after switching back.

Only if the old instance is ready AND can handle current production data, switch the active symlink back to blue:

sudo ln -s \
  /etc/nginx/myapp-upstreams/blue.conf \
  /etc/nginx/myapp-upstreams/active.conf.next &&

sudo mv -Tf \
  /etc/nginx/myapp-upstreams/active.conf.next \
  /etc/nginx/myapp-upstreams/active.conf &&

sudo nginx -t

After the check passes, run sudo nginx -s reload, then verify the blue version identifier, logs, and core business from both local and public entries. If rollback checks fail, revert the symlink to its pre-rollback state; do not let on-disk config and actual service drift apart.

If the old instance is not ready or data is incompatible, stop before switching back; decide on fix, maintenance, or write restriction based on the failure. Whether to stop writes is part of business failure handling, not automatically decided by Nginx.

Before rollback verify old instance readiness and data compatibility; config check failure restores original symlink; after successful rollback retain green to drain in-flight requests
Before rollback verify old instance readiness and data compatibility; config check failure restores original symlink; after successful rollback retain green to drain in-flight requests

This redirects subsequent requests to the old version. Orders written, notifications sent, and payments processed by green are not undone; requests already inside green may continue executing.

Therefore, after rollback, keep the green container and observe its remaining requests. For anomalous writes, the application must implement write stop, request draining, or business compensation; reloading the gateway does not mean all execution has stopped.

WebSocket, SSE, and streaming responses need specific handling. WebSocket requires a separate Upgrade forwarding config, not covered in this HTTP example; SSE and other long connections affect when old workers exit. Allow connections to end naturally, or define max connection time, reconnect, and recovery. Setting worker_shutdown_timeout in Nginx forces closure after timeout, but does not guarantee seamless long-connection switching.

Background consumers and cron jobs hand off separately: stop old consumers from accepting new tasks, finish existing tasks, then start compatible new consumers; cron jobs need a unique executor or business lock. Do not run the same periodic task in both blue and green and expect the HTTP switch to solve duplicate execution.

Database Compatibility Determines Rollback Feasibility

The most dangerous deploy is when the new version deletes a column on startup, the switch fails, and the old version can no longer read the database.

Small teams can split schema changes:

Add compatible columns or tables first; old code continues working.

New version handles both formats during transition, migrating data as needed.

After observation, old instance retirement, and the agreed rollback window closure, drop old columns or logic.

Column renames, message formats, and Redis serialization follow the same reasoning. GitLab's database migration practices discuss compatibility during new/old code coexistence; here we only borrow the phased approach — actual SQL, locking, and migration methods must be validated against the project's database.

New data written by the new version must be readable by the old version. Only confirming "the old table still exists" without verifying new data format compatibility still risks rollback failure. The presence of the old container and image does not guarantee rollback conditions hold.

Browser Still Holds Old Page

If the application serves frontend pages, old scripts in the browser do not auto-update on switch. The old page may call new APIs; after rollback, the new page may call old APIs; pre-deploy API compatibility checks must cover both directions.

Static files use content hashes or release directories, e.g., /assets/release-r1/ and /assets/release-r2/. These resources go to a shared static directory, object storage, or CDN, covering the observation and rollback window, not just inside each app container. Otherwise, after the entry switches, the old page requests the old filename, the new container returns 404.

HTML and dynamic APIs use explicit cache strategies; version probes bypass cache; static files can be long-cached via immutable URLs. Rollback does not rely on clearing all browser caches; old pages, APIs, and resources have a transition period.

Drain Old Requests Before Stopping Old Container

After a successful switch to green, do not immediately run docker stop app-blue.

During Nginx reload, old workers continue processing already-received requests. Stopping the blue service immediately may cause in-flight requests to fail. Check old worker/instance in-flight request metrics and deploy logs, confirm no more requests need the blue instance, then combine with the observation window to decide exit time. "No new log entries" does not prove no long requests are still running.

When stopping the container, allow the application its own graceful shutdown time, e.g.: docker stop --timeout 30 app-blue Docker sends a stop signal to the container's PID 1, then SIGKILL after the timeout. The 30 seconds is an example; the application must correctly handle the signal and wait for in-flight requests to complete. The container's entrypoint must propagate the signal to the application process.

Preserve the old image, deploy config, and associated records. Future restarts of the old instance use the explicit old image, not a mutable latest tag.

WebSocket, SSE, and streaming responses are more specific. WebSocket needs a separate Upgrade forwarding config, not promised in this HTTP example; SSE and long connections also affect when old workers exit. Allow connections to finish naturally, or agree on max connection time, reconnect, and recovery. Setting worker_shutdown_timeout in Nginx attempts to close old connections after timeout, but does not mean long connections can switch seamlessly.

Background consumers and cron jobs hand off separately: stop old consumers from taking new tasks, finish existing tasks, then start compatible new consumers; cron jobs require a unique executor or business lock. Do not let both blue and green run the same periodic task and expect the HTTP entry switch to resolve duplicate execution.

This workflow's endpoint is concrete: the new version is prepared, the entry switch is verifiable, the old version is conditionally retained, and rollback is verifiable. Data compatibility, old requests, and background tasks all have explicit handling, making the Nginx switch a usable deployment process.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Dockeroperationsblue-green-deploymentnginxzero-downtimerollbackhealth-checksdeployment-strategy
Tech Ocean
Written by

Tech Ocean

Focused on AI programming, sharing ready-to-use development efficiency solutions.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.