Deploy zero-downtime com Nginx e PM2

Zero-downtime deploy garante que nenhuma requisição recebe erro 502 durante a atualização da aplicação. Com PM2 em modo cluster e Nginx como proxy, é possível fazer rolling restarts que trocam os workers um a um, sem janela de indisponibilidade, mesmo durante deploys frequentes.

PM2 cluster mode e reload gracioso

Configurar PM2 para reiniciar sem downtime:

javascript
# ecosystem.config.js — PM2 com cluster mode:
module.exports = {
  apps: [{
    name: 'minha-api',
    script: 'dist/index.js',

    // Cluster mode: múltiplos workers (1 por CPU):
    instances: 'max',
    exec_mode: 'cluster',

    // Graceful shutdown — aguardar requisições em andamento:
    kill_timeout: 10000,      // matar worker após 10s se não fechar sozinho
    wait_ready: true,         // aguardar processo emitir 'ready' antes de considerar iniciado
    listen_timeout: 10000,    // timeout para o worker emitir 'ready'

    // Ambiente de produção:
    env_production: {
      NODE_ENV: 'production',
      PORT: 3000,
    },
  }]
}

# Iniciar em modo produção:
pm2 start ecosystem.config.js --env production

# Deploy zero-downtime (reload um worker por vez):
pm2 reload minha-api --update-env

# Ver workers em tempo real:
pm2 monit

SIGTERM handler na aplicação Node.js

Fechar conexões abertas antes de encerrar o processo:

typescript
// src/index.ts — graceful shutdown obrigatório para zero-downtime:
import express from 'express'
import { Pool } from 'pg'

const app = express()
const db = new Pool()

const server = app.listen(3000, () => {
  console.log('Servidor iniciado na porta 3000')
  // Sinalizar para o PM2 que o processo está pronto:
  process.send?.('ready')
})

// Handler de shutdown gracioso:
async function shutdown(signal: string) {
  console.log(`Recebido ${signal}, encerrando servidor...`)

  // 1. Parar de aceitar novas conexões:
  server.close(async () => {
    console.log('Servidor HTTP fechado')

    // 2. Aguardar conexões existentes terminarem e fechar o pool:
    await db.end()
    console.log('Pool de banco fechado')

    process.exit(0)
  })

  // 3. Forçar encerramento se demorar mais de 8s:
  setTimeout(() => {
    console.error('Timeout no shutdown — forçando saída')
    process.exit(1)
  }, 8000)
}

process.on('SIGTERM', () => shutdown('SIGTERM'))  // PM2 reload usa SIGTERM
process.on('SIGINT', () => shutdown('SIGINT'))    // Ctrl+C em dev

Blue-green deploy com Nginx

Trocar entre dois ambientes (blue/green) no Nginx sem downtime:

bash
# Dois processos rodando em portas diferentes:
# Blue: porta 3000 (versão atual)
# Green: porta 3001 (nova versão)

# /etc/nginx/conf.d/minha-api.conf:
upstream api_blue {
    server 127.0.0.1:3000;
}

upstream api_green {
    server 127.0.0.1:3001;
}

# Incluir o arquivo que define qual upstream está ativo:
include /etc/nginx/snippets/api-active.conf;

