Deploy zero-downtime sem Kubernetes: tutorial prático em 2026
Você não precisa de Kubernetes pra ter deploy sem downtime. Tutorial completo com 2 servidores, Caddy/Traefik na frente, e rolling update via script ou orquestrador leve.
Tem um mito persistente que zero-downtime deploy é exclusividade de quem rodou Kubernetes em produção. Não é. A técnica existe desde antes do colosso ter nome — qualquer time que rodou um par de servidores físicos atrás de um balanceador na década passada já fazia isso, com scripts de cinquenta linhas e nenhum CRD na vida. O que mudou foi o marketing em volta da prática, não a prática em si.
Esse post é um tutorial passo a passo pra montar deploy sem downtime do zero, em duas máquinas Linux, sem orquestrador pesado, sem painel mágico. No fim você vai ter um script de bash que troca uma instância por vez, espera a nova ficar saudável, e rola pra próxima — exatamente o algoritmo que orquestradores grandes implementam, só que sem o boilerplate.
TL;DR
Zero-downtime deploy depende de três ingredientes, não de uma ferramenta específica. Primeiro: duas ou mais instâncias da aplicação rodando em paralelo, atrás de um proxy básico. Segundo: um endpoint de health check confiável que valida dependências reais (banco, cache, fila), não só responde 200 instantaneamente. Terceiro: um script ou orquestrador que substitua um contêiner por vez, esperando o novo ficar saudável antes de prosseguir pro próximo.
Esse tutorial monta o setup completo em duas VPS Linux com Docker, Caddy na frente como proxy + balanceador, e um script bash de cinquenta linhas que faz rolling update com health check ativo, tempo mínimo saudável, e rollback automático se falhar. Resultado: deploy sem 5xx visível pro usuário, em menos de um minuto, sem janela de manutenção.
Pré-requisitos: duas VPS Linux com Docker (Hetzner CPX11 a R$30 cada), domínio com DNS controlável, app com health check decente. Tempo de setup: duas a três horas. Custo mensal: R$60 (R$75 se você quiser uma terceira VPS dedicada ao proxy). No fim mostramos a versão "robusta" via HeroCtl pra quem quer parar de scriptar.
Os três ingredientes (sem isso, não é zero-downtime)
Antes de qualquer comando, vale fixar a teoria — porque toda configuração mais elaborada que você vai ver na internet é variação dessas três peças.
- Múltiplas instâncias da app rodando em paralelo. Mínimo dois. Se você só tem uma, qualquer reinício é janela de erro. Não tem como contornar isso com truque de configuração.
- Um proxy/balanceador na frente, fazendo health check. O proxy decide pra qual instância mandar tráfego. Se uma cai (ou foi tirada deliberadamente pro deploy), o proxy só manda pras restantes.
- Um script que troca instâncias uma por vez. Nunca todas juntas. Espera a nova ficar saudável antes de mexer na próxima. Se a nova falha, para o deploy e mantém as antigas servindo.
É só isso. O resto — Kubernetes, painéis modernos, orquestradores leves — é embalagem em volta desses três pontos.
Por que single-server NUNCA é zero-downtime (mesmo se for rápido)
Vejo essa pergunta toda semana no Discord da comunidade: "consigo zero-downtime com um servidor só, se o deploy for rápido o suficiente?". Resposta curta: não.
Numa máquina única, o ciclo de deploy é: para o contêiner antigo, sobe o novo. Mesmo que tudo aconteça em três segundos, esses três segundos existem. Conexões TCP em curso são cortadas. Requisições que chegam nesse intervalo levam connection refused ou 502. Se você tem cinco requests por segundo, são quinze usuários vendo erro a cada deploy.
Tem variação esperta — subir o novo numa porta diferente, mudar o proxy local, derrubar o antigo. Isso melhora, mas não elimina. Se a app demora pra fechar conexões em curso, o cutover ainda gera erros. Se health check é fraco, o proxy aponta tráfego pra app que ainda não terminou de subir. Sempre tem uma janela.
A única forma confiável de eliminar a janela é ter pelo menos uma instância sempre disponível durante todo o deploy. Isso exige duas máquinas. Ponto.
O setup mínimo (duas VPS + um proxy)
A topologia mais barata que entrega zero-downtime real:
| Componente | Tamanho | Custo | Função |
|---|---|---|---|
| VPS A | 2 vCPU / 2 GB RAM | R$30/mês | App instância 1 |
| VPS B | 2 vCPU / 2 GB RAM | R$30/mês | App instância 2 |
| Proxy | rodando em VPS A ou terceira VPS | R$0 (compartilhado) ou R$15/mês | Caddy/nginx fazendo balance |
| Banco | Postgres gerenciado ou terceira VPS | varia | Estado compartilhado entre A e B |
Ter o proxy compartilhado em uma das próprias VPS economiza, mas tem trade-off: se a VPS que hospeda o proxy cair inteira, o site cai junto (mesmo com a outra VPS rodando). Pra time pequeno isso é aceitável. Quando crescer, o proxy migra pra VPS dedicada ou vira par redundante.
DNS A record do seu domínio aponta pro IP do proxy. Apps em A e B se conectam ao mesmo banco — sem essa parte compartilhada, as duas instâncias divergem e o usuário vê resultado diferente dependendo de qual respondeu.
Passo 1 — Provisionar duas VPS (15 min)
Eu uso Hetzner CPX11 (€4,75 ≈ R$30) como referência. DigitalOcean Droplet de US$6, Vultr Cloud Compute de US$6 ou Linode Nanode de US$5 entregam algo parecido. O importante é Linux moderno (Ubuntu 24.04 LTS ou Debian 12) com Docker.
Provisione as duas máquinas com a mesma chave SSH:
# do seu laptop
ssh-keygen -t ed25519 -f ~/.ssh/deploy_key -C "deploy@meudominio.com"
# adicione ~/.ssh/deploy_key.pub na console do provedor antes de criar a VPS
Crie cada VPS, anote os IPs. Vou usar 203.0.113.10 (VPS A) e 203.0.113.20 (VPS B) como placeholders no resto do post.
Instale Docker em cada uma:
ssh root@203.0.113.10 "curl -fsSL https://get.docker.com | sh"
ssh root@203.0.113.20 "curl -fsSL https://get.docker.com | sh"
Configure firewall pra permitir só 22 (SSH) e 8080 (porta interna onde a app vai escutar). Tráfego HTTP/HTTPS chega só no proxy:
ssh root@203.0.113.10 "ufw allow 22 && ufw allow 8080/tcp && ufw --force enable"
ssh root@203.0.113.20 "ufw allow 22 && ufw allow 8080/tcp && ufw --force enable"
Validação: docker run --rm hello-world em cada máquina deve completar sem erro.
Passo 2 — App com health check decente (30 min)
O endpoint /healthz é o coração do esquema. Se ele retorna 200 quando a app não está realmente pronta, o proxy manda tráfego pra instância quebrada e o usuário vê erro. Se ele retorna 500 quando a app está saudável, o proxy tira a instância boa do balanceamento. Ou seja: o health check é a fonte de verdade do sistema inteiro.
Regra de ouro: o /healthz valida dependências reais que a app precisa pra responder. Mínimo: conexão com o banco. Se você tem cache (Redis), inclui. Se você tem fila (SQS, RabbitMQ), inclui. NÃO retorne 200 logo no boot — espere assets compilarem, cache aquecer, conexões abrirem.
Node.js (Express)
import express from "express"
import { Pool } from "pg"
const app = express()
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
let ready = false
// warm-up assíncrono — só fica ready quando dependencies validam
;(async () => {
await pool.query("SELECT 1")
// outras inicializações: cache prime, etc.
ready = true
})()
app.get("/healthz", async (_req, res) => {
if (!ready) return res.status(503).send("warming up")
try {
await pool.query("SELECT 1")
res.status(200).send("ok")
} catch (e) {
res.status(503).send("db down")
}
})
app.get("/", (_req, res) => res.send("Hello v1"))
const server = app.listen(8080, () => console.log("listening 8080"))
// graceful shutdown — drena conexões antes de morrer
process.on("SIGTERM", () => {
ready = false // health check passa a falhar imediatamente
setTimeout(() => {
server.close(() => process.exit(0))
}, 5000) // 5s pro proxy notar e parar de mandar tráfego novo
})
Python (Django + gunicorn)
# health/views.py
from django.db import connection
from django.http import JsonResponse, HttpResponse
import redis, os
_r = redis.from_url(os.environ["REDIS_URL"])
def healthz(request):
try:
with connection.cursor() as c:
c.execute("SELECT 1")
_r.ping()
return HttpResponse("ok", status=200)
except Exception as e:
return HttpResponse(f"unhealthy: {e}", status=503)
Ruby (Rails)
# config/routes.rb
get "/healthz", to: "health#show"
# app/controllers/health_controller.rb
class HealthController < ApplicationController
def show
ActiveRecord::Base.connection.execute("SELECT 1")
Rails.cache.read("__healthcheck__")
head :ok
rescue => e
Rails.logger.warn("healthcheck failed: #{e.message}")
head :service_unavailable
end
end
O detalhe que diferencia health check amador de profissional é o graceful shutdown: ao receber SIGTERM, a app passa a retornar 503 no /healthz imediatamente, mas continua aceitando conexões em curso por mais alguns segundos. O proxy nota o 503, para de mandar tráfego novo, e quando o app finalmente fecha não tem mais ninguém esperando resposta.
Sem isso, o cutover sempre vaza alguns erros mesmo com tudo o resto certo.
Passo 3 — Subir duas instâncias Docker (15 min)
Faça build da sua app em imagem Docker. Pro tutorial vou usar uma imagem genérica que você substitui:
# no seu laptop, push pra registry (Docker Hub, ECR, GHCR)
docker build -t meuusuario/myapp:v1 .
docker push meuusuario/myapp:v1
Sobe instância em VPS A:
ssh root@203.0.113.10 "
docker pull meuusuario/myapp:v1 &&
docker run -d --name app --restart=unless-stopped \
-p 8080:8080 \
-e DATABASE_URL='postgres://user:pass@db.example.com:5432/app' \
--health-cmd='curl -f http://localhost:8080/healthz || exit 1' \
--health-interval=5s --health-timeout=2s --health-retries=3 \
meuusuario/myapp:v1
"
Repete pra VPS B trocando o IP. Valide:
curl http://203.0.113.10:8080/healthz # deve retornar "ok"
curl http://203.0.113.20:8080/healthz # deve retornar "ok"
Se as duas voltam 200, a base está pronta.
Passo 4 — Caddy como reverse proxy + balanceador (30 min)
Caddy é mais fácil de começar que nginx por causa do TLS automático embutido — Let's Encrypt funciona out of the box, sem configurar bot externo. nginx é mais flexível e tem ecossistema maior; Caddy é mais simples pra esse caso. Pro tutorial vou de Caddy.
Vou rodar o Caddy na VPS A, compartilhando a máquina com uma das instâncias da app. Se preferir uma terceira VPS dedicada, troca o IP onde for relevante.
Primeiro, libere portas 80 e 443 na VPS A:
ssh root@203.0.113.10 "ufw allow 80 && ufw allow 443"
Crie o Caddyfile:
meudominio.com {
reverse_proxy 203.0.113.10:8080 203.0.113.20:8080 {
lb_policy round_robin
health_uri /healthz
health_interval 5s
health_timeout 2s
health_status 200
fail_duration 30s
max_fails 2
unhealthy_status 5xx
transport http {
dial_timeout 2s
}
}
}
Quinze linhas. Tudo o que importa está aí: round-robin entre os dois IPs, health check ativo a cada cinco segundos no /healthz, marca como unhealthy depois de duas falhas seguidas em 30s, timeout de dois segundos pra abrir conexão.
Sobe Caddy:
ssh root@203.0.113.10 "
mkdir -p /etc/caddy &&
docker run -d --name caddy --restart=unless-stopped \
--network host \
-v /etc/caddy/Caddyfile:/etc/caddy/Caddyfile \
-v caddy_data:/data \
-v caddy_config:/config \
caddy:2-alpine
"
Aponte o DNS A do seu domínio pra 203.0.113.10. Em alguns minutos:
curl https://meudominio.com/
# deve retornar "Hello v1" (alternando entre as duas instâncias)
Caddy emitiu certificado Let's Encrypt automaticamente. Isso funciona porque o domínio resolve pro IP onde Caddy está escutando na porta 80 (challenge HTTP-01).
Passo 5 — Script bash de deploy (60 min)
Esse é o coração do tutorial. Um script que orquestra rolling update entre as duas VPS:
#!/usr/bin/env bash
# deploy.sh — rolling deploy zero-downtime entre duas VPS
set -euo pipefail
IMAGE="${1:?Uso: ./deploy.sh meuusuario/myapp:v2}"
HOSTS=("203.0.113.10" "203.0.113.20")
HEALTH_DEADLINE=300 # max segundos esperando health check
MIN_HEALTHY_TIME=10 # segundos saudável sustentado antes de prosseguir
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=5"
deploy_host() {
local host=$1
local image=$2
echo "==> [${host}] pulling ${image}"
ssh ${SSH_OPTS} "root@${host}" "docker pull ${image}"
# guarda imagem antiga pro caso de rollback
local old_image
old_image=$(ssh ${SSH_OPTS} "root@${host}" "docker inspect app --format '{{.Config.Image}}' 2>/dev/null || echo none")
echo "==> [${host}] versão atual: ${old_image}"
echo "==> [${host}] substituindo contêiner"
ssh ${SSH_OPTS} "root@${host}" "
docker stop app 2>/dev/null || true
docker rm app 2>/dev/null || true
docker run -d --name app --restart=unless-stopped \
-p 8080:8080 \
-e DATABASE_URL='${DATABASE_URL}' \
--health-cmd='curl -f http://localhost:8080/healthz || exit 1' \
--health-interval=5s --health-timeout=2s --health-retries=3 \
${image}
"
echo "==> [${host}] esperando health check (max ${HEALTH_DEADLINE}s)"
local start=$(date +%s)
local healthy_since=0
while true; do
local now=$(date +%s)
if (( now - start > HEALTH_DEADLINE )); then
echo "!! [${host}] healthy_deadline excedido — fazendo rollback pra ${old_image}"
ssh ${SSH_OPTS} "root@${host}" "
docker stop app && docker rm app &&
docker run -d --name app --restart=unless-stopped \
-p 8080:8080 -e DATABASE_URL='${DATABASE_URL}' \
${old_image}
"
return 1
fi
if curl -sf --max-time 2 "http://${host}:8080/healthz" > /dev/null; then
if (( healthy_since == 0 )); then
healthy_since=${now}
echo " [${host}] saudável — confirmando por ${MIN_HEALTHY_TIME}s"
elif (( now - healthy_since >= MIN_HEALTHY_TIME )); then
echo "==> [${host}] saudável sustentado — promovendo"
return 0
fi
else
healthy_since=0
fi
sleep 2
done
}
echo "### Deploy ${IMAGE} em ${#HOSTS[@]} hosts (rolling, max_parallel=1)"
for host in "${HOSTS[@]}"; do
if ! deploy_host "${host}" "${IMAGE}"; then
echo "### Deploy abortado em ${host}. Hosts anteriores mantidos como estavam."
exit 1
fi
done
echo "### Deploy completo: todos os hosts em ${IMAGE}"
Salva como deploy.sh, dá chmod +x, e:
export DATABASE_URL='postgres://user:pass@db.example.com:5432/app'
./deploy.sh meuusuario/myapp:v2
O algoritmo é literalmente o que orquestradores grandes fazem internamente:
- Para cada host, sequencialmente (max_parallel = 1)
- Pull da nova imagem antes de mexer no contêiner — assim o downtime entre
docker stopedocker runé mínimo - Guarda referência da imagem antiga pra rollback se algo der errado
- Substitui o contêiner
- Loop esperando health check com deadline de cinco minutos
- Min healthy time de dez segundos: só avança quando o
/healthzretornou 200 sustentadamente por dez segundos (se cair no meio, reinicia a contagem) - Rollback automático se passar do deadline
Os números (max_parallel: 1, min_healthy_time: 10s, healthy_deadline: 300s) são exatamente os defaults que usamos no HeroCtl. Não é coincidência — são os valores que sobreviveram a anos de tentativa e erro. Min healthy time muito curto detecta sintomas transitórios como "saudável" e quebra; muito longo deixa o deploy lento sem ganho. Dez segundos é o ponto onde ruído some e o deploy ainda termina rápido.
Passo 6 — Validar com teste de carga durante deploy (15 min)
Esse é o teste de fogo: rodar carga sustentada e fazer deploy ao mesmo tempo. Se algum 5xx aparecer, alguma parte do esquema está quebrada.
Numa máquina externa (seu laptop ou outra VPS):
# instale hey
go install github.com/rakyll/hey@latest
# carga sustentada de 60s, 5 conexões concorrentes
hey -z 60s -c 5 https://meudominio.com/
Em outra janela, simultaneamente:
./deploy.sh meuusuario/myapp:v2
No final do hey:
Status code distribution:
[200] 1847 responses
Só 200. Se aparecer um 502 ou 503, alguma das três peças tá fraca: health check retornando 200 cedo demais, graceful shutdown ausente, ou min healthy time curto. Investiga e corrige.
Os seis detalhes que separam zero-downtime real de aproximação
Cobrimos boa parte deles ao longo do tutorial, mas vale consolidar — porque um único desses ausentes converte o esquema todo em "mostly zero-downtime", que é diferente.
- Connection draining no SIGTERM. Quando o contêiner recebe sinal de parada, a app marca
/healthzcomo falhando imediatamente, mas continua aceitando conexões em curso por alguns segundos. Sem isso, conexões abertas no momento do stop são cortadas. - Pre-stop hook se você tem worker assíncrono. Filas que processam jobs em background precisam de pausa explícita antes de matar o processo, ou o job em execução fica órfão. Em Sidekiq, é o
:quietantes de:term. Em Celery, é--soft-time-limit. - Health check ANTES de promover, não "container running".
docker psmostra "running" milisegundos depois dodocker run. Não significa nada. Promover só depois de/healthzretornar 200 sustentadamente. - Min healthy time de dez segundos sustentados. Não vale ver um único 200 e seguir em frente — apps com warm-up irregular passam um momento e voltam a falhar.
- Versão anterior pré-puxada pra rollback rápido. Se você confiou em "manter a imagem antiga em cache do Docker", em algum momento ela é apagada por garbage collection e o rollback fica lento. Mantenha as últimas três imagens explicitamente.
- Auto-revert ao passar do healthy deadline. Sem isso, o deploy trava num estado parcial — metade dos hosts em v2, metade em v1, sem ninguém pra decidir o que fazer.
Database migrations + zero-downtime (a parte que quebra deploy de gente experiente)
Esse é o tópico que eu mais vejo desenvolvedor sênior errar. Rolling deploy assume que as duas versões da app rodam simultaneamente em produção por algum período. Se a v2 espera schema incompatível com o que a v1 entende, alguma das duas quebra durante a janela de transição.
Regra de ouro inegociável: migrations são sempre backward-compatible.
Caso clássico: você quer renomear coluna email pra email_address. Solução errada: faz a migration que renomeia direto antes do deploy. Resultado: durante o rolling, instâncias v1 ainda escrevem em email (que não existe mais) e quebram. Solução certa, em três deploys:
| Deploy | Migration | Código v* |
|---|---|---|
| 1 | Adiciona email_address (nullable). Nenhuma remoção. | App escreve em email E em email_address; lê de email. |
| 2 | Backfill: UPDATE users SET email_address = email WHERE email_address IS NULL. NOT NULL constraint. | App lê de email_address; ainda escreve nas duas. |
| 3 | Drop email. | App só usa email_address. |
Três deploys, semanas de espaçamento. É chato, é o jeito. Drop de coluna direto sempre quebra. Mudança de tipo direto sempre quebra. Adicionar NOT NULL sem default direto sempre quebra.
Ferramentas que ajudam: pg-osc e pgroll (Postgres), gh-ost (MySQL) — fazem schema change online, sem lock longo. Pra migrations leves, o jeito manual em três passos resolve.
Padrões além de rolling
Rolling é o padrão default e mais econômico. Tem outros que valem conhecer:
- Blue-green. Dois ambientes paralelos completos — "blue" rodando v1, "green" provisionado com v2 vazio. Você sobe v2 inteiro em green, valida, troca DNS (ou cutover do balanceador). Vantagem: rollback instantâneo (volta DNS pra blue). Desvantagem: custa o dobro de recursos durante a janela de deploy.
- Canary. Manda 5% do tráfego pra v2, observa métricas (erros, latência, taxa de conversão), decide se promove pra 100% ou aborta. Detecta bugs sutis que health check não pega — tipo regression em conversão de checkout. Exige proxy com weighted routing e observabilidade decente.
- Rainbow / N+1. Generalização do blue-green com N versões coexistindo. Útil quando você quer A/B test de longa duração entre versões inteiras.
Pro tutorial, rolling é o que faz sentido. Os outros valem quando o tamanho do tráfego justifica investimento extra.
Versão "fácil" — Coolify ou Dokploy
Se você não quer scriptar, dois painéis modernos fazem rolling deploy automaticamente:
- Coolify em modo multi-server faz rolling com health check configurável. Multi-server foi adicionado nas versões mais recentes — antes era single-server only. Vale checar a versão.
- Dokploy em cima de Docker Swarm faz rolling com
--update-parallelism 1 --update-delay. Aproveita o que o Swarm já oferece.
Trade-off: você troca o script de cinquenta linhas (que entende tudo o que está acontecendo) por um painel (que é mais rápido pra subir, mas vira caixa-preta quando algo dá errado). Pra time pequeno onde uma pessoa cuida de operação parcialmente, o painel ganha. Pra time onde você precisa entender exatamente o que rolou na 3h da manhã, o script ganha.
Versão "robusta" — HeroCtl
Pra quem quer parar de scriptar mas não quer caixa-preta, o HeroCtl combina rolling deploy automático com plano de controle replicado. Você descreve o serviço em arquivo de configuração e o orquestrador faz o resto:
job "minhaapp" {
group "web" {
count = 2
task "app" {
driver = "docker"
config {
image = "meuusuario/myapp:v2"
ports = ["http"]
}
service {
port = "http"
check {
type = "http"
path = "/healthz"
interval = "5s"
timeout = "2s"
}
}
}
update {
max_parallel = 1
min_healthy_time = "10s"
healthy_deadline = "5m"
auto_revert = true
}
}
}
Os mesmos parâmetros do script bash, declarativos. A diferença é que o orquestrador coordena rolling entre N servidores (não só dois), faz eleição automática de coordenador em torno de sete segundos se o nó atual cair, e mantém o plano de controle distribuído entre os primeiros três servidores. Cluster sobrevive a perda de qualquer servidor único sem intervenção humana.
Instalação:
curl -sSL https://get.heroctl.com/install.sh | sh
Plano Community é gratuito permanente — sem limite de servidores ou jobs, com todas as features de orquestração descritas no tutorial. Plano Business adiciona SSO/SAML, RBAC granular, auditoria detalhada e suporte com SLA, pra times que têm requisitos formais de plataforma. Plano Enterprise adiciona escrow de código-fonte, contrato de continuidade e suporte 24×7. Os preços de Business e Enterprise estão publicados na página de planos — sem "fale com vendas" obrigatório.
Comparativo: cinco caminhos lado a lado
| Critério | Script bash (2 servers) | Coolify multi-server | Dokploy + Swarm | HeroCtl | Kamal | Kubernetes |
|---|---|---|---|---|---|---|
| Tempo de setup | 2-3h | 30 min | 1h | 5 min | 1h | 4h-4 dias |
| Linhas de config | ~50 (script) | UI | ~20 | ~50 | ~40 | 300+ |
| HA do plano de controle | N/A | Não | Limitado | Sim | N/A | Sim (5+ componentes) |
| Health check declarativo | Manual | Sim | Sim | Sim | Sim | Sim |
| Rollback automático | Manual no script | Sim | Sim | Sim | Sim | Sim |
| Escala alvo | 1-3 servers | 1-10 servers | 1-20 servers | 1-500 servers | 1-10 servers | 50+ servers |
| Caixa-preta? | Não (você escreveu) | Sim | Parcial | Não (declarativo curto) | Não | Sim |
| Curva de aprendizado | Baixa | Baixa | Média | Baixa | Baixa | Alta |
Cada coluna tem seu nicho. Script bash é insuperável quando você quer entender cada linha. Coolify ganha quando você só quer um painel. HeroCtl ganha quando você precisa de HA real sem montar plano de controle externo. Kubernetes ganha em escala planetária — onde a complexidade compensa.
Os cinco erros mais comuns
- Health check em
/retornando 200 sem validar dependências. A app retorna 200 antes de conectar no banco, o proxy promove, e o usuário vê erro 500 nas primeiras requests. Solução:/healthzvalida banco, cache, fila — qualquer coisa que a app precise pra responder de verdade. - Min healthy time de 1 segundo. Apps com warm-up irregular podem retornar 200 num momento e 503 logo depois (cache populando, classe sendo lazy-loaded). O orquestrador promove na primeira janela boa, e a próxima request bate em estado ruim. Dez segundos sustentados eliminam noventa por cento desses casos.
- Sem max_parallel (ou max_parallel = N). Se você troca todas as instâncias juntas, durante a janela do cutover não tem ninguém saudável servindo. É single-server downtime disfarçado. Sempre
max_parallel = 1pra começar. - Mix de versões em produção sem schema compat. v1 escreve em
email, v2 lê deemail_address, e durante o rolling de cinco minutos as duas convivem — usuários que pegam v2 não veem dados que v1 acabou de gravar. Migration backward-compatible em três passos resolve. - Cache stale no cliente (CDN, browser, service worker). Backend já é v2 mas o usuário tem o JS de v1 em cache, e o JS antigo chama API que não existe mais. Solução: mantém endpoints antigos por uma janela; versionamento de API; cache-busting forte em assets críticos.
FAQ
Posso fazer zero-downtime com um servidor só?
Não. Toda variação que prometa isso tem janela de erro mensurável quando você mede com hey -c 20. A única forma de ter zero-downtime real é manter pelo menos uma instância sempre saudável durante todo o deploy — o que exige duas máquinas no mínimo.
DNS round-robin funciona como balanceador?
Funciona como balanceador básico, mas não como mecanismo de health check. DNS não tira IP morto da rotação rapidamente — TTLs caching em ISPs e clientes mantêm o IP errado em uso por minutos ou horas. Pra zero-downtime você precisa de um proxy real (Caddy, nginx, HAProxy) que tira instância unhealthy do balanceamento em segundos.
Caddy ou Traefik — qual é melhor pra esse setup?
Pra dois servidores e um setup estático, Caddy é mais simples — Caddyfile de quinze linhas resolve. Traefik brilha quando você tem descoberta dinâmica de serviços (tipo Docker labels ou Consul) e muitos backends mudando o tempo todo. nginx fica no meio: mais flexível, sem TLS automático embutido (precisa de certbot externo). Pra esse tutorial, Caddy.
WebSocket connections sobrevivem durante rolling?
Conexões abertas em instância que está sendo derrubada são cortadas. O cliente tem que reconectar. Boa biblioteca de WebSocket (Socket.IO, Phoenix Channels) reconecta automaticamente — usuário vê uma piscada de meio segundo no estado. Connection draining ajuda: a instância marca /healthz falhando, o proxy para de mandar conexões novas, mas as existentes continuam até o pre-stop timer. Trinta segundos de drain costumam ser suficientes pra que conexões longas se esvaziem naturalmente.
Database migrations — qual a regra de ouro?
Toda migration tem que ser backward-compatible. Drop de coluna nunca direto. Rename nunca direto. Mudança de tipo nunca direto. Em vez disso, três deploys: adiciona estrutura nova, backfill, remove a antiga. Lento, sim. Mas o rolling deploy depende disso pra não quebrar.
Rollback automático — como implementar?
Duas peças: deadline (tempo máximo esperando health check) e referência da imagem anterior pré-puxada. Se passar do deadline sem ficar saudável, o script reinstala a versão anterior. O exemplo no Passo 5 faz exatamente isso. Em orquestradores declarativos, vira auto_revert = true.
Sticky sessions complicam zero-downtime?
Sim. Se a app guarda estado de sessão em memória do processo, derrubar a instância derruba as sessões dos usuários conectados a ela. Solução: tira sessão da memória — Redis, Postgres ou JWT signed. Aí qualquer instância serve qualquer usuário, e rolling não corta nenhuma sessão.
Quanto tempo demora um deploy completo?
Dois servidores, app que sobe em quinze segundos: cerca de um minuto. Detalhamento: pull da imagem (5-15s, depende da rede e do tamanho), substituição do contêiner (1s), warm-up + health check (10-30s), min healthy time de 10s, total uns 30-50s por host, multiplicado por dois hosts em sequência = 1-2 min. Quatro servidores ficam em torno de 2-4 min. Com cinquenta servidores, deploy começa a tomar dez ou quinze minutos — momento de aumentar max_parallel pra dois ou três (mantendo health check rigoroso).
Fechamento
Zero-downtime deploy é arquitetura, não ferramenta. Os três ingredientes — múltiplas instâncias, proxy com health check, rolling controlado — funcionam com bash e Caddy tanto quanto com orquestrador grande. A diferença está em quanto da operação você quer escrever na mão e quanto delegar.
Pra um SaaS pequeno, três VPS e um script de cinquenta linhas resolvem indefinidamente. Quando o cluster cresce pra dezenas de servidores ou o time precisa de HA real do plano de controle, vale subir pro orquestrador declarativo:
curl -sSL https://get.heroctl.com/install.sh | sh
Mais sobre o algoritmo de rolling em Rolling deploy seguro: por que o seu talvez não seja. Pra quem está saindo de Compose pra setup multi-servidor, Deploy Docker em produção: do compose ao cluster cobre o caminho intermediário.
Orquestração de contêineres, sem cerimônia.