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.
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#
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
- productionCuatro triggers, cada uno sirviendo un propósito diferente:
pushamaines tu trigger de despliegue a producción. ¿Código mergeado? Despliégalo.pull_requestejecuta tus checks de CI en cada PR. Aquí es donde viven lint, verificación de tipos y tests.schedulees cron para tu repo. Lo uso para escaneos semanales de auditoría de dependencias y limpieza de caché obsoleta.workflow_dispatchte 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#
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 lintJobs 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#
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.
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: 1Por 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:
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 testfail-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#
- 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:
- 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.
- 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:
- Coincidencia exacta: mismas dependencias Y mismos archivos fuente. Hit completo de caché, build casi instantáneo.
- Coincidencia parcial (dependencias): mismas dependencias pero fuente cambiado. Build recompila solo archivos cambiados.
- 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.
- 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=maxtype=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.
- 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:
# 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#
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=maxPermíteme desglosar las decisiones importantes aquí.
GitHub Container Registry (ghcr.io)#
Uso ghcr.io en lugar de Docker Hub por tres razones:
- La autenticación es gratuita.
GITHUB_TOKENestá automáticamente disponible en cada workflow — no necesitas almacenar credenciales de Docker Hub. - Proximidad. Las imágenes se descargan desde la misma infraestructura donde corre tu CI. Los pulls durante CI son rápidos.
- 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#
platforms: linux/amd64,linux/arm64Esta 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#
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#
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:
#!/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:
script: |
cd /var/www/akousa.net && ./deploy.shEsto 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.
# ecosystem.config.js already has:
# instances: 2
# exec_mode: "cluster"
pm2 reload akousa --update-envpm2 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:
- 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-envEsto 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:
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
server 127.0.0.1:3000;
}# /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:
#!/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:
# 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:
docker compose pull
docker compose up -d --remove-orphansDocker 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#
# 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#
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.netMismo 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:
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.comSin 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:
{
"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:
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#
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:
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 linttypecheck— Sin errores de tipostest— Todos los tests pasanbuild— 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#
- 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:
- 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:
- 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.
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 }}"
}' || trueRecorrido del flujo#
Cuando hago push a main:
- Lint, Type Check y Test arrancan simultáneamente. Tres runners, tres jobs paralelos. Si alguno falla, el pipeline se detiene.
- Build se ejecuta solo si los tres pasan. Valida que la aplicación compila y produce salida funcional.
- Docker construye la imagen de producción y la sube a ghcr.io. Multi-plataforma, con caché de capas.
- Deploy se conecta por SSH al VPS, descarga la nueva imagen, inicia un nuevo contenedor, le hace health-check, cambia Nginx y limpia.
- 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:
- Lint, Type Check y Test se ejecutan. Las mismas puertas de calidad.
- Build se ejecuta para verificar que el proyecto compila.
- Docker y Deploy se saltan (las condiciones
iflos restringen solo a la ramamain).
Cuando necesito un despliegue de emergencia (saltar tests):
- Haz clic en "Run workflow" en la pestaña Actions.
- Selecciona
skip_tests: true. - Lint y typecheck aún se ejecutan (no puedes saltarlos — no me fío de mí mismo hasta ese punto).
- 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.
jobs:
deploy:
timeout-minutes: 15
runs-on: ubuntu-latestUsa 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.