惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Jina AI
Jina AI
C
Cybersecurity and Infrastructure Security Agency CISA
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
T
Threat Research - Cisco Blogs
L
LINUX DO - 热门话题
Simon Willison's Weblog
Simon Willison's Weblog
L
Lohrmann on Cybersecurity
S
Schneier on Security
T
The Exploit Database - CXSecurity.com
Know Your Adversary
Know Your Adversary
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Cyberwarzone
Cyberwarzone
T
Threatpost
Hugging Face - Blog
Hugging Face - Blog
博客园_首页
Scott Helme
Scott Helme
WordPress大学
WordPress大学
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
W
WeLiveSecurity
L
LINUX DO - 最新话题
G
GRAHAM CLULEY
酷 壳 – CoolShell
酷 壳 – CoolShell
S
SegmentFault 最新的问题
Vercel News
Vercel News
Microsoft Azure Blog
Microsoft Azure Blog
有赞技术团队
有赞技术团队
Cisco Talos Blog
Cisco Talos Blog
V2EX - 技术
V2EX - 技术
Apple Machine Learning Research
Apple Machine Learning Research
H
Help Net Security
F
Fortinet All Blogs
The Hacker News
The Hacker News
IT之家
IT之家
Forbes - Security
Forbes - Security
月光博客
月光博客
S
Security @ Cisco Blogs
SecWiki News
SecWiki News
博客园 - 聂微东
GbyAI
GbyAI
S
Security Affairs
H
Heimdal Security Blog
人人都是产品经理
人人都是产品经理
大猫的无限游戏
大猫的无限游戏
AWS News Blog
AWS News Blog
T
Tenable Blog
P
Privacy International News Feed
Microsoft Security Blog
Microsoft Security Blog
C
Cyber Attacks, Cyber Crime and Cyber Security
AI
AI

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver Node.js Event Loop Lag: The Hidden Metric Behind Random Latency Spikes API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution Node.js Cluster Mode: Scaling the Event Loop Across CPU Cores Postgres Partial Indexes: Stopping Soft Deletes from Ruining Your Query Performance Request Coalescing with the Singleflight Pattern: Stop Drowning Your Database on Every Cache Miss The Bulkhead Pattern: Why One Slow Endpoint Should Not Drown Your Whole Service Node.js AsyncLocalStorage: End-to-End Request Context Without the Propagation Hell Postgres Deadlocks: Logging the Victim, Reproducing the Race, and Fixing the Lock Order Your Node.js HTTP Client Is the Bottleneck: Connection Pool Tuning That Works Optimistic Locking in Postgres: Stop Losing Data to Race Conditions Postgres Read Replicas: Stop Serving Stale Data to Your Users Cursor Pagination: Why Offset Queries Explode at Scale and How to Fix Them Node.js Worker Threads: 60 Lines That Stop a CSV Upload from Timing Out Every Other Request Reliable Webhook Delivery: Architecture for Outbound HTTP You Can Trust Request Timeouts and Deadline Propagation: Stop the Chain of Slowness Advanced Security Practices in Node.js Graceful Shutdown in Node.js: The 40 Lines That Stop 502s During Deploys Finding Node.js Memory Leaks with Heap Snapshots Idempotency Keys in 30 Lines: Stop Your Webhook From Charging Customers Twice Backpressure In Node.js: The Fix For Slow-Motion Queue Meltdowns Retries Done Right: Jitter, Budgets, and the Stampede You Did Not See Coming The Cache Stampede: Why Your "Just Add Redis" Layer Crashes Postgres at 3 a.m. Postgres SKIP LOCKED: An 80-Line Job Queue You Can Run Without Redis Stop Doing Work Nobody Wants: AbortController in Node.js, Done Right The N+1 Query Problem: We Found 23 In One Codebase And Killed Every One I Tried 5 AI Coding Tools for a Month. Here Is What I Actually Use CI/CD From Zero to Production in 30 Minutes With GitHub Actions Node.js vs Bun vs Deno: Which Runtime Should You Pick in 2025? Kubernetes Resource Requests And Limits: The Numbers That Decide If Your Cluster Is Stable The Three Pillars of Observability Are A Myth: What Actually Matters In Production pnpm Vs npm Vs yarn Vs Bun For Monorepos: Which One Earns The Migration In 2024 JSONB Indexing In Postgres: GIN Vs Expression Indexes, And When Each Is The Right Choice A Code Review Checklist That Ends The Same Three Arguments Every Sprint gRPC Vs REST In 2024: When The Switch Pays For Itself React Suspense For Data Fetching: The Pattern That Replaces Half Your Loading State Code The Five-Stage Rollout: How To Ship A Risky Change Without Holding Your Breath GitHub Actions In A Monorepo: Caching, Path Filters, And Secret Boundaries That Actually Work The Blameless Postmortem That Actually Improves Things: A Template And Six Hard-Won Rules Recursive CTEs In Postgres: How To Query A Tree Without N Round Trips Node.js Streams: When They Actually Help, And When They Just Add Complexity Playwright Vs Cypress In 2024: The Honest Comparison Of Which One Earns The Test Time React Server Components: The Mental Model That Makes The "use client" Boundary Obvious Pod Disruption Budgets: The K8s Object That Keeps Your Service Up During Cluster Maintenance Postgres LISTEN/NOTIFY: The Pub/Sub You Already Have And Are Not Using Chaos Engineering Starter Kit: The Five Drills That Don't Need Netflix-Scale Spec-Driven API Development With OpenAPI: How To Stop Drifting From Your Docs Kubernetes Autoscaling Beyond CPU: The Custom-Metric HPA Pattern That Actually Works Postgres Partitioning For Time-Series: The Boring Setup That Saves Your Database Distributed Locks With Redis: An Honest Look At Redlock And When You Don't Need It HTTP/2 vs HTTP/3: What Actually Changes For Your App, And What Doesn't Image Optimization For The Web In 2023: srcset, AVIF, And The Lighthouse Score You Actually Want Kafka vs RabbitMQ: A Decision Tree That Doesn't Hate You UUID vs Bigint Primary Keys In Postgres: The Index Math That Decides For You Flame Graphs: How To Find The Slow Function In 30 Seconds Without Profiling Theatre Postgres Streaming Vs. Logical Replication: Which One Solves Your Actual Problem ESLint Rules That Earn Their Keep: The Twelve I Enable On Every Project Pre-Commit Hooks That Pay For Themselves: Husky, lint-staged, And The Five Rules That Stick Zero-Downtime Database Migrations: The Six-Step Pattern That Rules Them All Circuit Breakers In Node.js: 50 Lines That Stop A Failing Dependency From Taking Down Your Service Postgres VACUUM Is Not Magic: How Your Hot Table Bloats To 80GB And How To Fix It Kubernetes Liveness And Readiness Probes: The Difference That Causes Half Your Outages Rate Limiting In Production: A Token Bucket In 30 Lines Of Redis The Outbox Pattern: How To Stop Losing Events When Postgres And Kafka Disagree Load Testing With k6: The Three Scenarios That Find Real Bugs (Not Synthetic Numbers) Postgres Row-Level Security For Multi-Tenant Apps: The Pattern That Stops You From Leaking Data Rebase vs. Merge: The Team Policy That Ends The Argument Forever OpenTelemetry in Node.js: Distributed Tracing That Actually Helps During an Incident Feature Flags That Pay Rent: The 4 Flag Types And When To Delete Each ETag, Last-Modified, and the Caching Headers Most APIs Get Wrong Connection Pooling Without the Cargo Cult: pgbouncer in 100 Lines of Config JSONB Is Not a Schema: When To Reach For It in Postgres, And When To Stop Bash Strict Mode: The Three Lines That Stop Your Deploy Script From Lying To You
Blue-Green Deployments for Node.js: Nginx Upstream Switching and Zero-Downtime Rollbacks
The Practica · 2026-06-18 · via The Practical Developer

