zero-downtime-deploy

v2026.09.24

Zero-downtime deployment strategies and automation: blue-green, rolling, and canary deployments with Nginx upstream switching, symlink-based atomic deploys, rsync deploy scripts, GitHub Actions SSH deploy workflows, health check polling, and rollback runbooks. USE WHEN: - Implementing blue-green deployment with Nginx upstream swapping - Writing a production deploy script with symlink-based atomic release management - Configuring canary traffic splitting with Nginx split_clients - Setting up a GitHub Actions workflow that deploys via SSH with health check polling - Writing or testing a rollback runbook - Planning database migration strategy alongside zero-downtime deploys DO NOT USE FOR: - Kubernetes rolling updates and canary with Argo Rollouts (use the kubernetes skill) - Container image building and registry management (use the docker skill) - Cloud-specific deployment (AWS CodeDeploy, GCP Cloud Deploy — use the cloud skill) - Frontend-only CDN cache invalidation strategies

GitHub
Install command
npx skhub add claude-dev-suite/zero-downtime-deploy
Markdown
SKILL.md

Zero-Downtime Deployment — Strategies and Automation

Strategy Comparison

StrategyTraffic ShiftRollback SpeedCostRisk
Blue-GreenInstant (upstream or DNS swap)Instant (swap back)2x infrastructureLow — old version ready
RollingGradual (instance by instance)Medium (redeploy previous)1x infrastructureMedium — mixed versions briefly
CanaryPercentage-basedNear-instant (drop to 0%)1x + small canary fleetLow — small blast radius

Choose based on: infrastructure cost tolerance, rollback requirements, and whether users can tolerate seeing mixed versions during deploy.


Blue-Green Deployment

Concept

Maintain two identical environments: blue (current live) and green (new version). Deploy to green, run smoke tests, then switch traffic from blue to green. Blue stays warm for instant rollback.

          ┌──────────┐
Users ──→ │  Nginx   │
          └─────┬────┘
                │ active_upstream.conf
         ┌──────┴──────┐
         ▼             ▼
  ┌─────────────┐  ┌─────────────┐
  │  Blue       │  │  Green      │
  │  :3000      │  │  :3001      │
  │  (old)      │  │  (new)      │
  └─────────────┘  └─────────────┘

Nginx Configuration

/etc/nginx/conf.d/upstreams.conf:

upstream blue {
    server 127.0.0.1:3000;
    keepalive 32;
}

upstream green {
    server 127.0.0.1:3001;
    keepalive 32;
}

/etc/nginx/active_upstream.conf (managed by deploy script):

# This file is symlinked/replaced atomically by deploy scripts.
# Do not edit manually.
proxy_pass http://blue;

/etc/nginx/sites-enabled/myapp.conf:

server {
    listen 80;
    server_name example.com;

    location / {
        include /etc/nginx/active_upstream.conf;
        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;

        # Health check configuration
        proxy_connect_timeout 5s;
        proxy_read_timeout 60s;
    }
}

Blue-Green Upstream Swap Script

/opt/scripts/blue-green-swap.sh:

#!/bin/bash
# Blue-Green upstream swap for Nginx
# Usage: blue-green-swap.sh [blue|green]
set -euo pipefail

TARGET=${1:-}
ACTIVE_CONF="/etc/nginx/active_upstream.conf"
NGINX_CONF="/etc/nginx/nginx.conf"
LOG_FILE="/var/log/deploy/blue-green.log"
HEALTH_CHECK_TIMEOUT=30

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }

# ── Determine current and target upstream ─────────────────────────────────────
current=$(grep -oP 'http://\K[a-z]+' "$ACTIVE_CONF" 2>/dev/null || echo "blue")
if [[ -z "$TARGET" ]]; then
    TARGET=$([ "$current" = "blue" ] && echo "green" || echo "blue")
fi

if [[ "$TARGET" != "blue" && "$TARGET" != "green" ]]; then
    echo "Usage: $0 [blue|green]" >&2
    exit 1
fi

log "Switching from $current to $TARGET"

