Saltar al contenido
·32 min de lectura

GitHub Actions CI/CD: despliegues sin downtime que realmente funcionan

Mi setup completo de GitHub Actions: jobs de tests en paralelo, caché de builds Docker, despliegue por SSH a VPS, zero-downtime con PM2 reload, gestión de secretos y los patrones de workflow que he refinado en dos años.

Compartir:X / TwitterLinkedIn

Todos los proyectos en los que he trabajado eventualmente llegan al mismo punto de inflexión: el proceso de despliegue se vuelve demasiado doloroso para hacerlo manualmente. Te olvidas de ejecutar los tests. Haces el build localmente pero olvidas incrementar la versión. Te conectas por SSH a producción y te das cuenta de que la última persona que desplegó dejó un archivo .env obsoleto.

GitHub Actions resolvió esto para mí hace dos años. No perfectamente el primer día — el primer workflow que escribí fue una pesadilla de 200 líneas de YAML que se agotaba el tiempo la mitad de las veces y no cacheaba nada. Pero iteración tras iteración, llegué a algo que despliega este sitio de forma fiable, con cero downtime, en menos de cuatro minutos.

Este es ese workflow, explicado sección por sección. No la versión de la documentación. La versión que sobrevive al contacto con producción.

Entendiendo los bloques fundamentales#

Antes de meternos en el pipeline completo, necesitas un modelo mental claro de cómo funciona GitHub Actions. Si has usado Jenkins o CircleCI, olvida la mayor parte de lo que sabes. Los conceptos se mapean vagamente, pero el modelo de ejecución es lo suficientemente diferente como para confundirte.

Triggers: cuándo se ejecuta tu workflow#

yaml
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: "0 6 * * 1" # Every Monday at 6 AM UTC
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        default: "staging"
        type: choice
        options:
          - staging
          - production

Cuatro triggers, cada uno sirviendo un propósito diferente:

  • push a main es tu trigger de despliegue a producción. ¿Código mergeado? Despliégalo.
  • pull_request ejecuta tus checks de CI en cada PR. Aquí es donde viven lint, verificación de tipos y tests.
  • schedule es cron para tu repo. Lo uso para escaneos semanales de auditoría de dependencias y limpieza de caché obsoleta.
  • workflow_dispatch te da un botón manual de "Desplegar" en la UI de GitHub con parámetros de entrada. Invaluable cuando necesitas desplegar staging sin un cambio de código — quizás actualizaste una variable de entorno o necesitas volver a descargar una imagen Docker base.

Algo que atrapa a la gente: pull_request se ejecuta contra el merge commit, no contra el HEAD de la rama del PR. Esto significa que tu CI está probando cómo se verá el código después del merge. Eso es realmente lo que quieres, pero sorprende a la gente cuando una rama verde se pone roja después de un rebase.

Jobs, Steps y Runners#

yaml
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint

Jobs se ejecutan en paralelo por defecto. Cada job obtiene una VM nueva (el "runner"). ubuntu-latest te da una máquina razonablemente potente — 4 vCPUs, 16 GB RAM a partir de 2026. Eso es gratis para repos públicos, 2000 minutos/mes para privados.

Steps se ejecutan secuencialmente dentro de un job. Cada paso uses: incorpora una acción reutilizable del marketplace. Cada paso run: ejecuta un comando de shell.

La flag --frozen-lockfile es crucial. Sin ella, pnpm install podría actualizar tu lockfile durante CI, lo que significa que no estás probando las mismas dependencias que tu desarrollador committeó. He visto esto causar fallos de tests fantasma que desaparecen localmente porque el lockfile en la máquina del desarrollador ya está correcto.

Variables de entorno vs Secretos#

yaml
env:
  NODE_ENV: production
  NEXT_TELEMETRY_DISABLED: 1
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
        run: |
          echo "$SSH_PRIVATE_KEY" > key.pem
          chmod 600 key.pem
          ssh -i key.pem deploy@$DEPLOY_HOST "cd /var/www/app && ./deploy.sh"

Las variables de entorno establecidas con env: a nivel de workflow son texto plano, visibles en los logs. Úsalas para configuración no sensible: NODE_ENV, flags de telemetría, toggles de funcionalidades.

Los secretos (${{ secrets.X }}) están encriptados en reposo, enmascarados en los logs, y solo disponibles para workflows en el mismo repo. Se configuran en Settings > Secrets and variables > Actions.

La línea environment: production es significativa. Los Environments de GitHub te permiten acotar secretos a targets de despliegue específicos. Tu clave SSH de staging y tu clave SSH de producción pueden llamarse ambas SSH_PRIVATE_KEY pero contener valores diferentes dependiendo de qué environment el job apunta. Esto también desbloquea revisores requeridos — puedes condicionar despliegues a producción tras una aprobación manual.

El pipeline completo de CI#

Así es como estructuro la mitad de CI del pipeline. El objetivo: detectar cada categoría de error en el menor tiempo posible.

yaml
name: CI
 
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
 
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true
 
jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
 
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm tsc --noEmit
 
  test:
    name: Unit Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm test -- --coverage
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7
 
  build:
    name: Build
    needs: [lint, typecheck, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: .next/
          retention-days: 1

Por qué esta estructura#

Lint, typecheck y test se ejecutan en paralelo. No tienen dependencias entre sí. Un error de tipos no bloquea la ejecución del lint, y un test fallido no necesita esperar al verificador de tipos. En una ejecución típica, los tres se completan en 30-60 segundos mientras corren simultáneamente.

Build espera a los tres. La línea needs: [lint, typecheck, test] significa que el job de build solo comienza si lint, typecheck Y test pasan todos. No tiene sentido construir un proyecto que tiene errores de lint o fallos de tipos.

concurrency con cancel-in-progress: true es un gran ahorro de tiempo. Si haces push de dos commits en rápida sucesión, la primera ejecución de CI se cancela. Sin esto, tendrás ejecuciones obsoletas consumiendo tu presupuesto de minutos y desordenando la UI de checks.

Subida de cobertura con if: always() significa que obtienes el reporte de cobertura incluso cuando los tests fallan. Esto es útil para depuración — puedes ver qué tests fallaron y qué cubrieron.

Fail-Fast vs. dejar que todos se ejecuten#

Por defecto, si un job en una matrix falla, GitHub cancela los demás. Para CI, realmente quiero este comportamiento — si lint falla, no me importan los resultados de los tests. Corrige el lint primero.

Pero para matrices de tests (digamos, probando en Node 20 y Node 22), podrías querer ver todos los fallos a la vez:

yaml
test:
  strategy:
    fail-fast: false
    matrix:
      node-version: [20, 22]
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: pnpm/action-setup@v4
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ matrix.node-version }}
        cache: "pnpm"
    - run: pnpm install --frozen-lockfile
    - run: pnpm test

fail-fast: false deja que ambas ramas de la matrix se completen. Si Node 22 falla pero Node 20 pasa, ves esa información inmediatamente en lugar de tener que volver a ejecutar.

Caché para velocidad#

La mejora individual más grande que puedes hacer a la velocidad del CI es el caché. Un pnpm install en frío en un proyecto mediano toma 30-45 segundos. Con caché caliente, toma 3-5 segundos. Multiplica eso por cuatro jobs paralelos y estás ahorrando dos minutos en cada ejecución.

Caché del store de pnpm#

yaml
- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: "pnpm"

Esta sola línea cachea el store de pnpm (~/.local/share/pnpm/store). Al encontrar el caché, pnpm install --frozen-lockfile simplemente hace hard-links desde el store en lugar de descargar. Esto solo reduce el tiempo de instalación en un 80% en ejecuciones repetidas.

Si necesitas más control — digamos, quieres cachear basándote en el SO también — usa actions/cache directamente:

yaml
- uses: actions/cache@v4
  with:
    path: |
      ~/.local/share/pnpm/store
      node_modules
    key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
    restore-keys: |
      pnpm-${{ runner.os }}-

El fallback restore-keys es importante. Si pnpm-lock.yaml cambia (nueva dependencia), la clave exacta no coincidirá, pero la coincidencia por prefijo aún restaurará la mayoría de los paquetes cacheados. Solo la diferencia se descarga.

Caché de build de Next.js#

Next.js tiene su propia caché de build en .next/cache. Cachear esto entre ejecuciones significa builds incrementales — solo las páginas y componentes cambiados se recompilan.

yaml
- uses: actions/cache@v4
  with:
    path: .next/cache
    key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
    restore-keys: |
      nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
      nextjs-${{ runner.os }}-

Esta estrategia de clave de tres niveles significa:

  1. Coincidencia exacta: mismas dependencias Y mismos archivos fuente. Hit completo de caché, build casi instantáneo.
  2. Coincidencia parcial (dependencias): mismas dependencias pero fuente cambiado. Build recompila solo archivos cambiados.
  3. Coincidencia parcial (solo SO): dependencias cambiaron. Build reutiliza lo que puede.

Números reales de mi proyecto: build en frío toma ~55 segundos, build con caché toma ~15 segundos. Eso es una reducción del 73%.

Caché de capas Docker#

Los builds Docker son donde el caché se vuelve realmente impactante. Un build completo de Next.js con Docker — instalando dependencias del SO, copiando fuente, ejecutando pnpm install, ejecutando next build — toma 3-4 minutos en frío. Con caché de capas, son 30-60 segundos.

yaml
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

type=gha usa el backend de caché integrado de GitHub Actions. mode=max cachea todas las capas, no solo las finales. Esto es crítico para builds multi-stage donde las capas intermedias (como pnpm install) son las más costosas de reconstruir.

Caché remota de Turborepo#

Si estás en un monorepo con Turborepo, el caché remoto es transformador. El primer build sube los resultados de las tareas al caché. Los builds subsiguientes descargan en lugar de recalcular.

yaml
- run: pnpm turbo build --remote-only
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}

He visto tiempos de CI de monorepo bajar de 8 minutos a 90 segundos con caché remota de Turbo. La trampa: requiere una cuenta de Vercel o un servidor Turbo auto-hospedado. Para repos de una sola app, es excesivo.

Build y push de Docker#

Si estás desplegando a un VPS (o cualquier servidor), Docker te da builds reproducibles. La misma imagen que corre en CI es la misma imagen que corre en producción. No más "funciona en mi máquina" porque la máquina es la imagen.

Dockerfile multi-stage#

Antes de ir al workflow, aquí está el Dockerfile que uso para Next.js:

dockerfile
# Stage 1: Dependencies
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false
 
# Stage 2: Build
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
 
# Stage 3: Production
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
 
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
 
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]

Tres stages, separación clara. La imagen final es ~150MB en lugar de los ~1.2GB que obtendrías copiando todo. Solo los artefactos de producción llegan al stage runner.

El workflow de build-and-push#

yaml
name: Build and Push Docker Image
 