The deploy ran for six minutes, the health checks passed, and the dashboard showed all instances green. Then support tickets started arriving: users who had submitted a payment form during the deploy window got a 502 and a double charge. The old server pool, still registered with the load balancer, had been killed mid-request while draining connections. The new pool was accepting traffic, but the load balancer had routed the payment POST to the dying instance, which terminated the process before the HTTP response flushed.

This is the standard failure mode of a naive deploy. You stop the old service, start the new one, and hope the load balancer figures out the rest. It does not. The load balancer’s health check interval is five seconds, so for up to five seconds after you kill the old instances, it still routes traffic to dead processes. Those requests get connection refused or, worse, a half-flushed response that the client interprets as success before the TCP socket slams shut.

The fix is blue-green deployment: two fully independent server pools, a load balancer that switches between them with zero overlap, and a rollback path that does not require redeploying anything. This post walks through the pattern with nginx, a Node.js health-check contract, and a deployment script that handles the entire lifecycle from a GitHub Actions runner.

Why rolling updates are not enough

Kubernetes users will point out that a rolling update handles this. It does, if you have Kubernetes. Many teams do not. They run a handful of VMs or bare-metal boxes behind nginx, perhaps with some Docker Compose in staging and a hand-rolled deploy script for production. Rolling updates on bare metal require you to orchestrate instance drain, connection migration, and health verification yourself. At that point, you have rebuilt blue-green with extra complexity.

