Docker Security for Node.js Deployments: Hardened Images, Non-Root Containers, and Builds Without Leaked Secrets [2026]
A Container Is a Process, Not a Sandbox
Every Node.js deployment eventually becomes a Docker image. That image becomes the unit of deployment, the unit of rollback, and — whether you planned for it or not — the unit of compromise. When an attacker gets code execution through a vulnerable dependency, a deserialization bug, or a leaked token, what they can do next is decided almost entirely by how the container was built and how it is run.
So start with the correct mental model, because confusing it leads to every misconfiguration below:
- A container is not a virtual machine. There is no hypervisor. Processes inside a container run on the same host kernel as everything else, separated by namespaces and constrained by cgroups. The isolation boundary is the kernel itself, which means kernel and runtime CVEs (
runc,containerd, the Docker daemon) are container escapes. Keep Docker and the host kernel patched — and never treat "it's in a container" as a reason to run an image you do not trust. - Defaults are permissive. A container built with
FROM node:22and started withdocker runruns as root, with a writable root filesystem, a default capability set, andptrace-adjacent powers it does not need. If an attacker escapes the Node.js process, those defaults decide whether they are a www-data-shell or effectively the host. - The image is a supply chain artifact. Base images, OS packages, npm dependencies, and build tooling all end up in the artifact you ship. Anything you can scan, sign, and pin is a control you can enforce.
The goal of this article is a deployment where a compromised Node process is a contained incident: no root, no writable OS, no extra capabilities, no embedded secrets, no route to the host, and an image whose contents were verified before it started.
Rule 1: Pin and Minimize the Base Image
The base image is the largest single contributor to your CVE count, and most of what it contains never runs.
# 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>
Three decisions matter here:
- Tag → digest.
node:22-bookworm-slimis a mutable pointer. Whoever controls that tag controls what you deploy on your next build. Pin the digest and update it deliberately — with a bot, a PR, and a scan, not silently. The same rule applies to everyFROM, includingFROM alpineinside a multi-stage build. - Full → slim → distroless. The full
node:22image ships compilers,git, Python, and build headers you do not need at runtime. Every one of those packages is CVE surface, and the compiler pluscurlis a ready-made "download and run stage two" toolkit for an attacker who already has RCE. - Alpine is not automatically safer. It is smaller, but it uses musl instead of glibc and a different C library means native module surprises (
sharp, somenode-gypbuilds) and occasionally slower crashes. Alpine's smaller package set is a real win; its smaller packages are not. If you use it, usenode:22-alpineand verify your native dependencies build and run under musl.
Measure what you ship rather than trusting intuition:
docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}'
docker history --no-trunc ghcr.io/acme/web:latest | head -20
Rule 2: Multi-Stage Builds — Keep Build Tools and Secrets Out of the Runtime Layer
A single-stage Dockerfile that copies the whole repo, runs npm install, and then COPY . . into the same image ships your build toolchain, your dev dependencies, and — if you are careless — your .env file to production.
# 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"]
What this buys you: the runtime image contains production node_modules, compiled output, and nothing else. No git, no npm, no compiler, no source tree, no build cache.
Secrets: ARG and ENV write to disk, permanently
This is the most common serious mistake in Node.js images:
# VULNERABLE — the token stays in the image forever
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc && npm ci
ARG values are visible in docker history --no-trunc, and they persist in the layer even if a later RUN deletes the file. Same for ENV DATABASE_URL=.... Anyone who can pull the image — a teammate, a CI runner, a registry that gets breached, a leftover latest tag on a public repo — can read it.
# Proof: this prints the secret you thought was gone
docker history --no-trunc ghcr.io/acme/web:latest | grep -i token
The fix has two parts. Use BuildKit secret mounts so the value is never part of any layer:
DOCKER_BUILDKIT=1 docker build \
--secret id=npmrc,src=$HOME/.npmrc \
-t ghcr.io/acme/web:$GIT_SHA .
And keep runtime secrets out of the image entirely — inject them at run time:
# 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 is a security control
Without it, COPY . . sends your .env, .git (full history, including deleted secrets), .npmrc, local node_modules, test fixtures with real data, and .next/cache into the build context.
# .dockerignore
.git
.gitignore
.env
.env.*
!.env.example
node_modules
.next
npm-debug.log*
*.pem
*.key
secrets/
coverage
Dockerfile*
docker-compose*.yml
Then verify what actually landed in the image:
docker run --rm --entrypoint sh ghcr.io/acme/web:$GIT_SHA -c 'ls -la /app && find / -name "*.env" 2>/dev/null'
Rule 3: Run as Non-Root — and Make It Enforceable
Root inside a container is root for every namespace the container can reach. Combined with a writable bind mount, a device, or a kernel bug, that is a host compromise. Even without an escape, root can rewrite the application's own files — patching a malicious dependency in place, or replacing the binary you are about to restart.
# 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
With distroless you get this for free: the :nonroot tag runs as UID 65532 and contains no shell at all.
Enforcement details that catch people out:
- Own your files. If
node_modulesor the app directory is owned by root, the non-root user cannot read it after you harden permissions. Use--chownon everyCOPYin the final stage. - Don't rely on
USERalone.USERis a default, not a boundary —docker run --user 0overrides it. Enforce it at the platform: KubernetesrunAsNonRoot: true(which fails the pod if the image wants root), or Composeuser:plus a policy check in CI. - Prefer a random high UID.
runAsUser: 10001withrunAsNonRoot: trueavoids the OpenShift-style "any UID" edge cases and keeps volume permissions stable.
Rule 4: Read-Only Root Filesystem, Dropped Capabilities, No Privilege Escalation
Node.js needs to write almost nothing: logs go to stdout, temp files go to /tmp, and Next.js needs a writable cache directory. Everything else can be immutable, which removes an attacker's ability to persist.
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
The Docker CLI equivalent:
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>
Why each flag earns its place:
read_only: true— the container cannot modify its own binaries or drop a webshell into a writable webroot.tmpfsfor/tmp— keeps the read-only root filesystem compatible with tooling that expects temp space, and the contents vanish on restart.no-new-privileges:true— blocks setuid/setgid binaries from elevating the process, which is a common escape step for a setuid binary left in a base image.cap_drop: ALL— Node.js needs zero Linux capabilities. Networking, binding to port 3000 (unprivileged), and file I/O all work without them. This single line removesCAP_SYS_ADMIN,CAP_NET_RAW,CAP_DAC_OVERRIDE, and the rest of the escape toolkit.pids_limit— stops a fork bomb or a runaway cluster from exhausting the host's PID space.- Memory limits plus
--max-old-space-size— a container that exceeds its cgroup memory limit isSIGKILLed, which looks like a mystery crash. Set the V8 heap below the limit so V8 garbage-collects instead of dying. ports: []— dropping the published port means the database and app ports are only reachable from inside the network namespace. The reverse proxy connects internally.
On Kubernetes these map directly to the pod security context:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
seccompProfile: RuntimeDefault applies the runtime's syscall allowlist — on by default in Kubernetes for a while, but pinned explicitly so a cluster-level change cannot silently weaken your pods.
Rule 5: Never Privileged, Never the Docker Socket
Two configurations turn a contained incident into a host takeover. Both are common, and both are one line long.
# 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
Mounting the Docker socket gives the container the ability to ask the daemon to start a new container with --privileged and a host bind mount of /. There is no exploitation step: the socket is an API whose entire purpose is running arbitrary code as root. The same applies to containerd sockets, to --pid=host plus a debug tool, and to /proc/sys mounts.
--privileged is worse in a different way: it grants all capabilities, removes AppArmor/SELinux confinement, and exposes host devices. It exists for deep debugging and should never appear in a deployment manifest. If you genuinely need elevated access (a WiFi scanner, bpf tooling), grant the single capability you need (--cap-add NET_ADMIN) rather than all of them.
Audit for it, because it hides in Helm values and old compose files:
grep -rn -- '--privileged\|/var/run/docker.sock\|pid: host\|network_mode: host\|privileged: true' \
docker-compose*.yml deploy/ k8s/ .github/workflows/ 2>/dev/null
If a container truly runs untrusted code, containers are the wrong boundary: use gVisor (runsc) or Kata Containers, or a dedicated node/pool with a separate security domain.
Rule 6: Scan, Generate an SBOM, and Sign What You Ship
Hardening the image does not tell you whether it contains a vulnerable package. Scan it in CI and make the pipeline fail on real findings:
# 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 matters for signal quality: it excludes CVEs with no available patch, so a failed build always means "there is something you can actually fix." Pair the scan with an admission policy that refuses unsigned images (Kyverno verifyImages, or a policy controller of your choice) — otherwise signing is documentation, not a control.
The rest of the supply chain rules from our dependency article apply unchanged inside the image: npm ci (never npm install) for lockfile determinism, --ignore-scripts where your dependency tree allows it, and a base-image update bot that opens a PR so the digest bump gets reviewed like code.
Rule 7: Trim the Node.js Attack Surface Inside the Container
Once the process is non-root and read-only, an attacker with RCE looks for something to pivot through. Common findings:
npmpresent at runtime. A production image does not need the package manager. Multi-stage builds drop it automatically; with a full base image, remove it — or simply never install a full base image.- A shell plus
curl/wget. Together they are a downloader. Distroless images have neither, which is the point. NODE_ENVunset. WithoutNODE_ENV=production, frameworks enable verbose error pages, keep dev middleware, and skip production optimizations.- Source maps and
.tssources shipped. They leak internal structure and make an attacker's recon trivial. If you need maps for error tracking, upload them to your tracker in CI and exclude them from the image. - Full
node_modulesincluding dev dependencies.npm ci --omit=devin the production stage; dev tooling is CVE surface that never runs in production. - Cluster running with unbounded workers. Pin workers to the CPU allocation (
--cpus 1plusclustersized to 1–2 workers), otherwise the container fights its own cgroup for CPU time.
# 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
Also set the runtime hardening flags where they help: --disable-proto=throw rejects __proto__ assignment (a cheap second line against prototype pollution), and --frozen-intrinsics where the ecosystem tolerates it.
The Pitfalls That Undo Everything
- Single-stage images with the whole repo. Build tools,
.git,.env, and dev dependencies ship to production. - Secrets via
ARG/ENV. They persist indocker historyeven after a laterRUN rm. Use BuildKit--mount=type=secret. --privileged"just to make it work." Grants all capabilities and unconfines the container. Add the one capability you needed instead.- Docker socket mounted for convenience. Root on the host by design; never in an app container.
USERset but not enforced.docker run --user 0overrides it. Enforce withrunAsNonRoot: trueat the platform.- Read-only rootfs with no writable
/tmpor cache. The app crashes at startup and someone "fixes" it by removingread_only. - Floating base image tags. The image you tested is not the image you deploy tomorrow.
npm installin the Dockerfile. Non-deterministic lockfile resolution inside a build that may run with a private-registry token.- No
--ignore-unfixedin CI scanning. The pipeline fails on unfixable CVEs, gets muted, and real findings stop being read. - Signed images with no verification step. A signature nobody checks enforces nothing.
- Memory limit without a V8 heap limit. The cgroup OOM killer terminates the process; the logs show only exit code 137.
- Env-var secrets at runtime.
docker inspectand/proc/<pid>/environexpose them; use mounted secret files. - Publishing the database port "temporarily." Port bindings outlive intentions. Publish only the reverse proxy.
The CTO Checklist
- [ ] Every
FROMis pinned by digest, and a bot opens PRs to update them. - [ ] Runtime images use slim or distroless bases; no compiler,
git,curl, ornpmin the final stage. - [ ] Multi-stage builds: build dependencies and tooling never reach the runtime layer.
- [ ] No secret is passed via
ARGorENV;docker history --no-truncon any shipped image shows nothing sensitive. - [ ]
.dockerignoreexcludes.git,.env*,secrets/, keys, andnode_modules. - [ ] The container runs as non-root with a fixed UID, enforced by
runAsNonRoot: trueat the platform. - [ ] Root filesystem is read-only, with explicit
tmpfsfor/tmpand a volume for the framework cache. - [ ]
cap_drop: ALL,no-new-privileges: true, andseccompProfile: RuntimeDefaulton every workload. - [ ] No
--privileged, no socket mounts, no--pid=host, nonetwork_mode: hostanywhere in the repo — verified by grep in CI. - [ ]
pids_limit, memory limit, and CPU limit set, with--max-old-space-sizebelow the memory limit. - [ ] App and database ports are not published; only the reverse proxy is exposed.
- [ ] CI runs an image scan with
--ignore-unfixed --exit-code 1, publishes an SBOM, and signs the digest with cosign. - [ ] An admission policy refuses unsigned images; deploy references the digest, not a tag.
- [ ] Runtime secrets come from mounted files or an orchestrator secret store, never from image layers or plain env vars.
Conclusion
Hardening a Node.js container is not one heroic change; it is a dozen small, boring defaults applied consistently: a pinned minimal base image, a multi-stage build with BuildKit secret mounts, a non-root user, a read-only filesystem, no capabilities, no socket, enforced limits, and a signed artifact whose contents you can enumerate.
The reason to do it now rather than after an incident is asymmetry. Every one of these controls costs a few lines of Dockerfile and a handful of runtime flags. The alternative — a root process with a writable OS, a compiler, curl, and an environment full of credentials — converts one application vulnerability into a full environment compromise, and turns your incident response into a forensic rebuild of a host you no longer trust.
Start with the highest-value image you run in production. Check who it runs as, whether the root filesystem is writable, what docker history --no-trunc reveals, and whether --privileged appears anywhere in your deploy manifests. Those four answers tell you more about your real risk than any architecture diagram.
Want an experienced pair of eyes on your deployment pipeline? Book a security audit — we review Dockerfiles, runtime flags, CI scanning gates, image signing, and secret handling, and hand you a prioritized list of the paths from a compromised container to your host.
Next week: CI/CD Pipeline Security for JavaScript Repos — OIDC federation instead of long-lived cloud keys, least-privilege workflow tokens, artifact provenance, and why the build runner is the most privileged machine you own.
JS Security Audit
Audits led by a senior JavaScript security engineer with 10+ years of experience.