Parte VI · Ingeniería

21. Git, Docker, CI/CD, despliegue y escalabilidad

Una aplicación que solo funciona en tu portátil no es una aplicación: es una demo. Este capítulo cubre todo lo que ocurre entre «el código compila» y «miles de personas lo usan sin enterarse de que has desplegado tres veces hoy»: control de versiones con criterio, empaquetado reproducible en contenedores, integración continua de verdad, estrategias de despliegue sin cortes, el problema real de las migraciones de base de datos durante un despliegue progresivo, y cómo escalar cuando llega el tráfico.

COREAVANZADO Tiempo de lectura: ~120 min Prerrequisitos: capítulos 1, 9, 13, 19 y 20

21.1 Qué vas a poder hacer al terminar

Cómo leer este capítulo Es el capítulo más largo del libro y es deliberadamente operativo. Si vas con prisa, la ruta mínima imprescindible es: 21.2.3 (merge frente a rebase), 21.3.3 (Dockerfile multi-stage), 21.5.3 (workflow de CI) y 21.6.3 (migraciones en el despliegue). Esa última sección es la que separa un despliegue aburrido de una noche en vela.

21.2 Git profesional

Casi todo el mundo usa Git como una caja negra con cuatro comandos memorizados, y por eso el primer conflicto serio produce pánico. Git es en realidad un modelo de datos muy pequeño y muy regular: si entiendes ese modelo, todos los comandos dejan de ser magia y pasan a ser consecuencias evidentes.

21.2.1 El modelo de datos: contenido direccionable por hash

Git no guarda diferencias entre versiones: guarda fotografías completas del árbol de ficheros, comprimidas y deduplicadas. Todo lo que Git almacena es un objeto identificado por el hash de su contenido (SHA-1 históricamente, con soporte de SHA-256 en repositorios nuevos). Solo hay cuatro tipos de objeto:

ObjetoQué contieneAnalogía
blobEl contenido binario de un fichero. Sin nombre y sin permisos.El texto de una página
treeUna lista de entradas (modo, tipo, hash, nombre): es un directorio.El índice de un capítulo
commitUn puntero a un tree raíz, cero o más padres, autor, fecha y mensaje.Una edición fechada del libro entero
tag anotadoUn puntero con nombre, mensaje y firma a otro objeto (normalmente un commit).Una pegatina «1.ª edición»
  REFERENCIAS (ficheros de texto con un hash dentro: .git/refs/…)
    HEAD ──► refs/heads/feature/tareas
                    │
   ┌──────────┐     │      ┌──────────────┐
   │   main   │     └─────►│   feature    │
   └────┬─────┘            └──────┬───────┘
        │                         │
        ▼                         ▼
   ┌──────────┐  padre     ┌──────────┐  padre    ┌──────────┐
   │ commit C │◄───────────│ commit D │◄──────────│ commit E │
   │  a1b2c3  │            │  d4e5f6  │           │  789abc  │
   └────┬─────┘            └────┬─────┘           └────┬─────┘
        │ tree                  │ tree                 │ tree
        ▼                       ▼                      ▼
   ┌──────────┐            ┌──────────┐           ┌──────────┐
   │  tree /  │            │  tree /  │           │  tree /  │
   └──┬────┬──┘            └──┬────┬──┘           └──┬────┬──┘
      │    │                  │    │                 │    │
      ▼    ▼                  ▼    ▼                 ▼    ▼
  ┌──────┐ ┌─────────┐    (reutiliza los MISMOS objetos si el
  │ blob │ │ tree src│     contenido no ha cambiado: por eso un
  │README│ │   /     │     repo con 5.000 commits ocupa poco)
  └──────┘ └─────────┘
  Una RAMA es un fichero de 41 bytes con el hash del último commit.
  Crear una rama es, literalmente, escribir 41 bytes. Por eso es gratis.

De este modelo se derivan tres consecuencias que conviene interiorizar:

  ┌───────────────┐  git add   ┌───────────────┐  git commit  ┌──────────────┐
  │ WORKING TREE  │───────────►│  ÍNDICE       │─────────────►│ REPOSITORIO  │
  │ (tus ficheros)│            │  (staging)    │              │   (.git)     │
  └───────────────┘◄───────────└───────────────┘◄─────────────└──────────────┘
        ▲   git restore              git restore --staged
        │                            (antes: git reset HEAD)
        └── git stash guarda AQUÍ lo que estorba y lo devuelve luego
Analogía: el índice es el carrito de la compra El árbol de trabajo es la tienda entera; el índice es tu carrito; el commit es el ticket de caja. Puedes meter y sacar cosas del carrito sin comprar nada. Cuando pasas por caja, el ticket queda impreso para siempre: puedes emitir otro ticket que anule el anterior (revert), pero el papel ya está escrito.

21.2.2 Flujos de trabajo: GitHub Flow, trunk-based y Git Flow

Un flujo de trabajo es un acuerdo de equipo sobre qué ramas existen, cuánto viven y cómo se integran. La elección tiene consecuencias directas sobre la frecuencia de despliegue y sobre el dolor de los conflictos.