on:
  push:
    branches: [main]
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=ref,event=branch
            type=raw,value=latest,enable={{is_default_branch}}
 
      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Permíteme desglosar las decisiones importantes aquí.

GitHub Container Registry (ghcr.io)#

Uso ghcr.io en lugar de Docker Hub por tres razones:

  1. La autenticación es gratuita. GITHUB_TOKEN está automáticamente disponible en cada workflow — no necesitas almacenar credenciales de Docker Hub.
  2. Proximidad. Las imágenes se descargan desde la misma infraestructura donde corre tu CI. Los pulls durante CI son rápidos.
  3. Visibilidad. Las imágenes están vinculadas a tu repo en la UI de GitHub. Las ves en la pestaña Packages.

Builds multi-plataforma#

yaml
platforms: linux/amd64,linux/arm64

Esta línea añade quizás 90 segundos a tu build, pero vale la pena. Las imágenes ARM64 corren nativamente en:

  • Macs con Apple Silicon (M1/M2/M3/M4) durante desarrollo local con Docker Desktop
  • Instancias AWS Graviton (20-40% más baratas que equivalentes x86)
  • El tier gratuito ARM de Oracle Cloud

Sin esto, tus desarrolladores en Macs de la serie M están ejecutando imágenes x86 a través de emulación Rosetta. Funciona, pero es notablemente más lento y ocasionalmente aparecen bugs raros específicos de arquitectura.

QEMU proporciona la capa de compilación cruzada. Buildx orquesta el build multi-arch y empuja un manifest list para que Docker automáticamente descargue la arquitectura correcta.

Estrategia de etiquetado#

yaml
tags: |
  type=sha,prefix=
  type=ref,event=branch
  type=raw,value=latest,enable={{is_default_branch}}

Cada imagen obtiene tres etiquetas:

  • abc1234 (SHA del commit): Inmutable. Siempre puedes desplegar un commit exacto.
  • main (nombre de rama): Mutable. Apunta al último build de esa rama.
  • latest: Mutable. Solo se establece en la rama por defecto. Esto es lo que tu servidor descarga.

Nunca despliegues latest en producción sin también registrar el SHA en algún lugar. Cuando algo se rompe, necesitas saber cuál latest. Yo almaceno el SHA desplegado en un archivo en el servidor que el endpoint de health lee.

Despliegue SSH a VPS#

Aquí es donde todo se une. CI pasa, la imagen Docker está construida y subida, ahora necesitamos decirle al servidor que descargue la nueva imagen y reinicie.

La acción SSH#

yaml
deploy:
  name: Deploy to Production
  needs: [build-and-push]
  runs-on: ubuntu-latest
  environment: production
 
  steps:
    - name: Deploy via SSH
      uses: appleboy/ssh-action@v1
      with:
        host: ${{ secrets.DEPLOY_HOST }}
        username: ${{ secrets.DEPLOY_USER }}
        key: ${{ secrets.SSH_PRIVATE_KEY }}
        port: ${{ secrets.SSH_PORT }}
        script_stop: true
        script: |
          set -euo pipefail
 
          APP_DIR="/var/www/akousa.net"
          IMAGE="ghcr.io/${{ github.repository }}:latest"
          DEPLOY_SHA="${{ github.sha }}"
 
          echo "=== Deploying $DEPLOY_SHA ==="
 
          # Pull the latest image
          docker pull "$IMAGE"
 
          # Stop and remove old container
          docker stop akousa-app || true
          docker rm akousa-app || true
 
          # Start new container
          docker run -d \
            --name akousa-app \
            --restart unless-stopped \
            --network host \
            -e NODE_ENV=production \
            -e DATABASE_URL="${DATABASE_URL}" \
            -p 3000:3000 \
            "$IMAGE"
 
          # Wait for health check
          echo "Waiting for health check..."
          for i in $(seq 1 30); do
            if curl -sf http://localhost:3000/api/health > /dev/null 2>&1; then
              echo "Health check passed on attempt $i"
              break
            fi
            if [ "$i" -eq 30 ]; then
              echo "Health check failed after 30 attempts"
              exit 1
            fi
            sleep 2
          done
 
          # Record deployed SHA
          echo "$DEPLOY_SHA" > "$APP_DIR/.deployed-sha"
 
          # Prune old images
          docker image prune -af --filter "until=168h"
 
          echo "=== Deploy complete ==="

La alternativa del script de despliegue#

Para cualquier cosa más allá de un simple pull-and-restart, muevo la lógica a un script en el servidor en lugar de incluirla directamente en el workflow:

bash
#!/bin/bash
# /var/www/akousa.net/deploy.sh
set -euo pipefail
 
APP_DIR="/var/www/akousa.net"
LOG_FILE="$APP_DIR/deploy.log"
IMAGE="ghcr.io/akousa/akousa-net:latest"
 
log() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
 
log "Starting deployment..."
 
# Login to GHCR
echo "$GHCR_TOKEN" | docker login ghcr.io -u akousa --password-stdin
 
# Pull with retry
for attempt in 1 2 3; do
  if docker pull "$IMAGE"; then
    log "Image pulled successfully on attempt $attempt"
    break
  fi
  if [ "$attempt" -eq 3 ]; then
    log "ERROR: Failed to pull image after 3 attempts"
    exit 1
  fi
  log "Pull attempt $attempt failed, retrying in 5s..."
  sleep 5
done
 
