14h00 de uma quarta-feira. Você dá git push esperando que o deploy passe limpo, troca pra outra aba e em 90 segundos recebe a primeira mensagem no WhatsApp do cliente: "tá fora?". Em 5 minutos, 14 clientes reportaram. Em 12 minutos, suporte está em chamas. O bug? Uma migration de banco que travou 3 minutos rodando, segurou o boot do backend novo, e como o velho já tinha sido derrubado, o sistema ficou em white-screen.
Esse cenário só acontece quando o deploy é "ingênuo": derruba antigo, sobe novo, torce. Esse artigo mostra como configurar um pipeline com smoke test automático e rollback instantâneo (em segundos) que permite deploys diários sem nunca derrubar cliente — o mesmo padrão usado pela ZAP API em produção desde abril/2026.
Por que deploy ingênuo dá problema
Os 4 modos comuns de quebra silenciosa:
- Migration que trava: ALTER TABLE em tabela grande lockou tudo. App novo nem subiu.
- Env var faltando: .env de produção não tinha a chave nova. App sobe, mas crasha no primeiro request.
- Build com bug não pego em CI: tipo um
.env.productionnão inlinado direito. App sobe sem erro mas frontend chama URL errada. - Dependência externa morta: versão nova depende de Redis 7 mas o container ainda é Redis 6.
Em todos os casos, monitoring tradicional (uptime ping) demora 30-90s pra detectar. Cliente já viu o erro.
Arquitetura: git push → build → smoke → ativo OU rollback
O fluxo correto, em 5 etapas atomicas:
- Pre-deploy: salva referência do commit atual (
oldrev) — esse é o ponto de rollback. - Build: docker compose build com cache. Se falhar, aborta antes de tocar produção.
- Up: sobe containers novos com nova tag.
- Healthcheck + smoke test: verifica /health endpoints e roda suite de smoke (auth, envio sandbox, webhook, etc). Se passar, segue. Se falhar, dispara rollback.
- Rollback automático: volta para
oldrev, rebuild e up dos containers antigos. Notifica time.
Hook de pre-receive no servidor
O servidor expõe um bare git em /opt/git/seuapp.git. O hook post-receive é o orquestrador.
#!/bin/bash
# /opt/git/seuapp.git/hooks/post-receive
set -e
while read oldrev newrev refname; do
branch=$(git rev-parse --symbolic --abbrev-ref $refname)
if [ "$branch" != "main" ]; then
echo "Branch $branch não é main — ignorado"
continue
fi
echo "=== Deploy iniciado em $(date) ==="
echo "From: $oldrev"
echo "To: $newrev"
cd /opt/apps/seuapp
git --git-dir=/opt/git/seuapp.git --work-tree=/opt/apps/seuapp checkout -f $newrev
# Build & up
docker compose -f docker-compose.prod.yml build --no-cache backend
docker compose -f docker-compose.prod.yml up -d --force-recreate backend
# Espera healthy (max 60s)
echo "=== Aguardando backend healthy ==="
for i in {1..30}; do
health=$(docker inspect --format='{{.State.Health.Status}}' meuapp_backend 2>/dev/null || echo "starting")
if [ "$health" = "healthy" ]; then
echo "Backend healthy em ${i}s"
break
fi
sleep 2
done
if [ "$health" != "healthy" ]; then
echo "ERRO: backend não ficou healthy. Disparando rollback."
rollback "$oldrev"
exit 1
fi
# Smoke test
echo "=== Rodando smoke test ==="
if bash /opt/apps/seuapp/scripts/smoke-test.sh; then
echo "=== Deploy completo com sucesso ==="
else
echo "ERRO: smoke test falhou. Disparando rollback."
rollback "$oldrev"
exit 1
fi
done
rollback() {
local oldrev=$1
cd /opt/apps/seuapp
git --git-dir=/opt/git/seuapp.git --work-tree=/opt/apps/seuapp checkout -f $oldrev
docker compose -f docker-compose.prod.yml build backend
docker compose -f docker-compose.prod.yml up -d --force-recreate backend
curl -X POST "$DISCORD_WEBHOOK" -d "{\"content\":\"Deploy revertido para ${oldrev:0:7}\"}"
}
Smoke test: 9 casos críticos
O smoke deve rodar em <60 segundos e cobrir os caminhos críticos. Falhe-rápido em qualquer um.
#!/bin/bash
# /opt/apps/seuapp/scripts/smoke-test.sh
set -e
BASE="http://127.0.0.1:8280"
TOKEN="${SMOKE_TEST_TOKEN}"
FAIL=0
check() {
local name=$1
local expected=$2
local actual=$3
if [ "$actual" = "$expected" ]; then
echo "[OK] $name"
else
echo "[FAIL] $name (esperado $expected, recebeu $actual)"
FAIL=1
fi
}
# 1. Backend health
status=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/health")
check "backend health" "200" "$status"
# 2. DB health
status=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/health/db")
check "db health" "200" "$status"
# 3. Redis health
status=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/health/redis")
check "redis health" "200" "$status"
# 4. Auth: /v1/instances sem token = 401
status=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/v1/instances")
check "auth guard 401" "401" "$status"
# 5. Auth: /v1/instances com token = 200
status=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $TOKEN" "$BASE/v1/instances")
check "auth com token" "200" "$status"
# 6. Frontend root carrega
status=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:3450")
check "frontend root" "200" "$status"
# 7. Frontend login carrega
status=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:3450/login")
check "frontend login" "200" "$status"
# 8. Frontend register carrega
status=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:3450/register")
check "frontend register" "200" "$status"
# 9. Envio sandbox: end-to-end real
sandbox=$(curl -s -X POST "$BASE/v1/sandbox/inst_smoke/send" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"to":"5511999999999","type":"text","text":{"body":"smoke"}}')
echo "$sandbox" | grep -q '"ok":true' && echo "[OK] sandbox send" || { echo "[FAIL] sandbox send"; FAIL=1; }
if [ $FAIL -eq 1 ]; then
echo "=== SMOKE FALHOU ==="
exit 1
fi
echo "=== SMOKE PASSOU (9/9) ==="
Healthcheck no docker-compose
# docker-compose.prod.yml
services:
backend:
image: seuapp:${TAG:-latest}
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:8280/health"]
interval: 5s
timeout: 3s
retries: 6
start_period: 30s
Sem o healthcheck, docker inspect não retorna estado de health, e o hook não tem como saber se o app realmente está pronto.
Blue-green deployment (próximo passo)
O esquema acima ainda derruba o backend antigo antes de subir o novo (~5 segundos de glitch). Pra zero downtime real:
- Backend roda em 2 instâncias paralelas:
backend_blue(porta 8280) ebackend_green(porta 8281). - Nginx faz upstream apontando pra "ativo".
- Deploy sobe nova versão na cor inativa, roda smoke nela, e só então alterna upstream.
- Cor antiga fica viva 5 minutos como backup imediato — basta apontar nginx de volta.
# nginx-blue-green.conf
upstream backend_active {
server 127.0.0.1:8280; # blue (alterna pra :8281 quando green ativo)
}
server {
listen 443 ssl;
server_name api.seuapp.com;
location / {
proxy_pass http://backend_active;
}
}
Switch via sed -i 's/8280/8281/g' nginx-blue-green.conf && nginx -s reload. Reload é instantâneo, conexões ativas drenam graciosamente.
Monitoramento pós-deploy: 5 minutos críticos
Mesmo com smoke passando, alguns bugs só aparecem em load real. Os primeiros 5 minutos pós-deploy são monitorados intensivamente:
// Watcher pós-deploy
const start = Date.now();
const watchDuration = 5 * 60 * 1000; // 5 min
while (Date.now() - start < watchDuration) {
const metrics = await fetchMetrics();
if (metrics.errorRate > 0.05) {
console.error("Error rate >5%, disparando rollback");
await triggerRollback();
break;
}
if (metrics.p95Latency > 2000) {
console.error("P95 latency >2s, disparando rollback");
await triggerRollback();
break;
}
await sleep(15_000); // checa a cada 15s
}
Casos práticos
ZAP API (caso real)
Pipeline V2 ativo desde 27/abr/2026: 9 smoke tests, rollback automático em <30s, healthcheck 5s interval. Em ~30 deploys pós-V2, 4 disparam rollback automático e 0 cliente reportou downtime. Antes do V2 (V1 ingênuo), tínhamos média de 1 incidente/semana.
Veredicto
Pipeline similar mas com migration check separado: antes de subir backend novo, roda migration em transaction com timeout de 10s. Se travar, aborta e nada é tocado.
Estagiário fazendo deploy
Com pipeline V2 robusto, qualquer dev (incluindo estagiário) pode dar git push pra main em horário comercial. O pior caso é rollback automático em 30s — sem custo humano. Antes, deploy era ritual exclusivo do tech lead às 22h.
FAQ
Zero downtime real é possível mesmo?
Com blue-green sim — o switch de upstream do nginx é instantâneo (<100ms) e conexões ativas no backend antigo drenam até completar. Sem blue-green, você tem ~3-8s de glitch (containers reciclando), o que é aceitável pra maioria dos casos.
E se o deploy mudou schema do banco?
Migrations devem ser backward compatible: app novo deve funcionar com schema novo, app antigo deve funcionar com schema novo (rollback do app não desfaz schema). Isso quase sempre se traduz em: adicione coluna nullable, deploy app novo, depois (em deploy seguinte) remova suporte à coluna antiga.
Posso fazer rollback de migration?
Em teoria sim (down migration). Em prática raramente — dados já podem ter sido escritos. A regra é: faça migrations forward-only e backward-compatible. Rollback = reverter código, não schema.
Como fazer canary (1% dos usuários veem nova versão primeiro)?
Configure no nginx ou load balancer roteamento por hash do IP/cookie: 1% dos requests vão pra cor nova, 99% pra antiga. Se métricas da nova cor (error rate, latency) ficam dentro do esperado por 30 minutos, vira 100%. Senão, rollback. Ferramentas: Flagsmith, Unleash, ou implementação custom com Redis.
Custo extra de blue-green?
2x a memória/CPU do backend (você roda 2 instâncias permanentemente). Para um backend de 1GB RAM em droplet de R$120/mês, custo extra ~R$60/mês. Comparado ao custo de um único incidente em horário comercial (cliente cancelando, hora de eng), paga em 1-2 incidentes evitados.
Quer testar deploy seguro antes de migrar produção? A ZAP API expõe ambiente sandbox isolado para você testar pipelines, smoke tests e rollback sem risco em produção real. Criar conta grátis e ativar sandbox no painel.