A rolling update on two instances works like this: kill instance A, wait for it to drain, start the replacement, wait for its health check, then repeat on instance B. During the window between A going down and B coming up, the system operates at half capacity. If instance B’s startup takes thirty seconds, you lose half your throughput for that long. If instance B fails its health check, you are stuck with one healthy node and a partial rollout.

Blue-green avoids the capacity gap entirely. The old pool runs at full capacity until the new pool is fully verified. Then traffic shifts in a single atomic operation.

The architecture

Blue-green requires three things:

  • Two identical server pools, labeled “blue” and “green”. Only one is active at any time.
  • A load balancer that can switch traffic between pools without dropping connections.
  • A health-check contract that the load balancer uses to verify the active pool is healthy and the inactive pool is ready to receive traffic.

Here is the nginx configuration that supports this pattern:

upstream app_blue {
    server 10.0.1.10:3000 max_fails=3 fail_timeout=10s;
    server 10.0.1.11:3000 max_fails=3 fail_timeout=10s;
}

upstream app_green {
    server 10.0.2.10:3000 max_fails=3 fail_timeout=10s;
    server 10.0.2.11:3000 max_fails=3 fail_timeout=10s;
}

# Default: blue is active
map $http_x_deploy_color $backend {
    default    app_blue;
    blue       app_blue;
    green      app_green;
}

server {
    listen 80;
    listen 443 ssl;

    location / {
        proxy_pass http://$backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Deploy-Color $backend;

        # Timeouts that prevent hung connections from piling up
        proxy_connect_timeout 5s;
        proxy_send_timeout 10s;
        proxy_read_timeout 30s;

        # Buffer settings for long-running requests
        proxy_buffering off;
        proxy_request_buffering off;
    }

    # Health check endpoint that load balancers and deploy scripts hit
    location /health {
        proxy_pass http://$backend/health;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_connect_timeout 3s;
        proxy_read_timeout 5s;
    }
}

The map block is the switch. It defaults to app_blue. When you deploy, you update this value to app_green and reload nginx. Reloading nginx does not drop active connections. Workers are replaced one at a time, and each old worker keeps serving existing connections until they finish. New connections go to the new configuration. This is the atomic switch.

The X-Deploy-Color header is optional but useful. It lets your application know which pool it is running in, which matters for cache keys, database migration gating, and log correlation.

The Node.js health check contract

The load balancer is only half the pattern. The other half is a health check endpoint that tells the deploy tooling exactly what state the application is in. It must distinguish between “I am running” and “I am ready to accept production traffic.”

import express from 'express';
import { createServer } from 'http';
import { Pool } from 'pg';

const app = express();
const db = new Pool({ connectionString: process.env.DATABASE_URL });

type HealthStatus = 'starting' | 'ready' | 'draining' | 'failing';

let status: HealthStatus = 'starting';

// Mark the server as ready after startup tasks complete
async function initialize() {
    // Wait for database connectivity
    await db.query('SELECT 1');
    // Wait for any warmup requests (compile templates, load caches)
    await warmup();
    status = 'ready';
}

async function warmup(): Promise<void> {
    // Pre-heat connection pools, compile templates, hydrate caches
    // This prevents the cold-start latency spike after a switch
}

app.get('/health', async (_req, res) => {
    const health = {
        status,
        uptime: process.uptime(),
        database: 'unknown',
        memory: process.memoryUsage().heapUsed,
    };

    if (status === 'draining') {
        // Return 503 so the load balancer stops routing to this pool
        return res.status(503).json({
            ...health,
            message: 'Draining connections for deployment',
        });
    }

    if (status === 'starting') {
        // Return 503 until initialization completes
        return res.status(503).json({
            ...health,
            message: 'Still initializing',
        });
    }

    // Check database connectivity
    try {
        await db.query('SELECT 1');
        health.database = 'connected';
    } catch {
        health.database = 'disconnected';
        // Still return 200 if the app can function without DB for a moment
        // Return 503 for strict dependency gating
    }

    res.json(health);
});

