Safeguard
Container Security

How to Containerize a Node.js App Securely

The default Node.js Dockerfile runs as root, ships dev dependencies, and bakes secrets into layers. Here is a secure, multi-stage build you can copy, step by step.

Marcus Chen
Cloud Security Engineer
6 min read

The default Node.js Dockerfile that circulates in tutorials is a security liability. It starts from a full node:latest image, runs npm install as root, copies your entire working directory — .env files and .git history included — and ships development dependencies straight to production. Datadog's 2024 State of DevSecOps report found that most container images in production still run as root, and Sysdig has repeatedly measured that the majority carry high- or critical-severity vulnerabilities in packages the application never calls. For a Node.js service the blast radius is real: a process running as root inside a container is one runc breakout (CVE-2024-21626, disclosed January 2024) away from the host, and every unused OS package is another CVE your team has to triage. Containerizing a Node.js app securely is not about writing a longer Dockerfile — it is about a deliberately smaller one. Here is the build, step by step.

Step 1: Start from a minimal, pinned base image

Pin the base image by digest instead of a mutable tag, so a silently updated or compromised upstream tag cannot change what you ship. A -slim Debian variant is a reasonable build base; a distroless image is the ideal runtime base because it has no shell or package manager for an attacker to use after a code-execution bug.

# Build stage can use slim; runtime stage should be distroless
FROM node:22-bookworm-slim@sha256:1a2b3c4d5e6f AS base

Step 2: Use a multi-stage build to drop the toolchain

A multi-stage build compiles in a full-featured stage, then copies only the runtime artifact into a clean final stage. Build tools, dev dependencies, and any secret mounted during the build never reach the shipped image.

# ---- deps: production dependencies only ----
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# ---- build: full deps + compile ----
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# ---- runtime: distroless, non-root ----
FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runtime
WORKDIR /app
COPY --from=deps  /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["dist/server.js"]

Step 3: Never run as root

The distroless :nonroot tag above already runs as UID 65532. On a Debian or Alpine base you must set the user yourself, and you should do it explicitly rather than trusting the orchestrator to override it.

# On a slim/alpine base, create and switch to a non-root user
RUN groupadd --system app && useradd --system --gid app --uid 10001 app
USER 10001:10001

Step 4: Keep secrets and junk out of the layers

Add a .dockerignore so build context never carries credentials or local cruft. Docker layers are cacheable and extractable, so anything you COPY in — even if a later layer deletes it — can be recovered with docker history or by unpacking the layer tarball.

# .dockerignore
.env
.git
node_modules
npm-debug.log
Dockerfile
.dockerignore

When a private registry token is genuinely needed at build time, mount it with BuildKit so it is available to the command but never written to a layer.

DOCKER_BUILDKIT=1 docker build \
  --secret id=npmrc,src=$HOME/.npmrc \
  -t myapp:secure .
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --omit=dev

Step 5: Scan the lockfile and the image before you push

Application CVEs in npm packages ship inside the image regardless of how minimal the base is — Log4Shell (CVE-2021-44228) rode into production the same way. Gate the build on a scan of both the dependency tree and the finished image.

# Fail CI on new, fixable critical findings
safeguard scan --image myapp:secure --fail-on critical

Secure Node.js container checklist

ControlInsecure defaultSecure setting
Base imagenode:latestnode:22-*-slim pinned by digest, distroless runtime
Build modelsingle stagemulti-stage, artifact-only final stage
Runtime userroot (UID 0)non-root (USER 10001 or :nonroot)
Dependenciesnpm install (all)npm ci --omit=dev
Secretsbuild args / COPY .envBuildKit --mount=type=secret
Build contextwhole repotrimmed by .dockerignore
Gatenoneimage + lockfile scan in CI

How Safeguard scans your Node.js images

Safeguard scans the image and its package-lock.json together, reading language manifests directly from the filesystem layers so it works even against distroless images that have no package database to query. Rather than returning every CVE in your dependency tree, Safeguard runs reachability analysis against your service's actual call graph, so the findings that reach your queue are the ones an attacker could trigger from code you run — typically a fraction of the raw scanner output. It generates a CycloneDX SBOM for every build and, when a fix exists, opens an auto-fix pull request with the minimal version bump. You can run the same engine locally or in CI with the Safeguard CLI, wire it into pull requests through Secure Containers, and cover your npm dependency tree with software composition analysis. If you are weighing this against a standalone open-source scanner, the Safeguard vs Trivy comparison lays out the difference between raw detection and prioritized remediation.

Frequently Asked Questions

Should I use Alpine or a slim Debian base for a Node.js image?

Either can be secure. Alpine is smaller and, because it uses musl libc, avoids some glibc-specific CVEs, but native npm modules occasionally fail to compile against musl. Debian slim is more compatible and still small. Whichever you pick, use it only as a build stage and ship a distroless runtime stage where possible.

Do I still need to scan if I use a distroless base?

Yes. Distroless removes OS-package CVEs, not the vulnerabilities in your npm dependencies, which live in the application layer no matter what base you choose. You always scan the dependency tree; the base image only changes how many OS-level findings you start with.

How do I run database migrations without a shell in a distroless image?

Run migrations as a separate short-lived job or init container that uses a fuller image, keeping the long-running service on distroless. Do not add a shell back to the runtime image just to run occasional scripts — that reintroduces the exact attack surface distroless removes.

Is npm ci really safer than npm install?

npm ci installs exactly what the lockfile pins and fails if package.json and package-lock.json disagree, which makes builds reproducible and blocks a silently drifted or tampered dependency from slipping in. Combined with --omit=dev, it also keeps build-only packages out of the runtime image.

Ready to containerize securely? Connect a repository at app.safeguard.sh/register, and see setup steps for image and lockfile scanning in the documentation at docs.safeguard.sh.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.