Seguridad de Docker en despliegues de Node.js: imágenes endurecidas, contenedores no root y builds sin secretos filtrados [2026]
Un contenedor es un proceso, no una sandbox
Todo despliegue de Node.js acaba siendo una imagen de Docker. Esa imagen se convierte en la unidad de despliegue, en la unidad de rollback y —lo hayas planificado o no— en la unidad de compromiso. Cuando un atacante consigue ejecución de código a través de una dependencia vulnerable, un fallo de deserialización o un token filtrado, lo que puede hacer después lo decide casi por completo cómo se construyó el contenedor y cómo se ejecuta.
Así que empieza por el modelo mental correcto, porque confundirlo lleva a todas las malas configuraciones que vienen a continuación:
- Un contenedor no es una máquina virtual. No hay hipervisor. Los procesos de dentro de un contenedor se ejecutan sobre el mismo kernel del host que todo lo demás, separados por namespaces y limitados por cgroups. La frontera de aislamiento es el propio kernel, lo que significa que las CVE del kernel y del runtime (
runc,containerd, el demonio de Docker) son escapes de contenedor. Mantén Docker y el kernel del host parcheados, y nunca consideres "está en un contenedor" como motivo para ejecutar una imagen en la que no confías. - Los valores por defecto son permisivos. Un contenedor construido con
FROM node:22y arrancado condocker runse ejecuta como root, con el sistema de ficheros raíz escribible, un conjunto de capabilities por defecto y poderes cercanos aptraceque no necesita. Si un atacante escapa del proceso de Node.js, esos valores por defecto deciden si es un shell de www-data o prácticamente el host. - La imagen es un artefacto de cadena de suministro (supply chain). Las imágenes base, los paquetes del sistema operativo, las dependencias de npm y las herramientas de compilación acaban todas en el artefacto que publicas. Todo lo que puedes escanear, firmar y fijar es un control que puedes imponer.
El objetivo de este artículo es un despliegue en el que un proceso de Node comprometido sea un incidente contenido: sin root, sin sistema operativo escribible, sin capabilities extra, sin secretos incrustados, sin ruta hacia el host y con una imagen cuyo contenido se verificó antes de arrancar.
Regla 1: fija y minimiza la imagen base
La imagen base es el mayor contribuyente individual a tu recuento de CVE, y la mayor parte de lo que contiene nunca se ejecuta.
# Bad: full Debian image, floating tag, root by default
FROM node:22
# Better: slim variant, pinned to a digest
FROM node:22-bookworm-slim@sha256:<digest>
# Best for runtime: distroless — no shell, no package manager, no curl
FROM gcr.io/distroless/nodejs22-debian12:nonroot@sha256:<digest>
Aquí importan tres decisiones:
- Etiqueta → digest.
node:22-bookworm-slimes un puntero mutable. Quien controle esa etiqueta controla lo que despliegas en tu siguiente build. Fija el digest y actualízalo de forma deliberada: con un bot, un PR y un escaneo, no en silencio. La misma regla se aplica a cadaFROM, incluido elFROM alpinede un build multi-stage. - Completa → slim → distroless. La imagen
node:22completa incluye compiladores,git, Python y cabeceras de compilación que no necesitas en ejecución. Cada uno de esos paquetes es superficie de CVE, y el compilador junto concurles un kit listo para "descargar y ejecutar la segunda fase" para un atacante que ya tiene RCE. - Alpine no es automáticamente más segura. Es más pequeña, pero usa musl en lugar de glibc, y una librería C distinta implica sorpresas con módulos nativos (
sharp, algunas compilaciones connode-gyp) y, en ocasiones, fallos más lentos. El conjunto de paquetes más reducido de Alpine es una ventaja real; que sus paquetes sean más pequeños no lo es. Si la usas, usanode:22-alpiney verifica que tus dependencias nativas compilan y funcionan bajo musl.
Mide lo que publicas en lugar de fiarte de la intuición:
docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}'
docker history --no-trunc ghcr.io/acme/web:latest | head -20
Regla 2: builds multi-stage — mantén las herramientas de compilación y los secretos fuera de la capa de ejecución
Un Dockerfile de una sola etapa que copia todo el repositorio, ejecuta npm install y después hace COPY . . en la misma imagen envía a producción tu cadena de herramientas, tus dependencias de desarrollo y —si eres descuidado— tu fichero .env.
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# Secrets come from BuildKit, never from ARG/ENV. See below.
RUN --mount=type=cache,target=/root/.npm \
--mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --omit=dev --ignore-scripts
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
--mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --ignore-scripts
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=build --chown=nonroot:nonroot /app/.next ./.next
COPY --from=build --chown=nonroot:nonroot /app/public ./public
COPY --from=build --chown=nonroot:nonroot /app/package.json ./package.json
COPY --from=build --chown=nonroot:nonroot /app/next.config.mjs ./next.config.mjs
USER nonroot
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
CMD ["node_modules/.bin/next", "start", "-p", "3000"]
Lo que esto te aporta: la imagen de ejecución contiene los node_modules de producción, la salida compilada y nada más. Sin git, sin npm, sin compilador, sin árbol de fuentes, sin caché de compilación.
Secretos: ARG y ENV escriben en disco, de forma permanente
Este es el error grave más habitual en imágenes de Node.js:
# VULNERABLE — the token stays in the image forever
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc && npm ci
Los valores de ARG son visibles en docker history --no-trunc, y persisten en la capa incluso si un RUN posterior borra el fichero. Lo mismo ocurre con ENV DATABASE_URL=.... Cualquiera que pueda descargar la imagen —un compañero de equipo, un runner de CI, un registro que sufra una brecha, una etiqueta latest olvidada en un repositorio público— puede leerlo.
# Proof: this prints the secret you thought was gone
docker history --no-trunc ghcr.io/acme/web:latest | grep -i token
La solución tiene dos partes. Usa montajes de secretos con BuildKit para que el valor no forme parte de ninguna capa:
DOCKER_BUILDKIT=1 docker build \
--secret id=npmrc,src=$HOME/.npmrc \
-t ghcr.io/acme/web:$GIT_SHA .
Y mantén los secretos de ejecución completamente fuera de la imagen: inyéctalos en tiempo de ejecución:
# compose: a file mount, not an environment variable baked into the image
services:
web:
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
.dockerignore es un control de seguridad
Sin él, COPY . . envía tu .env, el .git (historial completo, incluidos secretos borrados), el .npmrc, los node_modules locales, fixtures de test con datos reales y .next/cache al contexto de compilación.
# .dockerignore
.git
.gitignore
.env
.env.*
!.env.example
node_modules
.next
npm-debug.log*
*.pem
*.key
secrets/
coverage
Dockerfile*
docker-compose*.yml
Después verifica qué ha acabado realmente en la imagen:
docker run --rm --entrypoint sh ghcr.io/acme/web:$GIT_SHA -c 'ls -la /app && find / -name "*.env" 2>/dev/null'
Regla 3: ejecuta como no root — y hazlo exigible
Root dentro de un contenedor es root para cada namespace al que el contenedor pueda llegar. Combinado con un bind mount escribible, un dispositivo o un fallo del kernel, eso es un compromiso del host. Incluso sin escape, root puede reescribir los ficheros de la propia aplicación: parchear in situ una dependencia maliciosa o reemplazar el binario que estás a punto de reiniciar.
# Create a fixed UID so volume ownership is predictable
RUN groupadd -g 10001 app && useradd -u 10001 -g 10001 -m -s /usr/sbin/nologin app
USER 10001:10001
Con distroless esto viene gratis: la etiqueta :nonroot se ejecuta con UID 65532 y no contiene ningún shell.
Detalles de exigibilidad que suelen pillar a la gente:
- Sé dueño de tus ficheros. Si
node_moduleso el directorio de la aplicación pertenecen a root, el usuario no root no podrá leerlos después de endurecer los permisos. Usa--chownen cadaCOPYde la etapa final. - No te apoyes solo en
USER.USERes un valor por defecto, no una frontera:docker run --user 0lo sobrescribe. Exígelo en la plataforma: en Kubernetes,runAsNonRoot: true(que hace fallar el pod si la imagen quiere root) o, en Compose,user:junto con una comprobación de política en CI. - Prefiere un UID alto y aleatorio.
runAsUser: 10001conrunAsNonRoot: trueevita los casos límite del estilo OpenShift ("cualquier UID") y mantiene estables los permisos de los volúmenes.
Regla 4: sistema de ficheros raíz de solo lectura, capabilities eliminadas y sin escalada de privilegios
Node.js casi no necesita escribir nada: los logs van a stdout, los ficheros temporales a /tmp y Next.js necesita un directorio de caché escribible. Todo lo demás puede ser inmutable, lo que elimina la capacidad del atacante de persistir.
services:
web:
image: ghcr.io/acme/web@sha256:<digest>
user: "10001:10001"
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 256
mem_limit: 512m
cpus: "1.0"
environment:
NODE_ENV: production
# Keep the V8 heap below the cgroup limit — otherwise the OOM killer wins
NODE_OPTIONS: "--max-old-space-size=384"
volumes:
- next-cache:/app/.next/cache
networks:
- internal
ports: [] # never publish directly; the reverse proxy reaches it on the internal network
volumes:
next-cache:
networks:
internal:
internal: false
El equivalente con la CLI de Docker:
docker run -d --name web \
--read-only --tmpfs /tmp:size=64m,mode=1777 \
--security-opt no-new-privileges:true \
--cap-drop ALL \
--pids-limit 256 --memory 512m --memory-swap 512m --cpus 1 \
--user 10001:10001 \
-e NODE_ENV=production \
-v next-cache:/app/.next/cache \
--network internal \
ghcr.io/acme/web@sha256:<digest>
Por qué cada flag merece su sitio:
read_only: true— el contenedor no puede modificar sus propios binarios ni dejar un webshell en un webroot escribible.tmpfspara/tmp— mantiene la compatibilidad del sistema de ficheros de solo lectura con herramientas que esperan espacio temporal, y el contenido desaparece al reiniciar.no-new-privileges:true— impide que binarios setuid/setgid eleven el proceso, que es un paso de escape habitual usando un binario setuid que quedó en la imagen base.cap_drop: ALL— Node.js no necesita ninguna capability de Linux. La red, el bind al puerto 3000 (no privilegiado) y la E/S de ficheros funcionan sin ellas. Esta sola línea eliminaCAP_SYS_ADMIN,CAP_NET_RAW,CAP_DAC_OVERRIDEy el resto del kit de escape.pids_limit— detiene una fork bomb o un clúster descontrolado antes de que agote el espacio de PIDs del host.- Límites de memoria más
--max-old-space-size— un contenedor que supera su límite de memoria del cgroup recibeSIGKILL, lo que se manifiesta como un cuelgue misterioso. Fija el heap de V8 por debajo del límite para que V8 haga recolección de basura en lugar de morir. ports: []— no publicar el puerto hace que los puertos de la base de datos y de la aplicación solo sean alcanzables desde dentro del namespace de red. El proxy inverso se conecta por la red interna.
En Kubernetes esto se traduce directamente en el security context del pod:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
seccompProfile: RuntimeDefault aplica la lista blanca de llamadas al sistema del runtime: está activa por defecto en Kubernetes desde hace tiempo, pero se fija de forma explícita para que un cambio a nivel de clúster no pueda debilitar tus pods en silencio.
Regla 5: nunca privilegiado, nunca el socket de Docker
Dos configuraciones convierten un incidente contenido en una toma de control del host. Ambas son habituales y ambas ocupan una línea.
# CATASTROPHIC — this is root on the host by design
docker run -v /var/run/docker.sock:/var/run/docker.sock acme/web
# CATASTROPHIC — all capabilities, all devices, unconfined profiles
docker run --privileged acme/web
Montar el socket de Docker da al contenedor la capacidad de pedirle al demonio que arranque un contenedor nuevo con --privileged y un bind mount del / del host. No hay paso de explotación: el socket es una API cuyo propósito entero es ejecutar código arbitrario como root. Lo mismo ocurre con los sockets de containerd, con --pid=host más una herramienta de depuración y con los montajes de /proc/sys.
--privileged es peor de otra forma: concede todas las capabilities, elimina el confinamiento de AppArmor/SELinux y expone los dispositivos del host. Existe para depuración profunda y no debería aparecer nunca en un manifiesto de despliegue. Si de verdad necesitas acceso elevado (un escáner WiFi, herramientas bpf), concede la única capability que necesites (--cap-add NET_ADMIN) en lugar de todas.
Audítalo, porque se esconde en values de Helm y en ficheros de compose antiguos:
grep -rn -- '--privileged\|/var/run/docker.sock\|pid: host\|network_mode: host\|privileged: true' \
docker-compose*.yml deploy/ k8s/ .github/workflows/ 2>/dev/null
Si un contenedor ejecuta código realmente no confiable, los contenedores son la frontera equivocada: usa gVisor (runsc), Kata Containers, o un nodo/pool dedicado con un dominio de seguridad separado.
Regla 6: escanea, genera un SBOM y firma lo que publicas
Endurecer la imagen no te dice si contiene un paquete vulnerable. Escanéala en CI y haz que el pipeline falle ante hallazgos reales:
# Fail the build on HIGH/CRITICAL with a fix available — no silent warnings
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 \
ghcr.io/acme/web:$GIT_SHA
# Also scan the filesystem/misconfig, not just OS packages
trivy fs --scanners vuln,secret,misconfig --exit-code 1 .
# Produce an SBOM so you can answer "are we affected?" in minutes, not days
trivy image --format spdx-json -o sbom.spdx.json ghcr.io/acme/web:$GIT_SHA
# Sign the artifact and verify it at deploy time
cosign sign --yes ghcr.io/acme/web@sha256:<digest>
cosign verify --certificate-identity-regexp '^https://github.com/acme/web/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/acme/web@sha256:<digest>
--ignore-unfixed importa para la calidad de la señal: excluye las CVE sin parche disponible, de modo que un build fallido siempre significa "hay algo que puedes arreglar de verdad". Combina el escaneo con una política de admisión que rechace imágenes sin firmar (Kyverno verifyImages o el controlador de políticas que prefieras); si no, firmar es documentación, no un control.
El resto de reglas de cadena de suministro de nuestro artículo sobre dependencias se aplican igual dentro de la imagen: npm ci (nunca npm install) para tener un lockfile determinista, --ignore-scripts donde tu árbol de dependencias lo permita, y un bot de actualización de imágenes base que abra un PR para que el salto de digest se revise como código.
Regla 7: recorta la superficie de ataque de Node.js dentro del contenedor
Una vez que el proceso es no root y de solo lectura, un atacante con RCE busca algo por donde pivotar. Hallazgos habituales:
npmpresente en ejecución. Una imagen de producción no necesita el gestor de paquetes. Los builds multi-stage lo eliminan automáticamente; con una imagen base completa, quítalo, o simplemente no instales nunca una imagen base completa.- Un shell más
curl/wget. Juntos son un descargador. Las imágenes distroless no tienen ninguno de los dos, y eso es precisamente el objetivo. NODE_ENVsin definir. SinNODE_ENV=production, los frameworks activan páginas de error detalladas, mantienen middleware de desarrollo y se saltan las optimizaciones de producción.- Source maps y fuentes
.tspublicados. Filtran la estructura interna y hacen trivial el reconocimiento del atacante. Si necesitas los maps para el seguimiento de errores, súbelos a tu plataforma en CI y exclúyelos de la imagen. node_modulescompleto, incluidas las dependencias de desarrollo.npm ci --omit=deven la etapa de producción; las herramientas de desarrollo son superficie de CVE que nunca se ejecuta en producción.- Clúster ejecutándose con workers sin límite. Fija los workers a la CPU asignada (
--cpus 1más unclusterdimensionado a 1-2 workers); si no, el contenedor compite consigo mismo por tiempo de CPU dentro de su propio cgroup.
# If you cannot use distroless, at least drop the tooling
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
/usr/local/bin/corepack /usr/local/bin/yarn
Además, activa los flags de endurecimiento del runtime donde ayuden: --disable-proto=throw rechaza la asignación de __proto__ (una segunda línea de defensa barata contra la contaminación de prototipos, prototype pollution), y --frozen-intrinsics donde el ecosistema lo tolere.
Los errores que deshacen todo lo anterior
- Imágenes de una sola etapa con el repositorio entero. Herramientas de compilación,
.git,.envy dependencias de desarrollo acaban en producción. - Secretos vía
ARG/ENV. Persisten endocker historyincluso después de unRUN rm. Usa--mount=type=secretde BuildKit. --privileged"solo para que funcione". Concede todas las capabilities y desconfina el contenedor. Añade la única capability que necesitabas.- Socket de Docker montado por comodidad. Root en el host por diseño; nunca en un contenedor de aplicación.
USERdefinido pero no exigido.docker run --user 0lo sobrescribe. Exígelo conrunAsNonRoot: trueen la plataforma.- Rootfs de solo lectura sin
/tmpo caché escribibles. La aplicación falla al arrancar y alguien "lo arregla" quitandoread_only. - Etiquetas de imagen base flotantes. La imagen que probaste no es la que despliegas mañana.
npm installen el Dockerfile. Resolución del lockfile no determinista dentro de un build que puede ejecutarse con un token de registro privado.- Escaneo en CI sin
--ignore-unfixed. El pipeline falla por CVE que no se pueden arreglar, se silencia y los hallazgos reales dejan de leerse. - Imágenes firmadas sin paso de verificación. Una firma que nadie comprueba no impone nada.
- Límite de memoria sin límite del heap de V8. El OOM killer del cgroup termina el proceso; en los logs solo aparece el código de salida 137.
- Secretos en variables de entorno en ejecución.
docker inspecty/proc/<pid>/environlos exponen; usa ficheros de secretos montados. - Publicar el puerto de la base de datos "temporalmente". Los bindings de puertos sobreviven a las intenciones. Publica solo el proxy inverso.
El checklist para el CTO
- [ ] Cada
FROMestá fijado por digest y un bot abre PRs para actualizarlos. - [ ] Las imágenes de ejecución usan bases slim o distroless; sin compilador,
git,curlninpmen la etapa final. - [ ] Builds multi-stage: las dependencias de compilación y las herramientas nunca llegan a la capa de ejecución.
- [ ] Ningún secreto se pasa por
ARGoENV;docker history --no-truncsobre cualquier imagen publicada no muestra nada sensible. - [ ]
.dockerignoreexcluye.git,.env*,secrets/, claves ynode_modules. - [ ] El contenedor se ejecuta como no root con un UID fijo, exigido por
runAsNonRoot: trueen la plataforma. - [ ] El sistema de ficheros raíz es de solo lectura, con
tmpfsexplícito para/tmpy un volumen para la caché del framework. - [ ]
cap_drop: ALL,no-new-privileges: trueyseccompProfile: RuntimeDefaulten cada workload. - [ ] Sin
--privileged, sin montajes de sockets, sin--pid=hosty sinnetwork_mode: hosten ninguna parte del repositorio: verificado con grep en CI. - [ ]
pids_limit, límite de memoria y límite de CPU definidos, con--max-old-space-sizepor debajo del límite de memoria. - [ ] Los puertos de la aplicación y de la base de datos no están publicados; solo el proxy inverso queda expuesto.
- [ ] CI ejecuta un escaneo de imagen con
--ignore-unfixed --exit-code 1, publica un SBOM y firma el digest con cosign. - [ ] Una política de admisión rechaza imágenes sin firmar; el despliegue referencia el digest, no una etiqueta.
- [ ] Los secretos de ejecución vienen de ficheros montados o de un almacén de secretos del orquestador, nunca de capas de imagen ni de variables de entorno en claro.
Conclusión
Endurecer un contenedor de Node.js no es un cambio heroico único; son una docena de valores por defecto pequeños y aburridos aplicados con constancia: una imagen base mínima y fijada, un build multi-stage con montajes de secretos de BuildKit, un usuario no root, un sistema de ficheros de solo lectura, ninguna capability, ningún socket, límites exigidos y un artefacto firmado cuyo contenido puedes enumerar.
La razón para hacerlo ahora y no después de un incidente es la asimetría. Cada uno de estos controles cuesta unas pocas líneas de Dockerfile y un puñado de flags de ejecución. La alternativa —un proceso root con un sistema operativo escribible, un compilador, curl y un entorno lleno de credenciales— convierte una única vulnerabilidad de aplicación en un compromiso completo del entorno, y transforma tu respuesta a incidentes en una reconstrucción forense de un host en el que ya no confías.
Empieza por la imagen de mayor valor que ejecutes en producción. Comprueba como qué usuario se ejecuta, si el sistema de ficheros raíz es escribible, qué revela docker history --no-trunc y si --privileged aparece en algún manifiesto de despliegue. Esas cuatro respuestas te dicen más sobre tu riesgo real que cualquier diagrama de arquitectura.
¿Quieres un par de ojos con experiencia sobre tu pipeline de despliegue? Reserva una auditoría de seguridad — revisamos Dockerfiles, flags de ejecución, barreras de escaneo en CI, firma de imágenes y gestión de secretos, y te entregamos una lista priorizada de las rutas desde un contenedor comprometido hasta tu host.
La semana que viene: seguridad en pipelines de CI/CD para repositorios JavaScript — federación OIDC en lugar de claves cloud de larga duración, tokens de workflow con privilegio mínimo, procedencia de artefactos y por qué el runner de build es la máquina más privilegiada que tienes.
JS Security Audit
Auditorías dirigidas por un ingeniero de seguridad JavaScript senior con más de 10 años de experiencia.