Deploy cero-downtime sin Kubernetes: tutorial práctico en 2026

No necesitas Kubernetes para tener deploy sin downtime. Tutorial completo con 2 servidores, Caddy/Traefik por delante, y rolling update vía script u orquestador ligero.

Equipo HeroCtl··15 min· Leer en portugués →

Hay un mito persistente de que el deploy cero-downtime es exclusividad de quien ejecutó Kubernetes en producción. No lo es. La técnica existe desde antes de que el coloso tuviera nombre — cualquier equipo que ejecutó un par de servidores físicos detrás de un balanceador en la década pasada ya hacía esto, con scripts de cincuenta líneas y ningún CRD en la vida. Lo que cambió fue el marketing alrededor de la práctica, no la práctica en sí.

Este post es un tutorial paso a paso para montar deploy sin downtime desde cero, en dos máquinas Linux, sin orquestador pesado, sin panel mágico. Al final vas a tener un script de bash que cambia una instancia por vez, espera a que la nueva esté saludable, y rota a la siguiente — exactamente el algoritmo que los orquestadores grandes implementan, solo que sin el boilerplate.

TL;DR

El deploy cero-downtime depende de tres ingredientes, no de una herramienta específica. Primero: dos o más instancias de la aplicación corriendo en paralelo, detrás de un proxy básico. Segundo: un endpoint de health check confiable que valida dependencias reales (base, cache, cola), no solo responde 200 instantáneamente. Tercero: un script u orquestador que sustituya un contenedor por vez, esperando a que el nuevo esté saludable antes de proseguir al siguiente.

Este tutorial monta el setup completo en dos VPS Linux con Docker, Caddy por delante como proxy + balanceador, y un script bash de cincuenta líneas que hace rolling update con health check activo, tiempo mínimo saludable, y rollback automático si falla. Resultado: deploy sin 5xx visible para el usuario, en menos de un minuto, sin ventana de mantenimiento.

Prerequisitos: dos VPS Linux con Docker (Hetzner CPX11 a 5 € cada una), dominio con DNS controlable, app con health check decente. Tiempo de setup: dos a tres horas. Coste mensual: 10 € (12 € si quieres una tercera VPS dedicada al proxy). Al final mostramos la versión "robusta" vía HeroCtl para quien quiere parar de scriptear.


Los tres ingredientes (sin esto, no es cero-downtime)

Antes de cualquier comando, vale la pena fijar la teoría — porque toda configuración más elaborada que vas a ver en internet es variación de estas tres piezas.

  1. Múltiples instancias de la app corriendo en paralelo. Mínimo dos. Si solo tienes una, cualquier reinicio es ventana de error. No hay forma de evitar esto con truco de configuración.
  2. Un proxy/balanceador por delante, haciendo health check. El proxy decide a qué instancia mandar tráfico. Si una se cae (o fue retirada deliberadamente para el deploy), el proxy solo manda a las restantes.
  3. Un script que cambia instancias una por vez. Nunca todas juntas. Espera a que la nueva esté saludable antes de tocar la siguiente. Si la nueva falla, para el deploy y mantiene las antiguas sirviendo.

Es solo eso. El resto — Kubernetes, paneles modernos, orquestadores ligeros — es embalaje alrededor de esos tres puntos.

Por qué single-server NUNCA es cero-downtime (incluso si es rápido)

Veo esta pregunta toda semana en el Discord de la comunidad: "¿puedo lograr cero-downtime con un solo servidor, si el deploy es lo suficientemente rápido?". Respuesta corta: no.

En una máquina única, el ciclo de deploy es: para el contenedor antiguo, sube el nuevo. Aunque todo suceda en tres segundos, esos tres segundos existen. Conexiones TCP en curso son cortadas. Peticiones que llegan en ese intervalo reciben connection refused o 502. Si tienes cinco requests por segundo, son quince usuarios viendo error en cada deploy.

Hay variación astuta — subir el nuevo en un puerto diferente, cambiar el proxy local, tirar el antiguo. Eso mejora, pero no elimina. Si la app tarda en cerrar conexiones en curso, el cutover aún genera errores. Si el health check es débil, el proxy apunta tráfico a app que aún no terminó de subir. Siempre hay una ventana.

