Todos los recursos

El docker compose up a secas detiene el contenedor viejo antes de que el nuevo esté listo, y cada request que cae en ese hueco recibe un rechazo. Este es el patrón de build-and-ship que uso en un solo VPS: Actions construye la imagen, la sube a un registro y luego entra por SSH para cambiar el contenedor. Con un health check de verdad y tags por SHA, el hueco se reduce a casi nada y un deploy malo se revierte en una línea. Terminas con un pipeline funcionando y claridad sobre dónde se esconde de verdad la caída.

Despliegues casi sin caída a un VPS desde GitHub Actions

En resumen

  • El build corre en CI, no en el VPS. El servidor solo hace pull de una imagen ya lista y recrea un contenedor, así un build lento nunca le quita aire a la máquina.
  • Etiqueta cada imagen con el SHA del commit, no solo con latest. Ese simple hábito es lo que convierte un rollback en un comando de una sola línea.
  • Un reverse proxy (Traefik) más un health check del contenedor es lo que achica el hueco del cambio. Con un solo servicio, Compose igual detiene el contenedor viejo antes de arrancar el nuevo, así que cuenta con una ventana breve a menos que corras dos réplicas.
  • Un usuario deploy dedicado y sin privilegios, con una llave SSH exclusiva para deploys, reduce el radio de impacto si se te filtra un secreto.
  • Los secretos viven en los secrets de GitHub Actions, nunca en el workflow ni en la imagen. No metas nada sensible dentro de una capa.

El «docker compose up -d» a secas te cuesta una ventana de caída, y la mayoría de los setups ni se enteran. El contenedor viejo se detiene antes de que el nuevo esté listo, y cada request que cae en ese hueco recibe un connection refused. ¿El sitio se cae de verdad durante un deploy? En la versión improvisada, sí, uno o dos segundos. Esta guía recorre el patrón exacto que uso en mis propias máquinas: GitHub Actions construye y sube la imagen, luego entra por SSH para cambiar el contenedor, y un reverse proxy más un health check achican el hueco para que los usuarios casi nunca noten el cambio. Al final vas a tener un pipeline funcionando, imágenes etiquetadas por SHA a las que puedes volver en segundos, y claridad sobre dónde están de verdad los puntos de falla.

Esto es a la vez un how-to y un por qué. Te voy a dar el workflow y la config de Compose listos para copiar y pegar, pero también te voy a decir qué línea carga con el peso del trabajo y dónde los equipos despliegan caídas sin darse cuenta. Si alguna vez viste un deploy "exitoso" mientras los usuarios recibían 502, la meta aquí es que eso sea casi imposible por diseño.

01 · Requisitos y el modelo mental

Antes de tocar YAML, ten claro el modelo, porque explica cada decisión que viene después. El build no ocurre en el VPS. Construir en el servidor pelea por el mismo CPU y la misma RAM que sirven el tráfico en vivo, y un «next build» pesado puede hundir la máquina a punta de swap. En cambio, CI construye la imagen una sola vez, la sube a un registro, y el VPS hace lo más barato posible: pull de una imagen ya lista y recrear un contenedor.

La segunda idea es que lo que limita la caída es el proxy, no Compose. Cuando recreas un contenedor, siempre hay un instante en que el viejo ya no está y el nuevo apenas está arrancando. Un proxy que reintenta un momento y solo enruta a un backend sano convierte ese instante en una pequeña demora en vez de un rechazo seco. Para que de verdad no haya hueco tienes que mantener vivo el contenedor viejo hasta que el nuevo pase un health check, lo que significa correr dos réplicas o un rolling update. Con un solo servicio y «docker compose up», cuenta con una ventana breve, solo que mucho más pequeña y más segura que la del setup improvisado.

Primero necesitas tener unas cuantas cosas listas:

  1. Un VPS al que puedas entrar por SSH, con Docker y el plugin de Docker Compose instalados.
  2. Un registro de contenedores. GitHub Container Registry (ghcr.io) es el camino de menor fricción desde Actions, mismas credenciales, misma organización.
  3. Un reverse proxy que ya termine el TLS y enrute por hostname. Aquí asumo Traefik, pero sirve cualquier proxy que enrute en función de un health check.
  4. Un usuario deploy dedicado en el VPS, dentro del grupo docker, con una llave SSH que se use solo para deploys.

Importante

Si tu proxy no le hace health check a la app antes de mandarle tráfico, no hay YAML ingenioso que mantenga limpio el deploy. Primero pon a funcionar el enrutamiento basado en health check; el pipeline es la mitad fácil.

