La tabla con la que cerramos 05-04 —qué se ejecuta al guardar, en el pull request, al desplegar y en producción— es una lista de buenas intenciones mientras alguien tenga que acordarse de ejecutarla. Y nadie se acuerda un viernes a las siete de la tarde con una corrección urgente entre manos.
Esta lección convierte esa lista en maquinaria. Vamos a empaquetar la API de Tienda Aroma en una imagen Docker que arranca en cualquier sitio, levantar el entorno completo con Redis en un comando, y escribir la tubería de GitHub Actions que ejecuta en orden el linting, Spectral, las pruebas con cobertura, npm audit, oasdiff, la construcción de la imagen y Newman contra preproducción. Después veremos lo que de verdad distingue a un equipo que despliega con tranquilidad de uno que despliega con miedo: migraciones retrocompatibles, estrategias de despliegue sin cortes, y un plan de vuelta atrás que funcione.
Todo lo construido en el módulo 4 reaparece aquí con un papel operativo: /salud y /salud/preparado deciden cuándo entra el tráfico, las métricas y los SLO de 04-07 deciden si el despliegue sigue o se revierte, y el apagado ordenado de 03-07 es lo que permite desplegar sin cortar peticiones a medias.
Contenido
- Integración continua, entrega continua y despliegue continuo
- Por qué la rama principal siempre debe estar desplegable
- Empaquetar con Docker: el
Dockerfilemultietapa .dockerignore, capas y tamaño de la imagen- El entorno completo con
docker-compose.yml - Configuración por entorno y secretos fuera de la imagen
- La tubería de CI:
.github/workflows/ci.yml - Matriz de versiones y caché de dependencias
- Qué hace fallar la tubería y por qué las puertas son inflexibles
- Construir y publicar la imagen
- Migraciones: expandir, migrar, contraer
- Estrategias de despliegue
- El papel de liveness y readiness
- Vuelta atrás y feature flags
- Dónde desplegar
- Versionado de artefactos y trazabilidad
- Después del despliegue: smoke tests y vigilancia
- Secretos en CI y mínimo privilegio
- Integración continua, entrega continua y despliegue continuo
Tres términos que se usan como sinónimos y no lo son:
| Integración continua (CI) | Entrega continua (CD) | Despliegue continuo | |
|---|---|---|---|
| Qué automatiza | Construir y probar cada cambio | Producir un artefacto desplegable siempre | Desplegar a producción sin intervención |
| Frecuencia | Cada push | Cada fusión en main |
Cada fusión en main |
| ¿Hay un botón humano? | No aplica | Sí: alguien decide cuándo | No |
| Requisito previo | Pruebas automáticas fiables | CI sólida + entornos reproducibles | CD + observabilidad + vuelta atrás automática |
| Riesgo si falta | Ramas que divergen semanas | Despliegues manuales y frágiles | Ninguno: es una elección legítima |
Dónde está Tienda Aroma. Integración continua completa, entrega continua a preproducción de forma automática, y despliegue a producción con un botón. Es la configuración adecuada para el punto en el que estamos, y merece justificación: el despliegue continuo a producción exige que la vuelta atrás sea automática y que la observabilidad detecte una degradación en minutos. Tenemos lo segundo desde 04-07; lo primero llegará cuando el canary del apartado 12 esté montado. Adoptar despliegue continuo antes de tener esas dos piezas no es madurez, es imprudencia.
La palabra que importa en los tres términos es continua: pequeño y frecuente. Un despliegue de diez cambios pequeños tiene diez oportunidades de fallar por separado y cada fallo es trivial de localizar. Un despliegue trimestral con doscientos cambios falla una vez y nadie sabe cuál de los doscientos fue.
- Por qué la rama principal siempre debe estar desplegable
La regla es sencilla de enunciar y difícil de sostener: cualquier commit de main debe poder desplegarse a producción en este momento. De ella se derivan varias prácticas:
- Ramas cortas. Una rama de tres semanas garantiza un conflicto doloroso y una revisión imposible de hacer bien. Uno o dos días es lo razonable.
- Puertas antes de fusionar, no después. Si la tubería se ejecuta después del merge,
mainestá roto mientras alguien lo arregla, y todo el equipo queda bloqueado. - Funcionalidad incompleta bajo bandera. Cuando algo no está listo, se fusiona desactivado con un feature flag en lugar de vivir en una rama aparte. Es la única alternativa real a las ramas largas.
- Arreglar la tubería es prioridad absoluta. Una tubería roja que nadie arregla en una hora deja de ser una señal y se convierte en ruido; a partir de ahí, la gente fusiona en rojo y el sistema entero pierde su valor.
Y una consecuencia menos obvia: la protección de la rama debe ser técnica, no cultural. En GitHub, eso son reglas de protección de rama con las comprobaciones marcadas como obligatorias. Un acuerdo verbal se rompe el día de una urgencia; una regla configurada, no.
- Empaquetar con Docker: el
Dockerfile multietapa
Dockerfile multietapaUn contenedor resuelve el problema más antiguo del oficio: que la aplicación se comporte igual en tu portátil, en CI y en producción. Empaqueta el código, sus dependencias y el runtime en una imagen inmutable.
Fichero nuevo Dockerfile en la raíz del proyecto:
# syntax=docker/dockerfile:1.7
# ============================================================================
# ETAPA 1 — dependencias de construcción
# Instala TODAS las dependencias (incluidas las de desarrollo) porque
# better-sqlite3 es un módulo nativo y necesita compilarse.
# ============================================================================
FROM node:20-bookworm-slim AS dependencias
# Herramientas de compilación para los módulos nativos. Solo viven en esta
# etapa: no llegan a la imagen final, que es todo el sentido del multietapa.
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copiamos SOLO los manifiestos antes que el código fuente.
# Docker cachea por capas: mientras package*.json no cambie, el npm ci
# de abajo se reutiliza aunque hayas tocado cien ficheros de src/.
COPY package.json package-lock.json ./
# npm ci (no npm install): instala EXACTAMENTE lo del lock, es reproducible
# y falla si el lock y el package.json no concuerdan. Es lo que se quiere en CI.
RUN npm ci
# ============================================================================
# ETAPA 2 — dependencias de producción
# Reinstala solo lo necesario para ejecutar. Reduce mucho la imagen final
# y, sobre todo, la superficie de ataque: menos código, menos CVE.
# ============================================================================
FROM dependencias AS dependencias-produccion
RUN npm ci --omit=dev
# ============================================================================
# ETAPA 3 — pruebas (opcional, se invoca con --target pruebas)
# Permite ejecutar la suite dentro de la misma imagen que se desplegará,
# eliminando el "en mi máquina pasaba".
# ============================================================================
FROM dependencias AS pruebas
COPY . .
RUN npm run lint && npm test
# ============================================================================
# ETAPA 4 — imagen final de ejecución
# ============================================================================
FROM node:20-bookworm-slim AS produccion
# NODE_ENV=production cambia el comportamiento de Express (vistas cacheadas,
# stack traces fuera de las respuestas) y de muchas bibliotecas. Es obligatorio.
ENV NODE_ENV=production \
PORT=3000 \
NPM_CONFIG_UPDATE_NOTIFIER=false
# tini es un init mínimo: reemite las señales al proceso hijo y recoge los
# procesos zombis. Sin él, un SIGTERM puede no llegar nunca a Node y el
# apagado ordenado de 03-07 no se ejecuta.
RUN apt-get update \
&& apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Usuario no root: la imagen de Node ya trae el usuario "node" (uid 1000).
# Ejecutar como root dentro del contenedor es un riesgo innecesario: si alguien
# logra ejecución de código, empieza con todos los privilegios del contenedor.
# --chown evita un "chown -R" posterior, que duplicaría la capa entera.
COPY --chown=node:node --from=dependencias-produccion /app/node_modules ./node_modules
COPY --chown=node:node package.json ./
COPY --chown=node:node src/ ./src/
COPY --chown=node:node migraciones/ ./migraciones/
COPY --chown=node:node openapi.yaml ./
USER node
EXPOSE 3000
# HEALTHCHECK usa el endpoint de LIVENESS de 04-07, no el de readiness:
# aquí preguntamos "¿el proceso está vivo?", no "¿puede atender tráfico?".
# Si usáramos /salud/preparado, una caída de Redis reiniciaría el contenedor
# en bucle en lugar de simplemente sacarlo del balanceador.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/salud').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
# tini como PID 1; el proceso Node es su hijo y recibe las señales limpiamente.
ENTRYPOINT ["/usr/bin/tini", "--"]
# Forma "exec" (array), NUNCA forma shell. Con `CMD node src/servidor.js` en
# forma shell, el PID 1 sería /bin/sh y SIGTERM no llegaría a Node.
CMD ["node", "src/servidor.js"]Las cinco decisiones que más importan de ese fichero:
- Multietapa. Las herramientas de compilación (
python3,make,g++, unos 300 MB) se quedan en la etapa 1. La imagen final solo lleva runtime y dependencias de producción. npm ci --omit=dev. Fuerasupertest,eslint,prettier, Spectral,autocannon, Prism y Newman. Menos tamaño y, sobre todo, menos superficie de ataque.- Usuario
node, no root. Combinado conreadOnlyRootFilesystemen el orquestador, cierra buena parte de los caminos de escalada. tini+ forma exec. Es lo que hace queSIGTERMllegue a Node y se ejecute elcerrarOrdenadamente()de 03-07. Sin esto, cada despliegue corta peticiones a medias y los clientes ven errores de red que no aparecen en ningún log.HEALTHCHECKsobre liveness. La distinción de 04-07 tiene aquí su consecuencia práctica: confundir los dos endpoints provoca reinicios en cascada cuando falla una dependencia.
Comprobación local:
docker build -t tienda-aroma-api:local .
docker run --rm -p 3000:3000 --env-file .env.local tienda-aroma-api:local
# Verificar el apagado ordenado: debe salir en menos de un segundo,
# no a los 10 s del timeout forzado de Docker.
docker stop $(docker ps -q --filter ancestor=tienda-aroma-api:local)Si docker stop tarda diez segundos, el SIGTERM no está llegando. Es la comprobación más útil y la que casi nadie hace.
.dockerignore, capas y tamaño de la imagen
.dockerignore, capas y tamaño de la imagenFichero nuevo .dockerignore:
# Nunca entran en la imagen node_modules npm-debug.log* .git .github .env .env.* *.local.json # Artefactos de desarrollo y pruebas pruebas/ cobertura/ informes/ postman/ docs/ *.md !README.md # Base de datos local y temporales datos/ *.sqlite *.sqlite-journal .DS_Store
Tres motivos, por orden de importancia:
- Seguridad. Sin
.dockerignore, unCOPY . .mete tu.envcon las claves reales dentro de una imagen que quizá acabe en un registro compartido. Es una de las fugas de credenciales más frecuentes que existen. - Corrección. Copiar tu
node_moduleslocal con binarios compilados para macOS a una imagen Linux produce fallos incomprensibles. - Velocidad y tamaño.
.gitpuede pesar cientos de megas y se envía entero al demonio de Docker en cada construcción.
Sobre las capas: cada instrucción crea una capa, y Docker las cachea. El orden del Dockerfile no es estético, es una estrategia de caché: lo que cambia poco arriba, lo que cambia mucho abajo. Copiar package*.json antes que src/ significa que un cambio de código reutiliza el npm ci, que es la instrucción cara. Al revés, cada construcción reinstalaría todo.
Referencia de tamaños aproximados para elegir base:
| Base | Tamaño de la imagen final | Notas |
|---|---|---|
node:20 |
~1,1 GB | Debian completo. Solo si necesitas muchas herramientas. |
node:20-bookworm-slim |
~250 MB | La nuestra. Buen equilibrio; glibc, sin sorpresas con módulos nativos. |
node:20-alpine |
~180 MB | musl en lugar de glibc: puede dar problemas con módulos nativos como better-sqlite3. |
gcr.io/distroless/nodejs20 |
~170 MB | Sin shell ni gestor de paquetes: máxima seguridad, depuración incómoda. |
La recomendación para Tienda Aroma es bookworm-slim: Alpine ahorra 70 MB y puede costarte una tarde compilando better-sqlite3 contra musl. Distroless es una excelente elección cuando el equipo tiene madurez operativa, pero entrar en el contenedor a depurar deja de ser una opción.
Y un consejo de seguridad que encaja con 04-02: escanea la imagen.
# Vulnerabilidades conocidas en la imagen construida
docker scout cves tienda-aroma-api:local
# o
trivy image tienda-aroma-api:local --severity HIGH,CRITICAL
- El entorno completo con
docker-compose.yml
docker-compose.ymlFichero nuevo docker-compose.yml:
# docker-compose.yml — entorno completo de Tienda Aroma para desarrollo y pruebas
services:
api:
build:
context: .
target: produccion
ports:
- "3000:3000"
environment:
NODE_ENV: development
PORT: 3000
# En Compose los servicios se resuelven por su nombre: "redis" es un host.
REDIS_URL: redis://redis:6379
RUTA_BASE_DATOS: /datos/aroma.sqlite
# Los secretos NO van aquí en texto: llegan del fichero .env local,
# que está en .gitignore y en .dockerignore.
JWT_SECRETO: ${JWT_SECRETO:?falta JWT_SECRETO en .env}
ORIGENES_PERMITIDOS: http://localhost:5173,http://localhost:4173
NIVEL_LOG: debug
volumes:
# Volumen con nombre para que la base sobreviva a docker compose down
- datos-api:/datos
depends_on:
redis:
condition: service_healthy # no arranca hasta que Redis responde
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: 5
start_period: 15s
restart: unless-stopped
redis:
image: redis:7-alpine
# appendonly sí: el rate limiting de 04-04 y la caché de 04-06 toleran
# perder datos, pero en desarrollo es cómodo que sobrevivan al reinicio.
command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
ports:
- "6379:6379"
volumes:
- datos-redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
# Mock del contrato (05-04): permite a la SPA trabajar sin depender de la API.
mock:
image: stoplight/prism:5
command: ["mock", "-h", "0.0.0.0", "-p", "4010", "--errors", "/tmp/openapi.yaml"]
ports:
- "4010:4010"
volumes:
- ./openapi.yaml:/tmp/openapi.yaml:ro
profiles: ["desarrollo"] # solo arranca con --profile desarrollo
# PostgreSQL: mencionado aquí porque es el destino natural cuando SQLite
# se queda corto. Migrar exige cambiar solo src/repositorios/*, gracias al
# patrón repositorio de 03-05; el resto del proyecto no se entera.
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: aroma
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-clave-local-ficticia}
POSTGRES_DB: aroma
ports:
- "5432:5432"
volumes:
- datos-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U aroma"]
interval: 5s
retries: 5
profiles: ["postgres"]
volumes:
datos-api:
datos-redis:
datos-postgres:Uso cotidiano:
docker compose up -d --wait # levanta API y Redis, espera a que estén sanos
docker compose --profile desarrollo up -d # añade el mock de Prism
docker compose logs -f api # sigue los logs de pino
docker compose exec api npm run migrar # ejecuta una migración dentro del contenedor
docker compose down -v # destruye todo, volúmenes incluidosDos detalles que evitan horas de depuración:
condition: service_healthyendepends_on. Sin él,depends_onsolo espera a que el contenedor arranque, no a que el servicio funcione, y la API intenta conectar a un Redis que aún no acepta conexiones.${JWT_SECRETO:?falta ...}. Esa sintaxis hace que Compose falle con un mensaje claro si la variable no está definida, en lugar de arrancar con un secreto vacío. Arrancar con un secreto vacío es peor que no arrancar.
Y un fichero adicional docker-compose.pruebas.yml, que es el que usan las pruebas de extremo a extremo de 05-04: igual pero con NODE_ENV=test, base de datos efímera (tmpfs, sin volumen persistente) y sin puertos expuestos salvo el de la API.
- Configuración por entorno y secretos fuera de la imagen
El principio, tomado de los Twelve-Factor App: una imagen, muchos entornos. La misma imagen que pasó las pruebas es la que va a preproducción y luego a producción, sin reconstruir. Si reconstruyes para cada entorno, no estás desplegando lo que probaste.
Todo lo que varía entre entornos son variables de entorno:
| Variable | desarrollo | pruebas | preproducción | producción |
|---|---|---|---|---|
NODE_ENV |
development |
test |
production |
production |
NIVEL_LOG |
debug |
silent |
info |
info |
RUTA_BASE_DATOS |
fichero local | :memory: |
volumen | gestionada |
REDIS_URL |
redis://redis:6379 |
redis://redis:6379 |
interna | gestionada con TLS |
JWT_SECRETO |
ficticio en .env |
ficticio fijo | del gestor de secretos | del gestor de secretos |
ORIGENES_PERMITIDOS |
localhost:5173 |
localhost |
*.pruebas.tiendaaroma.example |
tiendaaroma.example, panel.… |
LIMITE_GLOBAL_POR_MINUTO |
10000 |
10000 |
600 |
600 |
DOCS_PUBLICAS |
true |
true |
true |
false |
MUESTREO_TRAZAS |
1.0 |
0 |
1.0 |
0.05 |
Cuatro reglas sobre secretos que no admiten excepción:
- Ningún secreto dentro de la imagen. Ni en el
Dockerfile, ni conARG—losARGquedan en el historial de capas y se ven condocker history—, ni en un fichero copiado. - Ningún secreto en el repositorio.
.enven.gitignore;.env.examplecon las claves y valores ficticios, versionado, para documentar qué hace falta. - Los secretos se inyectan en tiempo de ejecución por el orquestador, desde su gestor: GitHub Secrets, AWS Secrets Manager, Google Secret Manager, HashiCorp Vault, Kubernetes Secrets.
src/config/entorno.jsvalida al arrancar. Ya lo escribimos en 03-01, y aquí se ve por qué importa: si faltaJWT_SECRETO, el proceso debe morir inmediatamente con un mensaje claro, no arrancar y fallar en la primera petición autenticada. Fallar rápido y ruidosamente en el arranque es lo que hace que un despliegue mal configurado no llegue a recibir tráfico.
- La tubería de CI:
.github/workflows/ci.yml
.github/workflows/ci.ymlFichero nuevo .github/workflows/ci.yml:
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
# Cancela ejecuciones anteriores de la misma rama: si haces tres push seguidos,
# solo se ejecuta el último. Ahorra minutos de CI y da retroalimentación antes.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
# Permisos mínimos por defecto (apartado 18). Cada trabajo pide lo que necesita.
permissions:
contents: read
env:
NODE_VERSION_PRINCIPAL: '20'
jobs:
# --------------------------------------------------------------------------
# 1. Calidad estática: rápido y sin dependencias externas. Falla en 30 s.
# --------------------------------------------------------------------------
calidad:
name: Lint y formato
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION_PRINCIPAL }}
cache: npm # cachea ~/.npm usando package-lock.json como clave
- name: Instalar dependencias
run: npm ci
- name: ESLint
run: npm run lint
- name: Prettier (comprobación, no escritura)
run: npx prettier --check .
# --------------------------------------------------------------------------
# 2. El contrato: validez estructural + guía de estilo + cambios rompedores.
# Corre en paralelo con las pruebas porque no depende de ellas.
# --------------------------------------------------------------------------
contrato:
name: Validación del contrato
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # necesitamos el historial para comparar con main
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION_PRINCIPAL }}
cache: npm
- run: npm ci
- name: ¿Es un documento OpenAPI válido? (05-02)
run: npx swagger-cli validate openapi.yaml
- name: ¿Cumple la guía de estilo? (Spectral, 04-01)
run: npx spectral lint openapi.yaml --fail-severity=error
- name: Instalar oasdiff
run: |
curl -fsSL https://raw.githubusercontent.com/oasdiff/oasdiff/main/install.sh | sh
- name: ¿Introduce cambios rompedores? (05-04)
if: github.event_name == 'pull_request'
run: bash herramientas/comprobar-contrato.sh origin/main
# --------------------------------------------------------------------------
# 3. Pruebas: unitarias + integración + contrato, en varias versiones de Node.
# --------------------------------------------------------------------------
pruebas:
name: Pruebas (Node ${{ matrix.node }})
runs-on: ubuntu-latest
strategy:
fail-fast: false # que fallen todas las que tengan que fallar
matrix:
node: ['20', '22'] # LTS actual y la siguiente: detecta roturas pronto
services:
redis:
image: redis:7-alpine
ports: ['6379:6379']
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- name: Migrar y sembrar la base de pruebas
run: npm run bd:reiniciar
env:
RUTA_BASE_DATOS: ':memory:'
- name: Pruebas con cobertura
run: node --test --experimental-test-coverage pruebas/
env:
NODE_ENV: test
REDIS_URL: redis://localhost:6379
JWT_SECRETO: secreto-ficticio-solo-para-ci
NIVEL_LOG: silent
- name: Publicar el informe de cobertura
if: matrix.node == '20'
uses: actions/upload-artifact@v4
with:
name: cobertura
path: cobertura/
retention-days: 7
# --------------------------------------------------------------------------
# 4. Seguridad de dependencias (04-02).
# --------------------------------------------------------------------------
seguridad:
name: Auditoría de dependencias
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION_PRINCIPAL }}
cache: npm
- run: npm ci
# Falla con vulnerabilidades altas o críticas. Las moderadas se revisan
# pero no bloquean: si bloquearan, la tubería estaría roja permanentemente
# por dependencias transitivas y el equipo aprendería a ignorarla.
- name: npm audit
run: npm audit --audit-level=high
- name: Comprobar que el lock está sincronizado
run: |
npm ci --dry-run 2>&1 | tee /tmp/salida
! grep -q "npm warn" /tmp/salida || echo "Revisar avisos de npm"
# --------------------------------------------------------------------------
# 5. Imagen: solo si todo lo anterior pasó. En PR se construye pero no se
# publica; en main se publica etiquetada con el SHA del commit.
# --------------------------------------------------------------------------
imagen:
name: Construir y publicar la imagen
needs: [calidad, contrato, pruebas, seguridad]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # necesario para empujar al registro
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Autenticarse en el registro
if: github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Etiquetas y metadatos
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}/api
tags: |
type=sha,format=long # la etiqueta = el commit: trazabilidad
type=ref,event=branch
type=semver,pattern={{version}}
- name: Construir (y publicar solo en main)
uses: docker/build-push-action@v6
with:
context: .
target: produccion
push: ${{ github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Escanear la imagen
uses: aquasecurity/trivy-action@master
with:
image-ref: ghcr.io/${{ github.repository }}/api:sha-${{ github.sha }}
severity: 'HIGH,CRITICAL'
exit-code: '1'
ignore-unfixed: true # sin parche disponible, no hay nada que hacer
# --------------------------------------------------------------------------
# 6. Despliegue a preproducción y verificación (solo en main).
# --------------------------------------------------------------------------
preproduccion:
name: Desplegar a preproducción y verificar
needs: [imagen]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment:
name: preproduccion
url: https://api.pruebas.tiendaaroma.example
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION_PRINCIPAL }}
cache: npm
- run: npm ci
- name: Aplicar migraciones (antes del despliegue, apartado 11)
run: npm run migrar
env:
URL_BASE_DATOS: ${{ secrets.URL_BASE_DATOS_PREPRODUCCION }}
- name: Desplegar la imagen
run: ./herramientas/desplegar.sh preproduccion "sha-${{ github.sha }}"
env:
TOKEN_DESPLIEGUE: ${{ secrets.TOKEN_DESPLIEGUE_PREPRODUCCION }}
- name: Esperar a que el readiness esté verde (04-07)
run: |
for intento in $(seq 1 30); do
if curl -fsS https://api.pruebas.tiendaaroma.example/salud/preparado; then
echo "Listo tras ${intento} intentos."; exit 0
fi
sleep 5
done
echo "El servicio no llegó a estar preparado en 150 s."; exit 1
- name: Pruebas de extremo a extremo (05-04)
run: node --test pruebas/e2e/
env:
URL_API: https://api.pruebas.tiendaaroma.example/v1
EMAIL_PRUEBA: ${{ secrets.EMAIL_PRUEBA }}
CLAVE_PRUEBA: ${{ secrets.CLAVE_PRUEBA }}
- name: Colección de Postman con Newman (05-01)
run: |
npx newman run postman/tienda-aroma-v1.postman_collection.json \
-e postman/pruebas.postman_environment.json \
--env-var "claveCliente=${{ secrets.CLAVE_PRUEBA }}" \
--delay-request 100 \
--reporters cli,junit \
--reporter-junit-export informes/newman.xml
- name: Publicar el informe de Newman
if: always()
uses: actions/upload-artifact@v4
with:
name: informe-newman
path: informes/newman.xml
- Matriz de versiones y caché de dependencias
La matriz. matrix: node: ['20', '22'] ejecuta las pruebas dos veces. El coste es doble tiempo de CI; el beneficio, descubrir con meses de antelación que algo se rompe en la siguiente LTS, cuando aún hay tiempo de arreglarlo con calma en lugar de bajo la presión de un fin de soporte.
fail-fast: false es importante: por defecto, GitHub cancela el resto de la matriz al primer fallo, y entonces no sabes si el problema es de Node 22 o de tu código. Con false ves ambos resultados.
Cuándo la matriz merece la pena: en bibliotecas, casi siempre; en una aplicación desplegada, cuando la migración de versión mayor es previsible y quieres anticiparte. En Tienda Aroma sí, porque desplegamos en Node 20 y la 22 será la siguiente LTS.
La caché. cache: npm en setup-node cachea el directorio ~/.npm usando el hash de package-lock.json como clave. Efecto típico: npm ci baja de 60-90 segundos a 10-15.
Un matiz que confunde: cachea la descarga, no node_modules. Es deliberado. Cachear node_modules es una fuente clásica de fallos fantasma —módulos nativos compilados para otra versión, restos de instalaciones anteriores— y no es reproducible. npm ci borra node_modules y reinstala desde cero; lo único que se ahorra es la red.
Para la imagen, la caché es distinta: cache-from: type=gha guarda las capas de Docker en la caché de GitHub Actions, de modo que la etapa cara —npm ci con compilación de better-sqlite3— se reutiliza mientras package-lock.json no cambie.
- Qué hace fallar la tubería y por qué las puertas son inflexibles
| Puerta | Falla si | ¿Bloquea el merge? | Por qué |
|---|---|---|---|
| ESLint | Cualquier error | Sí | Los avisos ya están filtrados; un error es un error |
| Prettier | Un fichero sin formatear | Sí | Formato automático: no hay excusa ni discusión |
swagger-cli validate |
El contrato no es OpenAPI válido | Sí | Rompe la documentación y todos los generadores |
| Spectral | Alguna regla error |
Sí | Es la guía de estilo acordada (04-01) |
oasdiff breaking |
Hay cambios rompedores | Sí, salvo etiqueta explícita | Rompe a Aroma Móvil, y es permanente |
| Pruebas | Una sola falla | Sí | Evidente |
| Cobertura | Baja del umbral acordado | Sí, pero con criterio | Ver más abajo |
npm audit |
Vulnerabilidad alta o crítica | Sí | Con --audit-level=high: las moderadas no bloquean |
| Trivy | CVE alta o crítica con parche | Sí | ignore-unfixed: sin parche no hay acción posible |
| E2E en preproducción | Una falla | Sí, no promociona a producción | Es la última red antes de los clientes |
Por qué la inflexibilidad importa. Una puerta que se puede saltar «solo esta vez» deja de ser una puerta al tercer «solo esta vez». La regla que funciona es: si una puerta molesta, se cambia la regla mediante un pull request que la modifique —discutido y visible— no se salta en una ejecución concreta. Ese es exactamente el procedimiento de Spectral de 04-01: una regla entra como warn, se limpian las infracciones y luego sube a error.
El único mecanismo de escape legítimo es explícito, deja rastro y exige justificación. Para oasdiff:
- name: ¿Introduce cambios rompedores?
if: >
github.event_name == 'pull_request' &&
!contains(github.event.pull_request.labels.*.name, 'cambio-rompedor')
run: bash herramientas/comprobar-contrato.sh origin/mainPoner la etiqueta cambio-rompedor es un acto deliberado, visible en el pull request, que obliga a explicarse y que además puede exigir la aprobación de alguien concreto mediante CODEOWNERS.
Sobre la cobertura. Un umbral es útil como red contra el descuido —«no bajemos del 80 %»— y perverso como objetivo: perseguir el 100 % produce pruebas que ejecutan código sin comprobar nada. La configuración sensata es exigir que la cobertura no baje respecto de main, en lugar de un número absoluto.
- Construir y publicar la imagen
El trabajo imagen construye siempre y publica solo desde main. Dos consecuencias buenas: los pull requests verifican que el Dockerfile sigue funcionando —un fallo de construcción se detecta en la revisión, no al fusionar— y el registro no se llena de imágenes de ramas efímeras.
Las etiquetas que produce docker/metadata-action:
ghcr.io/tiendaaroma/api:sha-3f9a2c1e8b474d2a9e0177c6b5d3a8129e4c1b2d ghcr.io/tiendaaroma/api:main ghcr.io/tiendaaroma/api:1.7.0 (si el commit lleva una etiqueta de versión)
La etiqueta que se despliega es siempre la del SHA. Nunca latest, nunca main. Motivo: son etiquetas móviles. Si despliegas main y mañana hay que investigar qué había en producción el martes, la respuesta es «no se sabe». Con sha-3f9a2c… la respuesta es un commit exacto, con su diff, su autor y su pull request. Es el principio de trazabilidad del apartado 16.
cache-from/cache-to: type=gha reutiliza las capas entre ejecuciones. Sin ello, cada construcción recompila better-sqlite3 desde cero: dos o tres minutos por ejecución que se convierten en veinte segundos.
- Migraciones: expandir, migrar, contraer
Aquí está el problema más subestimado del despliegue continuo. Durante un despliegue sin cortes conviven dos versiones del código sobre una sola base de datos. Si la migración no es compatible con ambas, hay errores garantizados.
Escenario concreto. Queremos renombrar notas_cata a notas en la tabla cafes.
La forma ingenua, que rompe:
-- migraciones/013-renombrar-notas.sql ← NO HAGAS ESTO
ALTER TABLE cafes RENAME COLUMN notas_cata TO notas;Secuencia de los hechos: la migración se aplica; durante los siguientes dos minutos, las instancias con el código antiguo siguen atendiendo tráfico y ejecutan SELECT notas_cata FROM cafes; esa columna ya no existe; cada petición al catálogo devuelve 500 hasta que termina el despliegue. Y si hay que revertir, el código antiguo tampoco funciona: has perdido la vuelta atrás.
La forma correcta: expandir → migrar → contraer. Tres despliegues separados.
-- PASO 1 — EXPANDIR (despliegue 1). Solo añade. Compatible con todo.
ALTER TABLE cafes ADD COLUMN notas TEXT;
UPDATE cafes SET notas = notas_cata WHERE notas IS NULL;// Código del despliegue 1: escribe en AMBAS, lee de la antigua.
export function guardarCafe(cafe) {
bd.prepare(`
UPDATE cafes SET notas_cata = ?, notas = ? WHERE id = ?
`).run(JSON.stringify(cafe.notasCata), JSON.stringify(cafe.notasCata), cafe.id);
}
export function leerCafe(id) {
const fila = bd.prepare('SELECT * FROM cafes WHERE id = ?').get(id);
return { ...fila, notasCata: JSON.parse(fila.notas_cata) }; // aún la antigua
}// PASO 2 — MIGRAR (despliegue 2). Escribe en ambas, LEE DE LA NUEVA.
// Si algo va mal, se revierte al despliegue 1 sin perder datos, porque
// ambas columnas están pobladas y sincronizadas.
export function leerCafe(id) {
const fila = bd.prepare('SELECT * FROM cafes WHERE id = ?').get(id);
return { ...fila, notasCata: JSON.parse(fila.notas ?? fila.notas_cata) };
}-- PASO 3 — CONTRAER (despliegue 3, días o semanas después).
-- Solo cuando NINGUNA instancia con código antiguo puede estar viva
-- y la vuelta atrás a esa versión ya no es una opción realista.
ALTER TABLE cafes DROP COLUMN notas_cata;Reglas prácticas de migración:
| Operación | ¿Segura durante un despliegue? | Nota |
|---|---|---|
ADD COLUMN con valor por defecto o nulo |
Sí | La forma segura de añadir |
ADD COLUMN NOT NULL sin defecto |
No | Las escrituras del código antiguo fallan |
DROP COLUMN |
No | Solo en la fase de contracción |
RENAME COLUMN |
No | Es un DROP disfrazado. Expandir/contraer |
CREATE INDEX |
Depende | En PostgreSQL, CONCURRENTLY; sin él bloquea la tabla |
ALTER TYPE que estrecha |
No | Datos existentes pueden no caber |
| Añadir tabla | Sí | Nadie la usa aún |
Añadir restricción NOT NULL |
No | Expandir: rellenar, validar y luego restringir |
Cuándo se aplican. Antes de desplegar el código nuevo, en un paso propio de la tubería, como en el trabajo preproduccion del apartado 7. Nunca al arrancar la aplicación: con tres instancias arrancando a la vez tendrías tres procesos migrando en paralelo sobre la misma base. Si tu sistema de migraciones no toma un bloqueo, el resultado es impredecible.
Y las migraciones tienen que probarse. Una prueba que aplica todas las migraciones sobre una base vacía y comprueba el esquema resultante cuesta poco y evita el peor tipo de incidente: el que ocurre en el paso previo al despliegue, cuando aún no hay nada desplegado que revertir.
- Estrategias de despliegue
| Estrategia | Cómo funciona | Corte | Coste | Riesgo | Vuelta atrás | Cuándo |
|---|---|---|---|---|---|---|
| Recreate | Para todo, arranca lo nuevo | Sí, segundos o minutos | Mínimo | Alto | Redesplegar | Desarrollo; sistemas que toleran parada |
| Rolling | Sustituye instancias por tandas | No | Mínimo | Medio | Rolling inverso, lento | El caso por defecto |
| Blue-green | Dos entornos completos; se conmuta el tráfico | No | Doble infraestructura | Bajo | Instantánea | Despliegues críticos |
| Canary | Un 1-5 % del tráfico a la versión nueva; se sube si las métricas aguantan | No | Medio | Muy bajo | Automática por métricas | Alto volumen; cambios arriesgados |
graph TD
subgraph Rolling
R1[3 instancias v1] --> R2[2 v1 + 1 v2] --> R3[1 v1 + 2 v2] --> R4[3 instancias v2]
end
subgraph BlueGreen
B1[Azul v1 recibe tráfico<br/>Verde v2 se despliega y calienta] --> B2[Conmutar el balanceador] --> B3[Verde v2 recibe tráfico<br/>Azul v1 en espera para revertir]
end
subgraph Canary
C1[100% a v1] --> C2[95% v1 y 5% v2<br/>vigilar errores y latencia] --> C3{¿SLO en verde?}
C3 -->|Sí| C4[50% y 50%, después 100% v2]
C3 -->|No| C5[Vuelta a 100% v1<br/>automática]
end
Rolling es el modo por defecto de Kubernetes y de casi todos los orquestadores, y es una elección sensata. Su condición imprescindible es la del apartado anterior: durante la sustitución conviven ambas versiones, así que la base de datos y el contrato deben ser compatibles con las dos.
Blue-green es muy tranquilizador —revertir es volver a conmutar el balanceador, segundos— y caro: durante el despliegue pagas el doble de infraestructura. Y ojo: la base de datos no es blue-green. Es compartida, así que las migraciones siguen teniendo que ser retrocompatibles.
Canary es la estrategia que hace viable el despliegue continuo real, porque une el despliegue con la observabilidad de 04-07: se envía un porcentaje pequeño de tráfico a la versión nueva y se comparan automáticamente su tasa de error y su latencia p99 contra las de la versión estable. Si se degradan, se revierte solo. Requiere volumen suficiente para que las métricas sean significativas —con diez peticiones por minuto, un 5 % no dice nada— y un gateway o malla que sepa repartir el tráfico, que es justo lo que veremos en 05-06.
La conexión con /v1 y /v2 de 02-07. Conviene no confundir dos cosas que se parecen:
- Desplegar
v1.7.0sobrev1.6.0es un despliegue: mismo contrato, código nuevo. Aquí aplican rolling, blue-green o canary. - Publicar
/v2junto a/v1es convivencia de versiones de API: dos contratos distintos vivos durante meses, conDeprecationySunseten/v1. No es una estrategia de despliegue, es una decisión de producto.
Se combinan: /v2 se despliega con rolling como cualquier otra versión, y ambas rutas conviven en el mismo servicio (o en servicios separados detrás del gateway, que es más limpio para poder retirar /v1 apagando algo).
- El papel de liveness y readiness
Los dos endpoints de 04-07 dejan de ser teoría en cuanto hay un orquestador delante.
// src/app.js — recordatorio de la distinción, posiciones 10 y 11
// LIVENESS: ¿el proceso está vivo? Nada de dependencias.
// Si responde mal, el orquestador MATA y REINICIA el contenedor.
app.get('/salud', (req, res) => res.json({ estado: 'vivo' }));
// READINESS: ¿puedo atender tráfico AHORA? Comprueba dependencias.
// Si responde mal, el orquestador SACA la instancia del balanceador,
// pero NO la reinicia: quizá Redis vuelva en diez segundos.
app.get('/salud/preparado', async (req, res) => {
const comprobaciones = {
baseDatos: await comprobarBaseDatos(),
redis: await comprobarRedis(),
migracionesAlDia: await comprobarMigraciones(),
};
const listo = Object.values(comprobaciones).every(Boolean);
res.status(listo ? 200 : 503).json({ estado: listo ? 'preparado' : 'no_preparado', comprobaciones });
});Configuración equivalente en Kubernetes, que muestra cómo se usan:
livenessProbe:
httpGet: { path: /salud, port: 3000 }
initialDelaySeconds: 10
periodSeconds: 30
failureThreshold: 3 # tres fallos seguidos → reiniciar
readinessProbe:
httpGet: { path: /salud/preparado, port: 3000 }
initialDelaySeconds: 5
periodSeconds: 5 # más frecuente: reacciona rápido
failureThreshold: 2
# startupProbe: da margen al arranque sin relajar el liveness.
# Hasta que pasa, liveness y readiness no se evalúan.
startupProbe:
httpGet: { path: /salud, port: 3000 }
periodSeconds: 5
failureThreshold: 30 # hasta 150 s para arrancar
lifecycle:
preStop:
# Espera antes del SIGTERM: da tiempo a que el balanceador se entere
# de que esta instancia sale, evitando peticiones enrutadas a un
# proceso que ya está cerrando. Es la causa nº1 de errores 502
# durante despliegues "sin cortes".
exec: { command: ["sleep", "5"] }
terminationGracePeriodSeconds: 30Los tres errores clásicos, que producen incidentes muy difíciles de diagnosticar:
- Usar readiness como liveness. Redis se cae, readiness responde
503, el orquestador reinicia todos los contenedores en bucle, y ahora tienes dos problemas. - Un liveness que consulta la base de datos. Una consulta lenta hace fallar el liveness y reinicia una aplicación perfectamente sana, agravando la carga sobre la base.
- Olvidar el
preStop. Sin él, el balanceador puede seguir enviando peticiones durante uno o dos segundos a un proceso que ya recibióSIGTERM, y aparecen502esporádicos en cada despliegue que nadie consigue reproducir.
La cadena completa de un apagado limpio, uniendo 03-07 con esta lección: preStop (5 s de margen) → SIGTERM → tini lo reemite a Node → cerrarOrdenadamente() → servidor.close() deja de aceptar conexiones nuevas y termina las que están en curso → se cierran base de datos y Redis → process.exit(0). Si algo se atasca, terminationGracePeriodSeconds acaba con un SIGKILL a los 30 segundos.
- Vuelta atrás y feature flags
Revertir el código es fácil. Es volver a desplegar la etiqueta anterior:
Que sea trivial depende de tres cosas que ya tenemos: imágenes inmutables etiquetadas por commit, ninguna configuración dentro de la imagen, y migraciones retrocompatibles. La tercera es la que suele faltar.
Revertir los datos es el problema difícil, y conviene tenerlo claro antes de necesitarlo:
- Si la versión nueva escribió datos con un formato que la antigua no entiende, revertir el código no arregla nada.
- Si la migración borró una columna, revertir el código lo deja apuntando a algo que ya no existe.
- Restaurar una copia de seguridad significa perder todo lo ocurrido desde la copia: pedidos, pagos, reseñas. Casi nunca es aceptable.
De ahí que la regla de oro sea: el rollback de una migración destructiva no existe; se previene. Expandir → migrar → contraer no es una ceremonia burocrática, es precisamente lo que hace posible revertir.
Feature flags como alternativa. En lugar de revertir el despliegue, se apaga la funcionalidad:
// src/config/banderas.js
// Banderas leídas del entorno o de un servicio de configuración.
// Cambiarlas NO requiere desplegar: es su razón de ser.
export const banderas = {
recomendacionesEnCatalogo: entorno.leerBooleana('BANDERA_RECOMENDACIONES', false),
nuevoCalculoEnvio: entorno.leerBooleana('BANDERA_NUEVO_CALCULO_ENVIO', false),
};// src/servicios/pedidos.js
const gastosEnvio = banderas.nuevoCalculoEnvio
? calcularEnvioPorZonas(pedido) // lógica nueva, apagable en un segundo
: calcularEnvioPlano(pedido); // lógica antigua, intactaVentajas: separan desplegar de activar, permiten activar solo para un grupo (empleados primero, luego el 5 % de clientes), y apagar es instantáneo sin desplegar nada. Inconvenientes que hay que gestionar: cada bandera duplica caminos de código y por tanto casos de prueba, y las banderas caducan: una bandera que lleva un año encendida es deuda técnica. La disciplina que funciona es apuntar la fecha de retirada en el mismo commit que la crea.
- Dónde desplegar
| Opción | Esfuerzo | Control | Coste | Escalado | Encaje con Tienda Aroma |
|---|---|---|---|---|---|
| PaaS (Render, Railway, Fly.io) | Muy bajo | Bajo | Medio | Automático, limitado | Excelente para empezar: git push y listo |
| Contenedores gestionados (Cloud Run, ECS Fargate) | Bajo | Medio | Bajo-medio | Automático, a cero | La opción sensata: Docker sin operar servidores |
| Kubernetes gestionado (GKE, EKS, AKS) | Alto | Alto | Medio-alto | Total | Solo con varios servicios y alguien que lo opere |
| Serverless (Lambda, Cloud Functions) | Bajo | Bajo | Muy bajo sin tráfico | Automático | Problemático: arranques en frío y conexiones (05-03) |
| VPS propio | Medio | Total | Bajo | Manual | Válido si el equipo sabe administrar sistemas |
Recomendación para Tienda Aroma: Cloud Run o ECS Fargate. El razonamiento: ya tenemos la imagen Docker, que es lo único que piden; el escalado es automático; no hay servidores que parchear; y /salud/preparado se integra directamente como comprobación. Kubernetes daría más control del que necesitamos hoy y exige dedicación operativa a tiempo parcial que un equipo pequeño no tiene.
Un aviso sobre Kubernetes, porque es la decisión sobredimensionada más común del sector: es una herramienta excelente para su problema, que es orquestar muchos servicios con equipos que puedan operarlos. Adoptarlo para una API es cambiar el problema «desplegar una aplicación» por el problema «operar Kubernetes», que es mucho mayor. La pregunta correcta no es «¿es bueno?», sino «¿tenemos su problema?».
- Versionado de artefactos y trazabilidad
Ante un incidente, hay tres preguntas que deben responderse en menos de un minuto: qué versión está desplegada, qué contiene y cuándo llegó.
Las prácticas que lo garantizan:
- Etiqueta de imagen = commit.
sha-3f9a2c1e…. Sin ambigüedad posible. - La aplicación expone su versión, en la raíz o en
/salud:
// src/rutas/salud.js
app.get('/salud', (req, res) => {
res.json({
estado: 'vivo',
version: entorno.versionApi, // 1.7.0, de info.version del contrato
commit: entorno.commitSha, // inyectado como variable de entorno
desplegadoEn: entorno.fechaDespliegue,
});
});- La versión como etiqueta en las métricas de 04-07.
aroma_peticiones_total{version="1.7.0"}permite ver en un gráfico el momento exacto del despliegue y correlacionarlo con un cambio en la tasa de error. Es la señal que más rápido resuelve incidentes: «empezó justo al desplegar». - La versión en cada línea de log, que ya sale gratis con el logger base de pino.
- Un registro de despliegues: qué versión, quién, cuándo, a qué entorno. GitHub Deployments lo hace solo con
environment:en el workflow.
Una nota sobre versiones semánticas: info.version de openapi.yaml describe el contrato; el SHA describe el código. No coinciden: veinte commits pueden compartir contrato 1.7.0. Ambos son necesarios y responden a preguntas distintas.
- Después del despliegue: smoke tests y vigilancia
El despliegue no termina cuando la tubería se pone verde. Termina cuando alguien ha comprobado que el sistema está bien.
Smoke tests. Un subconjunto mínimo, rápido y no destructivo, ejecutado contra producción justo después de desplegar:
#!/usr/bin/env bash
# herramientas/smoke.sh — comprobaciones mínimas tras el despliegue.
# NO crea datos: en producción, una prueba destructiva es inaceptable.
set -euo pipefail
URL="${1:?uso: smoke.sh https://api.tiendaaroma.example}"
echo "1/5 liveness"
curl -fsS "${URL}/salud" | grep -q '"estado":"vivo"'
echo "2/5 readiness (dependencias)"
curl -fsS "${URL}/salud/preparado" | grep -q '"estado":"preparado"'
echo "3/5 la versión desplegada es la esperada"
DESPLEGADA=$(curl -fsS "${URL}/salud" | sed -n 's/.*"commit":"\([^"]*\)".*/\1/p')
[ "${DESPLEGADA}" = "${COMMIT_ESPERADO}" ] || {
echo "Versión desplegada ${DESPLEGADA}, esperada ${COMMIT_ESPERADO}"; exit 1; }
echo "4/5 el catálogo responde y exige autenticación"
curl -fsS -o /dev/null -w '%{http_code}' "${URL}/v1/cafes" | grep -q 401
echo "5/5 el contrato está publicado"
curl -fsS "${URL}/docs/openapi.json" | grep -q '"openapi"'
echo "Smoke tests correctos."Cinco comprobaciones en dos segundos que detectan los fallos de despliegue más frecuentes: la aplicación no arranca, una dependencia no es alcanzable desde producción, se desplegó la imagen equivocada, o la autenticación se ha desconfigurado.
Vigilancia durante el despliegue. Es donde 04-07 rinde su valor. Los primeros quince minutos, mirando:
| Señal | Umbral de alarma | Acción |
|---|---|---|
Tasa de error 5xx |
Cualquier subida sobre la línea base | Revertir y luego investigar |
| Latencia p99 | +20 % sobre la referencia | Investigar; revertir si empeora |
| Tasa de peticiones | Caída brusca | Alguien no está llegando: DNS, balanceador, CORS |
| Presupuesto de error del SLO | Consumo acelerado | Revertir |
| Memoria y event loop | Crecimiento sostenido | Posible fuga introducida en esta versión |
Métricas de negocio (aroma_pedidos_creados_total) |
Caída | La señal más importante: el impacto real |
La última fila merece énfasis. Un despliegue puede estar técnicamente perfecto —cero errores, latencia estupenda— y haber roto el negocio, porque un cambio en la validación hace que ningún pedido se complete. Los 5xx no lo detectan; los pedidos por minuto, sí. Vigila siempre al menos una métrica de negocio durante un despliegue.
Y la regla cultural más importante: primero restaurar el servicio, después entender qué pasó. La tentación de investigar con producción rota es enorme y siempre es un error. Se revierte, se respira y se investiga con el sistema estable.
- Secretos en CI y mínimo privilegio
La tubería de CI es un objetivo de ataque de primer orden: tiene credenciales de despliegue y capacidad de ejecutar código arbitrario. Los controles imprescindibles:
1. Permisos mínimos por defecto. Ya está en el workflow del apartado 7:
y cada trabajo eleva solo lo suyo (packages: write únicamente en imagen). Sin esta declaración, el GITHUB_TOKEN tiene permisos amplios de escritura sobre el repositorio, y cualquier acción de terceros comprometida los hereda.
2. Nunca imprimir secretos. GitHub enmascara los valores conocidos en los logs, pero es fácil burlarlo sin querer: un env | sort o un secreto codificado en base64 se ve en claro. Regla simple: nada de volcar el entorno en un log.
3. Fijar las acciones de terceros. uses: alguien/accion@v3 sigue una etiqueta móvil: si esa etiqueta se mueve a código malicioso, se ejecuta en tu tubería con acceso a tus secretos. Ha ocurrido. Lo seguro es fijar el SHA:
4. Entornos protegidos. El environment: produccion de GitHub permite exigir aprobación humana, limitar qué ramas pueden desplegar y guardar secretos accesibles solo desde ese entorno. Así, el token de producción no está al alcance de un workflow de pull request.
5. Nada de secretos en workflows de pull requests de forks. Un pull request desde un fork ejecuta código que no controlas. GitHub no expone secretos a pull_request de forks por defecto; no lo cambies con pull_request_target sin entender exactamente lo que implica. Es una de las vías de exfiltración de credenciales más explotadas.
6. Credenciales efímeras en lugar de claves largas. OIDC entre GitHub Actions y el proveedor de nube emite un token de vida corta por ejecución, en lugar de guardar una clave de acceso permanente. Es la misma idea de 04-03 —tokens de vida corta, alcance limitado— aplicada a la infraestructura.
7. Rotación y auditoría. Los secretos caducan y se rotan; los accesos se registran. Y si un secreto ha aparecido alguna vez en un log o en un commit, se considera comprometido: se rota, no se borra el commit y se hace como si nada.
Errores Comunes y Consejos
COPY . .sin.dockerignore. Mete tu.envcon las claves reales dentro de la imagen. Es una de las fugas de credenciales más frecuentes que existen.CMD node src/servidor.jsen forma shell. El PID 1 pasa a ser/bin/sh,SIGTERMno llega a Node y cada despliegue corta peticiones a medias. Forma exec ytini.- Usar readiness como liveness. Redis se cae y el orquestador reinicia todos los contenedores en bucle. Liveness sin dependencias; readiness con ellas.
DROP COLUMNen un despliegue sin cortes. La versión antigua sigue viva y ejecutandoSELECTsobre esa columna. Expandir → migrar → contraer, siempre.- Migrar al arrancar la aplicación. Con tres instancias, tres migraciones en paralelo sobre la misma base. Paso propio en la tubería, con bloqueo.
- Desplegar la etiqueta
latest. Es móvil: no sabes qué hay en producción ni puedes revertir con precisión. La etiqueta es el SHA del commit. - Reconstruir la imagen para cada entorno. Entonces no despliegas lo que probaste. Una imagen, muchas configuraciones.
- Puertas de CI que se pueden saltar. Al tercer «solo esta vez» dejan de existir. Se cambian con un pull request, no se saltan en una ejecución.
- Bloquear con
npm audita nivel moderado. La tubería estará roja permanentemente por dependencias transitivas y el equipo aprenderá a ignorarla.--audit-level=high. - Un workflow con
permissions: write-all. Cualquier acción de terceros comprometida hereda el poder de escribir en tu repositorio. - Consejo: comprueba
docker stopen local. Si tarda diez segundos, elSIGTERMno llega y tu apagado ordenado no se ejecuta en producción tampoco. - Consejo: exporta la versión desplegada como etiqueta de métrica. Ver el escalón exacto en el gráfico de errores en el momento del despliegue resuelve incidentes en segundos.
- Consejo: vigila una métrica de negocio durante cada despliegue. Un despliegue técnicamente perfecto puede haber roto la creación de pedidos, y los
5xxno te lo dirán. - Consejo: escribe un ADR (04-01) con la estrategia de despliegue elegida. Dentro de un año alguien preguntará por qué no hacéis canary, y la respuesta debe estar escrita.
Ejercicios
Ejercicio 1: completar la tubería con un trabajo de despliegue a producción
Amplía .github/workflows/ci.yml con un trabajo produccion que se ejecute tras preproduccion, exija aprobación humana, aplique migraciones, despliegue la misma imagen que se verificó en preproducción, ejecute los smoke tests y revierta automáticamente si fallan. Explica por qué debe desplegarse exactamente la misma etiqueta de imagen y no reconstruirse.
Ejercicio 2: diseñar una migración segura
Tienda Aroma necesita separar el campo origen (hoy "Etiopía") en dos: pais y region ("Etiopía" / "Yirgacheffe"), porque el buscador debe filtrar por región. Hay 2.400 cafés en producción y el sistema despliega con rolling sobre tres instancias.
Diseña la secuencia completa expandir → migrar → contraer: qué SQL en cada paso, qué hace el código en cada despliegue, cuánto tiempo dejarías entre pasos y por qué, cómo tratarías el contrato de la API en cada fase (recuerda 02-07), y en qué punto exacto se pierde la posibilidad de revertir.
Ejercicio 3: elegir estrategia de despliegue para tres cambios
Para cada cambio, elige entre recreate, rolling, blue-green y canary, justifica la elección, indica qué vigilarías durante el despliegue y describe el plan de vuelta atrás:
A) Corrección de un fallo en el cálculo del IVA de las facturas. Afecta a todos los pedidos y hay clientes pagando ahora mismo.
B) Cambio del algoritmo de ordenación por defecto del catálogo, que ahora prioriza los cafés mejor valorados. No cambia el contrato, pero cambia lo que ven todos los usuarios.
C) Migración de la persistencia de SQLite a PostgreSQL, con el mismo contrato de API y el mismo código salvo src/repositorios/.
Soluciones
Solución 1
# --------------------------------------------------------------------------
# 7. Producción: aprobación humana, despliegue, smoke tests y vuelta atrás.
# --------------------------------------------------------------------------
produccion:
name: Desplegar a producción
needs: [preproduccion]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment:
name: produccion # con "required reviewers" configurado en GitHub:
url: https://api.tiendaaroma.example # el trabajo espera aprobación humana
permissions:
contents: read
deployments: write
steps:
- uses: actions/checkout@v4
- name: Anotar la versión que estaba desplegada (para la vuelta atrás)
id: anterior
run: |
ACTUAL=$(curl -fsS https://api.tiendaaroma.example/salud \
| sed -n 's/.*"commit":"\([^"]*\)".*/\1/p')
echo "sha=${ACTUAL}" >> "$GITHUB_OUTPUT"
echo "Versión actual en producción: ${ACTUAL}"
- name: Aplicar migraciones (retrocompatibles, apartado 11)
run: npm ci && npm run migrar
env:
URL_BASE_DATOS: ${{ secrets.URL_BASE_DATOS_PRODUCCION }}
- name: Desplegar EXACTAMENTE la imagen verificada en preproducción
run: ./herramientas/desplegar.sh produccion "sha-${{ github.sha }}"
env:
TOKEN_DESPLIEGUE: ${{ secrets.TOKEN_DESPLIEGUE_PRODUCCION }}
- name: Esperar readiness
run: |
for i in $(seq 1 40); do
curl -fsS https://api.tiendaaroma.example/salud/preparado && exit 0
sleep 5
done
exit 1
- name: Smoke tests
id: smoke
run: ./herramientas/smoke.sh https://api.tiendaaroma.example
env:
COMMIT_ESPERADO: ${{ github.sha }}
- name: Vigilar métricas durante 3 minutos
run: ./herramientas/vigilar-despliegue.sh --minutos 3 --umbral-error 0.5
- name: VUELTA ATRÁS automática si algo falló
if: failure()
run: |
echo "::error::Despliegue fallido. Revirtiendo a ${{ steps.anterior.outputs.sha }}"
./herramientas/desplegar.sh produccion "${{ steps.anterior.outputs.sha }}"
./herramientas/smoke.sh https://api.tiendaaroma.example
env:
TOKEN_DESPLIEGUE: ${{ secrets.TOKEN_DESPLIEGUE_PRODUCCION }}
COMMIT_ESPERADO: ${{ steps.anterior.outputs.sha }}
- name: Avisar al equipo
if: always()
run: ./herramientas/notificar.sh "${{ job.status }}" "sha-${{ github.sha }}"Por qué la misma imagen y no reconstruir. Cinco razones, de más a menos evidente:
- Es lo único que se ha verificado. Las pruebas de extremo a extremo y Newman se ejecutaron contra esa imagen concreta en preproducción. Reconstruir produce un artefacto que nadie ha probado, por muy idéntico que parezca el código.
- Las construcciones no son bit a bit reproducibles.
npm cirespeta el lock para tus dependencias directas y transitivas, pero la imagen basenode:20-bookworm-slimes una etiqueta móvil que se actualiza; los paquetes del sistema instalados conapt-getcambian de versión; y los módulos nativos se compilan contra lo que haya en ese momento. Dos construcciones del mismo commit con un día de diferencia pueden diferir. - Trazabilidad. Una etiqueta, un artefacto, un commit. Si en producción y en preproducción hay imágenes distintas con el mismo nombre, la depuración se vuelve un ejercicio de fe.
- Tiempo. Reconstruir añade minutos al camino crítico del despliegue, justo cuando puede haber una corrección urgente esperando.
- Superficie de ataque. Cada construcción es una oportunidad para que entre algo indebido en la cadena de suministro. Menos construcciones, menos oportunidades.
Detalles del trabajo que merecen atención: environment: produccion con revisores obligatorios es lo que convierte esto en entrega continua y no en despliegue continuo; el paso anterior consulta la versión desplegada antes de tocar nada, porque después ya no se puede saber; y el paso de vuelta atrás lleva if: failure(), que se dispara si falla cualquier paso previo del trabajo, incluidos los smoke tests y la vigilancia de métricas.
Una limitación honesta de esta solución: la vuelta atrás revierte el código, no los datos. Si la migración fue destructiva, esto no salva nada, y por eso el apartado 11 no es opcional.
Solución 2
Fase 0 — Preparación (antes de tocar nada).
Añadir al contrato los campos nuevos como opcionales, sin eliminar origen. Publicar openapi.yaml con pais y region documentados y origen todavía vigente. oasdiff lo aprueba: añadir campos a una respuesta no es rompedor.
Fase 1 — EXPANDIR (despliegue 1).
-- migraciones/014-expandir-origen.sql
ALTER TABLE cafes ADD COLUMN pais TEXT;
ALTER TABLE cafes ADD COLUMN region TEXT;
-- Relleno inicial: todo lo que hay hoy en origen se copia a pais.
-- La región queda nula: se completará manualmente o con un proceso aparte.
UPDATE cafes SET pais = origen WHERE pais IS NULL;
-- El índice para el buscador por región se crea aquí, no después.
CREATE INDEX IF NOT EXISTS idx_cafes_pais_region ON cafes(pais, region);// Código del despliegue 1: escribe en las TRES columnas, lee de `origen`.
export function guardarCafe(cafe) {
bd.prepare(`
UPDATE cafes SET origen = ?, pais = ?, region = ? WHERE id = ?
`).run(
cafe.region ? `${cafe.pais}, ${cafe.region}` : cafe.pais, // compone el legado
cafe.pais,
cafe.region ?? null,
cafe.id,
);
}
// La API sigue devolviendo `origen` y ya devuelve `pais` y `region` cuando existen.
export function aCafePublico(fila) {
return {
...campos,
origen: fila.origen, // sigue siendo la fuente para los consumidores
pais: fila.pais,
region: fila.region ?? undefined,
};
}Estado del contrato: origen vigente, pais y region disponibles. Ningún consumidor se entera de nada. Se puede revertir sin problema: las columnas nuevas sobran pero no molestan.
Fase 1b — Rellenar la región (proceso aparte, días).
2.400 cafés cuya región hay que deducir o introducir a mano. Un script por lotes que actualiza en tandas de 200 con pausas, para no bloquear la base ni disparar la latencia:
// herramientas/rellenar-region.js — ejecutable varias veces, idempotente
const lote = bd.prepare('SELECT id, origen FROM cafes WHERE region IS NULL LIMIT 200').all();
for (const fila of lote) {
const [pais, region] = separarOrigen(fila.origen); // heurística + tabla manual
bd.prepare('UPDATE cafes SET pais = ?, region = ? WHERE id = ?').run(pais, region, fila.id);
}Este paso es el que marca el ritmo real, y por eso la migración de esquema y el relleno de datos deben ir separados: mezclarlos produce migraciones que tardan minutos y bloquean el despliegue.
Fase 2 — MIGRAR (despliegue 2, una o dos semanas después).
// El código lee de las columnas NUEVAS y sigue escribiendo en las tres.
export function aCafePublico(fila) {
return {
...campos,
// `origen` se sigue devolviendo, pero ahora se COMPONE de pais y region.
// Los consumidores antiguos ven exactamente lo mismo que antes.
origen: fila.region ? `${fila.pais}, ${fila.region}` : fila.pais,
pais: fila.pais,
region: fila.region ?? undefined,
};
}El filtro ?region=Yirgacheffe se activa en esta fase. Todavía se puede revertir: origen sigue poblado y sincronizado, así que el código del despliegue 1 funciona perfectamente.
Espera de una o dos semanas antes de continuar. El motivo: dar tiempo a que aparezcan casos raros —cafés con nombres de origen que la heurística separó mal— con la vuelta atrás todavía disponible.
Fase 3 — Deprecar origen en el contrato (no toca la base de datos).
origen:
type: string
deprecated: true
description: |
**Obsoleto desde la 1.8.0. Se retirará el 30 de junio de 2027.**
Usa `pais` y `region`. Este campo se compone como
`"{pais}, {region}"` por compatibilidad.Con Deprecation y Sunset en las respuestas, y aviso a la SPA, a Aroma Móvil y a CataBox. Como Aroma Móvil tiene versiones antiguas vivas, este plazo tiene que ser generoso: seis meses como mínimo.
Fase 4 — CONTRAER (meses después, con /v2 o tras el Sunset).
-- migraciones/018-contraer-origen.sql
-- Solo cuando NINGUNA versión del código que lee `origen` puede estar viva
-- y el plazo de Sunset anunciado ha vencido.
DROP INDEX IF EXISTS idx_cafes_origen;
ALTER TABLE cafes DROP COLUMN origen;Dónde se pierde la posibilidad de revertir: exactamente en la fase 4, al ejecutar el DROP COLUMN. A partir de ese instante, desplegar cualquier versión anterior a la fase 2 produce errores SQL en cada lectura del catálogo, y recuperar la columna significa restaurar una copia de seguridad y perder todo lo escrito desde entonces. Es la razón por la que la contracción se hace meses después, cuando revertir a esa versión ya no es un escenario realista, y preferiblemente en un despliegue propio sin ningún otro cambio, para que si algo falla se sepa exactamente qué fue.
Con tres instancias en rolling, cada fase individual es segura porque en ningún momento coexisten un esquema y un código incompatibles: esa es la propiedad que garantiza el patrón, y la única razón por la que existe.
Solución 3
A) Corrección del IVA en facturas → blue-green (o rolling con vuelta atrás preparada).
| Aspecto | Decisión |
|---|---|
| Estrategia | Blue-green, o rolling si no hay infraestructura duplicada |
| Por qué no canary | El canary implica que un porcentaje de clientes recibe facturas con el cálculo antiguo durante la ventana. En un asunto fiscal, la incoherencia entre facturas emitidas el mismo día es peor que el error original: complica la contabilidad y la corrección posterior. Aquí se quiere un corte limpio con un instante exacto de cambio. |
| Por qué blue-green | Da un momento de conmutación preciso —anotable en el registro contable— y vuelta atrás en segundos. |
| Qué vigilar | Tasa de error de POST /pedidos/{id}/pago y de la generación de facturas; importes de las primeras facturas emitidas, comparados a mano con el cálculo esperado; aroma_pedidos_creados_total para confirmar que se siguen completando compras. |
| Vuelta atrás | Reconmutar el balanceador a azul. Y un plan para las facturas emitidas en la ventana: identificarlas por marca de tiempo y reemitirlas si procede. Esto es lo verdaderamente difícil, y hay que tenerlo escrito antes de desplegar. |
| Extra | Desplegar en la franja de menor actividad. Un cambio con implicaciones fiscales no se despliega un viernes a las 18:00. |
B) Nueva ordenación por defecto del catálogo → canary.
| Aspecto | Decisión |
|---|---|
| Estrategia | Canary, empezando con un 5 % |
| Por qué | Es un cambio de producto, no técnico: no hay una respuesta "correcta" que verificar, sino una hipótesis que medir. El canary permite comparar el comportamiento real de los usuarios entre ambas versiones, que es exactamente la pregunta. Además es reversible sin consecuencias: nadie pierde datos si la ordenación cambia. |
| Qué vigilar | Métricas de negocio por encima de las técnicas: tasa de conversión de visita a pedido, valor medio del pedido, profundidad de paginación (si la gente pagina menos, la ordenación acierta más). En lo técnico, la latencia p99 de GET /v1/cafes, porque ordenar por valoración media puede exigir un JOIN o una agregación que degrade la consulta; conviene comprobar que hay índice (04-06). |
| Vuelta atrás | Feature flag, no despliegue. BANDERA_ORDEN_POR_VALORACION apagada devuelve el orden anterior en un segundo, sin desplegar nada. Es el caso de libro para una bandera: cambio de comportamiento visible, reversible y sujeto a medición. |
| Extra | Si la comparación va a durar semanas, esto deja de ser un canary y pasa a ser una prueba A/B; conviene un reparto estable por cliente para que un mismo usuario no vea el catálogo reordenado en cada visita. |
C) Migración de SQLite a PostgreSQL → blue-green, con una fase previa de doble escritura.
| Aspecto | Decisión |
|---|---|
| Estrategia | Blue-green, precedida de una migración de datos por fases. No es un despliegue: es un proyecto. |
| Por qué no rolling | En rolling coexistirían instancias escribiendo en SQLite y en PostgreSQL simultáneamente. Las escrituras se repartirían entre dos bases que divergen, y reconciliarlas después es prácticamente imposible. |
| Fases | 1) Desplegar el código nuevo escribiendo en ambas y leyendo de SQLite. 2) Copia inicial masiva y verificación de que ambas bases coinciden, registro a registro. 3) Ventana breve de solo lectura o de mantenimiento para copiar el delta final. 4) Conmutar a lectura desde PostgreSQL (blue-green). 5) Vigilar días. 6) Dejar de escribir en SQLite. |
| Qué vigilar | Latencia p99 de todos los endpoints (el rendimiento de las consultas cambia por completo con otro motor y otro planificador); errores de restricciones de integridad que SQLite toleraba y PostgreSQL no; saturación del pool de conexiones, que en SQLite no existía y aquí es un límite real; y la coherencia de los datos comparando recuentos y sumas entre ambas bases. |
| Vuelta atrás | Reconmutar a SQLite, siempre que se haya seguido escribiendo en ella. Ese es el motivo de la fase de doble escritura, y su coste está justificado: sin ella, la migración es un viaje de ida. |
| Extra | Gracias al patrón repositorio de 03-05, el cambio de código se limita a src/repositorios/ y a src/config/base-datos.js; el resto del proyecto no se entera. Es la mejor demostración del valor de esa separación, y conviene comprobar que las pruebas de integración pasan contra ambos motores antes de empezar. |
Conclusión
La tabla de 05-04 es ahora maquinaria. La API de Tienda Aroma se empaqueta en un Dockerfile multietapa donde las herramientas de compilación se quedan en la primera etapa, la imagen final lleva solo dependencias de producción y corre como usuario node, con tini y forma exec para que el SIGTERM llegue de verdad a Node y se ejecute el apagado ordenado de 03-07, y con un HEALTHCHECK sobre liveness —no readiness— para no reiniciar contenedores sanos cuando falle Redis. El .dockerignore mantiene fuera tu .env y tu node_modules, y docker-compose.yml levanta API, Redis, el mock de Prism y, tras un perfil, PostgreSQL, con condition: service_healthy para que nada arranque antes de tiempo.
.github/workflows/ci.yml ejecuta las puertas en el orden correcto: calidad estática primero porque falla en treinta segundos, después el contrato con swagger-cli validate, Spectral y oasdiff breaking en paralelo con las pruebas sobre Node 20 y 22 con Redis como servicio y cobertura, npm audit --audit-level=high, y solo entonces la construcción y publicación de una imagen etiquetada con el SHA del commit y escaneada con Trivy; luego, migraciones, despliegue a preproducción, espera activa a /salud/preparado, pruebas de extremo a extremo y la colección de Newman de 05-01. Las puertas son inflexibles a propósito, con un único escape explícito y visible: la etiqueta cambio-rompedor en el pull request.
Y por encima de las herramientas, tres ideas que separan a un equipo que despliega tranquilo de uno que despliega con miedo. Las migraciones retrocompatibles: durante un despliegue sin cortes conviven dos versiones sobre una sola base de datos, así que un RENAME COLUMN es un DROP disfrazado y el patrón expandir → migrar → contraer no es burocracia, es lo único que preserva la posibilidad de revertir. Las estrategias de despliegue —recreate, rolling, blue-green y canary— con /salud y /salud/preparado de 04-07 decidiendo cuándo entra el tráfico y un preStop que evita los 502 fantasma de cada despliegue. Y la vuelta atrás: revertir código es trivial cuando las imágenes son inmutables y la configuración vive fuera; revertir datos no existe, se previene; y los feature flags separan desplegar de activar, con fecha de caducidad escrita el mismo día que se crean. Los artefactos nuevos del proyecto son Dockerfile, .dockerignore, docker-compose.yml, docker-compose.pruebas.yml, .github/workflows/ci.yml, herramientas/desplegar.sh, herramientas/smoke.sh y src/config/banderas.js.
Queda una última pieza del módulo, y es la que reordena buena parte de lo que hemos hecho a mano. Muchos de los mecanismos del módulo 4 —el rate limiting con Redis, la validación de tokens JWT y OAuth, la terminación TLS, CORS, la caché, la compresión, el enrutado por versión— existen resueltos una capa por encima de la aplicación. En 05-06, API gateways y portales de desarrollador, cerramos el módulo con esa perspectiva: qué es un gateway y qué funciones asume, comparadas una a una con nuestras implementaciones; el criterio para decidir qué se delega y qué nunca se delega —la autorización a nivel de recurso, la lógica de negocio y la validación semántica se quedan siempre en la aplicación—; una configuración declarativa real de Kong para Tienda Aroma con los límites de 04-04 y la lista blanca de 04-05, y qué middlewares de src/app.js podríamos retirar entonces y cuáles no; los riesgos del gateway como punto único de fallo; y los portales de desarrollador, donde el openapi.yaml de 05-02 se convierte en la puerta de entrada de terceros como CataBox, con el "tiempo hasta la primera llamada con éxito" como métrica de la calidad de tu API.
Curso de REST API: Principios de Diseño y Desarrollo de APIs RESTful
Módulo 1: Introducción a las APIs RESTful
- ¿Qué es una API?
- Historia y evolución de las APIs
- Fundamentos de HTTP para APIs
- Principios básicos de REST
- Modelo de madurez de Richardson y HATEOAS
- REST vs. SOAP
- REST frente a GraphQL, gRPC y webhooks
Módulo 2: Diseño de APIs RESTful
- Principios de diseño de APIs RESTful
- Recursos y URIs
- Métodos HTTP
- Códigos de estado HTTP
- Representaciones, cabeceras y negociación de contenido
- Filtrado, ordenación, paginación y búsqueda
- Versionado de APIs
- Documentación de APIs
Módulo 3: Desarrollo de APIs RESTful
- Configuración del entorno de desarrollo
- Creación de un servidor básico
- Manejo de peticiones y respuestas
- Validación de datos de entrada
- Persistencia y capa de acceso a datos
- Autenticación y autorización
- Manejo de errores
- Pruebas y validación
Módulo 4: Buenas Prácticas y Seguridad
- Buenas prácticas en el diseño de APIs
- Seguridad en APIs RESTful
- OAuth 2.0 y OpenID Connect en la práctica
- Rate limiting y throttling
- CORS y políticas de seguridad
- Caché HTTP y rendimiento
- Observabilidad: logs, métricas y trazas
Módulo 5: Herramientas y Frameworks
- Postman para pruebas de APIs
- Swagger y OpenAPI para documentación
- Frameworks populares para APIs RESTful
- Contratos, mocks y pruebas automatizadas de API
- Integración continua y despliegue
- API gateways y portales de desarrollador