FlujoRamasVida de una ramaCuándo tiene sentido
Trunk-based main y poco más; a veces commits directos con revisión posterior Horas Equipos con buena cobertura de tests, feature flags y despliegue continuo. Es el flujo asociado a los equipos de mayor rendimiento.
GitHub Flow main siempre desplegable + ramas de funcionalidad con pull request 1–3 días El estándar razonable para la inmensa mayoría de proyectos web, incluido el de este libro.
Git Flow main, develop, feature/*, release/*, hotfix/* Semanas Software con versiones numeradas que se distribuyen e instalan (una app de escritorio, un dispositivo, una librería con varias versiones mayores mantenidas a la vez).
Por qué Git Flow suele ser excesivo hoy Git Flow se publicó en 2010, cuando el software se entregaba en versiones cada varios meses. Su propio autor añadió años después una nota recomendando no usarlo si entregas software web de forma continua. El coste real es doble: la rama develop duplica el trabajo de integración (todo se fusiona dos veces) y las ramas de release largas garantizan conflictos y merges masivos. Si despliegas la web varias veces por semana, main + ramas cortas + tags de versión te da lo mismo con la mitad de ceremonia.

Integración continua de verdad. «Tener un pipeline» no es integración continua. Integración continua significa que el trabajo de todo el mundo se integra en la rama principal al menos una vez al día. Una rama que vive dos semanas no está integrada: está divergiendo en silencio, y el coste del conflicto crece de forma no lineal con el tiempo. Si una funcionalidad tarda tres semanas, la solución no es una rama de tres semanas, sino fusionar código incompleto pero inerte, protegido por un feature flag apagado. Así se separan dos decisiones que no tienen por qué coincidir: desplegar (técnica) y activar (de producto).

21.2.3 merge frente a rebase frente a squash

Los tres integran el trabajo de una rama en otra. La diferencia es el historial que dejan, y el historial es la documentación forense que usarás dentro de seis meses cuando algo falle.

  PUNTO DE PARTIDA
                       ┌── feature (2 commits) ──┐
  main:  A ── B ── C ── D ── E
                    └── F ── G      (main avanzó mientras trabajabas)
  ─────────────────────────────────────────────────────────────────────
  (1) git merge feature            → commit de fusión, historia real
  ─────────────────────────────────────────────────────────────────────
      A ── B ── C ── F ── G ── M         M tiene DOS padres (G y E)
                     \        /          Nada se reescribe.
                      D ── E ─┘          El grafo se ramifica.
  ─────────────────────────────────────────────────────────────────────
  (2) git rebase main              → historia lineal, commits NUEVOS
  ─────────────────────────────────────────────────────────────────────
      A ── B ── C ── F ── G ── D' ── E'  D' y E' son copias con otro hash
                                        (mismo contenido, distinto padre)
                                        D y E quedan huérfanos.
  ─────────────────────────────────────────────────────────────────────
  (3) squash merge                 → un único commit con todo el cambio
  ─────────────────────────────────────────────────────────────────────
      A ── B ── C ── F ── G ── S         S = D+E aplastados.
                                        La rama se borra; se pierde el
                                        detalle intermedio (a menudo, bien).
EstrategiaHistoriaVentajaInconvenienteÚsala para…
merge (sin fast-forward) Ramificada, con commits de fusión Fiel a lo ocurrido; nunca reescribe; trivial de revertir con revert -m 1 Grafo ruidoso si hay muchas ramas pequeñas Integrar ramas largas o compartidas, y fusionar release en main
rebase Lineal git log y git bisect limpios; cada commit compila si te has esmerado Reescribe hashes; peligroso en ramas compartidas; los conflictos se resuelven commit a commit Poner al día tu rama local antes de abrir o actualizar el pull request
squash merge Lineal, un commit por funcionalidad main legible: un commit = un cambio revisado y revertible Se pierde el detalle intermedio; los commits «wip» desaparecen (normalmente una bendición) El caso por defecto al cerrar un pull request pequeño en GitHub Flow
La regla de oro Nunca reescribas historia que otros ya han descargado. Si tu rama está publicada y alguien la ha usado como base, un rebase seguido de push --force le deja el repositorio en un estado incoherente y provocará fusiones duplicadas. Reescribe solo tu rama personal y, cuando tengas que forzar, usa siempre git push --force-with-lease: aborta si alguien ha subido algo que tú no tienes, en lugar de aplastarlo. En main la respuesta es simplemente no: para deshacer algo publicado se usa git revert.
terminalINCORRECTO
# Rama compartida con dos compañeros
git checkout main
git rebase feature/pagos      # reescribe main
git push --force              # aplasta lo que hubiera en el remoto
# Y el clásico "arreglo" tras un conflicto:
git checkout --ours .         # descarta TODO lo del otro lado
git add . && git commit -m "fix conflictos"
terminalCORRECTO
# Poner al día MI rama antes del pull request
git fetch origin
git rebase origin/main        # solo reescribe mis commits
git push --force-with-lease   # aborta si alguien subió algo entre medias
# Deshacer algo ya publicado en main: nuevo commit que lo revierte
git revert <hash>             # -m 1 si es un commit de fusión
# Resolver un conflicto de verdad: fichero a fichero, entendiendo ambos lados
git status                    # lista los ficheros en conflicto
git diff --diff-filter=U      # ver solo lo que está sin resolver

21.2.4 Conflictos: resolverlos con criterio

Un conflicto aparece cuando dos ramas modifican las mismas líneas de un fichero, o cuando una borra un fichero que la otra modifica. Git no intenta adivinar: marca el fichero y te pasa la decisión a ti.

src/tareas/tareas.service.ts · fichero en conflicto
<<<<<<< HEAD                       // lo que hay en MI rama (ours)
  const tareas = await this.repo.findAll({ populate: ['proyecto'] });
=======                                // frontera
  const tareas = await this.repo.find({ activa: true });
>>>>>>> origin/main                    // lo que trae la OTRA rama (theirs)

Resolver bien un conflicto es un ejercicio de comprensión, no de elegir un bando. Pregúntate siempre: ¿qué intentaba conseguir cada lado? En el ejemplo anterior, la respuesta correcta casi seguro no es ninguna de las dos líneas, sino this.repo.find({ activa: true }, { populate: ['proyecto'] }): ambos cambios eran válidos y hay que combinarlos. Después de resolver, ejecuta los tests: un conflicto mal resuelto compila perfectamente y rompe el comportamiento.

terminal · caja de herramientas para conflictos
# Rendirse limpiamente y volver al estado anterior (no pierdes nada)
git merge --abort
git rebase --abort
git cherry-pick --abort
# Ver el conflicto con los TRES lados: base común, mío y suyo
git config --global merge.conflictstyle zdiff3   # muy recomendable
# Reutilizar automáticamente resoluciones ya hechas ("reuse recorded resolution").
# Imprescindible si haces rebase de una rama larga: resuelves cada conflicto UNA vez.
git config --global rerere.enabled true
# Durante un rebase con muchos commits
git rebase --continue          # tras resolver y hacer git add
git rebase --skip              # si este commit ya no aporta nada
# Ver quién y por qué tocó esas líneas antes de decidir
git log -p --follow src/tareas/tareas.service.ts
git blame -L 40,60 src/tareas/tareas.service.ts
Cómo evitar el 80 % de los conflictos Ramas cortas, integradas a diario; ficheros pequeños con una sola responsabilidad; formateo automático con Prettier fijado en el repositorio (así nadie genera diferencias por espacios); y no reordenar imports o reformatear ficheros enteros en un pull request que además cambia lógica. Un cambio de formato masivo mezclado con un cambio funcional es imposible de revisar y garantiza conflictos a todo el equipo.

21.2.5 Comandos de rescate: stash, reflog, reset, bisect

Esta es la sección que conviene tener a mano el día malo. Empecemos por el comando peor entendido de Git: reset. Solo hace una cosa —mover la rama actual a otro commit— y sus tres modos se diferencian únicamente en qué zonas arrastra consigo.

ComandoMueve HEAD/ramaÍndiceÁrbol de trabajoUso típico
git reset --soft HEAD~1IntactoIntactoRehacer el último commit conservando todo preparado para commitear
git reset --mixed HEAD~1 (por defecto)Se reescribeIntactoDeshacer el commit y el add, conservando los cambios en el disco
git reset --hard HEAD~1Se reescribeSe sobrescribeTirar el trabajo. Es el único destructivo: lo no commiteado desaparece
terminal · rescates habituales
# 1) Necesito cambiar de rama YA y tengo trabajo a medias
git stash push -u -m "wip: filtro de tareas"   # -u incluye ficheros sin seguimiento
git switch hotfix/login
# ... arreglo, commit, push ...
git switch feature/filtros
git stash list                                  # stash@{0}: On feature/filtros: wip...
git stash pop                                   # aplica y elimina de la pila
# git stash apply stash@{2}                     # aplica sin eliminar
# 2) He hecho reset --hard y he perdido dos commits. NO están perdidos.
git reflog                                      # historial de TODO movimiento de HEAD
#  a1b2c3 HEAD@{0}: reset: moving to HEAD~2
#  d4e5f6 HEAD@{1}: commit: feat(tareas): filtro por estado   ← el bueno
git reset --hard d4e5f6                         # o: git branch rescate d4e5f6
# 3) Deshacer algo YA publicado (nunca reset en una rama compartida)
git revert d4e5f6                               # crea un commit inverso
git revert -m 1 <hash-del-merge>                # revertir una fusión: -m 1 = mantener main
# 4) Llevarme UN commit concreto de otra rama (un hotfix, por ejemplo)
git cherry-pick d4e5f6
git cherry-pick d4e5f6^..a1b2c3                 # un rango
# 5) Ver el estado exacto de un fichero en otro commit sin cambiar de rama
git show d4e5f6:src/main.ts
git restore --source=d4e5f6 -- src/main.ts

Encontrar el commit que rompió algo: git bisect

bisect hace una búsqueda binaria sobre el historial. Si hay 4.000 commits entre la última versión buena y la actual, encuentra el culpable en unos 12 pasos, porque cada prueba descarta la mitad de los candidatos. Es la diferencia entre una tarde de arqueología y cinco minutos.

terminal · bisect manual y automático
# --- Modo manual ---
git bisect start
git bisect bad                 # el commit actual falla
git bisect good v1.4.0         # esta etiqueta funcionaba
# Git hace checkout del commit intermedio. Pruebas y respondes:
git bisect good                # ... o: git bisect bad
# ... repite unas 12 veces ...
# a1b2c3 is the first bad commit
git bisect reset               # vuelve a donde estabas
# --- Modo automático: el ordenador lo hace solo ---
# El script debe salir con código 0 si el commit es bueno y distinto de 0 si es malo.
git bisect start HEAD v1.4.0
git bisect run npm test -- --run test/tareas.e2e-spec.ts
# Truco: si el fallo no lo detecta un test, escribe un script mínimo
#   #!/bin/sh
#   npm ci --silent && npm run build && node scripts/reproduce-bug.js
# y ejecútalo con: git bisect run ./scripts/check.sh
Bisect y el historial bisect funciona mucho mejor sobre un historial donde cada commit compila y pasa los tests. Es el argumento más fuerte a favor de squash merge o de un rebase cuidado: si main está lleno de commits «wip», «arreglo el arreglo» y «ahora sí», la mitad de los pasos de la bisección ni siquiera arrancan y hay que descartarlos con git bisect skip.

21.2.6 Commits convencionales, versionado semántico y changelog

Un mensaje de commit no es un trámite: es el único sitio donde queda escrito por qué se hizo un cambio. El estándar Conventional Commits añade una estructura mínima que además permite automatizar el versionado y el changelog.

Formato de Conventional Commits
<tipo>(<ámbito opcional>): <descripción en imperativo, minúscula, sin punto final>
[cuerpo opcional: POR QUÉ se hace el cambio, no qué líneas se tocan]
[nota al pie opcional: BREAKING CHANGE: …, Refs: #123, Co-authored-by: …]
# Ejemplos reales
feat(tareas): permitir filtrar por rango de fechas
fix(auth): renovar el token antes de que expire en peticiones largas
perf(tareas): añadir índice compuesto (proyecto_id, estado) y evitar el N+1
refactor(orm): extraer TareaRepository del servicio
build(docker): reducir la imagen de la API de 1,2 GB a 180 MB
ci: cachear ~/.npm entre ejecuciones del workflow
fix(api)!: el endpoint /tareas devuelve 404 en lugar de 200 con null
BREAKING CHANGE: los clientes que comprobaban `data === null` deben
manejar ahora el código 404.
TipoSignificadoEfecto en la versión (SemVer)
featNueva funcionalidad para el usuarioMINOR (1.4.0 → 1.5.0)
fixCorrección de un errorPATCH (1.4.2 → 1.4.3)
perfMejora de rendimiento sin cambio funcionalPATCH
refactorCambio interno sin alterar el comportamiento observableNinguno
docs, test, style, choreDocumentación, tests, formato, tareas de mantenimientoNinguno
build, ciSistema de construcción, dependencias, pipelineNinguno (o PATCH si afecta al artefacto publicado)
! o BREAKING CHANGE:Ruptura de compatibilidadMAJOR (1.4.2 → 2.0.0)

Versionado semántico (MAYOR.MENOR.PARCHE) es un contrato con quien consume tu artefacto: mayor significa «te va a romper algo, lee las notas»; menor, «hay cosas nuevas y compatibles»; parche, «he arreglado algo sin cambiar la interfaz». Para una API HTTP el equivalente es la versión del endpoint (/api/v1) y para una imagen Docker, la etiqueta. Etiqueta las imágenes con la versión y con el hash del commit; latest como única etiqueta hace imposible saber qué hay desplegado.

Con commits convencionales, generar el changelog y la etiqueta de versión deja de ser trabajo manual. Herramientas habituales: release-please (abre un pull request de versión y lo publica al fusionarlo), semantic-release (publica automáticamente desde CI) y Changesets (excelente en monorepos, donde cada paquete lleva su propia versión).

Hooks locales: husky, lint-staged y commitlint

terminal · instalación (husky 9)
npm install --save-dev husky lint-staged @commitlint/cli @commitlint/config-conventional
npx husky init                 # crea .husky/ y el script "prepare" en package.json
# .husky/pre-commit  → solo sobre lo que está en el índice, en segundos
echo "npx lint-staged" > .husky/pre-commit
# .husky/commit-msg  → valida el formato del mensaje
echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msg
package.json (fragmento)
{
  "scripts": {
    "prepare": "husky"
  },
  "lint-staged": {
    "*.ts": ["eslint --fix", "prettier --write"],
    "*.{html,scss,json,md,yml}": ["prettier --write"]
  }
}
commitlint.config.js
export default {
  extends: ['@commitlint/config-conventional'],
  rules: {
    // Ámbitos permitidos: fuerza vocabulario común en el equipo
    'scope-enum': [2, 'always',
      ['api', 'web', 'auth', 'tareas', 'orm', 'docker', 'ci', 'deps']],
    'header-max-length': [2, 'always', 100],
  },
};
Los hooks son una comodidad, no una garantía Cualquiera puede saltárselos con git commit --no-verify, y en un servidor de CI ni siquiera se ejecutan. Sirven para dar feedback rápido (dos segundos frente a cinco minutos), pero la verdad incuestionable está en el pipeline: lint, tipos y tests deben ejecutarse también en CI, con ramas protegidas que impidan fusionar si fallan. Y mantén el pre-commit por debajo de unos pocos segundos: un hook lento se acaba desactivando.

21.2.7 Pull requests que se revisan de verdad

Al abrir un pull request

  • Pequeño. Por debajo de unas 400 líneas modificadas la revisión es efectiva; por encima, el revisor deja de leer y empieza a aprobar. Si el cambio es grande, divídelo en varios pull requests encadenados.
  • Un solo propósito. Refactor o funcionalidad, nunca los dos a la vez.
  • Descripción con contexto: qué problema resuelve, qué decisión de diseño se ha tomado y qué alternativas se descartaron, cómo probarlo, y capturas si toca la interfaz.
  • Autorrevisión previa. Léelo tú antes de pedir revisión: encontrarás la mitad de las pegas.
  • CI en verde antes de pedir revisión. El tiempo del revisor es caro.
  • Anota los puntos delicados con comentarios en el propio pull request: «aquí he asumido que el proyecto siempre existe porque lo garantiza el guard».

Al revisar el de otro

  • Corrección antes que estilo. El formato lo arregla Prettier; tú busca errores de lógica, condiciones de carrera y casos límite (lista vacía, nulo, concurrencia, fallo de red).
  • Seguridad: entradas sin validar, autorización que falta, datos sensibles en logs, SQL construido por concatenación.
  • Rendimiento: consultas dentro de bucles (N+1), await en serie donde cabe Promise.all, índices que faltan.
  • Compatibilidad: ¿esta migración funciona con la versión anterior del código todavía desplegada? (sección 21.6.3).
  • Tests: ¿existe un test que falla sin el arreglo? Si no, el arreglo no está demostrado.
  • Distingue lo bloqueante de lo opinable. Prefija con «menor:» o «duda:» lo que no bloquea. Comenta el código, nunca a la persona.

21.2.8 .gitignore para este stack y qué no debe subirse jamás

.gitignore
# --- Dependencias y artefactos de construcción ---
node_modules/
dist/
build/
tmp/
out-tsc/
.angular/            # caché del CLI de Angular: enorme y regenerable
.nx/cache/
# --- Cobertura y resultados de tests ---
coverage/
.nyc_output/
playwright-report/
test-results/
# --- Secretos y configuración local (¡lo más importante!) ---
.env
.env.*
!.env.example        # la plantilla SÍ se sube, con valores falsos
*.pem
*.key
*.p12
secrets/
# --- Registros y volcados ---
*.log
npm-debug.log*
pnpm-debug.log*
*.dump
*.sql.gz             # volcados de base de datos con datos reales
# --- Editor y sistema operativo ---
.DS_Store
Thumbs.db
.idea/
.vscode/*
!.vscode/extensions.json   # las recomendaciones del equipo sí se comparten
!.vscode/settings.json     # si el equipo acuerda ajustes comunes
Qué NUNCA debe llegar al repositorio Credenciales de cualquier tipo (contraseñas, tokens, claves de API, claves privadas, cadenas de conexión con usuario y contraseña), volcados de base de datos con datos personales reales, ficheros de más de unos pocos megabytes (usa Git LFS o un bucket), node_modules, artefactos de compilación y ficheros de configuración personales del IDE. Y recuerda: borrar un secreto en un commit posterior no lo elimina; sigue en el historial y en todos los clones. Si se ha filtrado, el procedimiento es rotar la credencial inmediatamente y, después, limpiar el historial con git filter-repo o el BFG. Rotar primero, limpiar después: la limpieza es cosmética, la rotación es la que corta el riesgo. Activa además el escaneo de secretos del proveedor (push protection) y un hook con gitleaks.

21.3 Docker: empaquetar la aplicación entera

21.3.1 Contenedor frente a máquina virtual

Una máquina virtual emula hardware y ejecuta un sistema operativo completo encima de un hipervisor: su propio kernel, sus propios servicios, sus gigabytes. Un contenedor no virtualiza nada: es un proceso normal del anfitrión al que el kernel de Linux le ha recortado la visión del mundo mediante namespaces (ve su propio árbol de procesos, su red, su sistema de ficheros) y le ha limitado los recursos mediante cgroups (cuánta CPU y cuánta memoria puede consumir).

   MÁQUINAS VIRTUALES                    CONTENEDORES
  ┌──────┐┌──────┐┌──────┐             ┌──────┐┌──────┐┌──────┐
  │ App A││ App B││ App C│             │ App A││ App B││ App C│
  ├──────┤├──────┤├──────┤             ├──────┤├──────┤├──────┤
  │ libs ││ libs ││ libs │             │ libs ││ libs ││ libs │
  ├──────┤├──────┤├──────┤             └──┬───┘└──┬───┘└──┬───┘
  │  S.O ││  S.O ││  S.O │  ~1-4 GB       └───────┼───────┘
  │ HUÉSP││ HUÉSP││ HUÉSP│  cada una    ┌─────────▼─────────┐
  ├──────┴┴──────┴┴──────┤             │ Motor (containerd) │
  │     HIPERVISOR       │             ├────────────────────┤
  ├──────────────────────┤             │ KERNEL COMPARTIDO  │ namespaces
  │  S.O. ANFITRIÓN      │             │  S.O. ANFITRIÓN    │ + cgroups
  ├──────────────────────┤             ├────────────────────┤
  │      HARDWARE        │             │     HARDWARE       │
  └──────────────────────┘             └────────────────────┘
   Arranque: 30-60 s                    Arranque: 50-500 ms
   Aislamiento: fuerte (hardware)       Aislamiento: bueno (kernel compartido)

Una imagen es una plantilla inmutable formada por capas apiladas de solo lectura; cada instrucción del Dockerfile que modifica el sistema de ficheros produce una capa. Al arrancar un contenedor, Docker añade encima una capa de escritura efímera: todo lo que el contenedor escriba y no esté en un volumen desaparece al destruirlo. Las capas se identifican por el hash de su contenido, así que dos imágenes que compartan la misma base la almacenan y la descargan una sola vez. Un registro (Docker Hub, GitHub Container Registry, ECR, Artifact Registry) es el repositorio donde se publican esas imágenes.

Analogía La imagen es la receta y la mise en place de un plato: exactamente los mismos ingredientes, las mismas cantidades y el mismo orden. El contenedor es el plato concreto que sales a servir esta noche. Puedes servir cincuenta platos idénticos a partir de la misma receta, y si uno se cae al suelo no pasa nada: se tira y se hace otro. Ese «se tira y se hace otro» es exactamente lo que permite el escalado horizontal y el despliegue sin cortes.

Esto es lo que resuelve el «en mi máquina funciona»: la imagen contiene la versión exacta de Node, las dependencias del sistema, las variables de entorno y el código compilado. El artefacto que has probado en CI es bit a bit el que corre en producción, identificado por un digest (sha256:…) que no admite discusión.

21.3.2 Dockerfile: instrucciones, capas y caché

InstrucciónQué haceDetalle que importa
FROMImagen base (y comienza una etapa)Fija la versión: node:22.11-alpine, no node:latest
WORKDIRDirectorio de trabajoLo crea si no existe; mejor que RUN cd, que no persiste
COPYCopia del contexto de construcciónCrea capa. Admite --from=etapa y --chown
ADDComo COPY + descomprime y descarga URLsEvítalo: comportamiento implícito. Usa COPY
RUNEjecuta un comando en construcciónCrea capa. Encadena con && y limpia en la misma capa
ENV / ARGVariable de runtime / de construcciónARG queda en el historial de la imagen: nunca metas secretos
EXPOSEDocumenta el puertoNo publica nada; publicar es cosa de -p o de compose
USERUsuario que ejecuta el procesoPor defecto es root. Cámbialo siempre
HEALTHCHECKComando periódico de saludLo usa depends_on: condition: service_healthy
ENTRYPOINTEjecutable fijo del contenedorEn forma exec (JSON) para que el proceso sea PID 1 real
CMDArgumentos por defectoSe sustituye desde la línea de comandos; útil para variar el comando

El orden de las capas es la diferencia entre 3 segundos y 3 minutos

Docker cachea cada capa. Al reconstruir, reutiliza la caché mientras la instrucción y sus entradas no cambien; en cuanto una capa se invalida, todas las siguientes se reconstruyen. Como el código fuente cambia en cada commit y las dependencias solo cambian cuando tocas package.json, el orden correcto es evidente: primero los manifiestos, luego npm ci, y el código al final.

  ORDEN INCORRECTO                     ORDEN CORRECTO
  ┌──────────────────────┐             ┌──────────────────────┐
  │ FROM node:22-alpine  │ ✓ caché     │ FROM node:22-alpine  │ ✓ caché
  ├──────────────────────┤             ├──────────────────────┤
  │ COPY . .             │ ✗ INVALIDA  │ COPY package*.json . │ ✓ caché (no cambió)
  ├──────────────────────┤             ├──────────────────────┤
  │ RUN npm ci           │ ✗ 90 s      │ RUN npm ci           │ ✓ caché → 0 s
  ├──────────────────────┤             ├──────────────────────┤
  │ RUN npm run build    │ ✗ 40 s      │ COPY . .             │ ✗ invalida (tocaste código)
  └──────────────────────┘             ├──────────────────────┤
   Cada commit: ~2,5 min               │ RUN npm run build    │ ✗ 40 s
                                       └──────────────────────┘
                                        Cada commit: ~45 s
DockerfileINCORRECTO
FROM node:latest
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
ENV DB_PASSWORD=s3cr3t
EXPOSE 3000
CMD npm run start:prod
Base sin fijar (irreproducible), caché inútil, npm install ignora el lockfile, la imagen incluye devDependencies, el código fuente y el .git, un secreto queda grabado en una capa, corre como root y npm se traga la señal SIGTERM.
DockerfileCORRECTO
FROM node:22.11-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22.11-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]
Versión fijada, caché aprovechada, sin devDependencies ni fuentes en la imagen final, sin secretos, usuario no root y el proceso Node como PID 1, que sí recibe SIGTERM.

Señales: por qué tu contenedor tarda 10 segundos en morir

Cuando la plataforma quiere parar un contenedor envía SIGTERM al proceso PID 1 y espera un plazo de gracia (10 s por defecto en Docker, 30 s en Kubernetes) antes de rematar con SIGKILL. Dos problemas clásicos rompen esto:

src/main.ts · apagado ordenado en NestJS
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();              // escucha SIGTERM y SIGINT
// El equilibrio importante: el balanceador tarda unos segundos en dejar de
// enviarte tráfico. Si cierras al instante, esas peticiones se pierden.
process.on('SIGTERM', async () => {
  logger.log('SIGTERM recibido: dejo de aceptar conexiones nuevas');
  await new Promise((r) => setTimeout(r, 5000));   // margen para el balanceador
  await app.close();                               // cierra HTTP, ORM y colas
});

21.3.3 Dockerfile multi-stage para la API NestJS

apps/api/Dockerfile
# syntax=docker/dockerfile:1
# Etapa 1 · deps: TODAS las dependencias (incluidas las de desarrollo).
# Se separa para que la instalación se cachee mientras package-lock.json no cambie.
FROM node:22.11-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
# Etapa 2 · build: compila TypeScript a JavaScript en dist/.
# Aquí sí hace falta el código fuente y el compilador; nada de esto llegará
# a la imagen final.
FROM node:22.11-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Etapa 3 · prod-deps: node_modules SOLO de producción.
# Reinstalar desde cero es más fiable que borrar las de desarrollo a posteriori.
FROM node:22.11-alpine AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev && npm cache clean --force
# Etapa 4 · runtime: lo mínimo para ejecutar. Es la única etapa que se publica.
FROM node:22.11-alpine AS runtime
# dumb-init reenvía señales y adopta huérfanos: el proceso Node recibe SIGTERM
RUN apk add --no-cache dumb-init
ENV NODE_ENV=production \
    PORT=3000 \
    # Sin esto, Node solo ve la memoria del anfitrión y el OOM killer mata el
    # contenedor antes de que el recolector de basura reaccione.
    NODE_OPTIONS="--max-old-space-size=384"
WORKDIR /app
# La imagen node:alpine ya trae el usuario 'node' (uid 1000)
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build     --chown=node:node /app/dist         ./dist
COPY --chown=node:node package.json ./
USER node
EXPOSE 3000
# Docker y compose lo usan para 'service_healthy'. En Kubernetes se ignora:
# allí mandan las probes del manifiesto (sección 21.7).
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/main.js"]
Las migraciones de MikroORM y la imagen de producción El CLI de MikroORM (@mikro-orm/cli) es una dependencia de desarrollo y los ficheros de migración suelen ser .ts. Tienes dos opciones limpias: (a) compilar también las migraciones y ejecutarlas con un pequeño script de Node que use orm.getMigrator().up() desde dist/, o (b) publicar una segunda imagen «de herramientas» (la etapa build, que sí tiene el CLI) y usarla en el job previo al despliegue. Lo que no debes hacer es lanzar las migraciones al arrancar la aplicación: en la sección 21.6.3 se explica por qué eso rompe en cuanto tienes más de una réplica.
Secretos en tiempo de construcción Un ARG NPM_TOKEN o un COPY .env . queda grabado para siempre en el historial de capas de la imagen, aunque después lo borres con RUN rm: cualquiera con acceso a la imagen lo recupera con docker history. Si necesitas un token privado durante la construcción, usa montajes de secreto de BuildKit: RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci, que monta el fichero solo durante ese RUN y no deja rastro en ninguna capa.

21.3.4 Dockerfile del frontend Angular servido con nginx

apps/web/Dockerfile
# syntax=docker/dockerfile:1
# ─────────── Etapa 1 · construcción del bundle de Angular ───────────
FROM node:22.11-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
# Angular 17+ deja el resultado en dist/<proyecto>/browser
RUN npm run build -- --configuration=production
# ─────────── Etapa 2 · servidor estático ───────────
# nginx-unprivileged corre como uid 101 y escucha en 8080 sin necesitar root.
FROM nginxinc/nginx-unprivileged:1.27-alpine AS runtime
COPY --chown=nginx:nginx nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=build --chown=nginx:nginx /app/dist/web/browser /usr/share/nginx/html
# Script que escribe la configuración de RUNTIME antes de arrancar nginx.
# Todo lo que hay en /docker-entrypoint.d/ lo ejecuta la imagen oficial al iniciar.
COPY --chown=nginx:nginx docker/10-env-config.sh /docker-entrypoint.d/10-env-config.sh
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
# El CMD lo hereda de la imagen base: nginx -g "daemon off;"
apps/web/nginx/default.conf
server {
  listen       8080;
  server_name  _;
  root         /usr/share/nginx/html;
  index        index.html;
  # ---------- Compresión ----------
  gzip              on;
  gzip_vary         on;              # Vary: Accept-Encoding, imprescindible tras una CDN
  gzip_comp_level   6;
  gzip_min_length   1024;            # comprimir 200 bytes cuesta más de lo que ahorra
  gzip_types        text/plain text/css application/javascript application/json
                    application/xml image/svg+xml font/woff2;
  # Si el build genera .gz/.br, sirve el fichero ya comprimido en lugar de
  # comprimir en cada petición:
  # gzip_static on;
  # ---------- Cabeceras de seguridad ----------
  add_header X-Content-Type-Options "nosniff"        always;
  add_header X-Frame-Options        "SAMEORIGIN"     always;
  add_header Referrer-Policy        "strict-origin-when-cross-origin" always;
  # ---------- Sonda de salud (no debe caer en el fallback de la SPA) ----------
  location = /healthz { access_log off; return 200 "ok\n"; }
  # ---------- Recursos con hash en el nombre: caché eterna ----------
  # main-A7F3B2C1.js cambia de nombre en cada despliegue, así que puede
  # cachearse un año sin riesgo de servir una versión antigua.
  location ~* \.(?:js|css|woff2?|ttf|png|jpe?g|gif|svg|webp|avif|ico)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    access_log off;
    try_files $uri =404;             # sin fallback: un asset que falta es un 404
  }
  # ---------- index.html: NUNCA en caché ----------
  # Es el índice que apunta a los ficheros con hash. Si se cachea, el navegador
  # seguirá pidiendo el bundle antiguo durante horas después de desplegar.
  location = /index.html {
    add_header Cache-Control "no-cache, no-store, must-revalidate" always;
    expires    -1;
  }
  location = /assets/env.js {        # configuración de runtime: tampoco se cachea
    add_header Cache-Control "no-cache" always;
    expires    -1;
  }
  # ---------- Fallback de la SPA ----------
  # El enrutador de Angular vive en el cliente: /tareas/42 no existe como fichero,
  # así que hay que devolver index.html y dejar que el router resuelva la ruta.
  location / {
    try_files $uri $uri/ /index.html;
  }
  # ---------- Proxy a la API (solo si sirves ambos desde el mismo origen) ----------
  location /api/ {
    proxy_pass         http://api:3000/;
    proxy_http_version 1.1;
    proxy_set_header   Host              $host;
    proxy_set_header   X-Real-IP         $remote_addr;
    proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header   X-Forwarded-Proto $scheme;
    proxy_read_timeout 60s;
  }
}

21.3.5 Tamaño de la imagen: alpine, slim y distroless

BaseTamaño base aprox.Imagen final de la API aprox.VentajasInconvenientes
node:22 (Debian completa)~1,1 GB~1,3 GBTrae de todo: compiladores, git, curlEnorme, lenta de descargar y con mucha superficie de ataque
node:22-slim~230 MB~330 MBDebian recortada, glibc estándar, compatible con módulos nativosCasi el doble que alpine
node:22-alpine~140 MB~180 MBLa opción por defecto: pequeña y con apk disponibleUsa musl en lugar de glibc: algún módulo nativo o binario precompilado puede fallar
gcr.io/distroless/nodejs22~135 MB~175 MBSin shell, sin gestor de paquetes: superficie de ataque mínimaDepurar es incómodo (no hay sh para docker exec); requiere variante debug
Recomendación práctica Alpine para el 90 % de los casos. Cambia a slim si dependes de módulos nativos problemáticos (algunas versiones de sharp, node-canvas, grpc) o si mides tiempos de arranque peores por musl. Reserva distroless para entornos con requisitos de seguridad estrictos y con un equipo cómodo depurando sin shell. Y recuerda que lo que más pesa no es la base, sino node_modules: un npm ci --omit=dev honesto suele recortar más que cambiar de distribución.
.dockerignore
node_modules
dist
.angular
coverage
.git
.github
.env
.env.*
*.log
Dockerfile*
docker-compose*.yml
README.md
.vscode
.idea

21.3.6 docker compose para desarrollo

docker-compose.yml
# Entorno de desarrollo completo: un solo comando y cualquiera del equipo
# tiene exactamente la misma base de datos, la misma versión de Redis y los
# mismos puertos.  Uso:  docker compose up -d --build
#
# El atributo obsoleto 'version:' ya no es necesario en Docker Compose v2.
services:
  # ─────────────────────────── Base de datos ───────────────────────────
  postgres:
    image: postgres:16.4-alpine
    container_name: tareas-postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-tareas}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tareas}
      POSTGRES_DB: ${POSTGRES_DB:-tareas}
      # Acelera mucho los tests locales; JAMÁS en producción.
      POSTGRES_INITDB_ARGS: "--data-checksums"
    ports:
      - "5432:5432"          # expuesto solo para conectar con un cliente local
    volumes:
      - pgdata:/var/lib/postgresql/data
      # Los .sql de este directorio se ejecutan SOLO al crear el volumen
      - ./docker/postgres/init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      # Sin esto, la API arrancaría antes de que Postgres acepte conexiones
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tareas} -d ${POSTGRES_DB:-tareas}"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s
    networks: [backend]
  # ─────────────────────────── Caché y colas ───────────────────────────
  redis:
    image: redis:7.4-alpine
    container_name: tareas-redis
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb",
              "--maxmemory-policy", "allkeys-lru"]
    ports:
      - "6379:6379"
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10
    networks: [backend]
  # ─────────────────────────── API NestJS ───────────────────────────
  api:
    build:
      context: ./apps/api
      dockerfile: Dockerfile
      target: deps          # etapa con devDependencies: en desarrollo queremos tsc
    container_name: tareas-api
    restart: unless-stopped
    # Hot reload: el código vive en el anfitrión y se monta dentro.
    command: ["npm", "run", "start:dev"]
    environment:
      NODE_ENV: development
      PORT: 3000
      # 'postgres' y 'redis' son los nombres de servicio: el DNS interno de la
      # red de compose los resuelve. Nada de localhost aquí.
      DATABASE_URL: postgres://${POSTGRES_USER:-tareas}:${POSTGRES_PASSWORD:-tareas}@postgres:5432/${POSTGRES_DB:-tareas}
      REDIS_URL: redis://redis:6379
      JWT_SECRET: ${JWT_SECRET:-solo-para-desarrollo}
    env_file:
      - .env.development       # lo que no quieras escribir aquí
    ports:
      - "3000:3000"
      - "9229:9229"            # depurador de Node: --inspect=0.0.0.0:9229
    volumes:
      - ./apps/api/src:/app/src:ro     # el código, en solo lectura
      - ./apps/api/test:/app/test:ro
      # Volumen anónimo: evita que el node_modules del anfitrión (compilado para
      # otro sistema operativo) pise el que se instaló DENTRO de la imagen.
      - /app/node_modules
    depends_on:
      postgres:
        condition: service_healthy     # espera al healthcheck, no solo al arranque
      redis:
        condition: service_healthy
    init: true                          # PID 1 con reenvío de señales
    networks: [backend, frontend]
  # ─────────────────────────── Frontend Angular ───────────────────────────
  web:
    build:
      context: ./apps/web
      target: build            # en desarrollo usamos 'ng serve', no nginx
    container_name: tareas-web
    restart: unless-stopped
    command: ["npm", "run", "start", "--", "--host", "0.0.0.0", "--poll", "1000"]
    environment:
      NG_APP_API_URL: http://localhost:3000
    ports:
      - "4200:4200"
    volumes:
      - ./apps/web/src:/app/src
      - /app/node_modules
      - /app/.angular              # la caché del CLI, fuera del anfitrión
    depends_on:
      api:
        condition: service_started
    networks: [frontend]
  # ─────────────────────────── Utilidades ───────────────────────────
  adminer:
    image: adminer:4.8.1
    container_name: tareas-adminer
    restart: unless-stopped
    ports:
      - "8080:8080"
    environment:
      ADMINER_DEFAULT_SERVER: postgres
    depends_on:
      postgres:
        condition: service_healthy
    profiles: ["tools"]        # solo con: docker compose --profile tools up
    networks: [backend]
volumes:
  pgdata:
  redisdata:
networks:
  # Dos redes: el frontend no puede hablar directamente con la base de datos.
  backend:
  frontend:
Compose es para desarrollo (y para un VPS pequeño), no para producción seria Compose no reintenta el arranque de forma inteligente, no hace despliegue progresivo, no reparte carga entre nodos y no reprograma contenedores si la máquina cae. Para un proyecto pequeño con un único servidor es perfectamente razonable —y mucho más barato que Kubernetes—, pero conviene saber qué no te da. El fichero de producción es otro (docker-compose.prod.yml): sin montajes del código, sin puertos de base de datos publicados, con imágenes fijadas por digest y con límites de recursos.

21.3.7 Comandos de diagnóstico habituales

ComandoPara qué
docker compose logs -f --tail=100 apiSeguir los logs de un servicio. Lo primero, siempre.
docker compose psEstado y salud de cada servicio (healthy, starting, unhealthy).
docker exec -it tareas-api shAbrir una shell dentro del contenedor para mirar ficheros y variables.
docker exec tareas-api envVer qué variables de entorno recibe de verdad el proceso.
docker inspect tareas-apiConfiguración completa en JSON: montajes, redes, entrypoint, estado de salida.
docker inspect --format '{{.State.ExitCode}}' tareas-apiPor qué murió. 137 = SIGKILL (casi siempre falta de memoria), 143 = SIGTERM.
docker statsCPU, memoria y red en vivo. Para ver quién se está comiendo la máquina.
docker history --no-trunc imagen:tagVer las capas y su tamaño; también revela secretos incrustados.
docker image ls --filter reference='tareas*'Comprobar el tamaño real de tus imágenes.
docker compose exec postgres psql -U tareas -d tareasConsola SQL contra la base de datos del entorno.
docker system df / docker system prune -a --volumesVer y liberar espacio. Cuidado: con --volumes borra los datos.
docker compose down -vParar y borrar volúmenes: la forma de partir de una base de datos limpia.
docker build --progress=plain --no-cache .Ver toda la salida de construcción cuando la caché te está engañando.

21.4 Entornos y configuración

EntornoPara quéDatosQuién despliega
DesarrolloTrabajo diario en la máquina de cada personaSemilla ficticia, base de datos desechableCada desarrollador, continuamente
Test / CIEjecutar la suite en un entorno limpio y reproducibleBase de datos efímera creada y destruida por el pipelineEl propio pipeline, en cada push
Staging / preproducciónÚltima verificación con configuración idéntica a producciónCopia anonimizada de producción, o volumen realistaAutomático al fusionar en main
ProducciónUsuarios realesDatos reales; copias de seguridad probadasAutomático tras aprobación, o continuo

Paridad entre entornos significa que la única diferencia entre staging y producción debe ser la configuración (URLs, credenciales, tamaños), nunca el artefacto ni la topología. La misma imagen que pasó por CI se promociona por los entornos; si en staging usas SQLite y en producción PostgreSQL, tu staging no demuestra nada.

21.4.1 Los doce factores, comentados

La metodología Twelve-Factor App (Heroku, 2011) sigue siendo la mejor lista de comprobación para saber si una aplicación es apta para desplegarse de forma automatizada y escalar horizontalmente.

#FactorQué implica en este stack
1Base de códigoUn repositorio por aplicación (o un monorepo bien delimitado), muchos despliegues. Nada de una rama por entorno con código distinto.
2DependenciasDeclaradas y aisladas: package.json + package-lock.json, npm ci. Nada instalado «a mano» en el servidor.
3ConfiguraciónEn el entorno, no en el código. Si pudieras publicar el repositorio como código abierto sin filtrar nada, lo estás haciendo bien.
4Servicios de respaldoPostgreSQL, Redis o S3 son recursos accesibles por URL. Cambiar una base de datos local por una gestionada debe ser cambiar DATABASE_URL.
5Construir, publicar, ejecutarEtapas separadas y estrictas: la imagen se construye una vez, se etiqueta y se ejecuta. Prohibido editar código en el servidor.
6ProcesosSin estado y sin compartir nada. Nada de guardar ficheros subidos en el disco local ni sesiones en memoria.
7Asignación de puertosLa aplicación expone HTTP por sí misma (PORT); no depende de un servidor de aplicaciones externo.
8ConcurrenciaSe escala añadiendo procesos (réplicas), no engordando uno. Tipos de proceso distintos: web, worker, cron.
9DesechabilidadArranque rápido y apagado ordenado ante SIGTERM. Un proceso debe poder morir en cualquier momento sin corromper nada.
10Paridad desarrollo/producciónMismos servicios y versiones en todas partes: exactamente lo que resuelve Docker Compose en desarrollo.
11LogsFlujo de eventos por stdout; el entorno los recoge y los agrega. La aplicación no gestiona ficheros ni rotación.
12Procesos de administraciónMigraciones y tareas puntuales se ejecutan como procesos aparte, con el mismo código y la misma versión que la aplicación.

21.4.2 Variables de entorno, ficheros y gestión de secretos

Distingue tres categorías: configuración no sensible (nivel de log, URL pública, tiempos de espera), que puede vivir en un fichero versionado por entorno; configuración sensible (contraseñas, claves de firma, tokens), que solo puede venir del entorno o de un gestor de secretos; y constantes de negocio (el IVA, el número máximo de reintentos), que son código y deben ir versionadas.

config · INCORRECTOINCORRECTO
// src/config/database.ts
export const dbConfig = {
  host: 'prod-db.empresa.com',
  user: 'admin',
  password: 'P@ssw0rd2024',    // en el repositorio, para siempre
  ssl: false,
};
// docker-compose.prod.yml
//   environment:
//     JWT_SECRET: clave-super-secreta-123
// Dockerfile
//   COPY .env.production .env  ← el secreto viaja en una capa de la imagen
config · CORRECTOCORRECTO
// src/config/env.validation.ts
// Validar la configuración AL ARRANCAR: si falta algo, el proceso no debe
// levantarse a medias, debe fallar de inmediato y con un mensaje claro.
import { z } from 'zod';
const esquema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),          // longitud mínima obligada
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
export type Env = z.infer<typeof esquema>;
export function cargarEnv(): Env {
  const r = esquema.safeParse(process.env);
  if (!r.success) {
    console.error('Configuración inválida:', r.error.flatten().fieldErrors);
    process.exit(1);          // fallar rápido y ruidosamente
  }
  return r.data;
}
MecanismoDónde encajaNotas
.env local + .env.example versionadoSolo desarrolloEl ejemplo documenta qué variables existen, con valores falsos. El real, ignorado por Git.
Secretos del CI (GitHub Actions, GitLab CI)PipelineCifrados, enmascarados en los logs y limitados por entorno. Combínalos con environments y aprobación manual.
Gestor dedicado (HashiCorp Vault, AWS Secrets Manager, Google Secret Manager)Producción seriaAuditoría de accesos, rotación automática y credenciales dinámicas de corta vida.
Sealed Secrets / SOPSGitOps en KubernetesPermiten versionar el secreto cifrado; solo el clúster puede descifrarlo.
Roles de identidad (IAM, workload identity)Nube gestionadaLo mejor: no hay secreto que rotar porque no hay secreto. El servicio se autentica por su identidad.
Rotación Todo secreto debe poder cambiarse sin desplegar código y sin cortar el servicio. Eso obliga a dos cosas: leer los secretos del entorno (no del artefacto) y admitir dos valores válidos a la vez durante la ventana de rotación (por ejemplo, dos claves de firma de JWT: se firma con la nueva y se acepta la antigua hasta que expiren todos los tokens). Define una periodicidad —90 días es habitual— y rota inmediatamente ante cualquier sospecha de filtración o salida de una persona del equipo.

21.4.3 Configurar el frontend: compilación frente a ejecución

Aquí hay una asimetría incómoda con el backend. En Angular, los environment.ts se resuelven en tiempo de compilación: el valor queda incrustado en el bundle. Si la URL de la API es distinta en staging y en producción, con ese enfoque necesitas una imagen distinta por entorno, lo que rompe la promesa de «construir una vez, desplegar en todas partes» y hace que lo que validaste en staging no sea el artefacto que llega a producción.

En compilación (environment.ts, fileReplacements)En ejecución (env.js o config.json)
ArtefactoUno por entornoUno único, promocionable
OptimizaciónPermite tree shaking de ramas muertasEl valor es opaco al compilador
Cambiar un valorRequiere reconstruir y volver a desplegarReiniciar el contenedor o editar un fichero
Bueno paraFlags de compilación, production: trueURLs, claves públicas de terceros, activación de funcionalidades por entorno
apps/web/docker/10-env-config.sh · genera la configuración al arrancar el contenedor
#!/bin/sh
set -eu
# La imagen oficial de nginx ejecuta todo lo que encuentre en /docker-entrypoint.d/
# antes de levantar el servidor. Aquí escribimos un fichero JS con la
# configuración tomada de las variables de entorno del contenedor.
DESTINO=/usr/share/nginx/html/assets/env.js
cat > "$DESTINO" <<EOF
window.__env = {
  apiUrl: "${API_URL:-http://localhost:3000}",
  sentryDsn: "${SENTRY_DSN:-}",
  entorno: "${APP_ENV:-production}",
  version: "${APP_VERSION:-dev}"
};
EOF
echo "[env-config] configuración generada para ${APP_ENV:-production}"
src/index.html
<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <title>Gestor de tareas</title>
  <base href="/">
  <!-- Antes del bundle: cuando arranca Angular, window.__env ya existe.
       Este fichero se sirve con Cache-Control: no-cache. -->
  <script src="assets/env.js"></script>
</head>
<body><app-root></app-root></body>
</html>
src/app/core/config.ts
export interface ConfigRuntime {
  apiUrl: string;
  sentryDsn: string;
  entorno: 'development' | 'staging' | 'production';
  version: string;
}
declare global {
  interface Window { __env?: Partial<ConfigRuntime>; }
}
export const APP_CONFIG = new InjectionToken<ConfigRuntime>('APP_CONFIG');
export function proveerConfig() {
  return {
    provide: APP_CONFIG,
    useFactory: (): ConfigRuntime => ({
      apiUrl: window.__env?.apiUrl ?? '/api',
      sentryDsn: window.__env?.sentryDsn ?? '',
      entorno: window.__env?.entorno ?? 'production',
      version: window.__env?.version ?? 'dev',
    }),
  };
}
Alternativa: config.json con un inicializador En lugar de un script global puedes servir /assets/config.json y cargarlo con un inicializador de aplicación antes del primer render. En Angular 19 y posteriores se registra con provideAppInitializer(() => …); en versiones anteriores, con el token APP_INITIALIZER y multi: true. Es más idiomático, pero añade una petición de red bloqueante al arranque. Si sirves el frontend y la API bajo el mismo dominio, la opción más simple de todas es una ruta relativa (/api) y ninguna configuración.

21.5 Integración continua

Integración continua no es «tener un pipeline». Es una práctica de equipo: todo el mundo integra su trabajo en la rama principal al menos una vez al día, y un sistema automático verifica en minutos que la integración no ha roto nada. El pipeline es la herramienta; la práctica es la frecuencia. Un equipo con ramas de dos semanas y un ci.yml impecable no hace integración continua: hace verificación automatizada de ramas divergentes, que es otra cosa y mucho menos valiosa.

21.5.1 El pipeline recomendado, por orden

  ┌──────────┐  ┌──────┐  ┌───────────┐  ┌──────────────┐  ┌───────┐
  │ instalar │─►│ lint │─►│ typecheck │─►│ tests unit.  │─►│ build │─►…
  │  npm ci  │  │ 20 s │  │   30 s    │  │  + cobertura │  │  60 s │
  └──────────┘  └──────┘  └───────────┘  └──────────────┘  └───────┘
        …─►┌──────────────────┐  ┌────────────┐  ┌──────────────┐  ┌───────────┐
           │ tests integración│─►│ tests e2e  │─►│  seguridad   │─►│ publicar  │
           │ (Postgres real)  │  │ Playwright │  │ audit + scan │  │  imagen   │
           └──────────────────┘  └────────────┘  └──────────────┘  └───────────┘
  REGLA: lo más rápido y lo que más falla, primero. Un lint que tarda 20 s
  debe cortar el pipeline antes de gastar 8 minutos en tests e2e.
EtapaQué verificaCoste típico
Instalar (npm ci)Que el lockfile es coherente y reproducible10–60 s con caché
Lint (ESLint + Prettier --check)Errores de estilo y reglas de calidad20–40 s
Typecheck (tsc --noEmit)Tipos en todo el proyecto, incluidos los tests30–90 s
Tests unitariosLógica de dominio y servicios, con dobles1–3 min
BuildQue compila y que el bundle cabe en el presupuesto1–2 min
Tests de integraciónRepositorios y consultas contra PostgreSQL de verdad, con migraciones aplicadas2–5 min
Tests e2eRecorridos críticos de usuario sobre la aplicación levantada3–10 min
Seguridadnpm audit, escaneo de secretos y de la imagen1–2 min
Publicar artefactoImagen etiquetada con la versión y el SHA, subida al registro1–3 min

21.5.2 Workflow completo de GitHub Actions

.github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
    branches: [main]
# Si llegan dos pushes seguidos a la misma rama, cancela el anterior:
# ahorra minutos de CI y da feedback antes.
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
# Permisos mínimos por defecto; cada job amplía solo lo que necesita.
permissions:
  contents: read
jobs:
  # 1 · Calidad estática y tests unitarios, en varias versiones de Node
  calidad:
    name: Lint · tipos · unitarios (Node ${{ matrix.node }})
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false          # que una versión falle no oculta el resto
      matrix:
        node: ['20.x', '22.x']  # la LTS actual y la que usa la imagen de producción
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'          # cachea ~/.npm usando package-lock.json como clave
          cache-dependency-path: package-lock.json
      - name: Instalar dependencias
        run: npm ci --prefer-offline --no-audit --fund=false
      - name: Lint
        run: npm run lint
      - name: Comprobación de tipos
        run: npx tsc --noEmit -p tsconfig.json
      - name: Tests unitarios con cobertura
        run: npm run test -- --coverage --reporter=default --reporter=junit
      - name: Publicar cobertura como artefacto
        if: matrix.node == '22.x'      # una sola vez, no por cada versión
        uses: actions/upload-artifact@v4
        with:
          name: cobertura
          path: coverage/
          retention-days: 7
  # 2 · Tests de integración contra PostgreSQL y Redis REALES
  integracion:
    name: Integración (PostgreSQL + migraciones)
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16.4-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: tareas_test
        ports: ['5432:5432']
        # Sin healthcheck, los tests arrancan antes de que Postgres acepte conexiones
        options: --health-cmd "pg_isready -U test" --health-interval 5s --health-timeout 3s --health-retries 10
      redis:
        image: redis:7.4-alpine
        ports: ['6379:6379']
        options: --health-cmd "redis-cli ping" --health-interval 5s --health-retries 10
    env:
      NODE_ENV: test
      DATABASE_URL: postgres://test:test@localhost:5432/tareas_test
      REDIS_URL: redis://localhost:6379
      JWT_SECRET: clave-de-test-suficientemente-larga-para-el-esquema
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22.x', cache: 'npm' }
      - run: npm ci --prefer-offline --no-audit
      - name: Aplicar migraciones
        # Ejecutar las migraciones en CI valida DOS cosas: que la suite corre
        # sobre el esquema real y que las propias migraciones funcionan.
        run: npx mikro-orm migration:up
      - name: Comprobar que no faltan migraciones
        # Si alguien cambió una entidad y olvidó generar la migración, el
        # esquema y las entidades divergen. Esto lo detecta ANTES de desplegar.
        run: npx mikro-orm schema:update --dump --drop-tables=false | tee /tmp/diff.sql && test ! -s /tmp/diff.sql
      - name: Tests de integración
        run: npm run test:integration
  # 3 · Tests end-to-end
  e2e:
    name: End-to-end (Playwright)
    runs-on: ubuntu-latest
    needs: [calidad]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22.x', cache: 'npm' }
      - run: npm ci --prefer-offline --no-audit
      - run: npx playwright install --with-deps chromium
      - run: npm run test:e2e
      - uses: actions/upload-artifact@v4
        if: failure()          # las trazas solo interesan cuando algo falla
        with:
          name: playwright-report
          path: playwright-report/
  # 4 · Seguridad de la cadena de suministro
  seguridad:
    name: Auditoría y secretos
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }     # gitleaks necesita el historial completo
      - uses: actions/setup-node@v4
        with: { node-version: '22.x', cache: 'npm' }
      - run: npm ci --prefer-offline
      - name: Vulnerabilidades en dependencias
        run: npm audit --audit-level=high
      - name: Escaneo de secretos
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  # 5 · Construir y publicar la imagen (solo en main y en tags)
  imagen:
    name: Construir y publicar imagen
    runs-on: ubuntu-latest
    needs: [calidad, integracion, seguridad]
    if: github.event_name == 'push'
    permissions:
      contents: read
      packages: write            # necesario para empujar a ghcr.io
      id-token: write            # firma de artefactos sin claves (OIDC)
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}/api
          # Etiquetas: versión semántica si es un tag, y SIEMPRE el SHA del commit.
          # El SHA es lo que permite saber exactamente qué hay desplegado.
          tags: |
            type=semver,pattern={{version}}
            type=sha,format=long
            type=raw,value=latest,enable={{is_default_branch}}
      - uses: docker/build-push-action@v6
        with:
          context: ./apps/api
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha        # reutiliza capas entre ejecuciones
          cache-to: type=gha,mode=max
          provenance: true            # atestación de procedencia (SLSA)
      - name: Escanear la imagen publicada
        uses: aquasecurity/trivy-action@0.28.0
        with:
          image-ref: ghcr.io/${{ github.repository }}/api:sha-${{ github.sha }}
          severity: 'CRITICAL,HIGH'
          exit-code: '1'

21.5.3 Velocidad del pipeline y calidad como puerta

Un CI lento se acaba ignorando Si el pipeline tarda 40 minutos, la gente deja de esperarlo: fusiona «confiando», abre otro pull request mientras tanto y pierde el contexto de lo que rompió. Objetivos razonables: menos de 10 minutos para el ciclo completo de un pull request y menos de 5 para el feedback rápido (lint, tipos y unitarios). Y algo peor que la lentitud: los tests inestables. Un test que falla una de cada cinco veces enseña al equipo a pulsar «reintentar», y ese hábito hace que también se reintente cuando el fallo era real. Aísla los tests inestables, márcalos y arréglalos o bórralos.
Puerta de calidadUmbral razonableQué evita
Cobertura mínima70–80 % global; 100 % en el dominio crítico. Mejor aún: prohibir que la cobertura bajeQue el código nuevo llegue sin tests
Presupuesto de bundlebudgets en angular.json: aviso a 500 kB, error a 1 MB del bundle inicialQue una dependencia pesada degrade la carga sin que nadie se entere
Auditoría de dependenciasnpm audit --audit-level=highVulnerabilidades conocidas en producción
Escaneo de secretosgitleaks en CI + push protection del proveedorCredenciales filtradas en el historial
Ramas protegidasRevisión obligatoria, CI en verde y rama actualizada antes de fusionarQue alguien empuje directamente a main

21.6 Entrega y despliegue

PrácticaQué automatizaHasta dónde llega
Integración continuaConstruir y verificar cada cambio integradoArtefacto validado
Entrega continuaTodo lo anterior + dejar el artefacto listo para desplegar en cualquier momentoUn humano pulsa el botón
Despliegue continuoTodo lo anterior + desplegar automáticamente si las verificaciones pasanSin intervención humana

El salto de entrega a despliegue continuo no es técnico, es de confianza: requiere buenas pruebas, observabilidad para detectar el problema en minutos y una vuelta atrás automática. Empieza por entrega continua a producción y despliegue continuo a staging.

21.6.1 Estrategias de despliegue

EstrategiaCómo funcionaInactividadRiesgoCosteComplejidad
RecreateSe para todo y se levanta la versión nuevaSí (segundos o minutos)Alto: si falla, no hay servicioMínimoTrivial
Rolling updateSe sustituyen las réplicas de una en unaNoMedio: conviven dos versionesBajo (una réplica extra)Baja
Blue-greenDos entornos completos; se conmuta el tráfico de golpeNoBajo: vuelta atrás instantáneaAlto (doble infraestructura)Media
CanaryUn porcentaje pequeño del tráfico va a la versión nueva y se va subiendoNoMuy bajo: el fallo afecta al 1 %MedioAlta (enrutado y métricas por versión)
Feature flagsEl código nuevo se despliega apagado y se enciende por usuario o porcentajeNoMuy bajo: apagar es instantáneoBajoMedia (deuda de flags que nadie limpia)
  BLUE-GREEN                                CANARY
  ┌──────────────┐                          ┌──────────────┐
  │ BALANCEADOR  │                          │ BALANCEADOR  │
  └──────┬───────┘                          └──┬────────┬──┘
     100%│                                 95% │        │ 5%
         ▼                                     ▼        ▼
  ┌────────────┐   ┌────────────┐        ┌─────────┐ ┌─────────┐
  │  BLUE  v1  │   │ GREEN  v2  │        │  v1 ×19 │ │  v2 ×1  │
  │  (activo)  │   │ (en pruebas│        │ estable │ │ vigilada│
  └────────────┘   └────────────┘        └─────────┘ └─────────┘
                                          Si los errores y la latencia de v2
   Conmutar = cambiar el destino:          se mantienen: 5% → 25% → 50% → 100%
   instantáneo y reversible.               Si no: se retira y solo el 5% lo notó.
   Coste: dos entornos completos.          Requiere métricas separadas por versión.
   ⚠ En AMBOS casos la base de datos es UNA sola y compartida.
     Ahí es donde se rompen los despliegues «sin riesgo». Sigue leyendo.

21.6.2 Migraciones de base de datos en el despliegue

Esta es la sección que más incidentes evita de todo el capítulo. El problema es fácil de enunciar y fácil de olvidar: durante un despliegue progresivo hay dos versiones del código funcionando a la vez contra el mismo esquema. Da igual la estrategia: en rolling update conviven minutos, en blue-green conviven durante la conmutación y las peticiones en vuelo, y en canary conviven durante horas o días.

  LÍNEA DE TIEMPO DE UN ROLLING UPDATE CON 4 RÉPLICAS
  t0   [v1][v1][v1][v1]   esquema antiguo         ← estado inicial
  t1   [v1][v1][v1][v1]   MIGRACIÓN aplicada      ← el esquema cambia YA
  t2   [v2][v1][v1][v1]   ┐
  t3   [v2][v2][v1][v1]   ├ VENTANA PELIGROSA: v1 y v2 leen y escriben
  t4   [v2][v2][v2][v1]   ┘ sobre el MISMO esquema, ya migrado
  t5   [v2][v2][v2][v2]   ← estado final
  Si la migración renombró la columna 'nombre' a 'titulo', en t2-t4 todas las
  réplicas v1 lanzan: ERROR: column "nombre" does not exist
  → el 75 % de las peticiones falla durante varios minutos.
  Y si hay que volver atrás en t4, v1 vuelve... contra un esquema que ya no
  entiende. El rollback tampoco funciona.

La regla y el patrón expand/contract

Regla Toda migración debe ser compatible hacia atrás: el esquema resultante tiene que funcionar tanto con la versión que se está desplegando como con la inmediatamente anterior. Si un cambio no puede cumplirlo, se parte en varios despliegues.

El patrón expand/contract (también llamado parallel change) convierte cualquier cambio destructivo en una secuencia de cambios aditivos. Renombrar nombre a titulo en la tabla tarea no es una migración: son cuatro despliegues.

  DESPLIEGUE 1 · EXPAND      añade 'titulo' ANULABLE. El código v2 escribe en
  ────────────────────       las DOS columnas y lee 'titulo ?? nombre'.
   esquema: nombre + titulo  Compatible con v1 (que solo conoce 'nombre').
  DESPLIEGUE 2 · BACKFILL    UPDATE por lotes de 5.000 filas para copiar los
  ────────────────────       valores antiguos. Fuera de hora punta, con pausas.
                             Al final: titulo NOT NULL (ya no hay nulos).
  DESPLIEGUE 3 · MIGRATE     el código v3 lee y escribe SOLO 'titulo'.
  ────────────────────       'nombre' sigue existiendo, pero ya nadie lo usa.
                             Punto de no retorno seguro: se puede volver a v2.
  DESPLIEGUE 4 · CONTRACT    DROP COLUMN nombre. Días o semanas después,
  ────────────────────       cuando ninguna versión desplegable la necesita.
Operación¿Compatible hacia atrás?Cómo hacerla sin bloqueos
Añadir columna anulable o con valor por defectoDirecta. En PostgreSQL 11+ añadir con DEFAULT no reescribe la tabla
Añadir columna NOT NULL sin defectoNoAñadir anulable → rellenar → añadir la restricción en otra migración
Renombrar columna o tablaNoExpand/contract completo. Nunca RENAME en un despliegue vivo
Eliminar columnaNoSolo en la fase contract, cuando ninguna versión viva la lee
Cambiar el tipo de una columnaNoColumna nueva + doble escritura + copia + intercambio
Crear índiceSí, pero bloquea escriturasCREATE INDEX CONCURRENTLY (fuera de transacción: en MikroORM, con transactional: false en esa migración)
Añadir clave foráneaSí, pero valida toda la tablaADD CONSTRAINT … NOT VALID y después VALIDATE CONSTRAINT

Dónde ejecutar las migraciones

LugarVentajasRiesgosVeredicto
Job previo al despliegue (una sola ejecución, misma imagen)Se ejecuta una vez; si falla, el despliegue se aborta y nada cambia; logs clarosRequiere orquestación (un paso más en el pipeline)La opción recomendada
Init container en KubernetesVive con el deployment, sin pasos externosSe ejecuta en cada pod: con 4 réplicas, cuatro migraciones simultáneasAceptable solo con bloqueo explícito
Al arrancar la aplicación (migrator.up() en main.ts)Cero configuración; cómodo en desarrolloCada réplica lo intenta a la vez; el arranque se alarga y las probes matan el pod a mitad de una migración largaPeligroso en producción
Manualmente por SSHNingunaNadie sabe qué se ejecutó, ni cuándo, ni con qué versiónNo
src/main.tsINCORRECTO
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const orm = app.get(MikroORM);
  // Con 4 réplicas arrancando a la vez, cuatro procesos ejecutan
  // las mismas migraciones simultáneamente: interbloqueos, migraciones
  // aplicadas a medias y una tabla de control incoherente.
  await orm.getMigrator().up();
  await app.listen(3000);
}
scripts/migrate.ts · proceso aparteCORRECTO
// Se ejecuta como job previo, con la MISMA imagen que la aplicación:
//   docker run --rm miapp:sha-abc123 node dist/scripts/migrate.js
import { MikroORM } from '@mikro-orm/postgresql';
import config from '../mikro-orm.config';
const CLAVE_BLOQUEO = 987_654_321;   // constante arbitraria pero fija
async function main() {
  const orm = await MikroORM.init(config);
  const em = orm.em.fork();
  // Cinturón y tirantes: aunque solo debería haber un ejecutor, un bloqueo
  // consultivo garantiza que dos procesos nunca migren a la vez.
  // pg_try_advisory_lock devuelve false en lugar de esperar indefinidamente.
  const [{ ok }] = await em.getConnection()
    .execute(`SELECT pg_try_advisory_lock(${CLAVE_BLOQUEO}) AS ok`);
  if (!ok) {
    console.error('Otro proceso está migrando. Abortando.');
    process.exit(1);
  }
  try {
    // No dejes que una migración se quede esperando un lock de tabla
    // eternamente y bloquee a toda la aplicación detrás de ella.
    await em.getConnection().execute("SET lock_timeout = '5s'");
    await em.getConnection().execute("SET statement_timeout = '10min'");
    const pendientes = await orm.getMigrator().getPendingMigrations();
    console.log(`Migraciones pendientes: ${pendientes.length}`);
    await orm.getMigrator().up();
    console.log('Migraciones aplicadas correctamente');
  } finally {
    await em.getConnection().execute(`SELECT pg_advisory_unlock(${CLAVE_BLOQUEO})`);
    await orm.close(true);
  }
}
main().catch((e) => { console.error(e); process.exit(1); });
mikro-orm.config.ts (fragmento) · opciones que importan en producción
migrations: {
  path: './dist/migrations',        // compiladas, no .ts
  pathTs: './src/migrations',
  transactional: true,              // cada migración dentro de una transacción
  allOrNothing: true,               // todas las pendientes en UNA transacción:
                                    // si la tercera falla, se deshacen las tres
  disableForeignKeys: false,        // NO desactives las FK en producción
  safe: true,                       // no genera sentencias destructivas al crear
  snapshot: false,                  // evita falsos "sin cambios" en equipo
  emit: 'ts',
},
Qué hacer ante una migración fallida

1. Diagnostica antes de tocar. En PostgreSQL el DDL es transaccional: si la migración iba dentro de una transacción, ha quedado deshecha por completo y el esquema está intacto. En MySQL no es así: el DDL provoca commit implícito y puedes quedarte a medias. Comprueba primero el estado real de la tabla de control de migraciones.

2. Avanza hacia delante, no hacia atrás. Salvo que la migración sea trivialmente reversible, la respuesta correcta casi siempre es una migración nueva que corrige la situación, no un down. Los down se escriben, se prueban en staging y se usan en desarrollo, pero en producción rara vez son seguros: no pueden devolver los datos que borraron.

3. Nunca edites una migración ya aplicada. El hash y el registro dejan de coincidir con lo que hay en las bases de datos que ya la ejecutaron. Escribe otra.

4. Si el fallo fue un timeout de bloqueo, no reintentes a ciegas: averigua qué transacción larga estaba reteniendo la tabla (pg_stat_activity, pg_locks) y programa la migración en una ventana de menos carga.

21.6.3 Vuelta atrás

Hay dos vueltas atrás y solo una es fácil. La de la aplicación es sencilla si has hecho los deberes: la imagen anterior sigue en el registro, así que volver es redesplegar la etiqueta previa —segundos con Kubernetes (kubectl rollout undo) o con blue-green (conmutar el enrutado)—. Por eso conviene etiquetar las imágenes por SHA y no borrar las anteriores.

La de la base de datos casi nunca es viable, y es importante entender por qué. Una migración que borró una columna no puede «desborrarla» con los datos dentro. Y aunque el esquema fuera reversible, entre la migración y la vuelta atrás los usuarios han escrito datos en el formato nuevo: revertir el esquema significa perderlos o corromperlos. Restaurar una copia de seguridad implica perder todo lo ocurrido desde que se tomó. De ahí que la estrategia real no sea «poder revertir», sino no necesitarlo: migraciones compatibles hacia atrás y despliegues pequeños. Si el código nuevo falla, vuelves al anterior y el esquema —aditivo— sigue sirviendo a ambos.

21.6.4 Dónde desplegar

OpciónCoste mensual orientativoControlComplejidad operativaTe da
VPS + Docker Compose (Hetzner, DigitalOcean)5–40 €TotalMedia: actualizas el sistema, la seguridad y las copias túUn servidor, tu responsabilidad
PaaS (Railway, Render, Fly.io)10–100 €BajoMínima: git push y listoDespliegue, TLS, logs y base de datos gestionada
Contenedores gestionados (Cloud Run, AWS ECS/Fargate)Por uso; puede ser casi 0 en reposoMedioBaja-media: IAM, redes y despliegue como códigoAutoescalado real, incluso a cero
Kubernetes gestionado (GKE, EKS, AKS)150 € en adelanteTotalAlta: es una plataforma, no un servidorTodo, si tienes quien lo mantenga
Recomendación por tamaño Proyecto personal o MVP: PaaS. El tiempo que no dedicas a infraestructura lo dedicas al producto. Producto con clientes y un equipo pequeño (1–5 personas): un VPS con Docker Compose y copias de seguridad automatizadas, o contenedores gestionados si prefieres no administrar el sistema operativo. Ambas opciones aguantan mucho más tráfico del que la mayoría imagina. Varios equipos, varios servicios, requisitos de disponibilidad: Cloud Run/ECS primero; Kubernetes solo cuando la complejidad de tu sistema ya supere a la de Kubernetes.

21.6.5 Desplegar el frontend Angular

Una aplicación Angular sin SSR son ficheros estáticos: lo óptimo es un hosting estático detrás de una CDN (Cloudflare Pages, Netlify, Vercel, S3 + CloudFront, Firebase Hosting). Nginx en un contenedor es igual de válido si ya tienes esa infraestructura. Lo que no cambia son tres detalles que rompen despliegues a diario:

El caso del SSR Con renderizado en servidor (@angular/ssr) ya no despliegas ficheros: despliegas un proceso Node, con todo lo que implica —contenedor, salud, réplicas, memoria y arranque en frío—. En la práctica se convierte en un segundo backend: mismos Dockerfile, mismas probes y mismo apagado ordenado que la API. La parte estática (browser/) se sigue sirviendo desde la CDN, y solo el HTML inicial pasa por Node. Mide antes de adoptarlo: si tu aplicación es un panel tras autenticación, el SSR añade coste operativo y no aporta nada en SEO.

21.7 Kubernetes, lo justo

ObjetoQué es
PodLa unidad mínima: uno o varios contenedores que comparten red y almacenamiento. Es efímero y no se gestiona a mano.
DeploymentDeclara «quiero N réplicas de esta imagen» y gestiona el rolling update y la vuelta atrás.
ServiceNombre DNS estable y reparto de carga entre los pods que casan con un selector.
IngressEntrada HTTP desde fuera: rutas, dominios y TLS.
ConfigMap / SecretConfiguración y credenciales inyectadas como variables o ficheros. Un Secret solo está en base64, no cifrado: activa el cifrado en reposo o usa un gestor externo.
Probesliveness: si falla, reinicia el pod. readiness: si falla, lo saca del balanceo sin matarlo. startup: da margen al arranque lento antes de que actúen las otras dos.
HPAHorizontal Pod Autoscaler: ajusta el número de réplicas según CPU, memoria o métricas propias.
k8s/api.yaml · manifiestos mínimos comentados
apiVersion: apps/v1
kind: Deployment
metadata:
  name: tareas-api
  labels: { app: tareas-api }
spec:
  replicas: 3
  revisionHistoryLimit: 5        # permite 'kubectl rollout undo'
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1                # un pod extra durante la actualización
      maxUnavailable: 0          # nunca por debajo de la capacidad actual
  selector:
    matchLabels: { app: tareas-api }
  template:
    metadata:
      labels: { app: tareas-api }
    spec:
      # Margen para terminar las peticiones en vuelo (ver 21.3.2)
      terminationGracePeriodSeconds: 45
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
      containers:
        - name: api
          # SIEMPRE por digest o por SHA del commit, nunca ':latest'
          image: ghcr.io/empresa/tareas/api:sha-9f2c1ab
          ports: [{ containerPort: 3000 }]
          envFrom:
            - configMapRef: { name: tareas-config }
            - secretRef:    { name: tareas-secrets }
          resources:
            # requests = lo que el planificador reserva; limits = el techo duro.
            # Sin requests, el planificador no puede colocar bien los pods;
            # sin limits, un pod puede ahogar al nodo entero.
            requests: { cpu: "100m", memory: "256Mi" }
            limits:   { cpu: "500m", memory: "512Mi" }
          startupProbe:
            # Hasta 30 × 2 s = 60 s de margen para arrancar. Mientras tanto,
            # liveness y readiness no actúan.
            httpGet: { path: /health, port: 3000 }
            periodSeconds: 2
            failureThreshold: 30
          readinessProbe:
            # Debe comprobar las dependencias (base de datos, Redis):
            # si no puede servir, que no le manden tráfico.
            httpGet: { path: /health/ready, port: 3000 }
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 3
          livenessProbe:
            # Debe ser SUPERFICIAL: si comprueba la base de datos y esta se
            # cae, Kubernetes reiniciará todos los pods en bucle sin motivo.
            httpGet: { path: /health/live, port: 3000 }
            periodSeconds: 10
            failureThreshold: 3
          lifecycle:
            preStop:
              # Da tiempo al balanceador a dejar de enviarnos tráfico
              exec: { command: ["sleep", "10"] }
---
apiVersion: v1
kind: Service
metadata:
  name: tareas-api
spec:
  selector: { app: tareas-api }
  ports:
    - port: 80
      targetPort: 3000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: tareas
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: nginx
  tls:
    - hosts: [api.tareas.example]
      secretName: tareas-tls
  rules:
    - host: api.tareas.example
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: tareas-api
                port: { number: 80 }
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: tareas-config
data:
  NODE_ENV: "production"
  LOG_LEVEL: "info"
  REDIS_URL: "redis://redis.default.svc.cluster.local:6379"
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: tareas-api
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: tareas-api }
  minReplicas: 3
  maxReplicas: 12
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 70 }
  behavior:
    scaleDown:
      # Bajar despacio evita el efecto acordeón con picos intermitentes
      stabilizationWindowSeconds: 300
---
apiVersion: batch/v1
kind: Job
metadata:
  name: tareas-migracion-9f2c1ab   # nombre único por versión: no se reejecuta
spec:
  backoffLimit: 1                   # un solo reintento: migrar en bucle es peor
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: ghcr.io/empresa/tareas/api:sha-9f2c1ab   # MISMA imagen
          command: ["node", "dist/scripts/migrate.js"]
          envFrom:
            - secretRef: { name: tareas-secrets }
Advertencia honesta: probablemente no necesitas Kubernetes Kubernetes resuelve problemas reales —programación de cargas en muchos nodos, autorreparación, despliegues declarativos, multi-equipo— a cambio de un coste enorme y permanente: redes, RBAC, políticas, ingress, almacenamiento, actualizaciones del clúster, observabilidad, gestión de certificados y una curva de aprendizaje que consume meses de un equipo pequeño. Señales de que lo necesitas: varios equipos desplegando servicios independientes, requisitos de disponibilidad que exigen varias zonas, o cargas muy variables con autoescalado fino. Señales de que no: eres un equipo de menos de cinco personas con un frontend, una API y una base de datos. Ese sistema cabe holgadamente en un VPS de 20 € o en Cloud Run, y la diferencia de tiempo la puedes invertir en tests, en observabilidad y en producto. Adoptar Kubernetes «para aprender» en el proyecto que paga las nóminas es una decisión cara.

21.8 Escalabilidad

Escalar verticalmente es poner una máquina más grande: inmediato, sin cambios en el código, y con un techo físico y un precio que crece más rápido que la potencia. Escalar horizontalmente es poner más máquinas: sin techo práctico, tolerante a fallos y más barato por unidad de carga, pero exige que la aplicación sea sin estado. La regla práctica: escala vertical hasta donde sea cómodo (duplicar la máquina cuesta cinco minutos) y diseña desde el principio para poder escalar horizontal.

21.8.1 El requisito de no tener estado

Sin estado no significa que la aplicación no tenga datos, sino que cualquier réplica puede atender cualquier petición porque todo el estado compartido vive fuera del proceso. Estos son los tres sitios donde este requisito se rompe en la práctica:

api · con estadoINCORRECTO
// 1) Sesiones y caché en memoria del proceso
const sesiones = new Map<string, Usuario>();
const cache = new Map<string, Tarea[]>();
// Con 3 réplicas, el usuario "pierde la sesión" 2 de cada 3 peticiones
// y cada réplica cachea (y desactualiza) por su cuenta.
// 2) Ficheros subidos en el disco local
await fs.writeFile(`/app/uploads/${id}.pdf`, buffer);
// Otra réplica devolverá 404 al descargarlo, y al reiniciar desaparece.
// 3) Tarea programada dentro de la propia API
@Cron('0 3 * * *')
async enviarResumenDiario() {
  await this.mailer.enviarATodos();   // se ejecuta UNA VEZ POR RÉPLICA:
}                                     // 3 réplicas = 3 correos a cada usuario
api · sin estadoCORRECTO
// 1) Estado compartido fuera del proceso
//    Sesión: JWT sin estado, o sesión en Redis
//    Caché: Redis, con TTL e invalidación explícita
const tareas = await this.cache.get(`tareas:${proyectoId}`);
// 2) Ficheros en almacenamiento de objetos (S3, R2, GCS)
await this.storage.put(`tareas/${id}.pdf`, buffer);
// La API devuelve una URL firmada; no sirve el fichero ella misma.
// 3) Tareas programadas con bloqueo distribuido, o en un proceso aparte
@Cron('0 3 * * *')
async enviarResumenDiario() {
  // SET clave valor NX EX 3600: solo una réplica obtiene el bloqueo
  const soyElElegido = await this.redis.set(
    'cron:resumen-diario', process.pid, 'EX', 3600, 'NX');
  if (!soyElElegido) return;
  await this.mailer.enviarATodos();
}
// Mejor aún: un worker separado (BullMQ) o el cron de la plataforma,
// que invoca un endpoint protegido una sola vez.

WebSockets merecen mención aparte: son conexiones largas y con afinidad natural a un proceso. Con varias réplicas, un mensaje publicado en la réplica A no llega a los clientes conectados a la B. La solución estándar es un adaptador que use Redis como bus de publicación/suscripción (@socket.io/redis-adapter, o el RedisIoAdapter en NestJS), más sticky sessions en el balanceador si usas el polling de reserva de Socket.IO.

21.8.2 Cuellos de botella, en el orden en que aparecen

#Cuello de botellaSíntomaDiagnósticoSolución
1Consultas sin índiceUn endpoint que iba bien con 1.000 filas tarda 4 s con 500.000EXPLAIN (ANALYZE, BUFFERS); Seq Scan sobre tablas grandesÍndice adecuado (capítulo 19); revisar también el orden de las columnas del índice compuesto
2N+1 consultasLa latencia crece linealmente con el número de elementos de la listaContador de consultas por petición en los logs; debug: true en MikroORMpopulate, QueryBuilder con join, o carga por lotes (capítulo 15)
3Event loop bloqueadoTodas las peticiones se ralentizan a la vez, aunque la CPU no esté al 100 %Métrica event loop lag; perfilado con --cpu-profSacar el trabajo intensivo a worker_threads o a una cola; evitar JSON gigantes y regex con retroceso catastrófico
4Pool de conexiones agotadoErrores de timeout al obtener conexión justo después de escalar réplicasSELECT count(*) FROM pg_stat_activity frente a max_connectionsDimensionar pool.max × réplicas; PgBouncer en modo transacción
5MemoriaReinicios con código de salida 137; latencia con picos por pausas del recolectordocker stats, métrica de heap, instantáneas de memoriaStreaming en lugar de cargar en memoria, paginación obligatoria, cachés con límite, --max-old-space-size acorde al límite del contenedor
6Red y payloadsTiempo de espera alto con servidores ociososTamaño de respuesta, cascadas de peticiones en el navegadorCompresión, paginación, campos selectivos, HTTP/2, CDN, agrupar llamadas
El cuello de botella número 4 sorprende a todo el mundo Escalar de 2 a 10 réplicas para «aguantar más» puede tumbar la base de datos: si cada réplica abre un pool de 20 conexiones, pasas de 40 a 200, y una PostgreSQL por defecto admite 100. La aritmética es obligatoria: réplicas × pool.max + procesos_worker × pool.max + migraciones + tu cliente SQL debe quedar cómodamente por debajo de max_connections. Cuando no salga la cuenta, la respuesta no es subir max_connections (cada conexión de PostgreSQL es un proceso con su memoria), sino poner un pooler como PgBouncer delante.

21.8.3 Caché por niveles, colas y réplicas de lectura

  NAVEGADOR ──► CDN ──► BALANCEADOR ──► API (Node) ──► REDIS ──► POSTGRES
      │          │                        │              │          │
   memoria    borde         caché de aplicación      caché      caché de
   + disco   geográfico     (en proceso, corta)    compartida   páginas
      │          │                        │              │          │
   ms: 0       10-30        50-100        1-5          1-3        5-50
      └──────────┴────────── cuanto más a la IZQUIERDA se resuelve,
                             más barato y más rápido; pero más difícil
                             es invalidar cuando el dato cambia.
  CUÁNDO USAR CADA UNO
  · CDN            → estáticos con hash y respuestas públicas idénticas para todos
  · HTTP (ETag,    → recursos que cambian poco; el navegador revalida con 304
    Cache-Control)
  · En proceso     → catálogos pequeños y muy leídos (TTL de segundos; ojo: cada
                     réplica tiene el suyo, así que admite datos algo desfasados)
  · Redis          → sesiones, resultados caros compartidos, rate limiting, colas
  · Base de datos  → vistas materializadas y agregados precalculados

El orden importa. La secuencia correcta es: medir → índices → arreglar N+1 → caché → réplicas de lectura → colas → particionado. Casi nadie necesita pasar del cuarto paso, y quien empieza por el último acaba con un sistema complicadísimo que sigue haciendo una consulta sin índice.

21.8.4 Prueba de carga con k6

load/escenario-tareas.js · ejecutar con: k6 run load/escenario-tareas.js
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Trend, Rate } from 'k6/metrics';
const latenciaListado = new Trend('latencia_listado', true);
const erroresNegocio  = new Rate('errores_negocio');
const BASE = __ENV.BASE_URL || 'https://staging.tareas.example';
export const options = {
  // Escenario REALISTA: rampa de subida, meseta sostenida, pico y bajada.
  // Lanzar 1.000 usuarios de golpe solo mide cómo se cae tu sistema.
  stages: [
    { duration: '2m', target: 50 },    // calentamiento (cachés y JIT)
    { duration: '5m', target: 50 },    // carga normal sostenida
    { duration: '2m', target: 200 },   // pico (campaña, hora punta)
    { duration: '5m', target: 200 },   // ¿aguanta el pico sostenido?
    { duration: '3m', target: 0 },     // bajada: ¿se recupera bien?
  ],
  // Los umbrales convierten la prueba en un test que pasa o falla,
  // apto para ejecutarse en CI de forma programada.
  thresholds: {
    http_req_failed:   ['rate<0.01'],                    // menos del 1 % de errores
    http_req_duration: ['p(95)<400', 'p(99)<1000'],      // NUNCA la media
    latencia_listado:  ['p(95)<300'],
    errores_negocio:   ['rate<0.005'],
  },
};
export function setup() {
  const r = http.post(`${BASE}/auth/login`,
    JSON.stringify({ email: 'carga@test.dev', password: __ENV.PASS }),
    { headers: { 'Content-Type': 'application/json' } });
  return { token: r.json('accessToken') };
}
export default function (datos) {
  const params = {
    headers: { Authorization: `Bearer ${datos.token}` },
    tags: { escenario: 'tareas' },
  };
  group('listar tareas', () => {
    const res = http.get(`${BASE}/api/tareas?page=1&limit=20`, params);
    latenciaListado.add(res.timings.duration);
    const ok = check(res, {
      'estado 200':        (r) => r.status === 200,
      'devuelve items':    (r) => Array.isArray(r.json('items')),
      'bajo 500 ms':       (r) => r.timings.duration < 500,
    });
    erroresNegocio.add(!ok);
  });
  group('crear tarea', () => {
    const res = http.post(`${BASE}/api/tareas`,
      JSON.stringify({ titulo: `carga ${__VU}-${__ITER}`, proyectoId: 1 }),
      { ...params, headers: { ...params.headers, 'Content-Type': 'application/json' } });
    check(res, { 'creada 201': (r) => r.status === 201 });
  });
  // Tiempo de reflexión: un usuario real no pulsa 40 veces por segundo.
  // Sin esto mides una tormenta sintética, no tu tráfico.
  sleep(Math.random() * 3 + 1);
}
Percentiles, no medias Si 99 peticiones tardan 50 ms y una tarda 10 s, la media son 150 ms: un número tranquilizador que oculta que hay un usuario cada cien al que la aplicación se le ha colgado. Mira siempre p95 y p99, y compáralos con la mediana: si p50 es 60 ms y p99 es 3 s, tienes una cola larga que casi siempre apunta a contención (bloqueos en la base de datos, pool agotado, pausas del recolector de basura o un vecino ruidoso). Interpretar la degradación: mientras el sistema tiene margen, subir usuarios aumenta el rendimiento y la latencia apenas se mueve; al llegar al punto de saturación, el rendimiento se estanca y la latencia se dispara. Ese codo es tu capacidad real. Y si el rendimiento baja al añadir carga, hay contención grave: el sistema está gastando más en esperar que en trabajar.

Capacity planning y presupuesto de latencia. Con la ley de Little tienes una estimación suficiente: concurrencia = rendimiento × latencia. Para 300 peticiones por segundo con 200 ms de latencia media hay 60 peticiones en vuelo; si cada réplica atiende cómodamente 25, necesitas 3 réplicas más margen para picos y despliegues (mínimo 4). Dimensiona para el pico, no para la media, y deja un 30–40 % de holgura. En el reparto de latencia, un presupuesto realista para un objetivo de 300 ms en p95: CDN y red 100 ms, balanceador 10 ms, aplicación 60 ms, base de datos 80 ms, serialización 20 ms, margen 30 ms. Cuando alguien pide «una llamada más a un tercero», el presupuesto dice de dónde hay que sacarla.

21.9 Observabilidad en producción

El capítulo 13 cubre el instrumental; aquí queda el resumen operativo. Los tres pilares —logs (qué pasó), métricas (cuánto y con qué frecuencia) y trazas (por dónde pasó una petición concreta)— solo son útiles si están correlacionados: un identificador de petición que viaja del navegador a la API y a los workers convierte tres herramientas separadas en una sola historia.

21.10 Fiabilidad

Una copia de seguridad no probada no existe El día que la necesitas descubres que llevaba tres meses fallando en silencio, que el volcado está vacío, que nadie tiene la contraseña de cifrado o que restaurar tarda seis horas y tu compromiso eran dos. Programa una restauración de prueba periódica (mensual, automatizada, sobre un entorno aparte) y mide cuánto tarda. Aplica la regla 3-2-1: tres copias, en dos medios distintos, una de ellas fuera del proveedor principal —una copia que vive en la misma cuenta que se ha comprometido o que se ha borrado por error no es una copia—.

21.11 Costes

21.12 Seguridad de la cadena de suministro

Tu aplicación no es solo tu código: son también las 1.200 dependencias transitivas que descargas, la imagen base, las actions del pipeline y el registro donde publicas. Cualquiera de esos eslabones es una vía de entrada, y los ataques reales de los últimos años han venido casi siempre por ahí.

21.13 Errores comunes y cómo solucionarlos

SíntomaCausa realSolución
La imagen de la API pesa 1,5 GBUn solo stage, con devDependencies, código fuente, .git y caché de npm dentroMulti-stage, npm ci --omit=dev, base alpine y un .dockerignore completo
Un secreto aparece en docker historyARG/ENV con el valor, o COPY .env; borrarlo después no elimina la capaMontajes de secreto de BuildKit; inyectar en ejecución; rotar la credencial filtrada de inmediato
El contenedor tarda 10 s en parar y corta peticionesCMD npm start: PID 1 es sh y no reenvía SIGTERMForma exec (CMD ["node","dist/main.js"]), dumb-init/init: true y enableShutdownHooks()
Cuatro réplicas ejecutan la misma migración a la vezMigraciones lanzadas al arrancar la aplicaciónJob previo con la misma imagen y pg_try_advisory_lock (sección 21.6.2)
Tras desplegar, los usuarios siguen viendo la versión antiguaindex.html cacheado por el navegador o por la CDNCache-Control: no-cache en index.html, caché larga solo para ficheros con hash, e invalidar la CDN al desplegar
Unexpected token '&lt;' en la consola del navegadorEl fallback de la SPA devuelve index.html para un .js que ya no existeQue los assets devuelvan 404 (try_files $uri =404) y revisar el orden de subida al desplegar
El CI pasa en local y falla en el servidorOtra versión de Node, zona horaria o locale distintos, dependencias del sistema, orden de tests o dependencia de ficheros no versionadosMisma imagen en ambos sitios, engines en package.json, TZ=UTC, tests independientes del orden y npm ci
too many connections justo después de escalarréplicas × pool.max supera max_connectionsRecalcular el pool, poner PgBouncer en modo transacción y limitar réplicas del HPA
Los correos programados se envían por duplicado@Cron dentro de la API con varias réplicasBloqueo distribuido en Redis, un worker dedicado o el planificador de la plataforma
El pod reinicia en bucle (CrashLoopBackOff)Liveness probe que consulta la base de datos, o arranque más lento que el umbralLiveness superficial, readiness con dependencias, y startupProbe con margen
Código de salida 137 al azarEl proceso supera el límite de memoria del contenedor y el núcleo lo mataSubir el límite o bajar --max-old-space-size; buscar la fuga (paginar, hacer streaming, acotar cachés)
La construcción de Docker tarda 3 minutos en cada commitCOPY . . antes de npm ci: cada cambio de código invalida la cachéCopiar package*.json primero y usar cache-from: type=gha en CI

21.14 Buenas y malas prácticas

Haz esto

  • Ramas de vida corta integradas a diario, con feature flags para lo que no está listo.
  • Construye la imagen una vez y promociona el mismo artefacto por todos los entornos.
  • Etiqueta por SHA del commit, no solo latest: debes poder saber qué hay desplegado.
  • Migraciones compatibles hacia atrás, siempre, con expand/contract cuando haga falta.
  • Ejecuta las migraciones en un job previo con la misma imagen y con bloqueo.
  • Usuario no root, forma exec y apagado ordenado en todos los contenedores.
  • Valida la configuración al arrancar y falla rápido si falta algo.
  • Automatiza las copias de seguridad y prueba la restauración con periodicidad.
  • Mide percentiles y define SLO antes de que haya un incidente.
  • Empieza simple: un VPS o un PaaS resuelven más de lo que la gente cree.

Evita esto

  • Reescribir historia compartida o hacer push --force a main.
  • Secretos en el repositorio, en la imagen o en un ARG: quedan para siempre.
  • latest como única etiqueta: hace imposible saber qué hay desplegado y volver atrás.
  • npm install en CI o en Docker en lugar de npm ci.
  • Migrar al arrancar la aplicación con varias réplicas.
  • Estado en memoria: sesiones, cachés, ficheros subidos o crons dentro de la API replicada.
  • Liveness probes que comprueban la base de datos: convierten una incidencia en un bucle de reinicios.
  • Tests inestables tolerados: enseñan al equipo a ignorar los fallos reales.
  • Optimizar sin medir, y añadir caché encima de una consulta sin índice.
  • Adoptar Kubernetes «para aprender» en el proyecto que paga las nóminas.

21.15 Preguntas frecuentes

¿merge o rebase? Dame una respuesta práctica.
Rebase para poner al día tu rama personal antes de abrir o actualizar el pull request: así el revisor ve tus cambios sobre la última versión de main y sin commits de fusión de ruido. Squash merge al cerrar el pull request, para que main tenga un commit por funcionalidad, fácil de leer, de bisecar y de revertir. Merge real para ramas largas o compartidas por varias personas, donde reescribir sería destructivo. Y nunca rebase sobre una rama que otros ya han descargado.
He subido una contraseña a Git y ya he hecho push. ¿Qué hago?
En este orden: 1) rota la credencial ahora mismo —dala por comprometida, aunque el repositorio sea privado, porque ha pasado por logs, clones, réplicas y cachés del proveedor—; 2) comprueba en los registros de acceso si se ha usado; 3) limpia el historial con git filter-repo o el BFG y coordina con el equipo, porque todos tendrán que volver a clonar; 4) activa el escaneo de secretos y la push protection para que no vuelva a ocurrir. Borrar el fichero en un commit nuevo no sirve de nada: el valor sigue accesible en el historial.
¿Alpine o Debian slim para las imágenes de Node?
Alpine por defecto: unos 50 MB menos y una superficie de ataque menor. Cambia a slim si usas módulos nativos que dan problemas con musl en lugar de glibc (algunas versiones de sharp, node-canvas o clientes gRPC), si necesitas herramientas de diagnóstico que en Alpine no existen, o si mides un arranque o un rendimiento peores. La diferencia de tamaño rara vez es el factor decisivo: lo que domina el peso de la imagen es node_modules.
¿Puedo usar Docker Compose en producción?
Sí, para un proyecto de un solo servidor, y mucha gente lo hace con éxito. Lo que no te da es: reprogramación automática si el nodo cae, despliegue progresivo real entre varias máquinas, autoescalado ni actualizaciones sin corte por sí solo (aunque puedes aproximarlo con dos servicios y un proxy delante). Usa un fichero de producción distinto: sin montar el código, sin publicar el puerto de la base de datos, con imágenes fijadas por digest, límites de recursos, política de reinicio y logs con rotación. Con copias de seguridad automatizadas y un proxy con TLS delante, aguanta muchísimo más tráfico del que la mayoría de proyectos verá jamás.
¿Dónde ejecuto las migraciones exactamente?
En un paso del pipeline, después de publicar la imagen y antes de actualizar los contenedores, usando la misma imagen que se va a desplegar (así el código de migración y el de la aplicación son coherentes por construcción). Si falla, el despliegue se detiene y no ha cambiado nada. Añade un bloqueo consultivo (pg_try_advisory_lock) como red de seguridad y un lock_timeout para no bloquear la aplicación detrás de un DDL que espera. Nunca al arrancar la aplicación si hay más de una réplica.
¿Cómo hago rollback de una migración en producción?
Normalmente, no lo haces. Si la migración fue aditiva y compatible hacia atrás (como debe ser), basta con volver a la imagen anterior de la aplicación: el esquema nuevo sigue sirviendo al código antiguo. Si el problema está en la propia migración, la respuesta es una migración correctiva nueva, no un down: los down no pueden devolver datos borrados, y restaurar una copia implica perder todo lo escrito desde entonces. Esa es exactamente la razón por la que las migraciones se diseñan compatibles hacia atrás.
¿Cuál es la diferencia real entre liveness y readiness?
Liveness responde a «¿este proceso está irrecuperablemente atascado?»; si falla, se reinicia el pod. Readiness responde a «¿puede atender tráfico ahora mismo?»; si falla, se le retira el tráfico pero se le deja vivir. El error clásico es comprobar la base de datos en la liveness: cuando la base de datos tenga un problema, Kubernetes reiniciará todas tus réplicas en bucle, borrando cachés y empeorando la incidencia. Liveness superficial (¿responde el proceso?), readiness con dependencias, y startup probe para dar margen al arranque.
¿Por qué mi pipeline pasa en local y falla en CI?
Por diferencias de entorno, casi siempre en este orden: versión de Node distinta; base de datos con datos residuales en local y limpia en CI (o al revés); zona horaria y locale —en CI suele ser UTC—; tests que dependen del orden porque comparten estado; ficheros no versionados que existen en tu máquina; y condiciones de carrera que solo se manifiestan en una máquina más lenta. La solución de fondo es la paridad: ejecuta la suite localmente dentro del mismo contenedor que usa el CI, fija TZ=UTC y haz que cada test cree y destruya sus propios datos.
¿Cuántas réplicas necesito?
Mínimo dos, siempre, aunque el tráfico quepa en una: es lo que permite desplegar y perder un nodo sin corte. A partir de ahí, mide: haz una prueba de carga, encuentra el punto de saturación de una réplica y divide tu pico esperado entre ese número, añadiendo un 30–40 % de holgura. Comprueba después la aritmética del pool de conexiones (sección 21.8.2), que es lo que suele impedir subir réplicas alegremente.
¿Merece la pena el SSR de Angular?
Depende del objetivo. Si necesitas indexación en buscadores, buenas previsualizaciones al compartir enlaces o mejorar la métrica de primer contenido en páginas públicas, sí. Si tu aplicación es un panel tras autenticación, no aporta nada y a cambio te obliga a operar un proceso Node con sus réplicas, su memoria, su salud y sus arranques en frío, además de complicar el código (no hay window en el servidor). Mide primero con la aplicación estática servida desde una CDN: mucha gente adopta SSR para resolver un problema que no tiene.
¿Cada cuánto debería desplegar?
Cuanto más a menudo, mejor, y esto es contraintuitivo. Los despliegues grandes y raros son los peligrosos: acumulan decenas de cambios, así que cuando algo falla no sabes cuál fue y la vuelta atrás arrastra trabajo bueno. Desplegar varias veces al día obliga a que el proceso sea automático, rápido y reversible, y hace que cada despliegue contenga poco riesgo. Los estudios de DORA son consistentes en esto: los equipos que despliegan más a menudo tienen menos incidencias y se recuperan antes, no al revés.
¿Cuándo sé que ha llegado el momento de Kubernetes?
Cuando el dolor de no tenerlo supere al de tenerlo. Señales concretas: varios equipos que despliegan servicios independientes y se pisan; necesidad real de multi-zona o multi-región; cargas muy variables donde el autoescalado fino ahorra dinero de verdad; o requisitos de aislamiento y cuotas por equipo. Si tienes un frontend, una API y una base de datos, Kubernetes te va a costar meses y no te va a dar nada que no consigas con Cloud Run, ECS o un par de VPS con un proxy delante.

21.16 Ejercicios

Nivel 1 · básico

21.1 Crea una rama, haz tres commits con mensajes convencionales (feat, fix, docs), aplástalos en uno solo con git rebase -i HEAD~3 y comprueba con git reflog que los originales siguen accesibles. Recupéralos en una rama nueva.

21.2 Escribe el .gitignore y el .dockerignore de un monorepo con apps/api (NestJS) y apps/web (Angular). Justifica por escrito cada línea y explica qué ocurriría si faltara.

21.3 Construye una imagen de la API sin multi-stage, mide su tamaño con docker image ls, conviértela a multi-stage y compara. Usa docker history para identificar las tres capas que más pesan en cada versión.

Nivel 2 · intermedio

21.4 Escribe un docker-compose.yml con API, PostgreSQL y Redis en el que la API no arranque hasta que la base de datos responda a pg_isready. Demuéstralo: retrasa el arranque de Postgres y comprueba en los logs que la API espera en lugar de fallar.

21.5 Añade a la API un endpoint /health/ready que compruebe la base de datos y Redis, y otro /health/live que solo confirme que el proceso responde. Explica en dos frases por qué no deben ser el mismo endpoint.

21.6 Implementa el apagado ordenado: al recibir SIGTERM, la API debe dejar de aceptar peticiones nuevas, terminar las que tiene en curso y cerrar el pool. Verifícalo con docker stop mientras lanzas carga, y comprueba que ninguna petición se pierde.

21.7 Diseña la secuencia expand/contract completa para convertir la columna tarea.prioridad (texto libre) en un enum con valores baja, media y alta. Enumera cada despliegue, la migración que lo acompaña y qué versiones del código pueden convivir en cada punto.

Nivel 3 · avanzado

21.8 Construye el pipeline completo de CI con matriz de Node, servicio de PostgreSQL, migraciones, cobertura y publicación de la imagen en GHCR, y consigue que el tiempo total baje de 10 minutos. Documenta qué optimización aportó más.

21.9 Escribe un escenario de k6 con rampa, meseta y pico sobre tu API y encuentra el punto de saturación de una réplica. Con esos datos, calcula cuántas réplicas necesitarías para 500 peticiones por segundo y comprueba si el pool de conexiones lo permite.

21.10 Provoca deliberadamente los tres fallos siguientes en un entorno de pruebas y documenta cómo los detectaste y cómo los arreglaste: (a) un @Cron duplicado con tres réplicas; (b) un index.html cacheado un año en la CDN; (c) un pool de conexiones agotado tras escalar a diez réplicas.

21.11 Implementa un despliegue blue-green con Docker Compose y nginx: dos servicios (api-blue, api-green), conmutación cambiando el upstream y recarga de nginx sin cortar conexiones. Mide con k6 cuántas peticiones se pierden durante la conmutación y redúcelo a cero.

21.12 Añade al pipeline la firma de la imagen con Cosign mediante OIDC y una verificación previa al despliegue que rechace cualquier imagen no firmada por tu workflow.

Solución comentada · 21.3 · Dockerfile multi-stage optimizado

El objetivo es doble: reducir el tamaño y aprovechar la caché. Las cuatro decisiones que producen prácticamente toda la mejora son separar la instalación de dependencias del copiado del código, reinstalar solo las de producción en una etapa aparte, copiar únicamente dist/ y node_modules a la imagen final, y tener un .dockerignore que impida que .git y el node_modules local entren siquiera en el contexto.

FROM node:22.11-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci      # caché de npm entre builds
FROM node:22.11-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY tsconfig*.json nest-cli.json ./
COPY src ./src                                        # solo lo necesario para compilar
RUN npm run build
FROM node:22.11-alpine AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev && npm cache clean --force
FROM node:22.11-alpine
RUN apk add --no-cache dumb-init
ENV NODE_ENV=production
WORKDIR /app
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build     --chown=node:node /app/dist         ./dist
USER node
EXPOSE 3000
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/main.js"]

Cómo verificar la mejora. docker image ls debería mostrar un descenso del orden de 1,2 GB a 180–200 MB. Con docker history --no-trunc comprobarás que ya no hay ninguna capa con el código fuente ni con las dependencias de desarrollo. Y si tocas un fichero de src/ y reconstruyes, solo deben reejecutarse las capas desde COPY src ./src: el npm ci sale de caché. Detalle importante: COPY src ./src en lugar de COPY . . hace que cambiar un fichero de documentación ni siquiera invalide la compilación.

Solución comentada · 21.8 · Pipeline de CI con base de datos real

La clave de este ejercicio son cuatro cosas: el bloque services con un healthcheck (sin él, los tests arrancan antes de que PostgreSQL acepte conexiones y el fallo es intermitente y desconcertante), aplicar las migraciones antes de los tests, separar los jobs para que se ejecuten en paralelo, y cachear ~/.npm con la clave del lockfile.

jobs:
  integracion:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16.4-alpine
        env: { POSTGRES_USER: test, POSTGRES_PASSWORD: test, POSTGRES_DB: tareas_test }
        ports: ['5432:5432']
        options: --health-cmd "pg_isready -U test" --health-interval 5s --health-retries 10
    env:
      DATABASE_URL: postgres://test:test@localhost:5432/tareas_test
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22.x', cache: 'npm' }
      - run: npm ci --prefer-offline --no-audit
      - run: npx mikro-orm migration:up          # el esquema real, no un schema:create
      - run: npm run test:integration -- --coverage
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: cobertura-integracion, path: coverage/ }

De dónde sale el tiempo. En un proyecto medio, la caché de ~/.npm ahorra 40–60 s por job; ejecutar calidad, integración y e2e en paralelo en lugar de en serie ahorra otros 3–4 minutos; cache-from: type=gha en la construcción de la imagen, 1–2 minutos; y cancel-in-progress elimina ejecuciones que a nadie le importan. Una advertencia: no compartas la base de datos entre jobs paralelos —cada uno debe levantar la suya— y usa migration:up en lugar de schema:create, porque así el pipeline valida también que las migraciones se aplican limpiamente sobre una base vacía.

21.17 Resumen del capítulo

  • Git es un almacén de objetos inmutables con referencias móviles. Entenderlo convierte reset, rebase y reflog en herramientas predecibles; la regla de oro es no reescribir historia compartida.
  • Ramas de vida corta e integración diaria. El pipeline es la herramienta; la integración continua es la práctica. Los feature flags separan desplegar de activar.
  • Una imagen bien hecha es multi-stage, sin devDependencies ni secretos, con usuario no root, en forma exec y con apagado ordenado ante SIGTERM. El orden de las capas es lo que hace la construcción rápida.
  • Construye una vez y promociona el artefacto. La configuración vive en el entorno; en el frontend, eso obliga a resolver la configuración en tiempo de ejecución si quieres una sola imagen.
  • El pipeline ordena el trabajo de rápido a lento y actúa como puerta de calidad. Un CI lento o inestable se acaba ignorando, y entonces deja de proteger.
  • Durante cualquier despliegue progresivo conviven dos versiones del código contra un único esquema. Por eso toda migración debe ser compatible hacia atrás, y por eso existe expand/contract.
  • Las migraciones se ejecutan en un job previo, con la misma imagen y con bloqueo; nunca al arrancar la aplicación con varias réplicas.
  • La vuelta atrás de la aplicación es fácil; la de la base de datos casi nunca es viable. La estrategia real es no necesitarla.
  • Escalar horizontalmente exige no tener estado: sesiones, cachés, ficheros y tareas programadas fuera del proceso. Los cuellos de botella aparecen en orden: índices, N+1, event loop, pool, memoria, red.
  • Mide percentiles, no medias, y dimensiona para el pico con holgura. Prueba tus copias de seguridad: una copia no probada no existe.
  • Empieza simple. Un VPS o un PaaS resuelven la inmensa mayoría de los proyectos; Kubernetes es una plataforma, no un servidor, y hay que pagarla en horas de personas.

21.18 Recursos adicionales

Siguiente paso Con el capítulo 20 (arquitectura) y este (ingeniería y operación) cierras la Parte VI. La Parte VII es práctica pura: el capítulo 22 propone ejercicios integradores que recorren todo el stack, desde la entidad de MikroORM hasta el despliegue de la imagen que acabas de aprender a construir.