# ── Determine target port ──────────────────────────────────────────────────────
TARGET_PORT=$([ "$TARGET" = "blue" ] && echo "3000" || echo "3001")

# ── Health check target before switching ──────────────────────────────────────
log "Health checking $TARGET on port $TARGET_PORT..."
deadline=$((SECONDS + HEALTH_CHECK_TIMEOUT))
while [[ $SECONDS -lt $deadline ]]; do
    if curl -fsS --max-time 3 "http://127.0.0.1:${TARGET_PORT}/health" > /dev/null 2>&1; then
        log "$TARGET is healthy"
        break
    fi
    log "Waiting for $TARGET health check..."
    sleep 2
done

if ! curl -fsS --max-time 3 "http://127.0.0.1:${TARGET_PORT}/health" > /dev/null 2>&1; then
    log "ERROR: $TARGET health check failed after ${HEALTH_CHECK_TIMEOUT}s — aborting swap"
    exit 1
fi

# ── Write new active_upstream.conf ────────────────────────────────────────────
# Write to temp file first, then atomically move
TMP_CONF=$(mktemp)
cat > "$TMP_CONF" <<EOF
# Active upstream: $TARGET (updated $(date '+%Y-%m-%d %H:%M:%S'))
proxy_pass http://${TARGET};
EOF
sudo mv "$TMP_CONF" "$ACTIVE_CONF"

# ── Test Nginx config and reload ──────────────────────────────────────────────
if ! sudo nginx -t 2>&1; then
    log "ERROR: Nginx config test failed — reverting"
    echo "proxy_pass http://${current};" | sudo tee "$ACTIVE_CONF" > /dev/null
    exit 1
fi

sudo nginx -s reload
log "Nginx reloaded — now serving from $TARGET"

# ── Final external health check ───────────────────────────────────────────────
sleep 2
if curl -fsS --max-time 5 "https://example.com/health" > /dev/null 2>&1; then
    log "External health check passed — deploy complete"
else
    log "WARNING: External health check failed — monitor closely"
fi

echo ""
echo "Deploy summary:"
echo "  Previous: $current"
echo "  Active:   $TARGET"
echo "  Port:     $TARGET_PORT"
echo ""
echo "To rollback: sudo /opt/scripts/blue-green-swap.sh $current"
sudo chmod +x /opt/scripts/blue-green-swap.sh
sudo mkdir -p /var/log/deploy

# Deploy to green and switch
./deploy-to-green.sh    # Deploy your app to port 3001
sudo /opt/scripts/blue-green-swap.sh green

# Instant rollback
sudo /opt/scripts/blue-green-swap.sh blue

Rolling Deployment

Nginx upstream with max_fails for automatic routing around unhealthy instances:

upstream app {
    server 127.0.0.1:3000 max_fails=3 fail_timeout=30s;
    server 127.0.0.1:3001 max_fails=3 fail_timeout=30s;
    server 127.0.0.1:3002 max_fails=3 fail_timeout=30s;
    keepalive 32;
}

Rolling update loop:

#!/bin/bash
# Rolling update: restart each instance sequentially
APP_INSTANCES=(myapp@3000 myapp@3001 myapp@3002)
HEALTH_URL="http://127.0.0.1"

