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

  1. Integración continua, entrega continua y despliegue continuo
  2. Por qué la rama principal siempre debe estar desplegable
  3. Empaquetar con Docker: el Dockerfile multietapa
  4. .dockerignore, capas y tamaño de la imagen
  5. El entorno completo con docker-compose.yml
  6. Configuración por entorno y secretos fuera de la imagen
  7. La tubería de CI: .github/workflows/ci.yml
  8. Matriz de versiones y caché de dependencias
  9. Qué hace fallar la tubería y por qué las puertas son inflexibles
  10. Construir y publicar la imagen
  11. Migraciones: expandir, migrar, contraer
  12. Estrategias de despliegue
  13. El papel de liveness y readiness
  14. Vuelta atrás y feature flags
  15. Dónde desplegar
  16. Versionado de artefactos y trazabilidad
  17. Después del despliegue: smoke tests y vigilancia
  18. Secretos en CI y mínimo privilegio

  1. 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 : 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.

  1. 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, main está 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.

  1. Empaquetar con Docker: el Dockerfile multietapa

Un 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:

  1. 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.
  2. npm ci --omit=dev. Fuera supertest, eslint, prettier, Spectral, autocannon, Prism y Newman. Menos tamaño y, sobre todo, menos superficie de ataque.
  3. Usuario node, no root. Combinado con readOnlyRootFilesystem en el orquestador, cierra buena parte de los caminos de escalada.
  4. tini + forma exec. Es lo que hace que SIGTERM llegue a Node y se ejecute el cerrarOrdenadamente() 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.
  5. HEALTHCHECK sobre 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.

  1. .dockerignore, capas y tamaño de la imagen

Fichero 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, un COPY . . mete tu .env con 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_modules local con binarios compilados para macOS a una imagen Linux produce fallos incomprensibles.
  • Velocidad y tamaño. .git puede 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

  1. El entorno completo con docker-compose.yml

Fichero 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 incluidos

Dos detalles que evitan horas de depuración:

  • condition: service_healthy en depends_on. Sin él, depends_on solo 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.

  1. 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:

  1. Ningún secreto dentro de la imagen. Ni en el Dockerfile, ni con ARG —los ARG quedan en el historial de capas y se ven con docker history—, ni en un fichero copiado.
  2. Ningún secreto en el repositorio. .env en .gitignore; .env.example con las claves y valores ficticios, versionado, para documentar qué hace falta.
  3. 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.
  4. src/config/entorno.js valida al arrancar. Ya lo escribimos en 03-01, y aquí se ve por qué importa: si falta JWT_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.

  1. La tubería de CI: .github/workflows/ci.yml

Fichero 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

  1. 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.

  1. Qué hace fallar la tubería y por qué las puertas son inflexibles

Puerta Falla si ¿Bloquea el merge? Por qué
ESLint Cualquier error Los avisos ya están filtrados; un error es un error
Prettier Un fichero sin formatear Formato automático: no hay excusa ni discusión
swagger-cli validate El contrato no es OpenAPI válido Rompe la documentación y todos los generadores
Spectral Alguna regla error Es la guía de estilo acordada (04-01)
oasdiff breaking Hay cambios rompedores , salvo etiqueta explícita Rompe a Aroma Móvil, y es permanente
Pruebas Una sola falla Evidente
Cobertura Baja del umbral acordado Sí, pero con criterio Ver más abajo
npm audit Vulnerabilidad alta o crítica Con --audit-level=high: las moderadas no bloquean
Trivy CVE alta o crítica con parche ignore-unfixed: sin parche no hay acción posible
E2E en preproducción Una falla , 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/main

Poner 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.

  1. 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.

  1. 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 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 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.

  1. Estrategias de despliegue

Estrategia Cómo funciona Corte Coste Riesgo Vuelta atrás Cuándo
Recreate Para todo, arranca lo nuevo , 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.0 sobre v1.6.0 es un despliegue: mismo contrato, código nuevo. Aquí aplican rolling, blue-green o canary.
  • Publicar /v2 junto a /v1 es convivencia de versiones de API: dos contratos distintos vivos durante meses, con Deprecation y Sunset en /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).

  1. 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: 30

Los tres errores clásicos, que producen incidentes muy difíciles de diagnosticar:

  1. Usar readiness como liveness. Redis se cae, readiness responde 503, el orquestador reinicia todos los contenedores en bucle, y ahora tienes dos problemas.
  2. 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.
  3. Olvidar el preStop. Sin él, el balanceador puede seguir enviando peticiones durante uno o dos segundos a un proceso que ya recibió SIGTERM, y aparecen 502 esporá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) → SIGTERMtini 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.

  1. Vuelta atrás y feature flags

Revertir el código es fácil. Es volver a desplegar la etiqueta anterior:

./herramientas/desplegar.sh produccion "sha-<commit-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, intacta

Ventajas: 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.

  1. 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?».

  1. 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.

  1. 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.

  1. 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:

permissions:
  contents: read      # por defecto para todos los trabajos

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:

      - uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75  # v6.9.0

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 .env con las claves reales dentro de la imagen. Es una de las fugas de credenciales más frecuentes que existen.
  • CMD node src/servidor.js en forma shell. El PID 1 pasa a ser /bin/sh, SIGTERM no llega a Node y cada despliegue corta peticiones a medias. Forma exec y tini.
  • Usar readiness como liveness. Redis se cae y el orquestador reinicia todos los contenedores en bucle. Liveness sin dependencias; readiness con ellas.
  • DROP COLUMN en un despliegue sin cortes. La versión antigua sigue viva y ejecutando SELECT sobre 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 audit a 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 stop en local. Si tarda diez segundos, el SIGTERM no 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 5xx no 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:

  1. 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.
  2. Las construcciones no son bit a bit reproducibles. npm ci respeta el lock para tus dependencias directas y transitivas, pero la imagen base node:20-bookworm-slim es una etiqueta móvil que se actualiza; los paquetes del sistema instalados con apt-get cambian 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.
  3. 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.
  4. Tiempo. Reconstruir añade minutos al camino crítico del despliegue, justo cuando puede haber una corrección urgente esperando.
  5. 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

Módulo 2: Diseño de APIs RESTful

Módulo 3: Desarrollo de APIs RESTful

Módulo 4: Buenas Prácticas y Seguridad

Módulo 5: Herramientas y Frameworks

Módulo 6: Casos de Estudio y Proyectos

© Copyright 2026. Todos los derechos reservados