# Health check function
health_check() {
  local port=$1
  local max_attempts=30
  for i in $(seq 1 $max_attempts); do
    if curl -sf "http://localhost:$port/api/health" > /dev/null 2>&1; then
      return 0
    fi
    sleep 2
  done
  return 1
}
 
# Start new container on alternate port
docker run -d \
  --name akousa-app-new \
  --env-file "$APP_DIR/.env.production" \
  -p 3001:3000 \
  "$IMAGE"
 
# Verify new container is healthy
if ! health_check 3001; then
  log "ERROR: New container failed health check. Rolling back."
  docker stop akousa-app-new || true
  docker rm akousa-app-new || true
  exit 1
fi
 
log "New container healthy. Switching traffic..."
 
# Switch Nginx upstream
sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
 
# Stop old container
docker stop akousa-app || true
docker rm akousa-app || true
 
# Rename new container
docker rename akousa-app-new akousa-app
 
log "Deployment complete."

El workflow entonces se convierte en un solo comando SSH:

yaml
script: |
  cd /var/www/akousa.net && ./deploy.sh

Esto es mejor porque: (1) la lógica de despliegue está versionada en el servidor, (2) puedes ejecutarla manualmente por SSH para depuración, y (3) no tienes que escapar YAML dentro de YAML dentro de bash.

Estrategias de zero-downtime#

"Zero downtime" suena a lenguaje de marketing, pero tiene un significado preciso: ninguna solicitud recibe un connection refused o un 502 durante el despliegue. Aquí hay tres enfoques reales, del más simple al más robusto.

Estrategia 1: PM2 Cluster Mode Reload#

Si estás ejecutando Node.js directamente (no en Docker), el modo cluster de PM2 te da el camino más fácil a zero-downtime.

bash
# ecosystem.config.js already has:
#   instances: 2
#   exec_mode: "cluster"
 
pm2 reload akousa --update-env

pm2 reload (no restart) hace un reinicio gradual. Levanta nuevos workers, espera a que estén listos, luego mata los workers viejos uno por uno. En ningún momento hay cero workers sirviendo tráfico.

La flag --update-env recarga las variables de entorno desde la configuración del ecosystem. Sin ella, tu env anterior persiste incluso después de un despliegue que cambió .env.

En tu workflow:

yaml
- name: Deploy and reload PM2
  uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.DEPLOY_HOST }}
    username: ${{ secrets.DEPLOY_USER }}
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    script: |
      cd /var/www/akousa.net
      git pull origin main
      pnpm install --frozen-lockfile
      pnpm build
      pm2 reload ecosystem.config.js --update-env

Esto es lo que uso para este sitio. Es simple, fiable, y el downtime es literalmente cero — lo he probado con un generador de carga ejecutando 100 req/s durante los despliegues. Ni un solo 5xx.

Estrategia 2: Blue/Green con Nginx Upstream#

Para despliegues con Docker, blue/green te da una separación limpia entre la versión vieja y la nueva.

El concepto: ejecutar el contenedor viejo ("blue") en el puerto 3000 y el contenedor nuevo ("green") en el puerto 3001. Nginx apunta a blue. Inicias green, verificas que está sano, cambias Nginx a green, luego detienes blue.

Configuración de upstream de Nginx:

nginx
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
    server 127.0.0.1:3000;
}
nginx
# /etc/nginx/sites-available/akousa.net
server {
    listen 443 ssl http2;
    server_name akousa.net;
 
    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

El script de cambio:

bash
#!/bin/bash
set -euo pipefail
 
CURRENT_PORT=$(grep -oP 'server 127\.0\.0\.1:\K\d+' /etc/nginx/conf.d/upstream.conf)
 
if [ "$CURRENT_PORT" = "3000" ]; then
  NEW_PORT=3001
  OLD_PORT=3000
else
  NEW_PORT=3000
  OLD_PORT=3001
fi
 
echo "Current: $OLD_PORT -> New: $NEW_PORT"
 
# Start new container on the alternate port
docker run -d \
  --name "akousa-app-$NEW_PORT" \
  --env-file /var/www/akousa.net/.env.production \
  -p "$NEW_PORT:3000" \
  "ghcr.io/akousa/akousa-net:latest"
 
# Wait for health
for i in $(seq 1 30); do
  if curl -sf "http://localhost:$NEW_PORT/api/health" > /dev/null; then
    echo "New container healthy on port $NEW_PORT"
    break
  fi
  [ "$i" -eq 30 ] && { echo "Health check failed"; docker stop "akousa-app-$NEW_PORT"; docker rm "akousa-app-$NEW_PORT"; exit 1; }
  sleep 2
done
 
# Switch Nginx
sudo sed -i "s/server 127.0.0.1:$OLD_PORT/server 127.0.0.1:$NEW_PORT/" /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
 
# Stop old container
sleep 5  # Let in-flight requests complete
docker stop "akousa-app-$OLD_PORT" || true
docker rm "akousa-app-$OLD_PORT" || true
 
echo "Switched from :$OLD_PORT to :$NEW_PORT"

El sleep de 5 segundos después del reload de Nginx no es pereza — es tiempo de gracia. El reload de Nginx es graceful (las conexiones existentes se mantienen abiertas), pero algunas conexiones de long-polling o respuestas de streaming necesitan tiempo para completarse.

Estrategia 3: Docker Compose con Health Checks#

Para un enfoque más estructurado, Docker Compose puede gestionar el intercambio blue/green:

yaml
# docker-compose.yml
services:
  app:
    image: ghcr.io/akousa/akousa-net:latest
    restart: unless-stopped
    env_file: .env.production
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s
    deploy:
      replicas: 2
      update_config:
        parallelism: 1
        delay: 10s
        order: start-first
        failure_action: rollback
      rollback_config:
        parallelism: 0
        order: stop-first
    ports:
      - "3000:3000"

La línea clave es order: start-first. Significa "iniciar el contenedor nuevo antes de detener el viejo." Combinado con parallelism: 1, obtienes una actualización gradual — un contenedor a la vez, siempre manteniendo capacidad.

Despliega con:

bash
docker compose pull
docker compose up -d --remove-orphans

Docker Compose vigila el healthcheck y no enrutará tráfico al contenedor nuevo hasta que pase. Si el healthcheck falla, failure_action: rollback automáticamente revierte a la versión anterior. Esto es lo más cercano a despliegues graduales al estilo Kubernetes que puedes lograr en un solo VPS.

Gestión de secretos#

La gestión de secretos es una de esas cosas que es fácil hacer "mayormente bien" y catastróficamente mal en los casos límite restantes.

GitHub Secrets: lo básico#

yaml
# Set via GitHub UI: Settings > Secrets and variables > Actions
 
steps:
  - name: Use a secret
    env:
      DB_URL: ${{ secrets.DATABASE_URL }}
    run: |
      # The value is masked in logs
      echo "Connecting to database..."
      # This would print "Connecting to ***" in the logs
      echo "Connecting to $DB_URL"

GitHub automáticamente redacta los valores de secretos en la salida de logs. Si tu secreto es p@ssw0rd123 y cualquier paso imprime esa cadena, los logs muestran ***. Esto funciona bien, con una salvedad: si tu secreto es corto (como un PIN de 4 dígitos), GitHub podría no enmascararlo porque podría coincidir con cadenas inocentes. Mantén los secretos razonablemente complejos.

Secretos con alcance de Environment#

yaml
jobs:
  deploy-staging:
    environment: staging
    steps:
      - run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = staging.akousa.net
 
  deploy-production:
    environment: production
    steps:
      - run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = akousa.net

Mismo nombre de secreto, valores diferentes por environment. El campo environment en el job determina qué conjunto de secretos se inyecta.

Los environments de producción deberían tener revisores requeridos habilitados. Esto significa que un push a main activa el workflow, CI corre automáticamente, pero el job de despliegue se pausa y espera a que alguien haga clic en "Approve" en la UI de GitHub. Para un proyecto en solitario, esto podría sentirse como overhead. Para cualquier cosa con usuarios, es un salvavidas la primera vez que accidentalmente mergeas algo roto.

OIDC: no más credenciales estáticas#

Las credenciales estáticas (claves de acceso AWS, archivos JSON de cuentas de servicio GCP) almacenadas en GitHub Secrets son un pasivo. No expiran, no pueden acotarse a una ejecución específica de workflow, y si se filtran, tienes que rotarlas manualmente.

OIDC (OpenID Connect) resuelve esto. GitHub Actions actúa como un proveedor de identidad, y tu proveedor de nube confía en él para emitir credenciales de corta duración al vuelo:

yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # Required for OIDC
      contents: read
 
    steps:
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: eu-central-1
 
      - name: Push to ECR
        run: |
          aws ecr get-login-password --region eu-central-1 | \
            docker login --username AWS --password-stdin 123456789012.dkr.ecr.eu-central-1.amazonaws.com

Sin clave de acceso. Sin clave secreta. La acción configure-aws-credentials solicita un token temporal de AWS STS usando el token OIDC de GitHub. El token está acotado al repo específico, rama y environment. Expira después de la ejecución del workflow.

Configurar esto en el lado de AWS requiere un proveedor de identidad OIDC de IAM y una política de confianza de rol:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:akousa/akousa-net:ref:refs/heads/main"
        }
      }
    }
  ]
}

La condición sub es crucial. Sin ella, cualquier repo que de alguna manera obtenga los detalles de tu proveedor OIDC podría asumir el rol. Con ella, solo la rama main de tu repo específico puede.

GCP tiene una configuración equivalente con Workload Identity Federation. Azure tiene credenciales federadas. Si tu nube soporta OIDC, úsalo. No hay razón para almacenar credenciales estáticas de nube en 2026.

Claves SSH de despliegue#

Para despliegues VPS por SSH, genera un par de claves dedicado:

bash
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""

Añade la clave pública al ~/.ssh/authorized_keys del servidor con restricciones:

restrict,command="/var/www/akousa.net/deploy.sh" ssh-ed25519 AAAA... github-actions-deploy

El prefijo restrict deshabilita el reenvío de puertos, reenvío de agente, asignación de PTY y reenvío X11. El prefijo command= significa que esta clave solo puede ejecutar el script de despliegue. Incluso si la clave privada se ve comprometida, el atacante puede ejecutar tu script de despliegue y nada más.

Añade la clave privada a GitHub Secrets como SSH_PRIVATE_KEY. Esta es la única credencial estática que acepto — las claves SSH con comandos forzados tienen un radio de explosión muy limitado.

Workflows de PR: despliegues de preview#

Cada PR merece un entorno de preview. Detecta bugs visuales que los tests unitarios no captan, permite a los diseñadores revisar sin hacer checkout del código, y hace la vida de QA dramáticamente más fácil.

Desplegar un preview al abrir un PR#

yaml
name: Preview Deploy
 
on:
  pull_request:
    types: [opened, synchronize, reopened]
 