La única forma confiable de eliminar la ventana es tener al menos una instancia siempre disponible durante todo el deploy. Eso exige dos máquinas. Punto.

El setup mínimo (dos VPS + un proxy)

La topología más barata que entrega cero-downtime real:

ComponenteTamañoCosteFunción
VPS A2 vCPU / 2 GB RAM5 €/mesApp instancia 1
VPS B2 vCPU / 2 GB RAM5 €/mesApp instancia 2
Proxycorriendo en VPS A o tercera VPS0 € (compartido) o 3 €/mesCaddy/nginx haciendo balance
BasePostgres gestionado o tercera VPSvaríaEstado compartido entre A y B

Tener el proxy compartido en una de las propias VPS economiza, pero tiene trade-off: si la VPS que hospeda el proxy se cae entera, el sitio se cae con ella (incluso con la otra VPS corriendo). Para equipo pequeño eso es aceptable. Cuando crezca, el proxy migra a VPS dedicada o se vuelve par redundante.

DNS A record de tu dominio apunta al IP del proxy. Apps en A y B se conectan a la misma base — sin esa parte compartida, las dos instancias divergen y el usuario ve resultado diferente dependiendo de cuál respondió.


Paso 1 — Provisionar dos VPS (15 min)

Yo uso Hetzner CPX11 (€4,75) como referencia. DigitalOcean Droplet de US$6, Vultr Cloud Compute de US$6 o Linode Nanode de US$5 entregan algo parecido. Lo importante es Linux moderno (Ubuntu 24.04 LTS o Debian 12) con Docker.

Provisiona las dos máquinas con la misma clave SSH:

# desde tu laptop
ssh-keygen -t ed25519 -f ~/.ssh/deploy_key -C "deploy@meudominio.com"
# añade ~/.ssh/deploy_key.pub en la consola del proveedor antes de crear la VPS

Crea cada VPS, anota los IPs. Voy a usar 203.0.113.10 (VPS A) y 203.0.113.20 (VPS B) como placeholders en el resto del post.

Instala Docker en cada una:

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"

Configura firewall para permitir solo 22 (SSH) y 8080 (puerto interno donde la app va a escuchar). Tráfico HTTP/HTTPS llega solo al 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"

Validación: docker run --rm hello-world en cada máquina debe completar sin error.

Paso 2 — App con health check decente (30 min)

El endpoint /healthz es el corazón del esquema. Si retorna 200 cuando la app no está realmente lista, el proxy manda tráfico a la instancia rota y el usuario ve error. Si retorna 500 cuando la app está saludable, el proxy retira la instancia buena del balanceo. O sea: el health check es la fuente de verdad del sistema entero.

Regla de oro: el /healthz valida dependencias reales que la app necesita para responder. Mínimo: conexión con la base. Si tienes cache (Redis), incluye. Si tienes cola (SQS, RabbitMQ), incluye. NO retornes 200 nada más arrancar — espera a que assets compilen, cache caliente, conexiones abran.

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

El detalle que diferencia health check amateur de profesional es el graceful shutdown: al recibir SIGTERM, la app pasa a retornar 503 en /healthz inmediatamente, pero sigue aceptando conexiones en curso por algunos segundos más. El proxy nota el 503, deja de mandar tráfico nuevo, y cuando la app finalmente cierra ya no hay nadie esperando respuesta.

Sin esto, el cutover siempre filtra algunos errores incluso con todo lo demás correcto.

Paso 3 — Subir dos instancias Docker (15 min)

Haz build de tu app en imagen Docker. Para el tutorial voy a usar una imagen genérica que sustituyes:

# en tu laptop, push al registry (Docker Hub, ECR, GHCR)
docker build -t meuusuario/myapp:v1 .
docker push meuusuario/myapp:v1

Sube instancia en 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
"

Repite para VPS B cambiando el IP. Valida:

curl http://203.0.113.10:8080/healthz   # debe retornar "ok"
curl http://203.0.113.20:8080/healthz   # debe retornar "ok"