Si todavía no tienes un proxy, monta Traefik una sola vez como su propio proyecto de Compose, dueño de los puertos 80/443 y de una red de Docker externa; de ahí en adelante cada app simplemente se conecta a esa red. El resto de esta guía da por hecho que ese setup inicial ya está hecho.

02 · El usuario deploy y una llave SSH con un solo trabajo

Aguanta las ganas de desplegar como root con tu llave SSH personal. Si esa llave se filtra, en un log, una Action mal configurada, una laptop robada, el atacante se adueña de la máquina. Mejor crea una identidad con un único propósito.

En el VPS, como admin:

# crea un usuario sin privilegios que pueda manejar Docker
sudo adduser --disabled-password --gecos "" deploy
sudo usermod -aG docker deploy

# dale el directorio de la app y nada más
sudo mkdir -p /srv/web
sudo chown deploy:deploy /srv/web

Luego, en tu máquina, genera una llave exclusivamente para este pipeline y autoriza solo esa:

ssh-keygen -t ed25519 -C "gha-deploy" -f ./gha_deploy -N ""
# copia gha_deploy.pub dentro de /home/deploy/.ssh/authorized_keys en el VPS

La llave privada (gha_deploy) va a GitHub como secreto; la pública vive en el servidor. Ninguna de tus credenciales personales toca CI.

Atención

Estar en el grupo docker es prácticamente ser root en el host. Quien pueda correr contenedores puede montar el sistema de archivos. Está bien para un usuario deploy cuya llave solo vive en los secrets de GitHub, pero justo por eso esa llave nunca debe reutilizarse para nada más, y por eso el usuario no tiene login con contraseña.

03 · Etiqueta por SHA del commit, o no podrás hacer rollback

Este es el único hábito que separa un deploy en el que confías de uno al que le cruzas los dedos: etiqueta cada imagen con el SHA del commit, no solo con latest. Cuando cada build sobrescribe latest, "la versión anterior" deja de existir. Tendrías que reconstruirla desde el historial de git y bajo presión. Con un tag por SHA, el rollback es simplemente apuntar el servidor a una imagen que todavía existe.

El archivo de Compose referencia el tag, así el mismo archivo sirve tanto para un deploy normal como para un rollback:

# /srv/web/compose.yml en el VPS
services:
  web:
    image: ghcr.io/me/web:TAG_PLACEHOLDER
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s
    networks: [edge]
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.web.rule=Host(`example.com`)"
      - "traefik.http.routers.web.entrypoints=websecure"
      - "traefik.http.routers.web.tls.certresolver=le"
      - "traefik.http.services.web.loadbalancer.server.port=3000"
networks:
  edge:
    external: true

Donde dice TAG_PLACEHOLDER, el paso de deploy sustituye el SHA real en tiempo de ejecución (lo verás enseguida). El bloque healthcheck no es adorno. Es lo que Traefik y Compose usan para saber que el contenedor nuevo de verdad está sirviendo antes de mover el tráfico. Tu app necesita una ruta liviana /api/health que devuelva 200 solo cuando de verdad está lista (DB accesible, config cargada), no apenas cuando el proceso ya levantó.

04 · El workflow: build, push y avanzar

Ahora el pipeline en sí. Hace tres cosas en orden: construir y etiquetar la imagen con el SHA, subirla a ghcr.io, y luego entrar por SSH y recrear el contenedor con ese tag exacto.

name: deploy
on:
  push:
    branches: [main]
concurrency:
  group: deploy-prod
  cancel-in-progress: false
jobs:
  ship:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - name: Log in to ghcr
        run: echo "$REG_PAT" | docker login ghcr.io -u "$ACTOR" --password-stdin
        env:
          REG_PAT: SECRET( secrets.GITHUB_TOKEN )
          ACTOR: SECRET( github.actor )

      - name: Build and push (SHA + latest)
        run: |
          IMG=ghcr.io/me/web
          docker build -t "$IMG:SECRET( github.sha )" -t "$IMG:latest" .
          docker push "$IMG:SECRET( github.sha )"
          docker push "$IMG:latest"

      - name: Roll forward over SSH
        uses: appleboy/ssh-action@v1
        with:
          host: SECRET( secrets.VPS_HOST )
          username: deploy
          key: SECRET( secrets.VPS_KEY )
          script: |
            cd /srv/web
            sed -i "s|ghcr.io/me/web:.*|ghcr.io/me/web:SECRET( github.sha )|" compose.yml
            docker compose pull web
            docker compose up -d --wait web

