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.
21.1 Qué vas a poder hacer al terminar
- Explicar el modelo de datos de Git y usar
reflog,reset,revert,cherry-pickybisectsin miedo, incluso después de un error grave. - Elegir con criterio entre
merge,rebasey squash, y saber exactamente qué historial produce cada uno. - Escribir un
Dockerfilemulti-stage para la API NestJS y para el frontend Angular que produzca imágenes pequeñas, sin secretos, con usuario no root y que respondan aSIGTERM. - Levantar el entorno completo de desarrollo (API, frontend, PostgreSQL, Redis) con un único
docker compose up, con hot reload y comprobaciones de salud. - Construir un pipeline de integración continua en GitHub Actions con matriz de versiones de Node, caché, base de datos real para los tests de integración, cobertura y publicación de la imagen.
- Desplegar con rolling update, blue-green o canary sabiendo el riesgo y el coste de cada estrategia.
- Aplicar el patrón expand/contract para que una migración de base de datos no rompa el sistema cuando conviven dos versiones del código a la vez.
- Detectar y ordenar los cuellos de botella típicos de este stack, diseñar una prueba de carga con k6 e interpretar percentiles en lugar de medias.
- Decidir con honestidad si tu proyecto necesita Kubernetes o si un VPS con Docker Compose le sobra.
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:
| Objeto | Qué contiene | Analogía |
|---|---|---|
| blob | El contenido binario de un fichero. Sin nombre y sin permisos. | El texto de una página |
| tree | Una lista de entradas (modo, tipo, hash, nombre): es un directorio. | El índice de un capítulo |
| commit | Un puntero a un tree raíz, cero o más padres, autor, fecha y mensaje. | Una edición fechada del libro entero |
| tag anotado | Un 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:
- Los commits son inmutables. El hash depende del contenido, del árbol, de los padres y de los metadatos. Cambiar cualquier cosa produce un commit nuevo con otro hash. Por eso
rebase,commit --amendy squash no «modifican» historia: la reescriben creando objetos nuevos y moviendo la referencia. - Casi nada se pierde. Los commits antiguos siguen en la base de objetos aunque ninguna rama los apunte, hasta que el recolector de basura los borra (por defecto pasadas dos semanas). El
reflogguarda dónde estuvo cada referencia, así que unreset --harddesafortunado casi siempre es recuperable. - Hay tres zonas, y la mayoría de la confusión viene de no distinguirlas.
┌───────────────┐ 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
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.
| Flujo | Ramas | Vida de una rama | Cuá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). |
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).
| Estrategia | Historia | Ventaja | Inconveniente | Ú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 |
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.# 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"# 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 resolver21.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.
<<<<<<< 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.
# 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.ts21.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.
| Comando | Mueve HEAD/rama | Índice | Árbol de trabajo | Uso típico |
|---|---|---|---|---|
git reset --soft HEAD~1 | Sí | Intacto | Intacto | Rehacer el último commit conservando todo preparado para commitear |
git reset --mixed HEAD~1 (por defecto) | Sí | Se reescribe | Intacto | Deshacer el commit y el add, conservando los cambios en el disco |
git reset --hard HEAD~1 | Sí | Se reescribe | Se sobrescribe | Tirar el trabajo. Es el único destructivo: lo no commiteado desaparece |
# 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.tsEncontrar 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.
# --- 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.shbisect 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.
<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.| Tipo | Significado | Efecto en la versión (SemVer) |
|---|---|---|
feat | Nueva funcionalidad para el usuario | MINOR (1.4.0 → 1.5.0) |
fix | Corrección de un error | PATCH (1.4.2 → 1.4.3) |
perf | Mejora de rendimiento sin cambio funcional | PATCH |
refactor | Cambio interno sin alterar el comportamiento observable | Ninguno |
docs, test, style, chore | Documentación, tests, formato, tareas de mantenimiento | Ninguno |
build, ci | Sistema de construcción, dependencias, pipeline | Ninguno (o PATCH si afecta al artefacto publicado) |
! o BREAKING CHANGE: | Ruptura de compatibilidad | MAJOR (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
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{
"scripts": {
"prepare": "husky"
},
"lint-staged": {
"*.ts": ["eslint --fix", "prettier --write"],
"*.{html,scss,json,md,yml}": ["prettier --write"]
}
}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],
},
};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),
awaiten serie donde cabePromise.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
# --- 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 comunesnode_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.
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ón | Qué hace | Detalle que importa |
|---|---|---|
FROM | Imagen base (y comienza una etapa) | Fija la versión: node:22.11-alpine, no node:latest |
WORKDIR | Directorio de trabajo | Lo crea si no existe; mejor que RUN cd, que no persiste |
COPY | Copia del contexto de construcción | Crea capa. Admite --from=etapa y --chown |
ADD | Como COPY + descomprime y descarga URLs | Evítalo: comportamiento implícito. Usa COPY |
RUN | Ejecuta un comando en construcción | Crea capa. Encadena con && y limpia en la misma capa |
ENV / ARG | Variable de runtime / de construcción | ARG queda en el historial de la imagen: nunca metas secretos |
EXPOSE | Documenta el puerto | No publica nada; publicar es cosa de -p o de compose |
USER | Usuario que ejecuta el proceso | Por defecto es root. Cámbialo siempre |
HEALTHCHECK | Comando periódico de salud | Lo usa depends_on: condition: service_healthy |
ENTRYPOINT | Ejecutable fijo del contenedor | En forma exec (JSON) para que el proceso sea PID 1 real |
CMD | Argumentos por defecto | Se 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
FROM node:latest
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
ENV DB_PASSWORD=s3cr3t
EXPOSE 3000
CMD npm run start:prod
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.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"]
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:
CMD npm start(forma shell) hace que PID 1 sea/bin/sh, que no reenvía señales a su hijo. Tu aplicación nunca se entera de que debe cerrar y siempre muere de unSIGKILLtras el plazo: peticiones cortadas a mitad y conexiones a la base de datos colgadas. Usa la forma exec:CMD ["node", "dist/main.js"].- PID 1 no tiene manejadores por defecto ni recoge procesos huérfanos (zombies). Para eso está
dumb-initotinicomoENTRYPOINT, o la opción--initdedocker run/init: trueen compose. - Y en la aplicación, apagado ordenado: en NestJS,
app.enableShutdownHooks()cononModuleDestroy/beforeApplicationShutdownpara dejar de aceptar peticiones, terminar las que están en vuelo y cerrar el pool de la base de datos y las colas.
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
# 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"]@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.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
# 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;"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
| Base | Tamaño base aprox. | Imagen final de la API aprox. | Ventajas | Inconvenientes |
|---|---|---|---|---|
node:22 (Debian completa) | ~1,1 GB | ~1,3 GB | Trae de todo: compiladores, git, curl | Enorme, lenta de descargar y con mucha superficie de ataque |
node:22-slim | ~230 MB | ~330 MB | Debian recortada, glibc estándar, compatible con módulos nativos | Casi el doble que alpine |
node:22-alpine | ~140 MB | ~180 MB | La opción por defecto: pequeña y con apk disponible | Usa musl en lugar de glibc: algún módulo nativo o binario precompilado puede fallar |
gcr.io/distroless/nodejs22 | ~135 MB | ~175 MB | Sin shell, sin gestor de paquetes: superficie de ataque mínima | Depurar es incómodo (no hay sh para docker exec); requiere variante debug |
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..dockerignorees obligatorio. Sin él,COPY . .metenode_moduleslocal,.git(con todo el historial),disty.enven el contexto de construcción: imagen enorme, construcción lenta y posible fuga de secretos.- Una capa, una limpieza.
RUN apk add --no-cache …ynpm cache clean --forceen la misma instrucción que instala; borrarlo en unRUNposterior no reduce nada, porque la capa anterior ya existe. - Multi-stage siempre. Es lo que evita que el compilador de TypeScript, las 900 dependencias de desarrollo y el código fuente viajen a producción.
node_modules
dist
.angular
coverage
.git
.github
.env
.env.*
*.log
Dockerfile*
docker-compose*.yml
README.md
.vscode
.idea21.3.6 docker compose para desarrollo
# 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: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
| Comando | Para qué |
|---|---|
docker compose logs -f --tail=100 api | Seguir los logs de un servicio. Lo primero, siempre. |
docker compose ps | Estado y salud de cada servicio (healthy, starting, unhealthy). |
docker exec -it tareas-api sh | Abrir una shell dentro del contenedor para mirar ficheros y variables. |
docker exec tareas-api env | Ver qué variables de entorno recibe de verdad el proceso. |
docker inspect tareas-api | Configuración completa en JSON: montajes, redes, entrypoint, estado de salida. |
docker inspect --format '{{.State.ExitCode}}' tareas-api | Por qué murió. 137 = SIGKILL (casi siempre falta de memoria), 143 = SIGTERM. |
docker stats | CPU, memoria y red en vivo. Para ver quién se está comiendo la máquina. |
docker history --no-trunc imagen:tag | Ver 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 tareas | Consola SQL contra la base de datos del entorno. |
docker system df / docker system prune -a --volumes | Ver y liberar espacio. Cuidado: con --volumes borra los datos. |
docker compose down -v | Parar 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
| Entorno | Para qué | Datos | Quién despliega |
|---|---|---|---|
| Desarrollo | Trabajo diario en la máquina de cada persona | Semilla ficticia, base de datos desechable | Cada desarrollador, continuamente |
| Test / CI | Ejecutar la suite en un entorno limpio y reproducible | Base de datos efímera creada y destruida por el pipeline | El propio pipeline, en cada push |
| Staging / preproducción | Última verificación con configuración idéntica a producción | Copia anonimizada de producción, o volumen realista | Automático al fusionar en main |
| Producción | Usuarios reales | Datos reales; copias de seguridad probadas | Automá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.
| # | Factor | Qué implica en este stack |
|---|---|---|
| 1 | Base de código | Un repositorio por aplicación (o un monorepo bien delimitado), muchos despliegues. Nada de una rama por entorno con código distinto. |
| 2 | Dependencias | Declaradas y aisladas: package.json + package-lock.json, npm ci. Nada instalado «a mano» en el servidor. |
| 3 | Configuración | En el entorno, no en el código. Si pudieras publicar el repositorio como código abierto sin filtrar nada, lo estás haciendo bien. |
| 4 | Servicios de respaldo | PostgreSQL, Redis o S3 son recursos accesibles por URL. Cambiar una base de datos local por una gestionada debe ser cambiar DATABASE_URL. |
| 5 | Construir, publicar, ejecutar | Etapas separadas y estrictas: la imagen se construye una vez, se etiqueta y se ejecuta. Prohibido editar código en el servidor. |
| 6 | Procesos | Sin estado y sin compartir nada. Nada de guardar ficheros subidos en el disco local ni sesiones en memoria. |
| 7 | Asignación de puertos | La aplicación expone HTTP por sí misma (PORT); no depende de un servidor de aplicaciones externo. |
| 8 | Concurrencia | Se escala añadiendo procesos (réplicas), no engordando uno. Tipos de proceso distintos: web, worker, cron. |
| 9 | Desechabilidad | Arranque rápido y apagado ordenado ante SIGTERM. Un proceso debe poder morir en cualquier momento sin corromper nada. |
| 10 | Paridad desarrollo/producción | Mismos servicios y versiones en todas partes: exactamente lo que resuelve Docker Compose en desarrollo. |
| 11 | Logs | Flujo de eventos por stdout; el entorno los recoge y los agrega. La aplicación no gestiona ficheros ni rotación. |
| 12 | Procesos de administración | Migraciones 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.
// 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// 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;
}| Mecanismo | Dónde encaja | Notas |
|---|---|---|
.env local + .env.example versionado | Solo desarrollo | El ejemplo documenta qué variables existen, con valores falsos. El real, ignorado por Git. |
| Secretos del CI (GitHub Actions, GitLab CI) | Pipeline | Cifrados, 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 seria | Auditoría de accesos, rotación automática y credenciales dinámicas de corta vida. |
| Sealed Secrets / SOPS | GitOps en Kubernetes | Permiten versionar el secreto cifrado; solo el clúster puede descifrarlo. |
| Roles de identidad (IAM, workload identity) | Nube gestionada | Lo mejor: no hay secreto que rotar porque no hay secreto. El servicio se autentica por su identidad. |
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) | |
|---|---|---|
| Artefacto | Uno por entorno | Uno único, promocionable |
| Optimización | Permite tree shaking de ramas muertas | El valor es opaco al compilador |
| Cambiar un valor | Requiere reconstruir y volver a desplegar | Reiniciar el contenedor o editar un fichero |
| Bueno para | Flags de compilación, production: true | URLs, claves públicas de terceros, activación de funcionalidades por entorno |
#!/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}"<!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>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',
}),
};
}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.
| Etapa | Qué verifica | Coste típico |
|---|---|---|
Instalar (npm ci) | Que el lockfile es coherente y reproducible | 10–60 s con caché |
Lint (ESLint + Prettier --check) | Errores de estilo y reglas de calidad | 20–40 s |
Typecheck (tsc --noEmit) | Tipos en todo el proyecto, incluidos los tests | 30–90 s |
| Tests unitarios | Lógica de dominio y servicios, con dobles | 1–3 min |
| Build | Que compila y que el bundle cabe en el presupuesto | 1–2 min |
| Tests de integración | Repositorios y consultas contra PostgreSQL de verdad, con migraciones aplicadas | 2–5 min |
| Tests e2e | Recorridos críticos de usuario sobre la aplicación levantada | 3–10 min |
| Seguridad | npm audit, escaneo de secretos y de la imagen | 1–2 min |
| Publicar artefacto | Imagen etiquetada con la versión y el SHA, subida al registro | 1–3 min |
21.5.2 Workflow completo de GitHub Actions
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
- Paraleliza por naturaleza del trabajo, no por capricho: lint, tipos, unitarios, integración y e2e en jobs distintos, con dependencias solo donde son necesarias.
- Cachea lo que no cambia:
~/.npmcon la clave del lockfile, la caché de compilación de TypeScript, los navegadores de Playwright y las capas de Docker (type=gha). - Ejecuta solo lo afectado en monorepos: Nx o Turborepo saben qué proyectos dependen de lo que has tocado y se saltan el resto. En
main, ejecuta todo. - Fragmenta (sharding) los tests lentos en varias máquinas: Playwright y Jest lo soportan de forma nativa.
- Cancela ejecuciones obsoletas con
concurrency: nadie necesita el resultado de un commit que ya ha sido reemplazado.
| Puerta de calidad | Umbral razonable | Qué evita |
|---|---|---|
| Cobertura mínima | 70–80 % global; 100 % en el dominio crítico. Mejor aún: prohibir que la cobertura baje | Que el código nuevo llegue sin tests |
| Presupuesto de bundle | budgets en angular.json: aviso a 500 kB, error a 1 MB del bundle inicial | Que una dependencia pesada degrade la carga sin que nadie se entere |
| Auditoría de dependencias | npm audit --audit-level=high | Vulnerabilidades conocidas en producción |
| Escaneo de secretos | gitleaks en CI + push protection del proveedor | Credenciales filtradas en el historial |
| Ramas protegidas | Revisión obligatoria, CI en verde y rama actualizada antes de fusionar | Que alguien empuje directamente a main |
21.6 Entrega y despliegue
| Práctica | Qué automatiza | Hasta dónde llega |
|---|---|---|
| Integración continua | Construir y verificar cada cambio integrado | Artefacto validado |
| Entrega continua | Todo lo anterior + dejar el artefacto listo para desplegar en cualquier momento | Un humano pulsa el botón |
| Despliegue continuo | Todo lo anterior + desplegar automáticamente si las verificaciones pasan | Sin 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
| Estrategia | Cómo funciona | Inactividad | Riesgo | Coste | Complejidad |
|---|---|---|---|---|---|
| Recreate | Se para todo y se levanta la versión nueva | Sí (segundos o minutos) | Alto: si falla, no hay servicio | Mínimo | Trivial |
| Rolling update | Se sustituyen las réplicas de una en una | No | Medio: conviven dos versiones | Bajo (una réplica extra) | Baja |
| Blue-green | Dos entornos completos; se conmuta el tráfico de golpe | No | Bajo: vuelta atrás instantánea | Alto (doble infraestructura) | Media |
| Canary | Un porcentaje pequeño del tráfico va a la versión nueva y se va subiendo | No | Muy bajo: el fallo afecta al 1 % | Medio | Alta (enrutado y métricas por versión) |
| Feature flags | El código nuevo se despliega apagado y se enciende por usuario o porcentaje | No | Muy bajo: apagar es instantáneo | Bajo | Media (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
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 defecto | Sí | Directa. En PostgreSQL 11+ añadir con DEFAULT no reescribe la tabla |
Añadir columna NOT NULL sin defecto | No | Añadir anulable → rellenar → añadir la restricción en otra migración |
| Renombrar columna o tabla | No | Expand/contract completo. Nunca RENAME en un despliegue vivo |
| Eliminar columna | No | Solo en la fase contract, cuando ninguna versión viva la lee |
| Cambiar el tipo de una columna | No | Columna nueva + doble escritura + copia + intercambio |
| Crear índice | Sí, pero bloquea escrituras | CREATE INDEX CONCURRENTLY (fuera de transacción: en MikroORM, con transactional: false en esa migración) |
| Añadir clave foránea | Sí, pero valida toda la tabla | ADD CONSTRAINT … NOT VALID y después VALIDATE CONSTRAINT |
Dónde ejecutar las migraciones
| Lugar | Ventajas | Riesgos | Veredicto |
|---|---|---|---|
| Job previo al despliegue (una sola ejecución, misma imagen) | Se ejecuta una vez; si falla, el despliegue se aborta y nada cambia; logs claros | Requiere orquestación (un paso más en el pipeline) | La opción recomendada |
| Init container en Kubernetes | Vive con el deployment, sin pasos externos | Se ejecuta en cada pod: con 4 réplicas, cuatro migraciones simultáneas | Aceptable solo con bloqueo explícito |
Al arrancar la aplicación (migrator.up() en main.ts) | Cero configuración; cómodo en desarrollo | Cada réplica lo intenta a la vez; el arranque se alarga y las probes matan el pod a mitad de una migración larga | Peligroso en producción |
| Manualmente por SSH | Ninguna | Nadie sabe qué se ejecutó, ni cuándo, ni con qué versión | No |
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);
}// 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); });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',
},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ón | Coste mensual orientativo | Control | Complejidad operativa | Te da |
|---|---|---|---|---|
| VPS + Docker Compose (Hetzner, DigitalOcean) | 5–40 € | Total | Media: actualizas el sistema, la seguridad y las copias tú | Un servidor, tu responsabilidad |
| PaaS (Railway, Render, Fly.io) | 10–100 € | Bajo | Mínima: git push y listo | Despliegue, TLS, logs y base de datos gestionada |
| Contenedores gestionados (Cloud Run, AWS ECS/Fargate) | Por uso; puede ser casi 0 en reposo | Medio | Baja-media: IAM, redes y despliegue como código | Autoescalado real, incluso a cero |
| Kubernetes gestionado (GKE, EKS, AKS) | 150 € en adelante | Total | Alta: es una plataforma, no un servidor | Todo, si tienes quien lo mantenga |
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:
- Cabeceras de caché. Los ficheros con hash en el nombre (
main-A7F3B2C1.js) llevanCache-Control: public, max-age=31536000, immutable.index.htmlllevano-cache(omax-age=0, must-revalidate): es el índice que apunta a los demás y, si se cachea, los navegadores seguirán pidiendo bundles que ya no existen y verán errores de chunk no encontrado. - Fallback de SPA. Cualquier ruta desconocida devuelve
index.htmlcon código 200, salvo las rutas de assets y de API, que deben devolver 404 de verdad. Un fallback demasiado goloso convierte un.jsque falta en una página HTML servida como JavaScript, con el desconcertanteUnexpected token '<'. - Invalidación de la CDN tras cada despliegue, al menos de
index.html. Y sube primero los assets nuevos y después elindex.html: si lo haces al revés, durante unos segundos el índice apunta a ficheros que aún no existen.
@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
| Objeto | Qué es |
|---|---|
| Pod | La unidad mínima: uno o varios contenedores que comparten red y almacenamiento. Es efímero y no se gestiona a mano. |
| Deployment | Declara «quiero N réplicas de esta imagen» y gestiona el rolling update y la vuelta atrás. |
| Service | Nombre DNS estable y reparto de carga entre los pods que casan con un selector. |
| Ingress | Entrada HTTP desde fuera: rutas, dominios y TLS. |
| ConfigMap / Secret | Configuració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. |
| Probes | liveness: 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. |
| HPA | Horizontal Pod Autoscaler: ajusta el número de réplicas según CPU, memoria o métricas propias. |
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 }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:
// 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// 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 botella | Síntoma | Diagnóstico | Solución |
|---|---|---|---|---|
| 1 | Consultas sin índice | Un endpoint que iba bien con 1.000 filas tarda 4 s con 500.000 | EXPLAIN (ANALYZE, BUFFERS); Seq Scan sobre tablas grandes | Índice adecuado (capítulo 19); revisar también el orden de las columnas del índice compuesto |
| 2 | N+1 consultas | La latencia crece linealmente con el número de elementos de la lista | Contador de consultas por petición en los logs; debug: true en MikroORM | populate, QueryBuilder con join, o carga por lotes (capítulo 15) |
| 3 | Event loop bloqueado | Todas las peticiones se ralentizan a la vez, aunque la CPU no esté al 100 % | Métrica event loop lag; perfilado con --cpu-prof | Sacar el trabajo intensivo a worker_threads o a una cola; evitar JSON gigantes y regex con retroceso catastrófico |
| 4 | Pool de conexiones agotado | Errores de timeout al obtener conexión justo después de escalar réplicas | SELECT count(*) FROM pg_stat_activity frente a max_connections | Dimensionar pool.max × réplicas; PgBouncer en modo transacción |
| 5 | Memoria | Reinicios con código de salida 137; latencia con picos por pausas del recolector | docker stats, métrica de heap, instantáneas de memoria | Streaming en lugar de cargar en memoria, paginación obligatoria, cachés con límite, --max-old-space-size acorde al límite del contenedor |
| 6 | Red y payloads | Tiempo de espera alto con servidores ociosos | Tamaño de respuesta, cascadas de peticiones en el navegador | Compresión, paginación, campos selectivos, HTTP/2, CDN, agrupar llamadas |
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
- Colas para desacoplar picos. Si un pico de tráfico dispara envíos de correo, generación de PDFs o llamadas a terceros, no lo hagas en la petición: encola (BullMQ sobre Redis, capítulo 11) y responde
202 Accepted. La cola absorbe el pico y los workers lo drenan al ritmo que aguanten; además, escalas los workers por separado de la API. - Réplicas de lectura cuando el cuello es de lectura y ya has indexado bien. Cuestan poco pero introducen retraso de replicación: leer justo después de escribir puede devolver el valor antiguo. Rutina segura: escrituras y lecturas críticas al primario, informes y listados a las réplicas.
- Particionado y sharding. Particionar tablas enormes por fecha (
PARTITION BY RANGE) es asequible y muy efectivo para históricos. Repartir los datos en varias bases de datos por clave de cliente es la última herramienta: multiplica la complejidad de todo, incluidas las migraciones y las consultas entre particiones.
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
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);
}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.
- Logs centralizados y estructurados en JSON a
stdout, recogidos por la plataforma (Loki, CloudWatch, Datadog). Sin datos personales ni tokens. Con nivel, marca de tiempo, servicio, versión ytraceId. - Métricas mínimas de una API: peticiones por segundo, tasa de error (4xx y 5xx separadas), latencia p50/p95/p99 por endpoint, event loop lag, memoria del heap, conexiones del pool en uso, profundidad de la cola y consultas lentas. Eso es el dashboard mínimo: cabe en una pantalla y responde «¿está sano el servicio?» en tres segundos.
- Alertas útiles frente a alertas que se ignoran. Una alerta debe ser accionable (sé qué hacer), urgente (hay que hacerlo ahora) y rara. Alerta sobre síntomas que nota el usuario (tasa de error, latencia, disponibilidad), no sobre causas intermedias («CPU al 80 %» a las tres de la mañana no significa nada). Si una alerta salta y la respuesta habitual es «ya se arreglará», bórrala: está entrenando al equipo a ignorar el buscapersonas.
- SLI, SLO y presupuesto de error. El SLI es la medida (porcentaje de peticiones correctas por debajo de 300 ms); el SLO es el objetivo (99,9 % mensual); el presupuesto de error es lo que te puedes permitir fallar: un 0,1 % de un mes son unos 43 minutos. Ese presupuesto es una herramienta de decisión: si queda margen, se despliega y se experimenta; si se ha consumido, se congelan las novedades y se dedica el esfuerzo a fiabilidad. Convierte una discusión de opiniones en una de números.
21.10 Fiabilidad
- RTO y RPO. El Recovery Time Objective es cuánto puedes estar caído; el Recovery Point Objective, cuántos datos puedes permitirte perder. Un RPO de 5 minutos exige archivado continuo del registro de transacciones (point-in-time recovery), no un volcado nocturno. Ponles número antes del incidente: durante el incidente ya no se negocia.
- Degradación elegante. Si el servicio de recomendaciones no responde, la página se muestra sin recomendaciones; no se cae entera. Toda dependencia no crítica necesita un camino alternativo.
- Timeouts en todo. Una llamada HTTP sin timeout puede quedarse esperando indefinidamente, reteniendo memoria y una conexión del pool. Configura tiempo de espera de conexión y de respuesta en cada cliente HTTP, en el ORM y en Redis.
- Reintentos con retroceso exponencial y jitter, y solo en operaciones idempotentes. Reintentar un cobro no idempotente cobra dos veces. Sin jitter, mil clientes reintentan a la vez y rematan el servicio que se estaba recuperando.
- Circuit breaker. Tras N fallos consecutivos, el circuito se abre y las llamadas fallan inmediatamente durante un tiempo, en lugar de acumular peticiones esperando a un servicio caído. Pasado ese tiempo deja pasar una de prueba (estado semiabierto). Evita el fallo en cascada, que es como una incidencia pequeña se convierte en una caída total.
- Idempotencia. Toda operación que modifique datos y pueda reintentarse necesita una clave de idempotencia: el cliente envía un identificador único y el servidor devuelve el resultado ya calculado si esa clave se repite. Es lo que hace seguros los reintentos, los reenvíos de formulario y los webhooks.
- Post-mortem sin culpables. Tras un incidente, un documento con la cronología, el impacto, las causas (en plural: nunca hay una sola) y acciones concretas con responsable y fecha. Sin nombres propios en la columna de causas: si la respuesta es «Juan se equivocó», la conclusión útil —el sistema permitía que un error humano llegara a producción— se pierde, y la próxima vez nadie contará lo que ha pasado.
21.11 Costes
- Qué dispara la factura: el tráfico de salida (el egress es lo que más sorprende y lo que más se olvida), las bases de datos gestionadas sobredimensionadas, los entornos de staging encendidos 24×7, los logs y métricas con retención larga y alta cardinalidad, los balanceadores y las IP fijas por servicio, y las máquinas «temporales» de hace ocho meses que nadie apagó.
- Dimensionar sin sobredimensionar: empieza pequeño y mide. Autoescalado con un mínimo sensato, escalado a cero en entornos que no son de producción, apagado programado de staging fuera de horario, instancias reservadas solo para la carga base y estable, y una CDN delante de todo lo estático (que además reduce el egress).
- El coste oculto de la complejidad operativa es el mayor de todos y no aparece en ninguna factura: son las horas de personas. Un clúster de Kubernetes que «solo» cuesta 200 € al mes puede consumir media persona a tiempo completo en mantenimiento, actualizaciones e incidencias. Antes de añadir una pieza a la arquitectura, pregunta quién la va a mantener, quién la sabrá arreglar a las tres de la mañana y qué se deja de hacer para atenderla.
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í.
- Lockfile versionado y
npm cien todas partes. Es lo que garantiza que se instala exactamente lo auditado, y no una versión menor publicada anoche. npm auditen CI con umbral, y actualizaciones automatizadas y continuas (Dependabot o Renovate) en lotes pequeños. Actualizar poco y a menudo es infinitamente más barato que saltar tres versiones mayores el día que aparece una vulnerabilidad crítica.- Dependencias con scripts de instalación. Un
postinstallejecuta código arbitrario en tu máquina y en tu CI con los permisos del pipeline. Para las instalaciones donde no sean necesarios,npm ci --ignore-scripts; y desconfía de paquetes con pocas descargas, mantenimiento dudoso o nombres sospechosamente parecidos a otros populares (typosquatting). - Imágenes base actualizadas y fijadas. Fija por versión (y, en producción, por digest
sha256:…) y reconstruye periódicamente: una imagen de hace seis meses acumula CVE del sistema operativo aunque tu código no haya cambiado. - Escaneo de vulnerabilidades de contenedores (Trivy, Grype, el escáner del registro) en el pipeline y de forma programada sobre lo que ya está desplegado.
- Procedencia y firma de artefactos: atestaciones SLSA y firma con Cosign/Sigstore mediante OIDC, sin claves que guardar. Permiten verificar, antes de desplegar, que la imagen la construyó tu pipeline a partir de tu commit.
- Permisos mínimos del pipeline:
permissions: contents: readpor defecto y ampliación por job; fijar las actions de terceros por SHA en lugar de por etiqueta móvil; nunca exponer secretos a workflows disparados porpull_request_targetdesde forks; y federación de identidad (OIDC) en lugar de credenciales de larga vida guardadas en el repositorio.
21.13 Errores comunes y cómo solucionarlos
| Síntoma | Causa real | Solución |
|---|---|---|
| La imagen de la API pesa 1,5 GB | Un solo stage, con devDependencies, código fuente, .git y caché de npm dentro | Multi-stage, npm ci --omit=dev, base alpine y un .dockerignore completo |
Un secreto aparece en docker history | ARG/ENV con el valor, o COPY .env; borrarlo después no elimina la capa | Montajes de secreto de BuildKit; inyectar en ejecución; rotar la credencial filtrada de inmediato |
| El contenedor tarda 10 s en parar y corta peticiones | CMD npm start: PID 1 es sh y no reenvía SIGTERM | Forma exec (CMD ["node","dist/main.js"]), dumb-init/init: true y enableShutdownHooks() |
| Cuatro réplicas ejecutan la misma migración a la vez | Migraciones lanzadas al arrancar la aplicación | Job previo con la misma imagen y pg_try_advisory_lock (sección 21.6.2) |
| Tras desplegar, los usuarios siguen viendo la versión antigua | index.html cacheado por el navegador o por la CDN | Cache-Control: no-cache en index.html, caché larga solo para ficheros con hash, e invalidar la CDN al desplegar |
Unexpected token '<' en la consola del navegador | El fallback de la SPA devuelve index.html para un .js que ya no existe | Que 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 servidor | Otra versión de Node, zona horaria o locale distintos, dependencias del sistema, orden de tests o dependencia de ficheros no versionados | Misma imagen en ambos sitios, engines en package.json, TZ=UTC, tests independientes del orden y npm ci |
too many connections justo después de escalar | réplicas × pool.max supera max_connections | Recalcular 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éplicas | Bloqueo 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 umbral | Liveness superficial, readiness con dependencias, y startupProbe con margen |
| Código de salida 137 al azar | El proceso supera el límite de memoria del contenedor y el núcleo lo mata | Subir 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 commit | COPY . . 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 --forceamain. - Secretos en el repositorio, en la imagen o en un
ARG: quedan para siempre. latestcomo única etiqueta: hace imposible saber qué hay desplegado y volver atrás.npm installen CI o en Docker en lugar denpm 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.
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?
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?
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?
¿Dónde ejecuto las migraciones exactamente?
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?
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?
¿Por qué mi pipeline pasa en local y falla en CI?
TZ=UTC y haz que cada test cree y destruya sus propios datos.¿Cuántas réplicas necesito?
¿Merece la pena el SSR de Angular?
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?
¿Cuándo sé que ha llegado el momento de Kubernetes?
21.16 Ejercicios
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.
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.
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,rebaseyreflogen 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
- Pro Git (en español) — el libro oficial de Git; el capítulo 10, «Git internals», es el que hace clic con el modelo de objetos.
- Conventional Commits y Versionado semántico — las dos especificaciones, en español.
- Docker · Buenas prácticas para escribir Dockerfiles — referencia oficial sobre capas, caché y multi-stage.
- Especificación de Compose — todas las claves del fichero, incluidas
healthcheckydepends_on. - Documentación de GitHub Actions — sintaxis de workflows, servicios, matrices, cachés y permisos.
- The Twelve-Factor App — la metodología completa, con un capítulo por factor.
- Conceptos de Kubernetes — documentación oficial de pods, deployments, servicios y probes.
- Documentación de k6 — escenarios, umbrales, métricas personalizadas y ejecución en CI.
- MikroORM · Migraciones — configuración del migrador, transacciones y uso programático.
- DORA · Investigación sobre rendimiento de equipos — las cuatro métricas clave y la evidencia sobre frecuencia de despliegue y fiabilidad.
- Google SRE Books — SLI/SLO, presupuestos de error, post-mortem sin culpables y gestión de incidencias.