Si las dos vuelven 200, la base está lista.

Paso 4 — Caddy como reverse proxy + balanceador (30 min)

Caddy es más fácil de empezar que nginx por el TLS automático embebido — Let's Encrypt funciona out of the box, sin configurar bot externo. nginx es más flexible y tiene ecosistema mayor; Caddy es más simple para este caso. Para el tutorial voy con Caddy.

Voy a ejecutar Caddy en la VPS A, compartiendo la máquina con una de las instancias de la app. Si prefieres una tercera VPS dedicada, cambia el IP donde sea relevante.

Primero, libera puertos 80 y 443 en la VPS A:

ssh root@203.0.113.10 "ufw allow 80 && ufw allow 443"

Crea el 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
        }
    }
}

Quince líneas. Todo lo que importa está ahí: round-robin entre los dos IPs, health check activo cada cinco segundos en /healthz, marca como unhealthy después de dos fallos seguidos en 30s, timeout de dos segundos para abrir conexión.

Sube 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
"

Apunta el DNS A de tu dominio a 203.0.113.10. En unos minutos:

curl https://meudominio.com/
# debe retornar "Hello v1" (alternando entre las dos instancias)

Caddy emitió certificado Let's Encrypt automáticamente. Eso funciona porque el dominio resuelve al IP donde Caddy está escuchando en el puerto 80 (challenge HTTP-01).

Paso 5 — Script bash de deploy (60 min)

Este es el corazón del tutorial. Un script que orquesta rolling update entre las dos 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}"

Guarda como deploy.sh, dale chmod +x, y:

export DATABASE_URL='postgres://user:pass@db.example.com:5432/app'
./deploy.sh meuusuario/myapp:v2

El algoritmo es literalmente lo que los orquestadores grandes hacen internamente:

  1. Para cada host, secuencialmente (max_parallel = 1)
  2. Pull de la nueva imagen antes de tocar el contenedor — así el downtime entre docker stop y docker run es mínimo
  3. Guarda referencia de la imagen antigua para rollback si algo sale mal
  4. Sustituye el contenedor
  5. Loop esperando health check con deadline de cinco minutos
  6. Min healthy time de diez segundos: solo avanza cuando /healthz retornó 200 sostenidamente por diez segundos (si cae en el medio, reinicia el conteo)
  7. Rollback automático si pasa del deadline

Los números (max_parallel: 1, min_healthy_time: 10s, healthy_deadline: 300s) son exactamente los defaults que usamos en HeroCtl. No es coincidencia — son los valores que sobrevivieron a años de prueba y error. Min healthy time muy corto detecta síntomas transitorios como "saludable" y rompe; muy largo deja el deploy lento sin ganancia. Diez segundos es el punto donde ruido desaparece y el deploy aún termina rápido.

Paso 6 — Validar con test de carga durante deploy (15 min)

Esta es la prueba de fuego: ejecutar carga sostenida y hacer deploy al mismo tiempo. Si algún 5xx aparece, alguna parte del esquema está rota.

En una máquina externa (tu laptop u otra VPS):

# instala hey
go install github.com/rakyll/hey@latest

# carga sustentada de 60s, 5 conexões concorrentes
hey -z 60s -c 5 https://meudominio.com/

En otra ventana, simultáneamente:

./deploy.sh meuusuario/myapp:v2

Al final del hey:

Status code distribution:
  [200] 1847 responses

Solo 200. Si aparece un 502 o 503, alguna de las tres piezas está débil: health check retornando 200 demasiado pronto, graceful shutdown ausente, o min healthy time corto. Investiga y corrige.


Los seis detalles que separan cero-downtime real de aproximación

