Back to Blog
DockerContainer SecurityNode.jsNext.jsSupply ChainDevSecOpsDockerfile

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:22 and started with docker run runs as root, with a writable root filesystem, a default capability set, and ptrace-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.

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>

Three decisions matter here:

  1. Tag → digest. node:22-bookworm-slim is 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 every FROM, including FROM alpine inside a multi-stage build.
  2. Full → slim → distroless. The full node:22 image ships compilers, git, Python, and build headers you do not need at runtime. Every one of those packages is CVE surface, and the compiler plus curl is a ready-made "download and run stage two" toolkit for an attacker who already has RCE.
  3. 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, some node-gyp builds) and occasionally slower crashes. Alpine's smaller package set is a real win; its smaller packages are not. If you use it, use node:22-alpine and verify your native dependencies build and run under musl.

Measure what you ship rather than trusting intuition:

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

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"]

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:

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

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

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

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

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

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

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

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_modules or the app directory is owned by root, the non-root user cannot read it after you harden permissions. Use --chown on every COPY in the final stage.
  • Don't rely on USER alone. USER is a default, not a boundary — docker run --user 0 overrides it. Enforce it at the platform: Kubernetes runAsNonRoot: true (which fails the pod if the image wants root), or Compose user: plus a policy check in CI.
  • Prefer a random high UID. runAsUser: 10001 with runAsNonRoot: true avoids 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.

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

The Docker CLI equivalent:

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>

Why each flag earns its place:

  • read_only: true — the container cannot modify its own binaries or drop a webshell into a writable webroot.
  • tmpfs for /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 removes CAP_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 is SIGKILLed, 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:

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

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

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:

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

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:

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

  • npm present 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_ENV unset. Without NODE_ENV=production, frameworks enable verbose error pages, keep dev middleware, and skip production optimizations.
  • Source maps and .ts sources 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_modules including dev dependencies. npm ci --omit=dev in 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 1 plus cluster sized to 1–2 workers), otherwise the container fights its own cgroup for CPU time.
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

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 in docker history even after a later RUN 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.
  • USER set but not enforced. docker run --user 0 overrides it. Enforce with runAsNonRoot: true at the platform.
  • Read-only rootfs with no writable /tmp or cache. The app crashes at startup and someone "fixes" it by removing read_only.
  • Floating base image tags. The image you tested is not the image you deploy tomorrow.
  • npm install in the Dockerfile. Non-deterministic lockfile resolution inside a build that may run with a private-registry token.
  • No --ignore-unfixed in 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 inspect and /proc/<pid>/environ expose them; use mounted secret files.
  • Publishing the database port "temporarily." Port bindings outlive intentions. Publish only the reverse proxy.

The CTO Checklist

  • [ ] Every FROM is pinned by digest, and a bot opens PRs to update them.
  • [ ] Runtime images use slim or distroless bases; no compiler, git, curl, or npm in the final stage.
  • [ ] Multi-stage builds: build dependencies and tooling never reach the runtime layer.
  • [ ] No secret is passed via ARG or ENV; docker history --no-trunc on any shipped image shows nothing sensitive.
  • [ ] .dockerignore excludes .git, .env*, secrets/, keys, and node_modules.
  • [ ] The container runs as non-root with a fixed UID, enforced by runAsNonRoot: true at the platform.
  • [ ] Root filesystem is read-only, with explicit tmpfs for /tmp and a volume for the framework cache.
  • [ ] cap_drop: ALL, no-new-privileges: true, and seccompProfile: RuntimeDefault on every workload.
  • [ ] No --privileged, no socket mounts, no --pid=host, no network_mode: host anywhere in the repo — verified by grep in CI.
  • [ ] pids_limit, memory limit, and CPU limit set, with --max-old-space-size below 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.

Share this article:TwitterLinkedIn
JS

JS Security Audit

Audits led by a senior JavaScript security engineer with 10+ years of experience.