jobs:
  preview:
    runs-on: ubuntu-latest
    environment:
      name: preview-${{ github.event.number }}
      url: ${{ steps.deploy.outputs.url }}
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Build preview image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:pr-${{ github.event.number }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
      - name: Deploy preview
        id: deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            PORT=$((4000 + PR_NUM))
            IMAGE="ghcr.io/${{ github.repository }}:pr-${PR_NUM}"
 
            docker pull "$IMAGE"
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
 
            docker run -d \
              --name "preview-${PR_NUM}" \
              --restart unless-stopped \
              -e NODE_ENV=preview \
              -p "${PORT}:3000" \
              "$IMAGE"
 
            echo "url=https://pr-${PR_NUM}.preview.akousa.net" >> "$GITHUB_OUTPUT"
 
      - name: Comment PR with preview URL
        uses: actions/github-script@v7
        with:
          script: |
            const url = `https://pr-${{ github.event.number }}.preview.akousa.net`;
            const body = `### Preview Deployment
 
            | Status | URL |
            |--------|-----|
            | :white_check_mark: Deployed | [${url}](${url}) |
 
            _Last updated: ${new Date().toISOString()}_
            _Commit: \`${{ github.sha }}\`_`;
 
            // Find existing comment
            const comments = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
 
            const botComment = comments.data.find(c =>
              c.user.type === 'Bot' && c.body.includes('Preview Deployment')
            );
 
            if (botComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body,
              });
            }

El cálculo del puerto (4000 + PR_NUM) es un hack pragmático. El PR #42 obtiene el puerto 4042. Mientras no tengas más de unos pocos cientos de PRs abiertos, no hay colisiones. Una configuración wildcard de Nginx enruta pr-*.preview.akousa.net al puerto correcto.

Limpieza al cerrar el PR#

Los entornos de preview que no se limpian consumen disco y memoria. Añade un job de limpieza:

yaml
name: Cleanup Preview
 
on:
  pull_request:
    types: [closed]
 
jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Remove preview container
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
            docker rmi "ghcr.io/${{ github.repository }}:pr-${PR_NUM}" || true
            echo "Preview for PR #${PR_NUM} cleaned up."
 
      - name: Deactivate environment
        uses: actions/github-script@v7
        with:
          script: |
            const deployments = await github.rest.repos.listDeployments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              environment: `preview-${{ github.event.number }}`,
            });
 
            for (const deployment of deployments.data) {
              await github.rest.repos.createDeploymentStatus({
                owner: context.repo.owner,
                repo: context.repo.repo,
                deployment_id: deployment.id,
                state: 'inactive',
              });
            }

Status checks requeridos#

En la configuración de tu repositorio (Settings > Branches > Branch protection rules), requiere estos checks antes del merge:

  • lint — Sin errores de lint
  • typecheck — Sin errores de tipos
  • test — Todos los tests pasan
  • build — El proyecto compila exitosamente

Sin esto, alguien va a mergear un PR con checks fallidos. No maliciosamente — verán "2 de 4 checks pasados" y asumirán que los otros dos aún están ejecutándose. Bloquéalo.

También habilita "Require branches to be up to date before merging." Esto fuerza una re-ejecución del CI después de hacer rebase sobre el último main. Detecta el caso donde dos PRs pasan CI individualmente pero entran en conflicto al combinarse.

Notificaciones#

Un despliegue que nadie sabe que ocurrió es un despliegue en el que nadie confía. Las notificaciones cierran el ciclo de retroalimentación.

Webhook de Slack#

yaml
- name: Notify Slack
  if: always()
  uses: slackapi/slack-github-action@v2
  with:
    webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
    webhook-type: incoming-webhook
    payload: |
      {
        "blocks": [
          {
            "type": "header",
            "text": {
              "type": "plain_text",
              "text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
            }
          },
          {
            "type": "section",
            "fields": [
              {
                "type": "mrkdwn",
                "text": "*Repository:*\n${{ github.repository }}"
              },
              {
                "type": "mrkdwn",
                "text": "*Branch:*\n${{ github.ref_name }}"
              },
              {
                "type": "mrkdwn",
                "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>"
              },
              {
                "type": "mrkdwn",
                "text": "*Triggered by:*\n${{ github.actor }}"
              }
            ]
          },
          {
            "type": "actions",
            "elements": [
              {
                "type": "button",
                "text": {
                  "type": "plain_text",
                  "text": "View Run"
                },
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
              }
            ]
          }
        ]
      }

El if: always() es crítico. Sin él, el paso de notificación se salta cuando el despliegue falla — que es exactamente cuando más lo necesitas.

API de Deployments de GitHub#

Para un seguimiento de despliegues más rico, usa la API de Deployments de GitHub. Esto te da un historial de despliegues en la UI del repo y habilita badges de estado:

yaml
- name: Create GitHub Deployment
  id: deployment
  uses: actions/github-script@v7
  with:
    script: |
      const deployment = await github.rest.repos.createDeployment({
        owner: context.repo.owner,
        repo: context.repo.repo,
        ref: context.sha,
        environment: 'production',
        auto_merge: false,
        required_contexts: [],
        description: `Deploying ${context.sha.substring(0, 7)} to production`,
      });
      return deployment.data.id;
 
- name: Deploy
  run: |
    # ... actual deployment steps ...
 