for INSTANCE in "${APP_INSTANCES[@]}"; do
    PORT=${INSTANCE##*@}
    echo "Restarting $INSTANCE..."
    sudo systemctl restart "$INSTANCE"

    echo "Waiting for $INSTANCE to become healthy..."
    for i in $(seq 1 30); do
        if curl -fsS --max-time 2 "${HEALTH_URL}:${PORT}/health" > /dev/null 2>&1; then
            echo "$INSTANCE is healthy after ${i}s"
            break
        fi
        sleep 1
    done

    if ! curl -fsS --max-time 2 "${HEALTH_URL}:${PORT}/health" > /dev/null 2>&1; then
        echo "ERROR: $INSTANCE failed health check — stopping rolling update"
        exit 1
    fi

    echo "$INSTANCE healthy — continuing to next instance..."
    sleep 2   # Brief pause between restarts
done

echo "Rolling update complete"

Canary Deployment

Route a percentage of traffic to the new version using Nginx split_clients:

# Hash on client IP for sticky routing (same user always sees same version)
split_clients "${remote_addr}" $upstream_pool {
    10%     canary;   # 10% to new version
    *       stable;   # 90% to current version
}

upstream stable {
    server 127.0.0.1:3000;
    keepalive 32;
}

upstream canary {
    server 127.0.0.1:3001;
    keepalive 32;
}

server {
    location / {
        proxy_pass http://$upstream_pool;
    }
}

Adjust percentage by editing the split_clients block and reloading Nginx. Monitor error rate and latency in Grafana before increasing canary percentage. To promote canary to 100%: replace stable backend with canary code and set * to the stable upstream.


Rsync + Symlink Atomic Deploy Script

Directory Layout (Capistrano-style)

/var/www/myapp/
├── releases/
│   ├── 20241201_030000/    ← older release
│   ├── 20241202_030000/    ← previous release
│   └── 20241203_030000/    ← newest release
├── current -> releases/20241203_030000   ← symlink (atomic)
└── shared/
    ├── .env                ← never in repository
    ├── logs/ -> /var/log/myapp/
    └── uploads/

Deploy Script

/opt/scripts/deploy.sh:

#!/bin/bash
# Rsync + atomic symlink deploy
# Usage: deploy.sh [--rollback]
set -euo pipefail

# ── Config ────────────────────────────────────────────────────────────────────
APP_DIR="/var/www/myapp"
RELEASES_DIR="$APP_DIR/releases"
SHARED_DIR="$APP_DIR/shared"
CURRENT_LINK="$APP_DIR/current"
SOURCE_DIR="/tmp/myapp-deploy"        # Checked-out source from CI
SERVICE_NAME="myapp"
HEALTH_URL="http://127.0.0.1:3000/health"
KEEP_RELEASES=5
LOG_FILE="/var/log/deploy/deploy.log"

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }

mkdir -p "$RELEASES_DIR" /var/log/deploy

# ── Rollback mode ─────────────────────────────────────────────────────────────
if [[ "${1:-}" == "--rollback" ]]; then
    log "ROLLBACK: finding previous release..."
    # Get the second-most-recent release (current is most recent, previous is before that)
    PREVIOUS_RELEASE=$(ls -t "$RELEASES_DIR" | sed -n '2p')
    if [[ -z "$PREVIOUS_RELEASE" ]]; then
        log "ERROR: No previous release found"
        exit 1
    fi
    log "Rolling back to $PREVIOUS_RELEASE"
    ln -sfn "$RELEASES_DIR/$PREVIOUS_RELEASE" "$CURRENT_LINK"
    sudo systemctl restart "$SERVICE_NAME"
    sleep 3
    if curl -fsS --max-time 5 "$HEALTH_URL" > /dev/null; then
        log "Rollback successful — now running $PREVIOUS_RELEASE"
    else
        log "ERROR: Health check failed after rollback"
        exit 1
    fi
    exit 0
fi

# ── Create release directory ──────────────────────────────────────────────────
RELEASE_NAME=$(date +%Y%m%d_%H%M%S)
RELEASE_DIR="$RELEASES_DIR/$RELEASE_NAME"
mkdir -p "$RELEASE_DIR"
log "Creating release: $RELEASE_NAME"

# ── Rsync source to release directory ────────────────────────────────────────
log "Syncing files..."
rsync -av --delete \
    --exclude='.git/' \
    --exclude='node_modules/' \
    --exclude='.env*' \
    --exclude='*.log' \
    --exclude='uploads/' \
    "$SOURCE_DIR/" \
    "$RELEASE_DIR/"

# ── Link shared files and directories ────────────────────────────────────────
log "Linking shared resources..."
ln -sf "$SHARED_DIR/.env" "$RELEASE_DIR/.env"
ln -sf "$SHARED_DIR/uploads" "$RELEASE_DIR/public/uploads"
mkdir -p "$RELEASE_DIR/log"
ln -sf /var/log/myapp "$RELEASE_DIR/log"