Cubrimos buena parte de ellos a lo largo del tutorial, pero vale la pena consolidar — porque uno solo de estos ausente convierte el esquema entero en "mostly zero-downtime", que es diferente.

  1. Connection draining en SIGTERM. Cuando el contenedor recibe señal de parada, la app marca /healthz como fallando inmediatamente, pero sigue aceptando conexiones en curso por algunos segundos. Sin esto, conexiones abiertas en el momento del stop son cortadas.
  2. Pre-stop hook si tienes worker asíncrono. Colas que procesan jobs en background necesitan pausa explícita antes de matar el proceso, o el job en ejecución queda huérfano. En Sidekiq, es el :quiet antes del :term. En Celery, es --soft-time-limit.
  3. Health check ANTES de promover, no "container running". docker ps muestra "running" milisegundos después del docker run. No significa nada. Promueve solo después de que /healthz retorne 200 sostenidamente.
  4. Min healthy time de diez segundos sostenidos. No vale ver un único 200 y seguir adelante — apps con warm-up irregular pasan un momento y vuelven a fallar.
  5. Versión anterior pre-pulled para rollback rápido. Si confiaste en "mantener la imagen antigua en cache de Docker", en algún momento es borrada por garbage collection y el rollback se vuelve lento. Mantén las últimas tres imágenes explícitamente.
  6. Auto-revert al pasar del healthy deadline. Sin esto, el deploy traba en un estado parcial — la mitad de los hosts en v2, la mitad en v1, sin nadie para decidir qué hacer.

Database migrations + cero-downtime (la parte que rompe el deploy de gente experimentada)

Este es el tópico que más veo a desarrollador senior errar. Rolling update asume que las dos versiones de la app corren simultáneamente en producción por algún período. Si la v2 espera schema incompatible con lo que la v1 entiende, alguna de las dos rompe durante la ventana de transición.

Regla de oro innegociable: migrations son siempre backward-compatible.

Caso clásico: quieres renombrar columna email a email_address. Solución equivocada: haces la migration que renombra directo antes del deploy. Resultado: durante el rolling, instancias v1 aún escriben en email (que ya no existe) y rompen. Solución correcta, en tres deploys:

DeployMigrationCódigo v*
1Añade email_address (nullable). Ninguna eliminación.App escribe en email Y en email_address; lee de email.
2Backfill: UPDATE users SET email_address = email WHERE email_address IS NULL. NOT NULL constraint.App lee de email_address; aún escribe en las dos.
3Drop email.App solo usa email_address.

Tres deploys, semanas de espaciamiento. Es tedioso, es la forma. Drop de columna directo siempre rompe. Cambio de tipo directo siempre rompe. Añadir NOT NULL sin default directo siempre rompe.

Herramientas que ayudan: pg-osc y pgroll (Postgres), gh-ost (MySQL) — hacen schema change online, sin lock largo. Para migrations ligeras, la forma manual en tres pasos resuelve.

Patrones más allá de rolling

Rolling update es el patrón default y más económico. Hay otros que vale la pena conocer:

  • Blue-green. Dos entornos paralelos completos — "blue" corriendo v1, "green" provisionado con v2 vacío. Subes v2 entero en green, validas, cambias DNS (o cutover del balanceador). Ventaja: rollback instantáneo (vuelve DNS a blue). Desventaja: cuesta el doble de recursos durante la ventana de deploy.
  • Canary. Manda 5% del tráfico a v2, observa métricas (errores, latencia, tasa de conversión), decide si promueve a 100% o aborta. Detecta bugs sutiles que health check no agarra — tipo regression en conversión de checkout. Exige proxy con weighted routing y observabilidad decente.
  • Rainbow / N+1. Generalización del blue-green con N versiones coexistiendo. Útil cuando quieres A/B test de larga duración entre versiones enteras.

Para el tutorial, rolling update es lo que tiene sentido. Los otros valen cuando el tamaño del tráfico justifica inversión extra.

Versión "fácil" — Coolify o Dokploy

Si no quieres scriptear, dos paneles modernos hacen rolling update automáticamente:

  • Coolify en modo multi-server hace rolling update con health check configurable. Multi-server fue añadido en las versiones más recientes — antes era single-server only. Vale la pena chequear la versión.
  • Dokploy sobre Docker Swarm hace rolling update con --update-parallelism 1 --update-delay. Aprovecha lo que Swarm ya ofrece.

