Volver al blog
DockerSeguridad de contenedoresNode.jsNext.jsCadena de suministroDevSecOpsDockerfile

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:22 y arrancado con docker run se ejecuta como root, con el sistema de ficheros raíz escribible, un conjunto de capabilities por defecto y poderes cercanos a ptrace que 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.

dockerfile
# 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:

  1. Etiqueta → digest. node:22-bookworm-slim es 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 cada FROM, incluido el FROM alpine de un build multi-stage.
  2. Completa → slim → distroless. La imagen node:22 completa 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 con curl es un kit listo para "descargar y ejecutar la segunda fase" para un atacante que ya tiene RCE.
  3. 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 con node-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, usa node:22-alpine y verifica que tus dependencias nativas compilan y funcionan bajo musl.

Mide lo que publicas en lugar de fiarte de la intuición:

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

dockerfile
# 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:

dockerfile
# 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.

bash
# 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:

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

yaml
# 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.

text
# .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:

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

dockerfile
# 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_modules o el directorio de la aplicación pertenecen a root, el usuario no root no podrá leerlos después de endurecer los permisos. Usa --chown en cada COPY de la etapa final.
  • No te apoyes solo en USER. USER es un valor por defecto, no una frontera: docker run --user 0 lo 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: 10001 con runAsNonRoot: true evita 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.

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

bash
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.
  • tmpfs para /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 elimina CAP_SYS_ADMIN, CAP_NET_RAW, CAP_DAC_OVERRIDE y 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 recibe SIGKILL, 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:

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

bash
# 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:

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

bash
# 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:

  • npm presente 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_ENV sin definir. Sin NODE_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 .ts publicados. 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_modules completo, incluidas las dependencias de desarrollo. npm ci --omit=dev en 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 1 más un cluster dimensionado a 1-2 workers); si no, el contenedor compite consigo mismo por tiempo de CPU dentro de su propio cgroup.
dockerfile
# 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, .env y dependencias de desarrollo acaban en producción.
  • Secretos vía ARG/ENV. Persisten en docker history incluso después de un RUN rm. Usa --mount=type=secret de 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.
  • USER definido pero no exigido. docker run --user 0 lo sobrescribe. Exígelo con runAsNonRoot: true en la plataforma.
  • Rootfs de solo lectura sin /tmp o caché escribibles. La aplicación falla al arrancar y alguien "lo arregla" quitando read_only.
  • Etiquetas de imagen base flotantes. La imagen que probaste no es la que despliegas mañana.
  • npm install en 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 inspect y /proc/<pid>/environ los 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 FROM está fijado por digest y un bot abre PRs para actualizarlos.
  • [ ] Las imágenes de ejecución usan bases slim o distroless; sin compilador, git, curl ni npm en 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 ARG o ENV; docker history --no-trunc sobre cualquier imagen publicada no muestra nada sensible.
  • [ ] .dockerignore excluye .git, .env*, secrets/, claves y node_modules.
  • [ ] El contenedor se ejecuta como no root con un UID fijo, exigido por runAsNonRoot: true en la plataforma.
  • [ ] El sistema de ficheros raíz es de solo lectura, con tmpfs explícito para /tmp y un volumen para la caché del framework.
  • [ ] cap_drop: ALL, no-new-privileges: true y seccompProfile: RuntimeDefault en cada workload.
  • [ ] Sin --privileged, sin montajes de sockets, sin --pid=host y sin network_mode: host en 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-size por 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.

Comparte este artículo:TwitterLinkedIn
JS

JS Security Audit

Auditorías dirigidas por un ingeniero de seguridad JavaScript senior con más de 10 años de experiencia.