# ── Install dependencies ──────────────────────────────────────────────────────
log "Installing dependencies..."
cd "$RELEASE_DIR"
npm ci --omit=dev --prefer-offline

# ── Run database migrations ───────────────────────────────────────────────────
log "Running database migrations..."
npm run db:migrate -- --no-rollback-on-fail
# Use additive-only migrations (expand/contract pattern for zero-downtime)

# ── Build assets (if applicable) ─────────────────────────────────────────────
if [[ -f "$RELEASE_DIR/package.json" ]] && grep -q '"build"' "$RELEASE_DIR/package.json"; then
    log "Building assets..."
    npm run build
fi

# ── Atomic symlink swap ───────────────────────────────────────────────────────
log "Swapping symlink to $RELEASE_NAME..."
ln -sfn "$RELEASE_DIR" "$CURRENT_LINK"

# ── Restart service ───────────────────────────────────────────────────────────
log "Restarting $SERVICE_NAME..."
sudo systemctl restart "$SERVICE_NAME"

# ── Health check polling ──────────────────────────────────────────────────────
log "Polling health check..."
HEALTH_TIMEOUT=60
for i in $(seq 1 $HEALTH_TIMEOUT); do
    if curl -fsS --max-time 3 "$HEALTH_URL" > /dev/null 2>&1; then
        log "Health check passed after ${i}s"
        break
    fi
    if [[ $i -eq $HEALTH_TIMEOUT ]]; then
        log "ERROR: Health check failed after ${HEALTH_TIMEOUT}s — triggering rollback"
        "$0" --rollback
        exit 1
    fi
    sleep 1
done

# ── Clean old releases ────────────────────────────────────────────────────────
log "Cleaning up old releases (keeping last $KEEP_RELEASES)..."
ls -t "$RELEASES_DIR" | tail -n +$((KEEP_RELEASES + 1)) | while read -r OLD_RELEASE; do
    log "Removing old release: $OLD_RELEASE"
    rm -rf "$RELEASES_DIR/$OLD_RELEASE"
done

log "Deploy complete: $RELEASE_NAME"
echo ""
echo "Release: $RELEASE_NAME"
echo "Current: $(readlink -f "$CURRENT_LINK")"
sudo chmod +x /opt/scripts/deploy.sh

# Deploy
sudo /opt/scripts/deploy.sh

# Rollback
sudo /opt/scripts/deploy.sh --rollback

GitHub Actions Deploy Workflow

.github/workflows/deploy.yml:

name: Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:
    inputs:
      rollback:
        description: 'Rollback to previous release'
        type: boolean
        default: false

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm
      - run: npm ci
      - run: npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    environment: production
    concurrency:
      group: production-deploy
      cancel-in-progress: false   # Never cancel in-flight deploys

    steps:
      - uses: actions/checkout@v4

      # Set up SSH agent with deploy key
      - name: Set up SSH
        uses: webfactory/ssh-agent@v0.8.0
        with:
          ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}

      # Add server to known_hosts (prevents MITM prompt)
      - name: Add server to known_hosts
        run: |
          mkdir -p ~/.ssh
          ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts
          chmod 600 ~/.ssh/known_hosts

      # Copy source code to server (only if not rollback)
      - name: Sync source to server
        if: ${{ !inputs.rollback }}
        run: |
          rsync -av --delete \
            --exclude='.git/' \
            --exclude='node_modules/' \
            -e "ssh -o StrictHostKeyChecking=yes" \
            ./ \
            ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/tmp/myapp-deploy/

      # Run deploy script on server
      - name: Deploy
        run: |
          if [[ "${{ inputs.rollback }}" == "true" ]]; then
            ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \
              "sudo /opt/scripts/deploy.sh --rollback"
          else
            ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \
              "sudo /opt/scripts/deploy.sh"
          fi

      # Poll health check from GitHub Actions runner (external validation)
      - name: External health check
        run: |
          echo "Polling https://${{ secrets.DEPLOY_HOST }}/health..."
          for i in $(seq 1 30); do
            STATUS=$(curl -o /dev/null -s -w "%{http_code}" https://${{ secrets.DEPLOY_HOST }}/health)
            if [[ "$STATUS" == "200" ]]; then
              echo "Health check passed (HTTP $STATUS) after ${i}s"
              exit 0
            fi
            echo "Attempt $i: HTTP $STATUS — retrying..."
            sleep 2
          done
          echo "Health check never passed — deploy may have failed"
          exit 1

      - name: Notify Slack on failure
        if: failure()
        run: |
          curl -X POST ${{ secrets.SLACK_WEBHOOK_URL }} \
            -H 'Content-Type: application/json' \
            -d '{"text": ":red_circle: Deploy to production failed! Check GitHub Actions."}'

Required GitHub secrets: DEPLOY_SSH_KEY, DEPLOY_HOST, DEPLOY_USER, SLACK_WEBHOOK_URL.

The deploy user needs passwordless sudo for /opt/scripts/deploy.sh:

# /etc/sudoers.d/deploy
deploy ALL=(ALL) NOPASSWD: /opt/scripts/deploy.sh
deploy ALL=(ALL) NOPASSWD: /bin/systemctl restart myapp

Rollback Runbook

# ROLLBACK RUNBOOK — Production Deploy Rollback
# Trigger: health check failing post-deploy, error rate spike, user reports

## Step 1: Detect and Confirm Problem
  - [ ] Check Grafana: error rate, response time, 5xx count
  - [ ] Check application logs: journalctl -u myapp --since "5 minutes ago"
  - [ ] Confirm: problem started after deploy, not before

## Step 2: Immediate Mitigation (choose one)

  Option A — Symlink rollback (< 30 seconds):
    sudo /opt/scripts/deploy.sh --rollback

  Option B — Blue-green swap back (< 10 seconds):
    sudo /opt/scripts/blue-green-swap.sh blue

  Option C — Manual symlink swap:
    PREVIOUS=$(ls -t /var/www/myapp/releases | sed -n '2p')
    ln -sfn "/var/www/myapp/releases/$PREVIOUS" /var/www/myapp/current
    sudo systemctl restart myapp

## Step 3: Verify
  - [ ] curl https://example.com/health → 200 OK
  - [ ] Check Grafana: error rate returning to baseline
  - [ ] Check application logs: no new errors

## Step 4: Communicate
  - [ ] Post in #incidents: "Rolled back deploy at HH:MM — investigating root cause"
  - [ ] Update status page if customer-visible

## Step 5: Root Cause and Post-Mortem
  - [ ] Identify what caused the failure (diff the two releases)
  - [ ] Fix the issue in a branch
  - [ ] Add test coverage for the bug
  - [ ] Re-deploy after thorough testing
  - [ ] Write 5-minute post-mortem (what happened, why, what we changed)

Environment Management

Secrets and environment configuration must never live in the repository.

Systemd EnvironmentFile

/etc/systemd/system/myapp.service:

[Unit]
Description=My Application
After=network.target

[Service]
Type=simple
User=myapp
WorkingDirectory=/var/www/myapp/current
EnvironmentFile=/var/www/myapp/shared/.env
ExecStart=/usr/bin/node /var/www/myapp/current/dist/server.js
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

/var/www/myapp/shared/.env (chmod 640, owned by myapp:myapp):

NODE_ENV=production
DATABASE_URL=postgresql://user:pass@localhost:5432/myapp_prod
REDIS_URL=redis://localhost:6379
SECRET_KEY=very-long-random-secret

Inject from CI secrets (never commit):

# In GitHub Actions — write .env from secrets before rsync
cat > /tmp/myapp.env <<EOF
NODE_ENV=production
DATABASE_URL=${{ secrets.DATABASE_URL }}
SECRET_KEY=${{ secrets.SECRET_KEY }}
EOF
scp /tmp/myapp.env deploy@server:/var/www/myapp/shared/.env

Database Migration Strategy

Expand/Contract (Forward-Only) Pattern

Zero-downtime migrations require that new code and old code can run against the same database schema simultaneously.

PhaseSchema ChangeCode
ExpandAdd new column/table (nullable or with default)Both old and new code work
DeployRoll out new codeNew code uses new column
ContractRemove old column/table (old code no longer running)Cleanup migration

Safe live changes (additive-only):

  • Adding a nullable column
  • Adding a column with a DEFAULT value
  • Creating a new table
  • Adding an index CONCURRENTLY (Postgres): CREATE INDEX CONCURRENTLY idx_name ON table(col)

Unsafe live changes (require maintenance window or expand/contract):

  • Renaming a column or table
  • Dropping a column
  • Adding a NOT NULL column without DEFAULT
  • Adding a unique constraint (locks table)
  • Changing a column type
# Postgres: create index without locking the table
psql -c "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email ON users(email);"

Anti-Patterns

Anti-PatternProblemFix
Deploy directly to production without stagingNo validation that new version works before users see itAlways deploy to staging first; run smoke tests
No health check after deployFailed deploy discovered by users, not monitoringPoll /health endpoint in deploy script; auto-rollback on failure
Migrations that break old codeDuring rolling deploy, old pods crash against new schemaUse expand/contract: deploy backward-compatible schema first, then new code
No rollback planIncident duration extends while team figures out how to revertTest rollback on staging quarterly; keep rollback script in deploy pipeline
Storing secrets in deploy script or repositoryCredential exposure in git historyUse EnvironmentFile in systemd; inject secrets from CI environment
Running database migrations in the deploy script (blocking)Long-running migration holds deploy and may hold table lockRun migrations separately before code deploy; use CREATE INDEX CONCURRENTLY
Single latest tag for Docker imagesPrevious version destroyed — rollback requires rebuildTag images with git SHA: myapp:abc1234; keep last N images
No concurrent deploy protectionTwo deploys in parallel corrupt release directoryUse concurrency: group: production-deploy in GitHub Actions
Deploying without draining connections from old versionIn-flight requests to old instance are killed mid-responseAdd SIGTERM handler to app; configure systemd TimeoutStopSec=30

Troubleshooting

SymptomLikely CauseDiagnostic & Fix
Health check never passes after deployApp crashes on startup, wrong port, or missing env varjournalctl -u myapp -n 50; check port with ss -tlnp; verify .env file readable
Symlink swap race (two deploys collide)Concurrent deploys overwrite each other's current linkAdd concurrency group in CI; use deploy lock file (flock)
SSH key not found in CISecret not set, or wrong secret nameCheck GitHub Actions secrets tab; test with `echo "${{ secrets.DEPLOY_SSH_KEY }}"
rsync: connection unexpectedly closedSSH auth failure or server unreachableTest manually: ssh -i key deploy@host 'echo ok'; check known_hosts
Database migration fails mid-deployMigration error, constraint violation, or timeoutRoll back migration manually; restore from pre-deploy backup if data corrupted
sudo: /opt/scripts/deploy.sh: command not foundsudo PATH doesn't include script directoryUse full absolute path in sudoers; verify script exists at that path
Old release not cleaned up; disk fills upKEEP_RELEASES cleanup not running or failingls -lt /var/www/myapp/releases; run cleanup manually; check deploy script exit code
Nginx returns 502 after upstream swapNew backend not ready, or upstream block misconfigurednginx -t; check upstream port is listening (ss -tlnp); check Nginx error log
ln -sfn doesn't update symlinkTarget directory doesn't exist, or path typols -la /var/www/myapp/current; verify RELEASE_DIR was created successfully
Rollback deploys same broken versionMost recent and second-most-recent releases are both brokenCheck release list: ls -lt releases/; manually point to an older known-good release
Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.24

Published

Sep 24, 2026

Category

Uncategorized

License

MIT

Source path

skills/infrastructure/zero-downtime-deploy

Default branch

main

Latest commit

9496306

Tree SHA

fe4e2f1