server {
    listen 443 ssl http2;
    server_name api.seudominio.com.br;

    location /api/ {
        proxy_pass http://api_active;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

# /etc/nginx/snippets/api-active.conf (começa apontando para blue):
# upstream api_active { server 127.0.0.1:3000; }

# Script de deploy blue-green:
#!/bin/bash
# deploy-bluegreen.sh

CURRENT=$(cat /opt/current-env)  # "blue" ou "green"

if [ "$CURRENT" = "blue" ]; then
    NEXT="green"; NEXT_PORT=3001
else
    NEXT="blue"; NEXT_PORT=3000
fi

# Subir a nova versão na porta inativa:
cd /opt/minha-api-$NEXT
git pull && npm ci --only=production && npm run build
pm2 start ecosystem.config.js --name "api-$NEXT" -- --port $NEXT_PORT

# Aguardar healthcheck passar:
for i in {1..10}; do
    curl -sf http://localhost:$NEXT_PORT/health && break
    sleep 3
done

# Trocar o Nginx para a nova versão:
echo "upstream api_active { server 127.0.0.1:$NEXT_PORT; }" > /etc/nginx/snippets/api-active.conf
nginx -s reload

# Parar a versão antiga após 30s:
sleep 30
pm2 delete "api-$CURRENT"
echo $NEXT > /opt/current-env

Zero-downtime com Docker e docker-compose

Rolling update usando Docker Compose v2:

yaml
# docker-compose.yml — com múltiplas réplicas:
services:
  api:
    image: ghcr.io/org/minha-api:${IMAGE_TAG:-latest}
    restart: always
    deploy:
      replicas: 2
      update_config:
        parallelism: 1      # atualizar 1 réplica por vez
        delay: 10s          # aguardar 10s entre réplicas
        order: start-first  # subir nova antes de desligar a antiga
        failure_action: rollback
      rollback_config:
        parallelism: 1
        delay: 5s
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 15s

  nginx:
    image: nginx:alpine
    restart: always
    ports:
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/etc/nginx/certs:ro
    depends_on:
      - api

# Deploy zero-downtime (atualiza 1 réplica por vez):
IMAGE_TAG=sha-abc1234 docker compose up -d api

Monitorar o deploy e detectar regressões

Verificar métricas após o deploy e fazer rollback automático:

bash
#!/bin/bash
# post-deploy-check.sh — executar após o deploy

API_URL="https://api.seudominio.com.br"
TELEGRAM_TOKEN="..."
CHAT_ID="..."

alerta() {
    curl -s -X POST "https://api.telegram.org/bot$TELEGRAM_TOKEN/sendMessage" \
      -d "chat_id=$CHAT_ID" --data-urlencode "text=$1"
}

# Verificar taxa de erros via Prometheus (janela de 5 min pós-deploy):
sleep 300  # aguardar 5 minutos

ERROR_RATE=$(curl -s "http://localhost:9090/api/v1/query" \
  --data-urlencode 'query=rate(api_http_requests_total{status=~"5.."}[5m]) / rate(api_http_requests_total[5m])' \
  | jq -r '.data.result[0].value[1]' 2>/dev/null || echo "0")

# Se taxa de erros > 5%, fazer rollback:
if (( $(echo "$ERROR_RATE > 0.05" | bc -l) )); then
    alerta "🔴 Taxa de erros alta pós-deploy ($ERROR_RATE) — iniciando rollback!"
    cd /opt/minha-api
    git reset --hard HEAD~1
    npm ci --only=production
    pm2 reload minha-api --update-env
    alerta "✅ Rollback concluído para $(git rev-parse --short HEAD)"
else
    alerta "✅ Deploy verificado — taxa de erros normal ($ERROR_RATE)"
fi

Não quer configurar manualmente?

Implante o VPS para APIs em menos de 5 minutos com a Runstack. Infraestrutura da OPEN Datacenter, com servidores em São Paulo.

Perguntas frequentes

pm2 restart: mata todos os workers e sobe novos, há uma janela de downtime. pm2 reload: em cluster mode, reinicia workers um a um, o Nginx continua roteando para os workers que ainda estão rodando. Use sempre pm2 reload para deploys em produção. Para configurações que requerem restart completo (mudança de porta, variáveis de ambiente de inicialização): use pm2 restart.

Graceful shutdown é o processo de fechar o servidor corretamente quando o processo recebe SIGTERM: (1) parar de aceitar novas conexões, (2) aguardar as requisições em andamento terminarem, (3) fechar conexões de banco e Redis, (4) sair com código 0. Sem graceful shutdown: requisições em andamento recebem erro 502 durante o deploy. Com graceful shutdown + pm2 reload: zero erros visíveis para o usuário.

Rolling deploy (PM2 reload): mais simples, workers são trocados um a um no mesmo servidor, menos recursos necessários. Adequado para a maioria dos casos. Blue-green: dois ambientes completos (banco, workers, cache), troca atômica e instantânea no load balancer, rollback imediato sem downtime. Mais complexo, dobra o custo de infraestrutura durante o deploy. Use blue-green quando precisar de rollback garantido em < 1 segundo.

A migration é o ponto mais arriscado. Regras para migrations sem downtime: (1) migrations devem ser backward-compatible (coluna nova pode ser nullable, nunca remover coluna em uma migration junto com o código que a usa). (2) Deploy em 3 fases: migration (adicionar coluna nullable) → deploy código (que escreve na nova coluna) → cleanup (remover coluna antiga). Nunca faça migration e code change no mesmo deploy.

Nginx open-source: não tem health check ativo de upstream (só detecta falha quando a requisição falha). Nginx Plus (pago): tem health check ativo com remoção automática. Solução gratuita: use o módulo nginx_upstream_check_module (requer recompilar o Nginx) ou use o Caddy como proxy (tem health check nativo) ou adicione o upstream_check via OpenResty. Para a maioria dos casos com PM2 reload: não é necessário, o PM2 garante que o worker está up antes de remover o anterior.