Hay unas cuantas líneas que hacen más de lo que aparentan:

  • concurrency con cancel-in-progress: false hace que dos pushes a main se encolen en vez de pelearse por el servidor. Nunca terminas con media parte del deploy A y media del deploy B vivas a la vez.
  • permissions: packages: write junto con el GITHUB_TOKEN integrado te deja subir a ghcr.io sin tener que manejar un token de registro aparte.
  • docker compose up -d --wait es el héroe silencioso. La bandera --wait bloquea hasta que el contenedor nuevo se reporte healthy (vía el healthcheck de la sección 03). Si nunca llega a healthy, el comando sale con código distinto de cero, la Action falla a lo grande, y te enteras de que el deploy no entró, en lugar de un check verde encima de un sitio roto.

Nota

Donde dice SECRET( ... ), usa la sintaxis normal de expresiones de GitHub Actions, dobles llaves alrededor de la referencia. Aquí se escribe así solo para que el ejemplo sobreviva al estar guardado dentro de un archivo de código fuente.

Guardar los secretos

Dos valores van a los secrets de Actions del repositorio, nunca al archivo: VPS_HOST (la IP o el hostname del servidor) y VPS_KEY (el contenido de la llave privada gha_deploy de la sección 02). Nada más, porque el login al registro usa el GITHUB_TOKEN automático. Configúralos en Settings, luego Secrets and variables, luego Actions.

05 · Dónde se esconde realmente la caída (y el rollback)

La gente asume que el momento riesgoso es el docker compose up. Normalmente no lo es. Compose con --wait y un healthcheck de verdad vuelve esa parte segura. La caída se esconde en tres lugares más discretos.

Primero, el healthcheck miente. Si /api/health devuelve 200 en el instante en que arranca el proceso, antes de que el pool de la DB esté conectado o de que hayan corrido las migraciones, Compose marca el contenedor como healthy y el tráfico entra a una app que tira 500. Haz que la ruta de health verifique las cosas que de verdad importan para responder un request.

Segundo, las migraciones de base de datos. Un deploy que elimina una columna que el contenedor viejo todavía lee va a romper la versión vieja durante la ventana del cambio, y un contenedor nuevo que necesita una columna que aún no existe va a fallar su healthcheck. El arreglo es el patrón expand/contract: despliega primero cambios de esquema compatibles hacia atrás (agrega la columna, despliega código que escribe en ambas), y luego elimina la forma vieja en un deploy posterior. Nunca acoples una migración destructiva al mismo deploy que depende de ella.

Tercero, el rollback que nunca probaste. Como cada imagen está etiquetada por SHA y sigue en el registro, el rollback es un solo comando por SSH:

# en el VPS, apunta a un SHA bueno conocido y recrea
cd /srv/web
sed -i "s|ghcr.io/me/web:.*|ghcr.io/me/web:9a3f1c2|" compose.yml
docker compose up -d --wait web

Corre eso una vez a propósito, a plena luz del día, antes de tener que usarlo en caliente. Un procedimiento de rollback que nunca ejecutaste es una ilusión, no un plan.

Consejo

Mantén una política de retención corta en el registro, los últimos 10 tags por SHA, más o menos, para que los rollbacks siempre tengan dónde aterrizar pero las imágenes viejas no se acumulen para siempre. ghcr.io tiene reglas de retención de paquetes que puedes configurar por repositorio.

Un ejemplo completo del flujo: subes a main un fix de un typo. Actions construye ghcr.io/me/web:9a3f1c2, la sube, entra por SSH, reescribe el tag en compose.yml, hace pull y corre up -d --wait. El contenedor nuevo arranca, su /api/health pasa a los 4 segundos, Compose devuelve éxito, Traefik mueve el tráfico hacia él, y el contenedor viejo se elimina. Caída visible para el usuario: una ventana de menos de un segundo a lo sumo, y cero si corriste dos réplicas. Si /api/health hubiera fallado, --wait habría dado error, la Action quedaría en rojo, y el contenedor viejo seguiría sirviendo mientras investigas.

El patrón es deliberadamente aburrido, y ahí está justo la gracia. Sin Kubernetes, sin un daemon de deploy hecho a la medida, solo CI que construye, un registro que recuerda, y un proxy que espera a que esté healthy antes de cambiar. Los dos hábitos que lo vuelven confiable son etiquetar por SHA y mantener honesto el healthcheck. Acierta en esos dos y un solo VPS desplegará con la misma calma que una plataforma que cuesta diez veces más.