Trade-off: cambias el script de cincuenta líneas (que entiende todo lo que está sucediendo) por un panel (que es más rápido para subir, pero se vuelve caja-negra cuando algo sale mal). Para equipo pequeño donde una persona cuida operación parcialmente, el panel gana. Para equipo donde necesitas entender exactamente qué pasó a las 3h de la mañana, el script gana.

Versión "robusta" — HeroCtl

Para quien quiere parar de scriptear pero no quiere caja-negra, HeroCtl combina rolling update automático con plano de control replicado. Describes el servicio en archivo de configuración y el orquestador hace el 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
    }
  }
}

Los mismos parámetros del script bash, declarativos. La diferencia es que el orquestador coordina rolling update entre N servidores (no solo dos), hace elección automática de coordinador en alrededor de siete segundos si el nodo actual se cae, y mantiene el plano de control distribuido entre los primeros tres servidores. Cluster sobrevive a la pérdida de cualquier servidor único sin intervención humana.

Instalación:

curl -sSL https://get.heroctl.com/install.sh | sh

Plan Community es gratuito permanente — sin límite de servidores o jobs, con todas las features de orquestación descritas en el tutorial. Plan Business añade SSO/SAML, RBAC granular, auditoría detallada y soporte con SLA, para equipos que tienen requisitos formales de plataforma. Plan Enterprise añade escrow de código fuente, contrato de continuidad y soporte 24×7. Los precios de Business y Enterprise están publicados en la página de planes — sin "habla con ventas" obligatorio.

Comparativo: cinco caminos lado a lado

CriterioScript bash (2 servers)Coolify multi-serverDokploy + SwarmHeroCtlKamalKubernetes
Tiempo de setup2-3h30 min1h5 min1h4h-4 días
Líneas de config~50 (script)UI~20~50~40300+
HA del plano de controlN/ANoLimitadoN/ASí (5+ componentes)
Health check declarativoManual
Rollback automáticoManual en el script
Escala objetivo1-3 servers1-10 servers1-20 servers1-500 servers1-10 servers50+ servers
¿Caja-negra?No (lo escribiste tú)ParcialNo (declarativo corto)No
Curva de aprendizajeBajaBajaMediaBajaBajaAlta

Cada columna tiene su nicho. Script bash es insuperable cuando quieres entender cada línea. Coolify gana cuando solo quieres un panel. HeroCtl gana cuando necesitas HA real sin montar plano de control externo. Kubernetes gana en escala planetaria — donde la complejidad compensa.

Los cinco errores más comunes

  1. Health check en / retornando 200 sin validar dependencias. La app retorna 200 antes de conectar a la base, el proxy promueve, y el usuario ve error 500 en las primeras requests. Solución: /healthz valida base, cache, cola — cualquier cosa que la app necesite para responder de verdad.
  2. Min healthy time de 1 segundo. Apps con warm-up irregular pueden retornar 200 en un momento y 503 justo después (cache poblando, clase siendo lazy-loaded). El orquestador promueve en la primera ventana buena, y la próxima request golpea en estado malo. Diez segundos sostenidos eliminan noventa por ciento de esos casos.
  3. Sin max_parallel (o max_parallel = N). Si cambias todas las instancias juntas, durante la ventana del cutover no hay nadie saludable sirviendo. Es single-server downtime disfrazado. Siempre max_parallel = 1 para empezar.
  4. Mix de versiones en producción sin schema compat. v1 escribe en email, v2 lee de email_address, y durante el rolling de cinco minutos las dos conviven — usuarios que reciben v2 no ven datos que v1 acaba de grabar. Migration backward-compatible en tres pasos resuelve.
  5. Cache stale en el cliente (CDN, browser, service worker). Backend ya es v2 pero el usuario tiene el JS de v1 en cache, y el JS antiguo llama a API que ya no existe. Solución: mantén endpoints antiguos por una ventana; versionado de API; cache-busting fuerte en assets críticos.

FAQ

¿Puedo hacer cero-downtime con un solo servidor?

No. Toda variación que prometa eso tiene ventana de error mensurable cuando la mides con hey -c 20. La única forma de tener cero-downtime real es mantener al menos una instancia siempre saludable durante todo el deploy — lo que exige dos máquinas como mínimo.