app.post('/health/drain', (req, res) => {
    // Called by the deploy script before killing this pool
    status = 'draining';
    res.json({ status: 'draining' });

    // Give in-flight requests time to complete
    setTimeout(() => {
        server.close(() => {
            process.exit(0);
        });
    }, 30000); // 30-second drain window
});

const server = createServer(app);
server.listen(3000, () => {
    initialize().then(() => {
        console.log('Server ready for traffic');
    });
});

Three things matter in this implementation:

The status field is the source of truth. It transitions through starting -> ready -> draining over the server’s lifecycle. The deploy script and load balancer both read it. A starting or draining status returns 503, which tells nginx to skip this server and try another.

The POST /health/drain endpoint is what the deploy script calls before shutting down the old pool. It sets the status to draining, waits up to 30 seconds for in-flight requests to complete, then closes the server gracefully. This is the connection-drain step that most deploy scripts skip.

The warmup function is where you preload caches, establish database connections, or compile templates. Without it, the first requests to the new pool experience the cold-start latency that makes blue-green feel worse than a simple restart.

The deploy script

With nginx configured and the health contract in place, the deploy script becomes a state machine. Here it is in a self-contained bash script that a CI runner can execute:

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

ENVIRONMENT="${1:-production}"
COLOR_FILE="/etc/nginx/.deploy-color"
NGINX_CONF="/etc/nginx/sites-enabled/app"
NEW_IMAGE="myregistry.com/app:$(git rev-parse --short HEAD)"

# Read the current active color
if [[ -f "$COLOR_FILE" ]]; then
    CURRENT_COLOR=$(cat "$COLOR_FILE")
else
    CURRENT_COLOR="blue"
fi

# Determine the target color
if [[ "$CURRENT_COLOR" == "blue" ]]; then
    TARGET_COLOR="green"
    TARGET_HOSTS=("10.0.2.10" "10.0.2.11")
    CURRENT_HOSTS=("10.0.1.10" "10.0.1.11")
else
    TARGET_COLOR="blue"
    TARGET_HOSTS=("10.0.1.10" "10.0.1.11")
    CURRENT_HOSTS=("10.0.2.10" "10.0.2.11")
fi

echo "Current: $CURRENT_COLOR, Target: $TARGET_COLOR"

# Step 1: Deploy the new image to the inactive pool
for HOST in "${TARGET_HOSTS[@]}"; do
    echo "Deploying to $HOST..."
    ssh "deploy@$HOST" "
        docker pull $NEW_IMAGE
        docker stop app || true
        docker rm app || true
        docker run -d \
            --name app \
            --restart unless-stopped \
            -p 3000:3000 \
            -e NODE_ENV=production \
            $NEW_IMAGE
    "
done

# Step 2: Wait for the new pool to pass health checks
echo "Waiting for $TARGET_COLOR pool to become ready..."
for i in $(seq 1 30); do
    ALL_READY=true
    for HOST in "${TARGET_HOSTS[@]}"; do
        STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
            --connect-timeout 2 \
            --max-time 3 \
            "http://$HOST:3000/health" 2>/dev/null || echo "000")

        if [[ "$STATUS" != "200" ]]; then
            ALL_READY=false
            echo "  $HOST returned $STATUS (attempt $i/30)"
            break
        fi
    done

    if $ALL_READY; then
        echo "All $TARGET_COLOR hosts are healthy"
        break
    fi

    if [[ $i -eq 30 ]]; then
        echo "ERROR: $TARGET_COLOR pool failed health checks after 30 attempts"
        echo "Rolling back: removing $TARGET_COLOR containers"
        for HOST in "${TARGET_HOSTS[@]}"; do
            ssh "deploy@$HOST" "docker stop app && docker rm app" || true
        done
        exit 1
    fi

    sleep 2
done

# Step 3: Switch nginx traffic to the new pool
echo "Switching traffic to $TARGET_COLOR..."
sed -i "s/default    app_$CURRENT_COLOR;/default    app_$TARGET_COLOR;/" "$NGINX_CONF"
nginx -s reload

