Hasta ahora el pipeline produce un directorio dist/ que se guarda siete días y no va a ninguna parte. En esta lección cierras el circuito: empaquetarás Mini-Reservalia en una imagen de contenedor inmutable, la publicarás en un registro real, la desplegarás en dos entornos con una puerta de aprobación humana entre ellos, comprobarás automáticamente que el despliegue funciona, promocionarás a producción exactamente el mismo artefacto que validaste en staging —sin reconstruirlo— y escribirás el botón de emergencia que te devuelve a la versión anterior en menos de dos minutos. Y, como manda el módulo, romperás algo a propósito: desplegarás una versión que falla su propio health check para ver la puerta cerrarse y el rollback funcionar.
Todo esto sin AWS y sin tarjeta de crédito. El registro es ghcr.io, incluido gratis en tu cuenta de GitHub; el frontend va a GitHub Pages; y el "host de producción" es un contenedor Docker que corre en el propio runner o en tu máquina. Donde el Reservalia real usaría ECS, ECR y OIDC contra AWS, encontrarás una nota con el equivalente exacto. Los conceptos —artefacto inmutable, digest, idempotencia, promoción, smoke test, rollback— son idénticos; solo cambia dónde aterriza el contenedor.
Contenido
- Objetivo, requisitos previos y punto de partida
- El artefacto inmutable: Dockerfile multi-etapa
- Construir y publicar en
ghcr.iodesde el pipeline - Por qué el digest y no el tag
- Separar CI de CD con
workflow_run - Environments:
stagingautomático yproduccioncon revisor - El script de despliegue idempotente
- Las tres formas de ejecutarlo (con host, sin host, y en el runner)
- El smoke test con reintentos
- El
cd.ymlcompleto con promoción por digest - El frontend a GitHub Pages
- El
rollback.ymly el cronómetro - Provocar un despliegue malo
- Verificación final
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Objetivo, requisitos previos y punto de partida
Objetivo. Al terminar, un merge a main construirá una imagen, la publicará en ghcr.io, la desplegará automáticamente en staging, la verificará con un smoke test, esperará tu aprobación y promocionará el mismo digest a produccion; y tendrás un rollback.yml que revierte en menos de dos minutos, cronometrado.
Requisitos previos.
- Las lecciones 07-01 y 07-02 completadas.
- Docker instalado en tu máquina (
docker --version, 24 o superior) ydocker compose version. Ahora sí es obligatorio. - El repositorio en GitHub con
mainprotegida y el checkCI OK.
Punto de partida. El repositorio tras la 07-02: código con persistencia, tres capas de pruebas, cobertura con umbral, matriz y sharding.
- El artefacto inmutable: Dockerfile multi-etapa
La regla de la 02-06: build once, deploy many. Se construye un artefacto una sola vez y ese mismo artefacto —byte a byte— recorre todos los entornos. Si cada entorno reconstruye, cada entorno ejecuta algo distinto y la validación de staging no dice nada sobre producción.
Dockerfile:
# syntax=docker/dockerfile:1.7
# ---------- Etapa 1: dependencias de produccion ----------
# Se aisla para que la capa se cachee mientras el lockfile no cambie.
FROM node:20-bookworm-slim AS deps
WORKDIR /app
# build-essential y python3 son necesarios SOLO si algun modulo nativo
# (better-sqlite3) no encuentra binario precompilado. Se quedan en esta
# etapa y nunca llegan a la imagen final.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
# --omit=dev: nada de eslint ni herramientas de prueba en produccion.
RUN npm ci --omit=dev
# ---------- Etapa 2: construccion ----------
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG COMMIT=desconocido
ENV GITHUB_SHA=${COMMIT}
RUN npm run build
# ---------- Etapa 3: imagen final ----------
FROM node:20-bookworm-slim AS runtime
WORKDIR /app
# Usuario sin privilegios. La imagen oficial de Node ya trae el usuario
# `node` (uid 1000); no hace falta crearlo.
ENV NODE_ENV=production \
PORT=3000 \
BASE_DATOS=sqlite:/datos/mini.db
# Directorio de datos con permisos para el usuario no-root.
RUN mkdir -p /datos && chown -R node:node /datos
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --chown=node:node package.json ./
USER node
EXPOSE 3000
VOLUME ["/datos"]
# Metadatos OCI: quien construyo esto, desde que commit y cuando.
# Se leen con `docker inspect` y son la trazabilidad minima de un artefacto.
ARG COMMIT=desconocido
ARG FECHA=desconocida
LABEL org.opencontainers.image.source="https://github.com/OWNER/mini-reservalia" \
org.opencontainers.image.revision="${COMMIT}" \
org.opencontainers.image.created="${FECHA}" \
org.opencontainers.image.title="mini-reservalia" \
org.opencontainers.image.description="Calculo de huecos de cita"
# HEALTHCHECK: Docker sondea el contenedor y marca su estado.
# `docker inspect --format '{{.State.Health.Status}}'` devuelve
# starting -> healthy | unhealthy. El script de despliegue lo usara.
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/salud').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENV APP_VERSION=${COMMIT}
CMD ["node", "dist/src/servidor.js"].dockerignore —tan importante como el Dockerfile, porque determina qué se envía al demonio y qué invalida la caché—:
node_modules dist coverage informes .git .github .gitignore *.md *.log .env Dockerfile docker-compose*.yml test
Excluir test/ y .git no es solo velocidad: es superficie. Una imagen de producción que lleva dentro el historial de Git y las pruebas es una imagen que filtra información y ocupa el triple.
Construye y prueba en local:
docker build \
--build-arg COMMIT="$(git rev-parse HEAD)" \
--build-arg FECHA="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-t mini-reservalia:local .
docker run --rm -d --name mini-local -p 3000:3000 mini-reservalia:local
sleep 3
curl -s localhost:3000/salud
# {"estado":"ok","version":"8f3c1e2...","activoSeg":3}
# El estado del HEALTHCHECK, que es lo que mirara el despliegue:
docker inspect --format '{{.State.Health.Status}}' mini-local
# healthy (puede tardar hasta 15 s en pasar de "starting")
# Comprobaciones de endurecimiento basico:
docker exec mini-local whoami # node (NO root)
docker image inspect mini-reservalia:local --format '{{.Size}}' | numfmt --to=iec
# ~230M
docker rm -f mini-localQué debes ver: estado: ok, Health.Status: healthy y whoami: node. Si whoami devuelve root, la línea USER node no está o está antes de un COPY que la anula.
- Construir y publicar en
ghcr.io desde el pipeline
ghcr.io desde el pipelineghcr.io es el registro de contenedores de GitHub. Para un repositorio público es gratuito e ilimitado, y lo mejor: no necesitas ninguna credencial nueva. El GITHUB_TOKEN que el runner ya tiene sirve, siempre que le des el permiso packages: write.
Añade al ci.yml un job publicar que dependa de ci-ok:
publicar:
name: Publicar imagen
runs-on: ubuntu-latest
needs: [ci-ok]
# Solo se publica desde main: un PR no debe dejar imagenes en el registro.
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions:
contents: read
packages: write # necesario para escribir en ghcr.io
outputs:
digest: ${{ steps.construir.outputs.digest }}
imagen: ghcr.io/${{ github.repository }}@${{ steps.construir.outputs.digest }}
steps:
- uses: actions/checkout@v4
- name: Preparar Buildx
uses: docker/setup-buildx-action@v3
- name: Autenticarse en ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # token efimero del run
- name: Metadatos y etiquetas
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,format=long,prefix=sha-
type=raw,value=main,enable={{is_default_branch}}
- name: Construir y publicar
id: construir
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
COMMIT=${{ github.sha }}
FECHA=${{ github.event.repository.updated_at }}
# Cache de capas en el propio backend de Actions: reduce mucho
# el tiempo de las construcciones siguientes (04-04 y 06-05).
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true # attestacion de procedencia (SLSA)
- name: Publicar el digest en el resumen
run: |
{
echo "## Imagen publicada"
echo ""
echo "| Campo | Valor |"
echo "|---|---|"
echo "| Repositorio | \`ghcr.io/${{ github.repository }}\` |"
echo "| Digest | \`${{ steps.construir.outputs.digest }}\` |"
echo "| Commit | \`${{ github.sha }}\` |"
echo ""
echo "Referencia inmutable para desplegar:"
echo '```'
echo "ghcr.io/${{ github.repository }}@${{ steps.construir.outputs.digest }}"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"Mergea el PR y observa. Qué debes ver:
- En el resumen del run, la tabla con el digest:
sha256:3f9a.... - En la portada de tu perfil o del repositorio, la sección Packages con
mini-reservalia. - La imagen es privada por defecto aunque el repositorio sea público. Ve a Package settings → Change visibility → Public si quieres poder descargarla sin autenticar (útil para el laboratorio). Alternativamente, en Package settings → Manage Actions access, añade el repositorio con rol
Write.
Descárgala desde tu máquina para comprobar que existe de verdad:
echo "$GH_TOKEN" | docker login ghcr.io -u TU_USUARIO --password-stdin
docker pull ghcr.io/TU_USUARIO/mini-reservalia@sha256:3f9a...
docker run --rm -p 3000:3000 ghcr.io/TU_USUARIO/mini-reservalia@sha256:3f9a...Equivalente real en Reservalia. El registro es ECR y la autenticación no usa una contraseña sino OIDC: el workflow pide un token efímero a AWS con
aws-actions/configure-aws-credentials@v4yrole-to-assume: arn:aws:iam::...:role/reservalia-ci, sin ninguna clave almacenada en GitHub (03-02 y 04-03). Aquíghcr.ioconGITHUB_TOKENcumple exactamente el mismo principio —credencial efímera, con alcance mínimo, que caduca al terminar el run—, y por eso el ejercicio no pierde nada pedagógicamente. La 07-05 volverá sobre esto.
- Por qué el digest y no el tag
Este apartado es corto y es el más importante de la lección.
Un tag es un puntero mutable. ghcr.io/tu/mini-reservalia:main apunta hoy a una imagen y mañana a otra. Un digest (sha256:...) es el hash del contenido del manifiesto: una referencia por digest siempre resuelve a los mismos bytes, para siempre.
| Tag | Digest | |
|---|---|---|
:main, :latest, :v1.2 |
Puntero mutable | — |
@sha256:3f9a... |
— | Contenido inmutable |
| ¿Puede cambiar bajo tus pies? | Sí | No |
| ¿Sirve para promocionar? | No | Sí |
| ¿Sirve para volver atrás? | Solo si nadie lo movió | Siempre |
| ¿Sirve para hablar con humanos? | Sí | Regular |
El escenario que arruina el día de alguien todos los meses: despliegas :main en staging, lo pruebas, apruebas, y cuando el job de producción hace docker pull, otro merge ya ha movido :main. Producción ejecuta código que nadie validó. No es un fallo teórico; es la razón por la que este pipeline pasa el digest de un job a otro y por la que el job de producción comprueba que el digest que va a desplegar es el mismo que se validó.
Compruébalo tú mismo:
docker buildx imagetools inspect ghcr.io/TU_USUARIO/mini-reservalia:main --format '{{.Manifest.Digest}}'
# sha256:3f9a... <- ahora
# haz otro merge a main y repite: el digest ha cambiado, el tag noRegla: los tags son para las personas, los digests para las máquinas. Publica ambos; despliega siempre por digest.
- Separar CI de CD con
workflow_run
workflow_runCI y CD son dos ciclos con velocidades y permisos distintos. CI se ejecuta en cada PR, no necesita credenciales de despliegue y debe ser rápido. CD se ejecuta solo tras un merge a main, necesita permisos elevados y puede tardar minutos esperando una aprobación. Meterlos en el mismo workflow obliga a dar a cada PR los permisos del despliegue: exactamente lo contrario del mínimo privilegio.
flowchart LR
P["push a main"] --> CI["ci.yml<br/>calidad, test, cobertura,<br/>build, publicar imagen"]
CI -->|workflow_run: completed + success| CD["cd.yml"]
CD --> S["desplegar staging<br/>(automatico)"]
S --> SM1["smoke test"]
SM1 --> G{"Environment<br/>produccion<br/>revisor requerido"}
G -->|aprobado| PR2["desplegar produccion<br/>MISMO digest"]
PR2 --> SM2["smoke test"]
SM2 -->|falla| RB["rollback.yml"]
El disparador:
on:
workflow_run:
workflows: ['CI'] # el `name:` del otro workflow, no su fichero
types: [completed]
branches: [main]
workflow_dispatch: # para poder relanzar un despliegue a mano
inputs:
digest:
description: 'Digest a desplegar (sha256:...). Vacio = ultimo de main'
required: falseY la guarda imprescindible en el primer job:
# `completed` incluye failure y cancelled. Sin esta condicion,
# desplegarias el resultado de un CI en rojo.
if: >-
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'Dos peculiaridades de workflow_run que confunden a todo el mundo la primera vez:
- El workflow debe existir en
mainpara que se dispare. Mientras elcd.ymlesté solo en tu rama, no se ejecutará nunca por mucho que el CI pase. Hay que mergearlo primero y probarlo después. Es contraintuitivo y cuesta media tarde a quien no lo sabe. - El contexto es el del workflow disparador, no el del commit.
github.shaen unworkflow_runes el SHA de la rama por defecto en el momento del disparo. Si necesitas el commit exacto que se construyó, léelo degithub.event.workflow_run.head_sha.
- Environments:
staging automático y produccion con revisor
staging automático y produccion con revisorLos Environments de GitHub son la materialización de la puerta entre Delivery y Deployment de la 03-01: el artefacto está listo, pero alguien decide cuándo entra.
Créalos en Settings → Environments:
staging
- Sin reglas de protección.
- Environment URL: la dirección de tu staging (o
http://localhost:3001). - Variable de entorno (pestaña Variables):
PUERTO_APP = 3001.
produccion
- Required reviewers: añádete a ti mismo. Este es el punto del ejercicio.
- Wait timer: 0 (o 1 minuto si quieres ver el temporizador).
- Deployment branches:
Selected branches→main. Impide desplegar producción desde una rama cualquiera. - Variable:
PUERTO_APP = 3002.
Con gh:
gh api --method PUT "repos/{owner}/{repo}/environments/staging"
gh api --method PUT "repos/{owner}/{repo}/environments/produccion" \
-F "wait_timer=0" \
-F "reviewers[][type]=User" \
-F "reviewers[][id]=$(gh api user --jq .id)" \
-F "deployment_branch_policy[protected_branches]=true" \
-F "deployment_branch_policy[custom_branch_policies]=false"Un job se asocia a un entorno con dos líneas:
Lo que hace GitHub con eso: cuando la ejecución llega a ese job, se detiene, marca el run como Waiting, envía una notificación a los revisores y espera. Ningún paso del job se ejecuta —ni siquiera el checkout— hasta que alguien aprueba. Los secretos y variables del entorno solo se materializan después de la aprobación, lo que significa que un job no aprobado no puede tocar las credenciales de producción ni por accidente ni a propósito. Ese es el valor de seguridad, además del de proceso.
- El script de despliegue idempotente
Aquí aplicamos la regla de la 06-07: la lógica en scripts, el YAML delgado. El script debe poder ejecutarse desde tu portátil, desde el runner o desde un servidor por SSH, sin cambios. Si solo funciona dentro de GitHub Actions, no lo puedes depurar y no lo puedes usar en una emergencia.
scripts/desplegar.sh:
#!/usr/bin/env bash
# Despliega Mini-Reservalia como contenedor Docker.
#
# IDEMPOTENTE: ejecutarlo N veces con la misma imagen deja el sistema en el
# mismo estado que ejecutarlo una vez. Esa propiedad es lo que permite
# reintentar un despliegue fallido sin miedo (03-02).
#
# Uso:
# ENTORNO=staging PUERTO=3001 IMAGEN=ghcr.io/x/y@sha256:... ./scripts/desplegar.sh
#
# Variables:
# IMAGEN (obligatoria) referencia COMPLETA por digest
# ENTORNO (por defecto: staging) sufijo del nombre del contenedor
# PUERTO (por defecto: 3001) puerto del host
# RETENER (por defecto: 3) cuantas imagenes antiguas conservar
set -Eeuo pipefail # -E: las trampas heredan; -e: aborta al fallar;
# -u: variable no definida es error; -o pipefail: falla la tuberia entera
IMAGEN="${IMAGEN:?Falta IMAGEN (referencia por digest)}"
ENTORNO="${ENTORNO:-staging}"
PUERTO="${PUERTO:-3001}"
RETENER="${RETENER:-3}"
CONTENEDOR="mini-reservalia-${ENTORNO}"
VOLUMEN="mini-reservalia-datos-${ENTORNO}"
log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*"; }
# --- 0. Validaciones tempranas ------------------------------------------------
if [[ "$IMAGEN" != *"@sha256:"* ]]; then
echo "ERROR: IMAGEN debe ser una referencia por DIGEST (@sha256:...), no por tag." >&2
echo " Recibido: $IMAGEN" >&2
echo " Un tag es mutable: no garantiza que despliegues lo que validaste." >&2
exit 2
fi
command -v docker >/dev/null || { echo "ERROR: docker no esta instalado" >&2; exit 3; }
log "Entorno: $ENTORNO"
log "Contenedor: $CONTENEDOR"
log "Puerto: $PUERTO"
log "Imagen: $IMAGEN"
# --- 1. Descargar la imagen ANTES de tocar nada -------------------------------
# Si el pull falla, el servicio actual sigue vivo. Nunca pares lo que funciona
# antes de tener lo que lo va a sustituir.
log "Descargando imagen..."
docker pull --quiet "$IMAGEN"
DIGEST_LOCAL="$(docker image inspect "$IMAGEN" --format '{{index .RepoDigests 0}}')"
log "Digest verificado: $DIGEST_LOCAL"
# --- 2. Idempotencia: si ya corre ESTA imagen, no hacer nada ------------------
if docker ps --filter "name=^${CONTENEDOR}$" --format '{{.Names}}' | grep -q .; then
ACTUAL="$(docker inspect "$CONTENEDOR" --format '{{.Image}}')"
NUEVA="$(docker image inspect "$IMAGEN" --format '{{.Id}}')"
if [[ "$ACTUAL" == "$NUEVA" ]]; then
log "El contenedor ya ejecuta esta imagen. Nada que hacer (idempotencia)."
docker ps --filter "name=^${CONTENEDOR}$" --format 'table {{.Names}}\t{{.Status}}'
exit 0
fi
log "Version en ejecucion distinta; se sustituye."
fi
# --- 3. Guardar la version anterior para el rollback --------------------------
ANTERIOR=""
if docker inspect "$CONTENEDOR" >/dev/null 2>&1; then
ANTERIOR="$(docker inspect "$CONTENEDOR" --format '{{index .Config.Labels "mini.digest"}}' 2>/dev/null || true)"
fi
if [[ -n "$ANTERIOR" ]]; then
log "Version anterior (para rollback): $ANTERIOR"
echo "$ANTERIOR" > "/tmp/mini-reservalia-${ENTORNO}.anterior"
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
echo "digest_anterior=$ANTERIOR" >> "$GITHUB_OUTPUT"
fi
fi
# --- 4. Volumen de datos (idempotente por definicion) -------------------------
docker volume create "$VOLUMEN" >/dev/null
# --- 5. Parar y borrar el anterior --------------------------------------------
# `|| true` porque en el primer despliegue no existe, y eso no es un error.
log "Deteniendo la version anterior (si existe)..."
docker rm -f "$CONTENEDOR" >/dev/null 2>&1 || true
# --- 6. Arrancar la nueva version ---------------------------------------------
log "Arrancando la nueva version..."
docker run -d \
--name "$CONTENEDOR" \
--restart unless-stopped \
-p "${PUERTO}:3000" \
-v "${VOLUMEN}:/datos" \
-e "APP_VERSION=${APP_VERSION:-$IMAGEN}" \
-e "ENTORNO=${ENTORNO}" \
--label "mini.digest=${IMAGEN}" \
--label "mini.entorno=${ENTORNO}" \
--label "mini.desplegado=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--health-cmd "node -e \"fetch('http://127.0.0.1:3000/salud').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" \
--health-interval 5s --health-retries 6 --health-start-period 3s \
"$IMAGEN" >/dev/null
# --- 7. Esperar a que el HEALTHCHECK pase a healthy ---------------------------
log "Esperando a que el contenedor este healthy..."
for i in $(seq 1 24); do
ESTADO="$(docker inspect "$CONTENEDOR" --format '{{.State.Health.Status}}' 2>/dev/null || echo 'sin-datos')"
case "$ESTADO" in
healthy) log "Healthy tras ${i} sondeos."; break ;;
unhealthy) log "ERROR: el contenedor esta unhealthy."; docker logs --tail 50 "$CONTENEDOR"; exit 4 ;;
*) sleep 5 ;;
esac
if [[ "$i" -eq 24 ]]; then
log "ERROR: no llego a healthy en 120 s (estado: $ESTADO)."
docker logs --tail 50 "$CONTENEDOR"
exit 5
fi
done
# --- 8. Limpieza: conservar las N ultimas imagenes ----------------------------
# Sin esto, el disco del host se llena en unas semanas. Es la causa numero uno
# de "el despliegue fallo y no sabemos por que" en hosts pequenos.
log "Limpiando imagenes antiguas (conservando $RETENER)..."
docker image prune -f --filter "until=168h" >/dev/null 2>&1 || true
log "Despliegue completado: $CONTENEDOR en el puerto $PUERTO"
docker ps --filter "name=^${CONTENEDOR}$" --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'Las cuatro propiedades que hacen que este script sea usable en producción:
| Propiedad | Cómo se consigue | Qué pasa sin ella |
|---|---|---|
| Idempotente | Comprueba si ya corre esa imagen y sale (paso 2) | Reintentar un despliegue reinicia el servicio sin necesidad |
| Falla temprano | pull antes de parar nada (paso 1) |
Paras lo que funciona y descubres que la imagen nueva no existe |
| Verificable | Espera a healthy con límite de tiempo (paso 7) |
El script termina "bien" con un contenedor que ni siquiera arranca |
| Rastreable | Guarda el digest anterior en una label (pasos 3 y 6) | No sabes a qué volver en un rollback |
Ese último punto merece énfasis: la label mini.digest convierte al propio contenedor en el registro de qué está desplegado. docker inspect mini-reservalia-produccion --format '{{index .Config.Labels "mini.digest"}}' responde a la pregunta más urgente de un incidente: ¿qué demonios estamos ejecutando?
- Las tres formas de ejecutarlo (con host, sin host, y en el runner)
El mismo script, tres destinos. Elige el que puedas.
Opción A (principal, sin coste): el destino simulado dentro del runner
El job de despliegue ejecuta el script contra el Docker del propio runner. El "servidor" vive los tres minutos del job y se destruye después. Es artificial —no hay persistencia entre despliegues— pero ejercita el 100 % del camino: pull por digest, arranque, health check, smoke test, rollback. Es la opción por defecto de este laboratorio.
- name: Desplegar
run: ./scripts/desplegar.sh
env:
IMAGEN: ${{ needs.preparar.outputs.imagen }}
ENTORNO: staging
PUERTO: '3001'Opción B: tu máquina, con un runner autoalojado
Si quieres persistencia real entre despliegues y ver el contenedor vivo en tu portátil, registra un runner autoalojado:
mkdir ~/runner && cd ~/runner
# Copia los comandos exactos de Settings > Actions > Runners > New self-hosted runner
./config.sh --url https://github.com/TU_USUARIO/mini-reservalia --token XXXX --labels casa
./run.shY cambia runs-on: ubuntu-latest por runs-on: [self-hosted, casa]. Ahora http://localhost:3001 y http://localhost:3002 son tus dos entornos de verdad, sobreviven a los despliegues y puedes verlos con docker ps.
Aviso de seguridad, no opcional. No pongas un runner autoalojado en un repositorio público: cualquiera que abra un PR podría ejecutar código arbitrario en tu máquina. La 06-06 lo explicaba y la 07-05 volverá sobre ello. Si vas por la opción B, haz el repositorio privado o usa un contenedor desechable como runner.
Opción C: un host real por SSH
Si tienes una máquina accesible (una VM antigua, una Raspberry Pi, un VPS de un euro), este es el camino más parecido a producción:
- name: Desplegar por SSH
env:
IMAGEN: ${{ needs.preparar.outputs.imagen }}
run: |
install -m 600 /dev/null clave
echo "${{ secrets.SSH_CLAVE_PRIVADA }}" > clave
# StrictHostKeyChecking=accept-new: confia la primera vez y despues
# detecta cambios de host. NUNCA uses `no`: desactiva la proteccion
# contra suplantacion para siempre.
scp -i clave -o StrictHostKeyChecking=accept-new \
scripts/desplegar.sh "${{ secrets.SSH_USUARIO }}@${{ secrets.SSH_HOST }}:/tmp/"
ssh -i clave -o StrictHostKeyChecking=accept-new \
"${{ secrets.SSH_USUARIO }}@${{ secrets.SSH_HOST }}" \
"IMAGEN='$IMAGEN' ENTORNO=produccion PUERTO=3002 bash /tmp/desplegar.sh"
shred -u claveCon docker-compose.yml en el host para tener también el reverse proxy:
# docker-compose.yml - el "host de produccion" simulado en tu maquina
services:
api-staging:
image: ${IMAGEN_STAGING:-ghcr.io/OWNER/mini-reservalia:main}
container_name: mini-reservalia-staging
restart: unless-stopped
ports: ['3001:3000']
environment:
ENTORNO: staging
BASE_DATOS: sqlite:/datos/mini.db
volumes: ['datos-staging:/datos']
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/salud').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s
timeout: 3s
retries: 3
api-produccion:
image: ${IMAGEN_PRODUCCION:-ghcr.io/OWNER/mini-reservalia:main}
container_name: mini-reservalia-produccion
restart: unless-stopped
ports: ['3002:3000']
environment:
ENTORNO: produccion
BASE_DATOS: sqlite:/datos/mini.db
volumes: ['datos-produccion:/datos']
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/salud').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s
timeout: 3s
retries: 3
volumes:
datos-staging:
datos-produccion:Equivalente real en Reservalia. El despliegue es
aws ecs update-servicecon una nueva definición de tarea que apunta al digest, y ECS hace un rolling update respetando el health check del target group del ALB (03-02 y 03-04). El scriptdesplegar.shde Reservalia hace lo mismo que el tuyo —comprobar idempotencia, aplicar, esperar estabilización, informar— conaws ecs wait services-stableen lugar del bucle dedocker inspect. La forma del script es idéntica; cambia la primitiva.
- El smoke test con reintentos
Un despliegue que termina no es un despliegue que funciona. El smoke test es la diferencia.
scripts/smoke.sh:
#!/usr/bin/env bash
# Smoke test posterior al despliegue.
# No prueba funcionalidad exhaustiva (para eso esta la suite): comprueba que
# el sistema DESPLEGADO responde y sirve su funcion principal.
#
# Uso: BASE=http://localhost:3001 ./scripts/smoke.sh [digest_esperado]
set -Eeuo pipefail
BASE="${BASE:?Falta BASE (ej. http://localhost:3001)}"
ESPERADO="${1:-}"
INTENTOS="${INTENTOS:-20}"
ESPERA="${ESPERA:-3}"
log() { printf '[smoke] %s\n' "$*"; }
fallo() { echo "[smoke] FALLO: $*" >&2; exit 1; }
# --- 1. Espera de estabilizacion ---------------------------------------------
# El servicio puede tardar en aceptar conexiones. Reintentar NO es tapar un
# problema: es reconocer que el arranque no es instantaneo. Lo que si seria
# tapar el problema es reintentar SIN limite o ignorar el resultado final.
log "Esperando a que $BASE responda (max $((INTENTOS * ESPERA))s)..."
for i in $(seq 1 "$INTENTOS"); do
if curl -fsS --max-time 5 "$BASE/salud" >/dev/null 2>&1; then
log "Responde tras $((i * ESPERA))s aprox."
break
fi
[[ "$i" -eq "$INTENTOS" ]] && fallo "no respondio a /salud en $((INTENTOS * ESPERA))s"
sleep "$ESPERA"
done
# --- 2. /salud devuelve estado ok --------------------------------------------
SALUD="$(curl -fsS --max-time 5 "$BASE/salud")"
log "/salud -> $SALUD"
echo "$SALUD" | grep -q '"estado":"ok"' || fallo "/salud no devuelve estado ok"
# --- 3. La version desplegada es la esperada ---------------------------------
# Esta comprobacion es la que caza el fallo mas silencioso de todos:
# el despliegue "funciono" pero el trafico sigue yendo a la version antigua.
if [[ -n "$ESPERADO" ]]; then
VERSION="$(echo "$SALUD" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')"
if [[ "$VERSION" != *"$ESPERADO"* ]]; then
fallo "version desplegada '$VERSION' != esperada '$ESPERADO'"
fi
log "Version verificada: $VERSION"
fi
# --- 4. La funcionalidad principal responde ----------------------------------
RESPUESTA="$(curl -fsS --max-time 10 "$BASE/api/huecos?fecha=2026-03-02&duracion=60")"
echo "$RESPUESTA" | grep -q '"huecos"' || fallo "/api/huecos no devuelve huecos: $RESPUESTA"
TOTAL="$(echo "$RESPUESTA" | sed -n 's/.*"total":\([0-9]*\).*/\1/p')"
[[ "${TOTAL:-0}" -gt 0 ]] || fallo "/api/huecos devuelve 0 huecos en un dia que deberia tenerlos"
log "/api/huecos -> $TOTAL huecos"
# --- 5. Los errores siguen siendo errores ------------------------------------
# Una API que responde 200 a todo tambien "pasa" un smoke test ingenuo.
CODIGO="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$BASE/api/huecos")"
[[ "$CODIGO" == "400" ]] || fallo "una peticion sin fecha deberia dar 400, dio $CODIGO"
log "Validacion de errores OK (400 sin fecha)"
# --- 6. Latencia razonable ---------------------------------------------------
MS="$(curl -s -o /dev/null -w '%{time_total}' --max-time 10 "$BASE/api/huecos?fecha=2026-03-02" \
| awk '{printf "%d", $1 * 1000}')"
log "Latencia de /api/huecos: ${MS} ms"
[[ "$MS" -lt 2000 ]] || fallo "latencia de ${MS} ms, por encima del umbral de 2000 ms"
log "TODAS LAS COMPROBACIONES OK"El paso 5 es el que separa un smoke test útil de uno decorativo: comprueba que algo que debe fallar, falla. Un servidor mal configurado que devuelve 200 con una página de error para cualquier ruta pasaría los pasos 1 a 4 sin despeinarse.
- El
cd.yml completo con promoción por digest
cd.yml completo con promoción por digest# .github/workflows/cd.yml
name: CD
on:
workflow_run:
workflows: ['CI']
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
digest:
description: 'Digest a desplegar (sha256:...). Vacio = ultimo publicado en main'
required: false
type: string
# NUNCA cancelar un despliegue a mitad: puede dejar el sistema inconsistente.
# Se encolan, no se cancelan. Diferencia clave respecto al `concurrency` del CI.
concurrency:
group: cd-mini-reservalia
cancel-in-progress: false
permissions:
contents: read
env:
REGISTRO: ghcr.io
IMAGEN_BASE: ghcr.io/${{ github.repository }}
jobs:
# ---------------------------------------------------------------
# 1. Resolver QUE se va a desplegar. Una sola vez, para todos.
# ---------------------------------------------------------------
preparar:
name: Resolver artefacto
runs-on: ubuntu-latest
if: >-
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
permissions:
contents: read
packages: read
outputs:
digest: ${{ steps.resolver.outputs.digest }}
imagen: ${{ steps.resolver.outputs.imagen }}
commit: ${{ steps.resolver.outputs.commit }}
steps:
- name: Autenticarse en el registro
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resolver el digest a desplegar
id: resolver
run: |
set -Eeuo pipefail
ENTRADA="${{ inputs.digest }}"
if [[ -n "$ENTRADA" ]]; then
DIGEST="$ENTRADA"
echo "Digest indicado a mano: $DIGEST"
else
# Resolvemos el tag movil :main a su digest UNA sola vez.
# A partir de aqui, todo el pipeline usa el digest fijo.
DIGEST=$(docker buildx imagetools inspect "${{ env.IMAGEN_BASE }}:main" \
--format '{{.Manifest.Digest}}')
echo "Digest resuelto desde :main -> $DIGEST"
fi
IMAGEN="${{ env.IMAGEN_BASE }}@${DIGEST}"
COMMIT=$(docker buildx imagetools inspect "$IMAGEN" --format \
'{{json .Image}}' | grep -o '"org.opencontainers.image.revision":"[^"]*"' \
| cut -d'"' -f4 || echo "${{ github.sha }}")
{
echo "digest=$DIGEST"
echo "imagen=$IMAGEN"
echo "commit=$COMMIT"
} >> "$GITHUB_OUTPUT"
{
echo "## Artefacto a desplegar"
echo ""
echo "| Campo | Valor |"
echo "|---|---|"
echo "| Imagen | \`$IMAGEN\` |"
echo "| Commit | \`$COMMIT\` |"
echo "| Origen | ${{ github.event_name }} |"
} >> "$GITHUB_STEP_SUMMARY"
# ---------------------------------------------------------------
# 2. Staging: automatico, sin aprobacion.
# ---------------------------------------------------------------
staging:
name: Desplegar en staging
runs-on: ubuntu-latest
needs: [preparar]
timeout-minutes: 15
environment:
name: staging
url: http://localhost:3001
permissions:
contents: read
packages: read
outputs:
digest_validado: ${{ steps.marcar.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Desplegar
id: desplegar
run: ./scripts/desplegar.sh
env:
IMAGEN: ${{ needs.preparar.outputs.imagen }}
ENTORNO: staging
PUERTO: ${{ vars.PUERTO_APP || '3001' }}
APP_VERSION: ${{ needs.preparar.outputs.commit }}
- name: Smoke test
run: ./scripts/smoke.sh "${{ needs.preparar.outputs.commit }}"
env:
BASE: http://localhost:${{ vars.PUERTO_APP || '3001' }}
- name: Marcar el digest como validado
id: marcar
run: |
echo "digest=${{ needs.preparar.outputs.digest }}" >> "$GITHUB_OUTPUT"
echo "### ✅ Staging validado: \`${{ needs.preparar.outputs.digest }}\`" >> "$GITHUB_STEP_SUMMARY"
- name: Diagnostico si algo falla
if: failure()
run: |
echo "::group::Contenedores"
docker ps -a
echo "::endgroup::"
echo "::group::Logs"
docker logs --tail 100 mini-reservalia-staging || true
echo "::endgroup::"
# ---------------------------------------------------------------
# 3. Produccion: MISMO digest, con aprobacion humana.
# ---------------------------------------------------------------
produccion:
name: Desplegar en produccion
runs-on: ubuntu-latest
needs: [preparar, staging]
timeout-minutes: 30
environment:
name: produccion # <- aqui se detiene esperando aprobacion
url: http://localhost:3002
permissions:
contents: read
packages: read
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# LA COMPROBACION CLAVE DE LA PROMOCION.
# Producción despliega EXACTAMENTE lo que staging valido. Si por
# cualquier motivo el digest no coincide, abortamos: mejor no desplegar
# que desplegar algo distinto de lo validado.
- name: Verificar que es el MISMO artefacto validado en staging
run: |
set -Eeuo pipefail
VALIDADO="${{ needs.staging.outputs.digest_validado }}"
A_DESPLEGAR="${{ needs.preparar.outputs.digest }}"
echo "Validado en staging: $VALIDADO"
echo "A desplegar en prod: $A_DESPLEGAR"
if [[ "$VALIDADO" != "$A_DESPLEGAR" ]]; then
echo "::error::El digest de produccion NO coincide con el validado en staging."
exit 1
fi
echo "✅ Mismo artefacto. No se reconstruye nada." >> "$GITHUB_STEP_SUMMARY"
- name: Desplegar
id: desplegar
run: ./scripts/desplegar.sh
env:
IMAGEN: ${{ needs.preparar.outputs.imagen }}
ENTORNO: produccion
PUERTO: ${{ vars.PUERTO_APP || '3002' }}
APP_VERSION: ${{ needs.preparar.outputs.commit }}
- name: Smoke test de produccion
run: ./scripts/smoke.sh "${{ needs.preparar.outputs.commit }}"
env:
BASE: http://localhost:${{ vars.PUERTO_APP || '3002' }}
- name: Registrar el despliegue
run: |
{
echo "## 🚀 Desplegado en produccion"
echo ""
echo "| Campo | Valor |"
echo "|---|---|"
echo "| Digest | \`${{ needs.preparar.outputs.digest }}\` |"
echo "| Commit | \`${{ needs.preparar.outputs.commit }}\` |"
echo "| Aprobado por | @${{ github.actor }} |"
echo "| Hora (UTC) | $(date -u +%Y-%m-%dT%H:%M:%SZ) |"
echo ""
echo "**Digest anterior (para rollback):** \`${{ steps.desplegar.outputs.digest_anterior || 'ninguno' }}\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Diagnostico si algo falla
if: failure()
run: |
docker ps -a
docker logs --tail 100 mini-reservalia-produccion || true
echo "::error::Despliegue de produccion fallido. Lanza el workflow Rollback con el digest anterior."Haz el PR, mergéalo y observa la secuencia completa:
- El
ci.ymlcorre y publica la imagen. - Unos segundos después arranca el
cd.ymlsolo (fíjate en que el run dice "triggered by CI"). Resolver artefactoimprime el digest.Desplegar en stagingse ejecuta y el smoke test pasa.- El run se detiene. El job
Desplegar en produccionaparece con un aviso amarillo: "Deployment protection rules — Review required" y un botón Review deployments. - Pulsa el botón, marca
produccion, escribe un comentario y pulsa Approve and deploy. - El job arranca, verifica el digest, despliega y publica el resumen.
Ese momento en el que el pipeline te espera es la puerta de la 03-01 hecha realidad. Vale la pena mirarlo un segundo antes de aprobar.
- El frontend a GitHub Pages
Mini-Reservalia también tiene web. Crea web/index.html:
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mini-Reservalia</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
.hueco { display: inline-block; padding: .4rem .8rem; margin: .2rem; border: 1px solid #888; border-radius: .4rem; }
#meta { color: #666; font-size: .85rem; margin-top: 2rem; }
</style>
</head>
<body>
<h1>Mini-Reservalia</h1>
<label>Fecha <input type="date" id="fecha" value="2026-03-02"></label>
<label>Duracion <input type="number" id="duracion" value="60" min="15" step="15"></label>
<button id="buscar">Buscar huecos</button>
<div id="resultado"></div>
<div id="meta"></div>
<script src="app.js"></script>
</body>
</html>web/app.js — con la configuración en tiempo de ejecución, no incrustada en el build (05-01):
// web/app.js
// La configuracion se lee de config.json EN TIEMPO DE EJECUCION.
// Asi el MISMO artefacto estatico sirve para staging y para produccion:
// lo unico que cambia es el config.json que escribe el despliegue.
// Si la URL de la API estuviera incrustada en el build, cada entorno
// necesitaria su propio build y se rompe "build once, deploy many".
let CONFIG = { apiBase: 'http://localhost:3001', entorno: 'desconocido', version: 'dev' };
async function cargarConfig() {
try {
CONFIG = { ...CONFIG, ...(await (await fetch('config.json', { cache: 'no-store' })).json()) };
} catch {
console.warn('config.json no disponible; se usa la configuracion por defecto');
}
document.getElementById('meta').textContent =
`Entorno: ${CONFIG.entorno} · Version: ${CONFIG.version} · API: ${CONFIG.apiBase}`;
}
async function buscar() {
const fecha = document.getElementById('fecha').value;
const duracion = document.getElementById('duracion').value;
const salida = document.getElementById('resultado');
salida.textContent = 'Buscando...';
try {
const respuesta = await fetch(`${CONFIG.apiBase}/api/huecos?fecha=${fecha}&duracion=${duracion}`);
const datos = await respuesta.json();
if (!respuesta.ok) throw new Error(datos.error ?? 'error desconocido');
salida.innerHTML = datos.total === 0
? '<p>No quedan huecos ese dia.</p>'
: `<p>${datos.total} huecos:</p>` +
datos.huecos.map((h) => `<span class="hueco">${h.inicio}–${h.fin}</span>`).join('');
} catch (error) {
salida.textContent = `Error: ${error.message}`;
}
}
document.getElementById('buscar').addEventListener('click', buscar);
cargarConfig();Job de despliegue de Pages, en el cd.yml:
web:
name: Desplegar web (Pages)
runs-on: ubuntu-latest
needs: [preparar, staging]
permissions:
contents: read
pages: write # publicar en Pages
id-token: write # OIDC hacia el servicio de Pages
environment:
name: github-pages
url: ${{ steps.publicar.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- name: Generar config.json del entorno
run: |
cat > web/config.json <<JSON
{
"apiBase": "${{ vars.API_BASE || 'http://localhost:3002' }}",
"entorno": "produccion",
"version": "${{ needs.preparar.outputs.commit }}",
"digest": "${{ needs.preparar.outputs.digest }}"
}
JSON
cat web/config.json
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: web/
- id: publicar
uses: actions/deploy-pages@v4Activa Pages en Settings → Pages → Source: GitHub Actions. Qué debes ver: tras el despliegue, https://TU_USUARIO.github.io/mini-reservalia/ con el formulario y, al pie, la línea Entorno: produccion · Version: 8f3c1e2 · API: ....
Un detalle honesto del laboratorio: la web pública no podrá llamar a tu API en localhost (y si lo hiciera, el navegador bloquearía la petición por CORS). Eso está bien: lo que ilustra este job es el despliegue atómico de un artefacto estático con configuración en tiempo de ejecución, que es la parte trasladable. En Reservalia, este mismo patrón sube el dist/ de Vite a S3 y hace una invalidación de CloudFront, con el mismo config.json generado en el despliegue (05-01).
- El
rollback.yml y el cronómetro
rollback.yml y el cronómetroVolver atrás no debe requerir pensar. Un rollback que exige recordar comandos es un rollback que no se usará a las tres de la madrugada.
# .github/workflows/rollback.yml
name: Rollback
on:
workflow_dispatch:
inputs:
entorno:
description: 'Entorno a revertir'
required: true
default: 'produccion'
type: choice
options: [staging, produccion]
digest:
description: 'Digest al que volver (sha256:...)'
required: true
type: string
motivo:
description: 'Motivo (queda registrado en el resumen)'
required: true
type: string
concurrency:
group: cd-mini-reservalia # MISMO grupo que el cd.yml: un rollback y un
cancel-in-progress: false # despliegue nunca deben solaparse
permissions:
contents: read
jobs:
rollback:
name: Revertir ${{ inputs.entorno }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
packages: read
# Ojo: para que un rollback sea RAPIDO, el entorno de rollback NO debe
# tener revisor requerido, o volveras a esperar una aprobacion en plena
# incidencia. Usamos un entorno distinto, sin puerta.
environment:
name: ${{ inputs.entorno }}-rollback
steps:
- name: Cronometro - inicio
id: inicio
run: echo "t=$(date +%s)" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Validar el digest recibido
run: |
set -Eeuo pipefail
DIGEST="${{ inputs.digest }}"
[[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] || {
echo "::error::'$DIGEST' no es un digest valido (sha256: + 64 hex)"; exit 2; }
# Comprobar que existe ANTES de tocar produccion.
docker buildx imagetools inspect "ghcr.io/${{ github.repository }}@${DIGEST}" >/dev/null
echo "Digest verificado y disponible en el registro."
- name: Ejecutar el rollback
run: ./scripts/desplegar.sh
env:
IMAGEN: ghcr.io/${{ github.repository }}@${{ inputs.digest }}
ENTORNO: ${{ inputs.entorno }}
PUERTO: ${{ inputs.entorno == 'produccion' && '3002' || '3001' }}
- name: Verificar con el smoke test
run: ./scripts/smoke.sh
env:
BASE: http://localhost:${{ inputs.entorno == 'produccion' && '3002' || '3001' }}
- name: Cronometro - fin y registro
if: always()
run: |
SEGUNDOS=$(( $(date +%s) - ${{ steps.inicio.outputs.t }} ))
{
echo "## ⏪ Rollback de ${{ inputs.entorno }}"
echo ""
echo "| Campo | Valor |"
echo "|---|---|"
echo "| Digest restaurado | \`${{ inputs.digest }}\` |"
echo "| Motivo | ${{ inputs.motivo }} |"
echo "| Ejecutado por | @${{ github.actor }} |"
echo "| **Duracion** | **${SEGUNDOS} s** |"
echo "| Resultado | ${{ job.status }} |"
echo ""
echo "> Tiempo de restauracion del servicio (metrica DORA #4)."
} >> "$GITHUB_STEP_SUMMARY"
echo "Rollback completado en ${SEGUNDOS} s"Ejecútalo:
# Averigua el digest anterior (el penultimo publicado)
gh api "/user/packages/container/mini-reservalia/versions" \
--jq '.[1] | "\(.name) \(.created_at)"'
gh workflow run rollback.yml \
-f entorno=produccion \
-f digest=sha256:ANTERIOR... \
-f motivo="Prueba cronometrada del procedimiento de rollback"
gh run watchQué debes ver: el resumen con la duración. En este laboratorio el rollback tarda 60-110 segundos, la mayoría en el docker pull. Reservalia tarda 4 minutos porque ECS hace un rolling update con drenaje de conexiones. Los dos están muy por debajo del objetivo de 10 minutos, y los dos están medidos, que es lo que importa: un procedimiento de rollback cuyo tiempo nadie ha cronometrado es una suposición.
- Provocar un despliegue malo
Ahora la parte divertida: comprobar que la puerta cierra.
Edita src/servidor.js y sabotea /salud:
if (req.method === 'GET' && url.pathname === '/salud') {
+ // FALLO DELIBERADO: simula una dependencia critica caida
+ if (process.env.ENTORNO) {
+ return responderJson(res, 503, { estado: 'degradado', error: 'base de datos no disponible' });
+ }
return responderJson(res, 200, {La condición process.env.ENTORNO hace que las pruebas locales y el CI sigan en verde (no definen ENTORNO) pero el contenedor desplegado falle. Es una simulación bastante fiel de la categoría de bug más peligrosa: el que solo aparece con la configuración de un entorno real.
npm test # verde: las pruebas NO detectan esto
git commit -am "fix: comprobacion adicional en /salud"
git push -u origin romper-salud
gh pr create --fill && gh pr merge --squash --delete-branch --autoQué debes ver, en orden:
- CI en verde. Las 38 pruebas pasan. La imagen se publica. Este es el punto: el CI no lo detecta.
cd.ymlarranca.Resolver artefactoOK.Desplegar en stagingfalla. Y falla en dos sitios, lo cual es interesante:- Primero, dentro de
desplegar.sh, el paso 7: elHEALTHCHECKdel contenedor nunca llega ahealthy.[10:42:31] Esperando a que el contenedor este healthy... [10:44:31] ERROR: no llego a healthy en 120 s (estado: unhealthy) - Si el health check fuera más laxo, lo cazaría
smoke.sh:[smoke] FALLO: /salud no devuelve estado ok
- Primero, dentro de
- El paso de diagnóstico vuelca los logs del contenedor, gracias al
if: failure(). Desplegar en produccionno se ejecuta. Ni siquiera pide aprobación: suneeds: [staging]no se cumplió. Producción nunca ve este código.
Esa es exactamente la promesa del despliegue continuo bien montado: un bug que el CI no detecta se para en el primer entorno real, automáticamente, sin que nadie tenga que mirar nada.
Ahora simula que hubiera llegado a producción. Fuerza el despliegue de la versión mala en producción y ejecuta el rollback cronometrando:
# 1. Desplegar a mano la version mala en produccion
gh workflow run cd.yml -f digest=sha256:MALO...
# aprobar en la web cuando lo pida; el smoke test de produccion fallara
# 2. Rollback, con el cronometro en marcha
date +%s
gh workflow run rollback.yml \
-f entorno=produccion \
-f digest=sha256:BUENO... \
-f motivo="Incidente: /salud devuelve 503 tras el despliegue de sha256:MALO"
gh run watch
date +%sAnota los tres números: tiempo de detección (cuánto tardó el smoke test en fallar), tiempo de decisión (cuánto tardaste en encontrar el digest bueno) y tiempo de ejecución (lo que dice el resumen del rollback). En un incidente real, el segundo suele ser el mayor de los tres, y es el que se reduce anotando el digest anterior en el resumen de cada despliegue, que es exactamente lo que hace nuestro cd.yml.
Revierte el sabotaje:
git checkout main && git pull
git checkout -b arreglar-salud
git revert --no-edit <sha-del-commit-malo>
git push -u origin arreglar-salud
gh pr create --fill && gh pr merge --squash --delete-branch --auto
- Verificación final
| # | Comprobación | Cómo | Esperado |
|---|---|---|---|
| 1 | La imagen no corre como root | docker run --rm IMAGEN whoami |
node |
| 2 | El HEALTHCHECK funciona |
docker inspect --format '{{.State.Health.Status}}' |
healthy |
| 3 | La imagen está en ghcr.io |
Pestaña Packages | mini-reservalia con versiones |
| 4 | El despliegue es idempotente | Ejecutar desplegar.sh dos veces |
La 2.ª dice "Nada que hacer" |
| 5 | El script rechaza un tag | IMAGEN=ghcr.io/x/y:main ./scripts/desplegar.sh |
Salida 2, mensaje sobre el digest |
| 6 | CD se dispara solo tras CI | Merge a main |
Run de CD "triggered by CI" |
| 7 | Producción espera aprobación | Ver el run | Review required + botón |
| 8 | Se promociona el mismo digest | Log del paso de verificación | Los dos digests idénticos |
| 9 | No se reconstruye para producción | Log del job | No hay ningún docker build |
| 10 | El smoke test detecta el fallo | Sabotear /salud |
Staging en rojo, producción sin ejecutar |
| 11 | El rollback está cronometrado | Resumen del run | Duración en segundos, < 10 min |
| 12 | El digest anterior queda registrado | Resumen de cada despliegue | Línea "Digest anterior" |
Errores Comunes y Consejos
Síntoma: denied: installation not allowed to Create organization package al hacer push a ghcr.io.
Causa: falta permissions: packages: write en el job, o el repositorio tiene restringido el permiso por defecto del GITHUB_TOKEN a solo lectura.
Arreglo: añade el bloque permissions al job (no basta con tenerlo a nivel de workflow si el job lo sobrescribe) y revisa Settings → Actions → General → Workflow permissions.
Síntoma: Error response from daemon: unauthorized al hacer docker pull de tu propia imagen desde otro sitio.
Causa: el paquete es privado por defecto, aunque el repositorio sea público.
Arreglo: Packages → mini-reservalia → Package settings → Change visibility → Public, o Manage Actions access para dar acceso al repositorio.
Síntoma: el cd.yml no se dispara nunca, aunque el CI termina en verde.
Causas posibles, en orden de frecuencia: (1) el fichero aún no está en main —workflow_run solo se dispara con la versión de la rama por defecto—; (2) el workflows: ['CI'] no coincide con el name: del otro workflow (es sensible a mayúsculas); (3) el CI terminó con conclusion: failure y la guarda del if lo bloqueó correctamente.
Arreglo: mergea primero, comprueba el name: exacto y mira el conclusion en la API: gh run list --workflow=ci.yml --json conclusion,name.
Síntoma: desplegar.sh falla con unbound variable en la línea de GITHUB_OUTPUT.
Causa: set -u con una variable no definida al ejecutar el script fuera de Actions.
Arreglo: ya está previsto con ${GITHUB_OUTPUT:-}. Si escribes tus propios scripts, la expansión con valor por defecto es obligatoria en todo lo que venga del entorno.
Síntoma: el contenedor arranca y muere inmediatamente, sin logs útiles.
Causa habitual: permisos del volumen. El usuario node (uid 1000) no puede escribir en /datos si el volumen se creó con propietario root.
Arreglo: el RUN mkdir -p /datos && chown -R node:node /datos del Dockerfile lo resuelve para volúmenes nuevos. Si el volumen ya existía con otros permisos: docker volume rm mini-reservalia-datos-staging y vuelve a desplegar. Diagnóstico: docker logs mini-reservalia-staging (por eso el paso if: failure() los vuelca).
Síntoma: el smoke test pasa pero está probando la versión antigua.
Causa: el contenedor nuevo no arrancó y el reverse proxy sigue enrutando al viejo, o el puerto responde otra cosa.
Arreglo: por eso smoke.sh compara la versión devuelta por /salud con la esperada. Sin esa comprobación, un despliegue que no hizo nada pasa el smoke test perfectamente. Es el fallo más silencioso de todos.
Síntoma: el disco del host se llena al cabo de unas semanas.
Causa: cada despliegue deja una imagen sin usar de ~230 MB.
Arreglo: el docker image prune del paso 8. En un host real, además, una tarea semanal de docker system prune -af --filter "until=720h" y una alerta de espacio en disco. La 07-04 pondrá esa alerta.
Consejo — nunca latest. docker pull imagen:latest es la forma más eficiente de no saber qué estás ejecutando. Nuestro script lo rechaza explícitamente. Que tu tooling impida el error es mucho mejor que documentar que no se debe cometer.
Consejo — el concurrency del CD es distinto del de CI. En CI, cancel-in-progress: true (nadie quiere el resultado de un commit superado). En CD, false (cancelar un despliegue a mitad deja el sistema en un estado que nadie ha diseñado). Y el rollback.yml comparte grupo con el cd.yml para que no se pisen.
Ejercicios
Ejercicio 1: rollback sin buscar el digest a mano
En un incidente, encontrar el digest anterior es el paso lento. Haz que el rollback.yml acepte el input digest vacío y, en ese caso, resuelva automáticamente la versión inmediatamente anterior a la desplegada.
Ejercicio 2: despliegue canary por porcentaje
Implementa un despliegue canary: levanta la versión nueva junto a la antigua y envía solo el 10 % del tráfico a la nueva durante 2 minutos; si el smoke test extendido pasa, promociona al 100 %; si no, retira la canary. Usa un contenedor Nginx como balanceador.
Ejercicio 3: bloquear el despliegue fuera del horario laboral
Añade una regla que impida desplegar a producción los viernes por la tarde y los fines de semana, con una vía de escape explícita para emergencias que quede registrada.
Soluciones
Solución 1.
- name: Resolver el digest anterior si no se ha indicado
id: resolver
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -Eeuo pipefail
DIGEST="${{ inputs.digest }}"
if [[ -z "$DIGEST" ]]; then
echo "Sin digest indicado: se busca la version anterior a la desplegada."
# 1. Que esta corriendo AHORA (la label que puso desplegar.sh)
ACTUAL=$(docker inspect "mini-reservalia-${{ inputs.entorno }}" \
--format '{{index .Config.Labels "mini.digest"}}' 2>/dev/null \
| sed 's/.*@//' || echo "")
echo "Desplegado actualmente: ${ACTUAL:-(desconocido)}"
# 2. Las versiones del paquete, mas recientes primero
mapfile -t VERSIONES < <(
gh api "/user/packages/container/mini-reservalia/versions" \
--jq '.[] | select(.metadata.container.tags | length > 0 or true) | .name' \
| head -20
)
# 3. La primera que NO sea la actual
for v in "${VERSIONES[@]}"; do
if [[ "$v" != "$ACTUAL" ]]; then DIGEST="$v"; break; fi
done
[[ -n "$DIGEST" ]] || { echo "::error::No se encontro una version anterior"; exit 1; }
echo "Version anterior resuelta: $DIGEST"
fi
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"Y usa ${{ steps.resolver.outputs.digest }} en los pasos siguientes. Cambia también el input a required: false.
Una versión más robusta no consulta el registro sino un historial de despliegues que el propio cd.yml mantiene: un fichero despliegues.jsonl en una rama estado, o la API de Deployments de GitHub (gh api "repos/{owner}/{repo}/deployments?environment=produccion"), que ya registra cada despliegue con su ref. La diferencia importa: el registro te dice qué imágenes existen; el historial te dice qué se desplegó y en qué orden, que es la pregunta real.
Solución 2.
nginx-canary.conf:
upstream mini_reservalia {
# El peso reparte las peticiones: 9 de cada 10 a la estable.
server host.docker.internal:3002 weight=9; # estable
server host.docker.internal:3003 weight=1; # canary (10 %)
}
server {
listen 8080;
location / {
proxy_pass http://mini_reservalia;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Si la canary falla, nginx la saca del pool y reintenta en la estable:
# el usuario no ve el error. Es el "circuit breaker" del pobre.
proxy_next_upstream error timeout http_502 http_503;
}
} canary:
name: Despliegue canary
runs-on: ubuntu-latest
needs: [preparar, staging]
environment: produccion
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with: { registry: ghcr.io, username: ${{ github.actor }}, password: ${{ secrets.GITHUB_TOKEN }} }
- name: 1. Levantar la canary junto a la estable
run: ./scripts/desplegar.sh
env:
IMAGEN: ${{ needs.preparar.outputs.imagen }}
ENTORNO: canary
PUERTO: '3003'
- name: 2. Balanceador al 10 %
run: |
docker rm -f balanceador 2>/dev/null || true
docker run -d --name balanceador -p 8080:8080 \
--add-host host.docker.internal:host-gateway \
-v "$PWD/nginx-canary.conf:/etc/nginx/conf.d/default.conf:ro" \
nginx:alpine
sleep 3
- name: 3. Observar 2 minutos y medir la tasa de error
id: observar
run: |
set -Eeuo pipefail
FIN=$(( $(date +%s) + 120 ))
TOTAL=0; ERRORES=0
while [ "$(date +%s)" -lt "$FIN" ]; do
CODIGO=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
"http://localhost:8080/api/huecos?fecha=2026-03-02" || echo 000)
TOTAL=$((TOTAL+1))
[[ "$CODIGO" == "200" ]] || ERRORES=$((ERRORES+1))
sleep 1
done
TASA=$(awk "BEGIN {printf \"%.2f\", $ERRORES*100/$TOTAL}")
echo "Peticiones: $TOTAL · Errores: $ERRORES · Tasa: ${TASA}%"
echo "tasa=$TASA" >> "$GITHUB_OUTPUT"
echo "### Canary: ${TASA}% de error en $TOTAL peticiones" >> "$GITHUB_STEP_SUMMARY"
# Umbral del 1 %: por encima, no promociona.
awk "BEGIN {exit !($TASA > 1.0)}" && { echo "::error::Tasa de error por encima del 1 %"; exit 1; }
- name: 4a. Promocionar al 100 %
if: success()
run: |
./scripts/desplegar.sh
docker rm -f mini-reservalia-canary balanceador || true
env:
IMAGEN: ${{ needs.preparar.outputs.imagen }}
ENTORNO: produccion
PUERTO: '3002'
- name: 4b. Retirar la canary
if: failure()
run: |
echo "::warning::Canary retirada. Produccion sigue con la version anterior."
docker logs --tail 100 mini-reservalia-canary || true
docker rm -f mini-reservalia-canary balanceador || trueLo que este ejercicio enseña, y que la 03-04 explicaba en teoría: el canary no es "desplegar despacio", es "desplegar y medir". Sin el paso 3 —una métrica, un umbral y una decisión automática— tienes un despliegue lento, no un canary. Y fíjate en que el fallo del paso 3 deja producción intacta: el 90 % del tráfico nunca vio la versión nueva.
Solución 3.
- name: Ventana de despliegue
if: inputs.emergencia != true
run: |
set -Eeuo pipefail
# El runner va en UTC; convertimos a la hora local del equipo.
export TZ='Europe/Madrid'
DIA=$(date +%u) # 1=lunes ... 7=domingo
HORA=$(date +%H)
AHORA=$(date '+%A %H:%M %Z')
bloquear() {
{
echo "## ⛔ Despliegue bloqueado por la ventana"
echo ""
echo "**Momento:** $AHORA"
echo "**Motivo:** $1"
echo ""
echo "La ventana permitida es **lunes a jueves de 09:00 a 17:00** y"
echo "**viernes de 09:00 a 13:00**."
echo ""
echo "Para una emergencia real, relanza el workflow con"
echo "\`emergencia: true\` y un motivo. Quedara registrado."
} >> "$GITHUB_STEP_SUMMARY"
echo "::error::Fuera de la ventana de despliegue: $1"
exit 1
}
[ "$DIA" -ge 6 ] && bloquear "fin de semana"
[ "$DIA" -eq 5 ] && [ "$HORA" -ge 13 ] && bloquear "viernes por la tarde"
{ [ "$HORA" -lt 9 ] || [ "$HORA" -ge 17 ]; } && bloquear "fuera del horario laboral"
echo "✅ Dentro de la ventana de despliegue ($AHORA)." >> "$GITHUB_STEP_SUMMARY"
- name: Registrar el uso de la via de escape
if: inputs.emergencia == true
run: |
{
echo "## 🚨 DESPLIEGUE DE EMERGENCIA"
echo ""
echo "| Campo | Valor |"
echo "|---|---|"
echo "| Autorizado por | @${{ github.actor }} |"
echo "| Motivo | ${{ inputs.motivo_emergencia }} |"
echo "| Momento (UTC) | $(date -u) |"
echo "| Digest | \`${{ needs.preparar.outputs.digest }}\` |"
echo ""
echo "> Este despliegue se ha saltado la ventana. Debe revisarse en la retrospectiva."
} >> "$GITHUB_STEP_SUMMARY"
echo "::warning::Despliegue de emergencia fuera de ventana por @${{ github.actor }}"Con los inputs correspondientes:
workflow_dispatch:
inputs:
emergencia:
description: 'Saltarse la ventana de despliegue (queda registrado)'
type: boolean
default: false
motivo_emergencia:
description: 'Obligatorio si emergencia = true'
type: stringLa discusión que este ejercicio abre es más interesante que el código, y conviene tenerla clara. Las ventanas de despliegue son un antipatrón en un equipo con buen CD: si desplegar da miedo el viernes, el problema no es el viernes, es el despliegue. Con rollback en 90 segundos y smoke tests automáticos, un viernes es un día como otro, y los datos del informe State of DevOps son consistentes en esto: los equipos de élite despliegan cuando hace falta.
Dicho eso, la ventana es una medida de transición razonable mientras el equipo construye confianza, y también es un requisito de negocio legítimo en algunos contextos (una plataforma de reservas puede no querer tocar nada un sábado por la mañana). Si la pones, ponle fecha de revisión y una vía de escape registrada como la de arriba: una restricción sin escape se acaba saltando de formas peores (desplegar a mano por SSH), y una restricción sin fecha de revisión se vuelve permanente por inercia.
Reto opcional
Sustituye la aprobación manual de producción por una puerta automática basada en datos: que producción se despliegue sola si el smoke test de staging pasa y la tasa de error de staging en los últimos 10 minutos es inferior al 0,5 % y el cambio no toca ficheros marcados como sensibles (migraciones, configuración de seguridad); y que pida aprobación humana solo en caso contrario. Es el paso de "despliegue continuo con puerta" a "despliegue continuo con puerta condicional", y es lo que hacen los equipos que despliegan 50 veces al día sin que nadie apruebe nada. Necesitarás las métricas de la 07-04, así que guárdalo para después.
Qué has construido
- Un Dockerfile multi-etapa con usuario no-root,
HEALTHCHECK, metadatos OCI y.dockerignore. - Publicación en
ghcr.iodesde el pipeline con credencial efímera, caché de capas y attestación de procedencia. - Separación CI/CD con
workflow_runy la guarda que impide desplegar un CI en rojo. - Dos Environments —
stagingautomático yproduccioncon revisor requerido— y la experiencia de ver el pipeline esperándote. - Un script de despliegue idempotente, ejecutable en tres destinos distintos sin cambios, que rechaza los tags, guarda el digest anterior y espera a
healthy. - Un smoke test con reintentos que verifica la versión desplegada y comprueba que los errores siguen siendo errores.
- Promoción por digest con verificación explícita de que producción despliega exactamente lo validado, sin reconstruir.
- Un
rollback.ymlcronometrado que revierte en menos de dos minutos. - La comprobación de que un bug que el CI no detecta se para en staging y nunca llega a producción.
Conclusión
El circuito está cerrado: un commit puede llegar a producción sin que nadie ejecute un comando a mano, y puede volver atrás igual de rápido. Has visto la pieza que hace que todo esto sea seguro y que se resume en una frase: el mismo artefacto, identificado por su contenido, recorre todos los entornos, y cada paso comprueba que efectivamente es el mismo. Sin esa cadena, staging no valida nada y el rollback es una lotería.
Pero hay una pregunta que tu pipeline todavía no sabe responder, y es la que más importa. El smoke test te dice que el servicio respondió correctamente durante los treinta segundos posteriores al despliegue. ¿Y a los veinte minutos? ¿Y cuando entra el tráfico de verdad? ¿Y si la versión nueva funciona pero es tres veces más lenta? ¿Y cuántas veces has desplegado esta semana, cuánto tardó cada cambio desde el commit hasta producción, y cuántos de esos despliegues acabaron en rollback? Ahora mismo la única respuesta honesta es "no lo sé", y un pipeline que no sabe si mejoró las cosas es un pipeline que no se puede defender ante nadie.
En la 07-04 instrumentas el sistema. Añadirás a src/servidor.js la medición de latencia y el conteo por código de estado, expondrás /metricas en formato Prometheus sin dependencias, levantarás Prometheus y Grafana con docker compose con un panel versionado en el repositorio, definirás un SLO con su presupuesto de error calculado con aritmética explícita, escribirás una alerta por síntoma y la dispararás a propósito metiendo un sleep en un endpoint, marcarás los despliegues en el panel para ver la correlación entre "desplegamos" y "empeoró", calcularás las cuatro métricas DORA de tu propio repositorio con un job programado, y —cerrando el bucle que esta lección deja abierto— harás que el cd.yml sondee las métricas después de desplegar y dispare el rollback solo si el error supera el umbral.
Curso de CI/CD: Integración y Despliegue Continuo
Módulo 1: Introducción a CI/CD
- Conceptos Básicos de CI/CD
- Beneficios de CI/CD
- Herramientas Populares de CI/CD
- El Proyecto del Curso: la Aplicación que Vamos a Automatizar
- Métricas DORA: Cómo se Mide la Entrega de Software
Módulo 2: Integración Continua (CI)
- Introducción a la Integración Continua
- Configuración de un Entorno de CI
- Automatización de la Construcción
- Pruebas Automatizadas
- Calidad de Código y Análisis Estático
- Artefactos, Versionado y Promoción
- Integración con Control de Versiones
Módulo 3: Despliegue Continuo (CD)
- Introducción al Despliegue Continuo
- Automatización del Despliegue
- Infraestructura como Código y Entornos Reproducibles
- Estrategias de Despliegue
- Feature Flags, Rollback y Recuperación ante Fallos
- Monitoreo y Retroalimentación
Módulo 4: Prácticas Avanzadas de CI/CD
- Pipelines de CI/CD
- Gestión de Dependencias
- Seguridad en CI/CD
- Escalabilidad y Rendimiento
- Pipeline as Code: Plantillas, Reutilización y Pruebas del Pipeline
- Bases de Datos en el Pipeline: Migraciones Seguras
Módulo 5: Implementación de CI/CD en Proyectos Reales
- Caso de Estudio: Proyecto Web
- Caso de Estudio: Aplicación Móvil
- Caso de Estudio: Microservicios
- Caso de Estudio: Modernizar un Proyecto Legacy
Módulo 6: Herramientas y Tecnologías
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker y Kubernetes
- GitHub Actions a Fondo
- Comparativa y Criterios para Elegir Herramienta
Módulo 7: Ejercicios Prácticos
- Ejercicio 1: Configuración de un Pipeline Básico
- Ejercicio 2: Integración de Pruebas Automatizadas
- Ejercicio 3: Despliegue en un Entorno de Producción
- Ejercicio 4: Monitoreo y Retroalimentación
- Ejercicio 5: Endurecer el Pipeline con Seguridad y Secretos
- Proyecto Final: Pipeline Completo de Extremo a Extremo