¿DNS round-robin funciona como balanceador?

Funciona como balanceador básico, pero no como mecanismo de health check. DNS no retira IP muerto de la rotación rápidamente — TTLs cacheando en ISPs y clientes mantienen el IP equivocado en uso por minutos u horas. Para cero-downtime necesitas un proxy real (Caddy, nginx, HAProxy) que retira instancia unhealthy del balanceo en segundos.

¿Caddy o Traefik — cuál es mejor para este setup?

Para dos servidores y un setup estático, Caddy es más simple — Caddyfile de quince líneas resuelve. Traefik brilla cuando tienes descubrimiento dinámico de servicios (tipo Docker labels o Consul) y muchos backends cambiando todo el tiempo. nginx queda en el medio: más flexible, sin TLS automático embebido (necesita certbot externo). Para este tutorial, Caddy.

¿Conexiones WebSocket sobreviven durante rolling update?

Conexiones abiertas en instancia que está siendo tirada son cortadas. El cliente tiene que reconectar. Buena biblioteca de WebSocket (Socket.IO, Phoenix Channels) reconecta automáticamente — usuario ve un parpadeo de medio segundo en el estado. Connection draining ayuda: la instancia marca /healthz fallando, el proxy deja de mandar conexiones nuevas, pero las existentes siguen hasta el pre-stop timer. Treinta segundos de drain suelen ser suficientes para que conexiones largas se vacíen naturalmente.

Database migrations — ¿cuál es la regla de oro?

Toda migration tiene que ser backward-compatible. Drop de columna nunca directo. Rename nunca directo. Cambio de tipo nunca directo. En su lugar, tres deploys: añade estructura nueva, backfill, elimina la antigua. Lento, sí. Pero el rolling update depende de eso para no romper.

Rollback automático — ¿cómo implementar?

Dos piezas: deadline (tiempo máximo esperando health check) y referencia de la imagen anterior pre-pulled. Si pasa del deadline sin estar saludable, el script reinstala la versión anterior. El ejemplo en el Paso 5 hace exactamente eso. En orquestadores declarativos, se vuelve auto_revert = true.

¿Sticky sessions complican el cero-downtime?

Sí. Si la app guarda estado de sesión en memoria del proceso, tirar la instancia tira las sesiones de los usuarios conectados a ella. Solución: saca sesión de la memoria — Redis, Postgres o JWT signed. Ahí cualquier instancia sirve a cualquier usuario, y rolling update no corta ninguna sesión.

¿Cuánto tiempo tarda un deploy completo?

Dos servidores, app que sube en quince segundos: cerca de un minuto. Detalle: pull de la imagen (5-15s, depende de la red y del tamaño), sustitución del contenedor (1s), warm-up + health check (10-30s), min healthy time de 10s, total unos 30-50s por host, multiplicado por dos hosts en secuencia = 1-2 min. Cuatro servidores quedan en torno a 2-4 min. Con cincuenta servidores, el deploy empieza a tomar diez o quince minutos — momento de aumentar max_parallel a dos o tres (manteniendo health check riguroso).


Cierre

El deploy cero-downtime es arquitectura, no herramienta. Los tres ingredientes — múltiples instancias, proxy con health check, rolling update controlado — funcionan con bash y Caddy tanto como con orquestador grande. La diferencia está en cuánto de la operación quieres escribir a mano y cuánto delegar.

Para un SaaS pequeño, tres VPS y un script de cincuenta líneas resuelven indefinidamente. Cuando el cluster crece a decenas de servidores o el equipo necesita HA real del plano de control, vale la pena subir al orquestador declarativo:

curl -sSL https://get.heroctl.com/install.sh | sh

Más sobre el algoritmo de rolling update en Rolling update seguro: por qué el tuyo puede no serlo. Para quien está saliendo de Compose a setup multi-servidor, Deploy Docker en producción: de compose a cluster cubre el camino intermedio.

Orquestación de contenedores, sin ceremonia.

#deploy#cero-downtime#tutorial#ingenieria