- name: Update deployment status
  if: always()
  uses: actions/github-script@v7
  with:
    script: |
      const deploymentId = ${{ steps.deployment.outputs.result }};
      await github.rest.repos.createDeploymentStatus({
        owner: context.repo.owner,
        repo: context.repo.repo,
        deployment_id: deploymentId,
        state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
        environment_url: 'https://akousa.net',
        log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
        description: '${{ job.status }}' === 'success'
          ? 'Deployment succeeded'
          : 'Deployment failed',
      });

Ahora tu pestaña de Environments en GitHub muestra un historial completo de despliegues: quién desplegó qué, cuándo, y si tuvo éxito.

Email solo en caso de fallo#

Para despliegues críticos, también disparo un email en caso de fallo. No a través del email integrado de GitHub Actions (demasiado ruidoso), sino mediante un webhook específico:

yaml
- name: Alert on failure
  if: failure()
  run: |
    curl -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
      -H "Content-Type: application/json" \
      -d '{
        "subject": "DEPLOY FAILED: ${{ github.repository }}",
        "body": "Commit: ${{ github.sha }}\nActor: ${{ github.actor }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
      }'

Esta es mi última línea de defensa. Slack es genial pero también es ruidoso — la gente silencia canales. Un email de "DEPLOY FAILED" con un enlace a la ejecución capta la atención.

El archivo de workflow completo#

Aquí está todo conectado en un solo workflow listo para producción. Esto es muy similar a lo que realmente despliega este sitio.

yaml
name: CI/CD Pipeline
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
    inputs:
      skip_tests:
        description: "Skip tests (emergency deploy)"
        required: false
        type: boolean
        default: false
 
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
 
env:
  NODE_VERSION: "22"
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  # ============================================================
  # CI: Lint, type check, and test in parallel
  # ============================================================
 
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run ESLint
        run: pnpm lint
 
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run TypeScript compiler
        run: pnpm tsc --noEmit
 
  test:
    name: Unit Tests
    if: ${{ !inputs.skip_tests }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run tests with coverage
        run: pnpm test -- --coverage
 
      - name: Upload coverage report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7
 
  # ============================================================
  # Build: Only after CI passes
  # ============================================================
 
  build:
    name: Build Application
    needs: [lint, typecheck, test]
    if: always() && !cancelled() && needs.lint.result == 'success' && needs.typecheck.result == 'success' && (needs.test.result == 'success' || needs.test.result == 'skipped')
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Cache Next.js build
        uses: actions/cache@v4
        with:
          path: .next/cache
          key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
          restore-keys: |
            nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
            nextjs-${{ runner.os }}-
 
      - name: Build Next.js application
        run: pnpm build
 
  # ============================================================
  # Docker: Build and push image (main branch only)
  # ============================================================
 
  docker:
    name: Build Docker Image
    needs: [build]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      image_tag: ${{ steps.meta.outputs.tags }}
 
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Set up QEMU for multi-platform builds
        uses: docker/setup-qemu-action@v3
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract image metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest,enable={{is_default_branch}}
 
      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
  # ============================================================
  # Deploy: SSH into VPS and update
  # ============================================================
 
  deploy:
    name: Deploy to Production
    needs: [docker]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://akousa.net
 
    steps:
      - name: Create GitHub Deployment
        id: deployment
        uses: actions/github-script@v7
        with:
          script: |
            const deployment = await github.rest.repos.createDeployment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              ref: context.sha,
              environment: 'production',
              auto_merge: false,
              required_contexts: [],
              description: `Deploy ${context.sha.substring(0, 7)}`,
            });
            return deployment.data.id;
 
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script_stop: true
          command_timeout: 5m
          script: |
            set -euo pipefail
 
            APP_DIR="/var/www/akousa.net"
            IMAGE="ghcr.io/${{ github.repository }}:latest"
            SHA="${{ github.sha }}"
 
            echo "=== Deploy $SHA started at $(date) ==="
 
            # Pull new image
            docker pull "$IMAGE"
 
            # Run new container on alternate port
            docker run -d \
              --name akousa-app-new \
              --env-file "$APP_DIR/.env.production" \
              -p 3001:3000 \
              "$IMAGE"
 
            # Health check
            echo "Running health check..."
            for i in $(seq 1 30); do
              if curl -sf http://localhost:3001/api/health > /dev/null 2>&1; then
                echo "Health check passed (attempt $i)"
                break
              fi
              if [ "$i" -eq 30 ]; then
                echo "ERROR: Health check failed"
                docker logs akousa-app-new --tail 50
                docker stop akousa-app-new && docker rm akousa-app-new
                exit 1
              fi
              sleep 2
            done
 
            # Switch traffic
            sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
            sudo nginx -t && sudo nginx -s reload
 
            # Grace period for in-flight requests
            sleep 5
 
            # Stop old container
            docker stop akousa-app || true
            docker rm akousa-app || true
 
            # Rename and reset port
            docker rename akousa-app-new akousa-app
            sudo sed -i 's/server 127.0.0.1:3001/server 127.0.0.1:3000/' /etc/nginx/conf.d/upstream.conf
            # Note: we don't reload Nginx here because the container name changed,
            # not the port. The next deploy will use the correct port.
 
            # Record deployment
            echo "$SHA" > "$APP_DIR/.deployed-sha"
            echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $SHA" >> "$APP_DIR/deploy.log"
 
            # Cleanup old images (older than 7 days)
            docker image prune -af --filter "until=168h"
 
            echo "=== Deploy complete at $(date) ==="
 
      - name: Update deployment status
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const deploymentId = ${{ steps.deployment.outputs.result }};
            await github.rest.repos.createDeploymentStatus({
              owner: context.repo.owner,
              repo: context.repo.repo,
              deployment_id: deploymentId,
              state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
              environment_url: 'https://akousa.net',
              log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
            });
 
      - name: Notify Slack
        if: always()
        uses: slackapi/slack-github-action@v2
        with:
          webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
          webhook-type: incoming-webhook
          payload: |
            {
              "blocks": [
                {
                  "type": "header",
                  "text": {
                    "type": "plain_text",
                    "text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
                  }
                },
                {
                  "type": "section",
                  "fields": [
                    {
                      "type": "mrkdwn",
                      "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|`${{ github.sha }}`>"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*Actor:*\n${{ github.actor }}"
                    }
                  ]
                },
                {
                  "type": "actions",
                  "elements": [
                    {
                      "type": "button",
                      "text": { "type": "plain_text", "text": "View Run" },
                      "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
                    }
                  ]
                }
              ]
            }
 
      - name: Alert on failure
        if: failure()
        run: |
          curl -sf -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
            -H "Content-Type: application/json" \
            -d '{
              "subject": "DEPLOY FAILED: ${{ github.repository }}",
              "body": "Commit: ${{ github.sha }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
            }' || true