# Step 4: Verify the switch via the load balancer
echo "Verifying traffic reaches $TARGET_COLOR..."
for i in $(seq 1 10); do
    LB_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
        --connect-timeout 2 \
        --max-time 3 \
        "http://localhost/health" 2>/dev/null || echo "000")

    if [[ "$LB_STATUS" == "200" ]]; then
        echo "Load balancer returning 200 through $TARGET_COLOR"
        break
    fi

    if [[ $i -eq 10 ]]; then
        echo "ERROR: Load balancer not healthy after switch"
        echo "Rolling back nginx config..."
        sed -i "s/default    app_$TARGET_COLOR;/default    app_$CURRENT_COLOR;/" "$NGINX_CONF"
        nginx -s reload
        exit 1
    fi

    sleep 2
done

# Step 5: Smoke test via the load balancer (not the backend directly)
echo "Running smoke tests through $TARGET_COLOR..."
for TEST in \
    "GET /api/status -> 200" \
    "POST /api/echo -> 200"; do
    
    METHOD=$(echo "$TEST" | cut -d' ' -f1)
    PATH=$(echo "$TEST" | cut -d' ' -f3)
    EXPECTED=$(echo "$TEST" | cut -d' ' -f5)
    
    ACTUAL=$(curl -s -o /dev/null -w "%{http_code}" \
        -X "$METHOD" \
        --connect-timeout 3 \
        --max-time 5 \
        "http://localhost$PATH" 2>/dev/null || echo "000")
    
    if [[ "$ACTUAL" != "$EXPECTED" ]]; then
        echo "FAIL: $METHOD $PATH expected $EXPECTED got $ACTUAL"
        echo "Rolling back..."
        sed -i "s/default    app_$TARGET_COLOR;/default    app_$CURRENT_COLOR;/" "$NGINX_CONF"
        nginx -s reload
        exit 1
    fi
    
    echo "PASS: $METHOD $PATH -> $ACTUAL"
done

# Step 6: Signal the old pool to drain
echo "Draining $CURRENT_COLOR pool..."
for HOST in "${CURRENT_HOSTS[@]}"; do
    ssh "deploy@$HOST" "curl -s -X POST --connect-timeout 3 http://localhost:3000/health/drain" &
done

# Wait for drain signal to be sent
sleep 2

# Record the new active color
echo "$TARGET_COLOR" > "$COLOR_FILE"
echo "Deploy complete! Active pool: $TARGET_COLOR"

This script does six things in order:

  1. Determines the target pool by reading the color file on the nginx server.
  2. Deploys to the inactive pool so there is zero overlap with live traffic.
  3. Waits for health checks with a configurable retry budget. If the new pool never becomes healthy, it removes the containers and exits with a failure, without touching the live pool at all.
  4. Switches nginx via sed and a reload. The reload is atomic: old workers finish existing requests, new workers use the new upstream.
  5. Smoke tests through the load balancer to verify the full path works, not just the backend health endpoint.
  6. Drains the old pool by calling the /health/drain endpoint on each host, then records the new active color.

The rollback path

The script already handles two rollback scenarios:

  • Deploy failure: The new pool never passes health checks. The script removes the new containers and exits. The old pool never stopped serving traffic.
  • Switch failure: The load balancer does not return 200 after the switch. The script reverts the nginx config and reloads.

There is a third scenario worth handling separately: the switch succeeds but a production incident is discovered minutes or hours later. For that, you need a manual rollback command:

#!/usr/bin/env bash
# rollback.sh - Switch traffic back to the previous pool
set -euo pipefail

COLOR_FILE="/etc/nginx/.deploy-color"
NGINX_CONF="/etc/nginx/sites-enabled/app"

if [[ ! -f "$COLOR_FILE" ]]; then
    echo "No deploy color file found. Cannot determine rollback target."
    exit 1
fi

CURRENT_COLOR=$(cat "$COLOR_FILE")

if [[ "$CURRENT_COLOR" == "blue" ]]; then
    ROLLBACK_COLOR="green"
else
    ROLLBACK_COLOR="blue"
fi

echo "Rolling back from $CURRENT_COLOR to $ROLLBACK_COLOR..."
sed -i "s/default    app_$CURRENT_COLOR;/default    app_$ROLLBACK_COLOR;/" "$NGINX_CONF"
nginx -s reload

# Verify
sleep 3
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/health)
if [[ "$STATUS" == "200" ]]; then
    echo "$ROLLBACK_COLOR" > "$COLOR_FILE"
    echo "Rollback complete. Active pool: $ROLLBACK_COLOR"
else
    echo "CRITICAL: Rollback health check failed. Manual intervention required."
    exit 1
fi