Puntos clave

  • Construye en CI y sube una imagen; deja que el VPS solo haga pull y recree. Nunca construyas en la máquina que sirve el tráfico.
  • Etiqueta cada imagen con el SHA del commit; ese hábito es lo que vuelve el rollback un solo comando en vez de un rebuild a la carrera.
  • El casi sin caída sale del proxy más un healthcheck honesto y el docker compose up --wait. Para un hueco de cero, corre dos réplicas para que el contenedor viejo siga vivo hasta que el nuevo esté healthy.
  • La caída real se esconde en healthchecks que mienten y en migraciones destructivas. Usa expand/contract y verifica readiness, no liveness.
  • Usa un usuario deploy con un único propósito y una llave SSH exclusiva para deploys en los secrets de GitHub; no metas nada sensible en la imagen.

Preguntas frecuentes

¿El docker compose up -d ya es sin caída?

No. Compose a secas detiene el contenedor viejo y arranca el nuevo, y hay una ventana en la que ninguno de los dos está sirviendo. Dos piezas achican esa ventana: la bandera --wait, que bloquea hasta que el contenedor nuevo pase su healthcheck, y un reverse proxy que reintenta y solo enruta a un backend sano. Para que de verdad no haya hueco tienes que mantener vivo el contenedor viejo hasta que el nuevo esté healthy, lo que significa dos réplicas o un rolling update. Con un solo servicio, cuenta con una ventana breve, solo que mucho más pequeña que la del setup improvisado.

¿Por qué construir en CI en vez de construir directo en el VPS?

Porque construir pelea por el mismo CPU y la misma RAM que sirven el tráfico en vivo. Un build pesado puede disparar la memoria lo suficiente para mandar la máquina a swap y degradar el sitio en producción, y si el build falla ya afectaste producción. Construir en CI deja al VPS haciendo una sola cosa barata: pull de una imagen terminada y recrear un contenedor. El servidor nunca tiene que saber nada de tu toolchain.

¿De verdad necesito tags por SHA si tengo latest?

Si en algún momento quieres hacer rollback, sí. Cuando cada build sobrescribe latest, la versión anterior deja de existir como artefacto. Tendrías que reconstruirla desde un commit específico y con producción rota. Un tag por SHA significa que la imagen anterior sigue ahí en el registro, así que el rollback es una sola línea que apunta el servidor a ella. Mantén ambos: latest por comodidad, el SHA por seguridad.

¿Qué pasa con las migraciones de base de datos durante el swap?

De aquí viene la mayor parte de la caída real, no del cambio de contenedor. Usa el patrón expand/contract: despliega primero cambios de esquema compatibles hacia atrás (agrega una columna, sube código que escribe en la forma vieja y en la nueva), y luego elimina la forma vieja en un deploy posterior. Una migración que elimina una columna que el contenedor viejo todavía lee va a romper la versión que aún está sirviendo durante el cambio. Nunca acoples una migración destructiva al deploy que depende de ella.

¿Por qué un usuario deploy dedicado en vez de root?

Para reducir el radio de impacto si la llave SSH se filtra. Una llave exclusiva para deploys que maneja un usuario sin privilegios significa que, si se filtra, lo único que consigue es un deploy de contenedor, no un shell de root sobre toda tu infraestructura. Sí, el grupo docker es prácticamente root en ese host, eso es inevitable en un deploy con Docker, pero la llave tiene un único propósito, vive solo en los secrets de GitHub, y el usuario no tiene login con contraseña, así que no es una credencial que reutilices en otro lado.

¿No es exagerado para un solo VPS? ¿Por qué no Kubernetes o un PaaS?

Es justo lo contrario de exagerado, y ahí está la gracia. Para una sola máquina, Kubernetes trae muchas más piezas móviles de las que el problema realmente tiene, y un PaaS cambia dinero y lock-in por una comodidad que puedes reproducir en unas cincuenta líneas de YAML. Este patrón es CI que construye, un registro que recuerda, y un proxy que espera a que esté healthy. Te da deploys casi sin caída y rollbacks rápidos sin un control plane que andar cuidando. Pásate a una plataforma cuando un solo servidor se te quede corto, no antes.

¿Prefieres que lo hagamos por ti?

Esto mismo lo construimos para negocios como el tuyo. La primera conversación es gratis y sin compromiso.

Escríbenos por WhatsApp

Escríbenos por WhatsApp

Escanéalo con tu teléfono para escribirnos por WhatsApp.

Escanéalo con tu teléfono para escribirnos por WhatsApp.

¿Estás desde el teléfono y no puedes escanear? Escríbenos a info@ilustrari.com

Primera conversación gratis. Te responde el fundador.

Recursos relacionados