Recorrido del flujo#

Cuando hago push a main:

  1. Lint, Type Check y Test arrancan simultáneamente. Tres runners, tres jobs paralelos. Si alguno falla, el pipeline se detiene.
  2. Build se ejecuta solo si los tres pasan. Valida que la aplicación compila y produce salida funcional.
  3. Docker construye la imagen de producción y la sube a ghcr.io. Multi-plataforma, con caché de capas.
  4. Deploy se conecta por SSH al VPS, descarga la nueva imagen, inicia un nuevo contenedor, le hace health-check, cambia Nginx y limpia.
  5. Notificaciones se disparan sin importar el resultado. Slack recibe el mensaje. GitHub Deployments se actualiza. Si falló, sale un email de alerta.

Cuando abro un PR:

  1. Lint, Type Check y Test se ejecutan. Las mismas puertas de calidad.
  2. Build se ejecuta para verificar que el proyecto compila.
  3. Docker y Deploy se saltan (las condiciones if los restringen solo a la rama main).

Cuando necesito un despliegue de emergencia (saltar tests):

  1. Haz clic en "Run workflow" en la pestaña Actions.
  2. Selecciona skip_tests: true.
  3. Lint y typecheck aún se ejecutan (no puedes saltarlos — no me fío de mí mismo hasta ese punto).
  4. Tests se saltan, build se ejecuta, Docker construye, despliegue se activa.

Este ha sido mi workflow durante dos años. Ha sobrevivido migraciones de servidor, actualizaciones de versión mayor de Node.js, pnpm reemplazando a npm, y la adición de 15 herramientas a este sitio. El tiempo total de extremo a extremo desde push hasta producción: 3 minutos 40 segundos en promedio. El paso más lento es el build Docker multi-plataforma con ~90 segundos. Todo lo demás está cacheado hasta ser casi instantáneo.

Lecciones de dos años de iteración#

Cierro con los errores que cometí para que tú no tengas que hacerlo.

Fija las versiones de tus actions. uses: actions/checkout@v4 está bien, pero para producción, considera uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 (el SHA completo). Una acción comprometida podría exfiltrar tus secretos. El incidente de tj-actions/changed-files en 2025 demostró que esto no es teórico.

No cachees todo. Una vez cacheé node_modules directamente (no solo el store de pnpm) y pasé dos horas depurando un fallo fantasma de build causado por bindings nativos obsoletos. Cachea el store del gestor de paquetes, no los módulos instalados.

Establece timeouts. Cada job debería tener timeout-minutes. El valor por defecto son 360 minutos (6 horas). Si tu despliegue se cuelga porque la conexión SSH se cayó, no quieres descubrirlo seis horas después cuando hayas agotado tu presupuesto mensual de minutos.

yaml
jobs:
  deploy:
    timeout-minutes: 15
    runs-on: ubuntu-latest

Usa concurrency sabiamente. Para PRs, cancel-in-progress: true siempre es correcto — a nadie le importa el resultado del CI de un commit que ya fue sobrescrito con force-push. Para despliegues a producción, establécelo en false. No quieres que un commit rápido posterior cancele un despliegue que está a mitad de ejecución.

Prueba tu archivo de workflow. Usa act (https://github.com/nektos/act) para ejecutar workflows localmente. No captura todo (los secretos no están disponibles, y el entorno del runner difiere), pero captura errores de sintaxis YAML y bugs de lógica obvios antes de hacer push.

Monitorea tus costos de CI. Los minutos de GitHub Actions son gratuitos para repos públicos y baratos para privados, pero se acumulan. Los builds Docker multi-plataforma son 2x los minutos (uno por plataforma). Las estrategias de matrix de tests multiplican tu tiempo de ejecución. Vigila la página de facturación.

El mejor pipeline de CI/CD es en el que confías. La confianza viene de la fiabilidad, la observabilidad y la mejora incremental. Empieza con un pipeline simple de lint-test-build. Añade Docker cuando necesites reproducibilidad. Añade despliegue SSH cuando necesites automatización. Añade notificaciones cuando necesites confianza. No construyas el pipeline completo el primer día — te equivocarás en las abstracciones.

Construye el pipeline que necesitas hoy, y déjalo crecer con tu proyecto.

Artículos relacionados