Node.js en Docker: imagen productiva | Nicolás Garzón
Texto
Copiar package.json + lockfile
→ instalación reproducible
→ tests y compilación
→ runtime mínimo
→ Node como proceso principal
→ shutdown controladoDocker no corrige una aplicación que ignora señales, acumula memoria, escribe estado local o depende de configuración incorporada en el bundle. La imagen debe reforzar un diseño operable, no esconderlo.
mismo artefacto entre ambientes;
dependencias deterministas;
runtime sin herramientas de desarrollo;
proceso no root;
señales entregadas directamente a Node;
configuración inyectada en runtime;
logs por stdout/stderr;
filesystem mayormente read-only;
health y shutdown comprobables;
compatibilidad de arquitectura y módulos nativos.
docker
Copiar # syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS base
WORKDIR /app
FROM base AS development-deps
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
npm ci
FROM development-deps AS test
COPY tsconfig.json ./
COPY src ./src
COPY test ./test
RUN npm test
FROM development-deps AS build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM base AS production-deps
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
npm ci --omit=dev \
&& npm cache clean --force
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=production-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
CMD ["node", "dist/server.js"]CI debe construir explícitamente test y runtime. La etapa final no depende automáticamente de test.
Es preferible en builds porque:
exige lockfile coherente;
elimina el árbol existente;
instala la resolución fijada;
falla si package.json y lockfile divergen;
reduce diferencias entre runners.
No garantiza que las dependencias sean seguras ni que el registry entregue contenido legítimo. Combínalo con controles de supply chain.
docker
Copiar COPY package.json package-lock.json ./
RUN npm ci
COPY . .Así, cambiar source no invalida necesariamente la instalación de dependencias. Copiar todo antes de npm ci destruye cache ante cualquier cambio.
.dockerignore debe excluir:
Texto
Copiar node_modules
.git
.env*
coverage
dist
logs
*.md no requeridoNo copies node_modules del host. Puede contener binarios de otra plataforma o libc.
Bash
Copiar npm ci --omit = devRevisa que paquetes usados realmente en runtime estén en dependencies. Un módulo importado por la aplicación pero clasificado como devDependency funcionará localmente y fallará en producción.
Otra estrategia es instalar todo, compilar y ejecutar npm prune --omit=dev. Puede ser válida, pero debes probar lifecycle scripts y módulos nativos. Un árbol de producción independiente suele ser más claro.
El runtime normalmente necesita artefactos compilados, no compilador ni source completo.
Texto
Copiar src/*.ts
→ tsc/bundler
→ dist/*.js
→ runtimeSin embargo, algunos frameworks requieren:
archivos estáticos;
templates;
migrations;
manifests;
generated clients;
source maps internos.
Copia el contrato real del runtime de forma explícita. No asumas que dist siempre basta.
docker
Copiar CMD ["node", "dist/server.js"]docker
Copiar CMD npm startporque agrega una capa de proceso y puede alterar señales. Si un script es imprescindible, asegúrate de usar exec al lanzar Node.
recibir SIGTERM;
dejar de aceptar tráfico;
cerrar HTTP server;
terminar consumers;
drenar jobs;
cerrar pools;
salir antes del timeout.
TypeScript
Copiar import http from 'node:http' ;
const server = http. createServer ( app) ;
server. listen ( 3000 , '0.0.0.0' ) ;
let shuttingDown = false ;
async function shutdown ( signal: string ) {
if ( shuttingDown) return ;
shuttingDown = true ;
console . info ( { signal, message: 'Shutdown started' } ) ;
server. close ( async ( error) => {
try {
await database. end ( ) ;
await worker. stop ( ) ;
process. exit ( error ? 1 : 0 ) ;
} catch ( shutdownError) {
console . error ( shutdownError) ;
process. exit ( 1 ) ;
}
} ) ;
setTimeout ( ( ) => process. exit ( 1 ) , 25_000 ) . unref ( ) ;
}
process. on ( 'SIGTERM' , ( ) => void shutdown ( 'SIGTERM' ) ) ;
process. on ( 'SIGINT' , ( ) => void shutdown ( 'SIGINT' ) ) ; El timeout interno debe ser menor que el stop timeout del runtime para permitir salida controlada.
Si la aplicación crea procesos hijos y no los recolecta correctamente:
Bash
Copiar docker run --init my-apiañade un init pequeño para forwarding y reaping. No sustituye el manejo de shutdown de la aplicación.
TypeScript
Copiar server. listen ( port, '0.0.0.0' ) ; Escuchar en 127.0.0.1 hace que solo el mismo namespace pueda conectar. EXPOSE tampoco corrige un bind incorrecto.
TypeScript
Copiar const schema = z. object ( {
NODE_ENV : z. enum ( [ 'development' , 'test' , 'production' ] ) ,
PORT : z. coerce. number ( ) . int ( ) . min ( 1 ) . max ( 65535 ) ,
DATABASE_URL : z. string ( ) . min ( 1 ) ,
} ) ; Valida al iniciar y falla antes de aceptar tráfico. No imprimas la configuración completa: puede contener secretos.
Node usa heap y memoria nativa. El límite del container incluye:
V8 heap;
Buffers;
native addons;
stacks;
page cache;
procesos hijos.
No iguale --max-old-space-size al límite total.
Texto
Copiar memory limit 512 MB
heap máximo 320 MB
margen para nativo, buffers y overheadMide RSS, heap, GC pauses y event loop lag bajo carga real.
Un OOM puede terminar el proceso sin permitir cleanup. Backpressure y límites de concurrencia importan más que reiniciar indefinidamente.
Paquetes como drivers, image processors o crypto pueden compilar binarios.
Compatibilidad depende de:
arquitectura;
Node ABI;
libc;
shared libraries;
distribución base.
No copies node_modules construido en macOS a Linux ni desde Alpine/musl a Debian/glibc sin comprobar compatibilidad.
Construye para la plataforma objetivo y prueba cada variante publicada.
slim: equilibrio común entre compatibilidad y tamaño.
Alpine: pequeña, pero musl puede exigir recompilación.
distroless: menos tooling, requiere observabilidad y estrategia de debug.
Elige por compatibilidad y operación, no solo megabytes.
La imagen oficial de Node suele incluir el usuario node.
docker
Copiar COPY --chown=node:node ...
USER nodeBash
Copiar docker run --rm my-api id
docker run --rm --read-only my-apiSi falla por permisos, corrige ownership y paths. No vuelvas a root o chmod 777 como solución permanente.
Bash
Copiar docker run \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size= 64m \
my-apiDefine mounts específicos para uploads o archivos durables. La aplicación no debe escribir logs ni caches permanentes en /app.
Escribe JSON a stdout/stderr. Incluye:
service;
version/digest;
request ID;
trace ID;
duration;
error type.
No registres bodies, tokens o process.env completos.
Puede usar Node ya presente:
JavaScript
Copiar import http from 'node:http' ;
const request = http. get ( 'http://127.0.0.1:3000/health' , ( response ) => {
process. exit ( response. statusCode === 200 ? 0 : 1 ) ;
} ) ;
request. setTimeout ( 2000 , ( ) => {
request. destroy ( ) ;
process. exit ( 1 ) ;
} ) ;
request. on ( 'error' , ( ) => process. exit ( 1 ) ) ; No instales curl únicamente para health si el runtime puede comprobarse a sí mismo.
YAML
Copiar services :
api :
build :
context : .
target : development- deps
command : npm run dev
volumes :
- .: /app
- node- modules: /app/node_modules
ports :
- "3000:3000"
volumes :
node- modules: Source viene del host; dependencias Linux viven en volume. Producción no debe usar hot reload ni bind mount del repository.
Evita enviar todo el monorepo sin necesidad. Define:
context correcto;
lockfile raíz;
workspace packages requeridos;
pruning del grafo;
cache estable.
Una poda incorrecta puede excluir un paquete interno requerido en runtime. Prueba desde checkout limpio.
Una misma image puede exponer comandos distintos:
YAML
Copiar services :
api :
command : [ "node" , "dist/server.js" ]
worker :
command : [ "node" , "dist/worker.js" ] Ambos deben tener shutdown apropiado. Un worker debe dejar de tomar jobs, finalizar o reencolar el activo y salir de forma idempotente.
YAML
Copiar services :
api :
image : registry.example.com/api@sha256: ...
user : "1000:1000"
read_only : true
cap_drop : [ ALL]
security_opt :
- no- new- privileges: true
tmpfs :
- /tmp: size=64m, noexec, nosuid
mem_limit : 512m
pids_limit : 200
stop_grace_period : 30sBash
Copiar docker ps -a
docker logs api
docker inspect api --format '{{json .State}}' Revisa command, configuración, permisos y módulos ausentes.
dependencia está en devDependencies;
archivo no copiado;
case sensitivity;
cwd diferente;
variable ausente;
módulo nativo incompatible.
escucha en 127.0.0.1;
puerto incorrecto;
proceso bloqueado;
health no representa readiness.
Correlaciona OOMKilled, events y stop actions. No asumas automáticamente memoria.
Node no recibe señal, existen sockets abiertos, consumers no drenan o un timer mantiene event loop activo.
Señales y proceso intermediario menos claros.
Secretos en layers y artefactos por ambiente.
Tooling de desarrollo y restart interno confuso.
Incompatibilidad de plataforma.
OOM aunque heap parezca estable.
Docker empaqueta Node; no arregla su lifecycle.
npm ci y lockfile mejoran reproducibilidad.
Node debe ser PID 1 efectivo y manejar SIGTERM.
Heap no representa toda la memoria del container.
Módulos nativos atan arquitectura y libc.
Desarrollo mutable y runtime productivo deben usar etapas distintas.
Comprueba lo aprendido
Diseña un Dockerfile multi-stage para una API TypeScript.
¿Por qué npm ci --omit=dev puede revelar errores de clasificación de paquetes?
Implementa graceful shutdown para HTTP, worker y database pool.
¿Cómo dimensionarías heap dentro de un límite de 512 MB?
Diagnostica una app que funciona en macOS pero falla en Linux ARM64.
Bases de datos en Docker , donde persistencia, consistencia, upgrades y backups requieren mucho más que montar un volume.