The manual rollback takes under five seconds. It does not require building or deploying any code. It just switches nginx back to the pool that was running before the deploy. This is the killer feature of blue-green: rollback is a config change, not a redeploy.

The GitHub Actions workflow

Here is how you wire the deploy script into CI:

name: Deploy

on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Build and push Docker image
        run: |
          docker build -t myregistry.com/app:${GITHUB_SHA::7} .
          docker push myregistry.com/app:${GITHUB_SHA::7}

      - name: Deploy to production
        run: |
          ssh deploy@loadbalancer "bash -s" < deploy.sh production
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Notify on failure
        if: failure()
        run: |
          curl -X POST -H "Content-Type: application/json" \
            -d '{"text":"Deploy failed. Traffic remains on previous pool."}' \
            ${{ secrets.SLACK_WEBHOOK }}

The key detail: if the deploy step fails, the workflow sends a notification but does not trigger a rollback. The rollback already happened inside the deploy script. The notification just tells the team that the new version failed to deploy and the old version is still serving traffic. No incident, no pager. Just a failed CI run.

What to watch out for

Blue-green is simple in theory, but a few details will bite you in practice:

Database migrations. Both pools share the same database. If the new code expects a migration that the old code does not understand, a rollback breaks. Run backward-compatible migrations before the deploy, or gate the migration behind a feature flag. Never run a destructive migration (DROP COLUMN, ALTER COLUMN TYPE) in the same deploy that switches traffic.

Session state. If users are logged into a session stored in the old pool’s memory, the switch makes them lose their session. Store sessions in Redis or a database, not in process memory.

WebSocket connections. Nginx can proxy WebSocket connections, but the Upgrade header must be forwarded correctly (the config above handles this). On switch, existing WebSocket connections stay connected to the old pool because the old nginx worker keeps serving them. Those connections eventually break when the old pool drains. If zero WebSocket interruption matters, use a sticky-session load balancer or a shared WebSocket gateway.

Cache warmup. The first request to the new pool for every cache key is a cold miss. If your application depends on a warm cache for acceptable latency, preheat the cache during the warmup() step of the health check lifecycle.

Monitoring gap. Between the switch and the old pool drain (about 30 seconds), metrics come from two pools simultaneously. Make sure your monitoring can distinguish between app_blue and app_green instances, or accept that metrics are noisy during this window. Add the X-Deploy-Color response header to correlate requests to the right pool.

When not to use blue-green

Blue-green is not always the right choice. If you run a single server, you cannot afford the hardware duplication. If your startup time is under two seconds and your traffic is low, a graceful shutdown with a health check drain window is simpler and cheaper. If you are on Kubernetes, its native rolling update with maxSurge and maxUnavailable handles most of this automatically.

But for teams running Node.js on bare metal, VMs, or a handful of cloud instances behind a reverse proxy, blue-green is the pattern that eliminates the “deploy during lunch” risk without adding orchestration complexity. Two nginx upstream blocks, one health check endpoint, and one deploy script. That is the whole thing.

Before your next deploy, run through this checklist:

  • The inactive pool deploys before traffic switches.
  • The deploy script waits for health checks (200, not just “process is running”).
  • Nginx reload happens via nginx -s reload, not systemctl restart nginx.
  • The old pool drains connections before shutting down.
  • Rollback is a config change, not a code redeploy.
  • Database migrations are backward-compatible with the old pool.
  • Sessions and caches live outside process memory.
  • Monitoring distinguishes between blue and green instances.

Deployments that drop connections are not inevitable. They are a missing pattern. Add this one, and the next deploy is just a CI run that happens to switch traffic with zero user impact.


A note from Yojji

The difference between a deploy that nobody notices and a deploy that triggers a post-mortem usually comes down to the orchestration layer: how the load balancer switches, how long the health check waits, and whether the rollback path is tested under failure. That kind of infrastructure discipline does not happen by accident. It comes from teams that have been through the outage and built the fix into their deployment pipeline.

Building production-grade infrastructure pipelines requires the same engineering rigor as building the application itself. Yojji is an international custom software development company, founded in 2016, with offices in Europe, the US, and the UK. Their senior engineers specialize in the JavaScript ecosystem, cloud infrastructure across AWS, Azure, and Google Cloud, and the kind of full-cycle delivery that includes deployment pipelines, monitoring, and runbooks alongside feature development. If your team wants the deployment pattern but not the learning curve, Yojji builds it for you.