Safeguard
Containers

Node.js Docker Containers: Build Small, Run Safe

A Node.js Docker container that is both small and secure: multi-stage builds, npm ci with a lockfile, non-root users, and why you should not run as PID 1.

Marcus Chen
DevSecOps Engineer
5 min read

A good Node.js Docker container is built in stages, installs dependencies from a lockfile with npm ci, runs as a non-root user, and handles signals correctly so it shuts down cleanly. The one-liner most projects start with, FROM node, COPY ., npm install, CMD npm start, produces a bloated image running as root, with dev dependencies shipped to production and a signal-handling bug that leaves zombie processes. This post fixes each of those in turn.

The Base Image Choice

node:22 is roughly 1.1 GB because it is built on a full Debian userland. node:22-slim trims that to a couple hundred MB by dropping the extras. node:22-alpine is smaller still (musl libc, ~150 MB) but occasionally trips up native modules compiled against glibc. For most services, -slim is the sweet spot: small, glibc-compatible, well supported.

Whatever you pick, pin it. Use a specific minor version and ideally a digest (node:22.5.1-slim@sha256:...) so a base-image update cannot silently change what you ship.

A Multi-Stage Node Dockerfile

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

# ---- build (if you transpile/bundle) ----
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# ---- runtime ----
FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./

USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

The decisions that matter:

  • npm ci, not npm install. npm ci installs exactly what the lockfile specifies and fails if package.json and package-lock.json disagree. npm install can silently resolve to newer versions, which means your image is not reproducible and a compromised or typosquatted update can slip in.
  • --omit=dev for the runtime deps. Test frameworks, linters, and build tools have no business in production; they add size and CVE surface.
  • NODE_ENV=production so frameworks like Express skip dev-only behavior and libraries load their production paths.
  • USER node. The official Node images ship a non-root node user (UID 1000). Switch to it. Do not run your app as root.

Do Not Run Node as PID 1 Naively

A Node.js-specific gotcha. When node runs as PID 1 in a container, it does not get the default signal handlers the kernel gives other processes, and Node does not reap orphaned child processes. The practical symptoms: docker stop takes the full 10-second timeout before SIGKILL because SIGTERM was ignored, and any child processes your app spawns become zombies.

Two fixes. The lightweight one is Docker's built-in init:

docker run --init myorg/app

Or in Compose, init: true. This inserts tini as PID 1 to forward signals and reap children. The other option is to handle SIGTERM and SIGINT explicitly in your app and shut the HTTP server down gracefully, which you should do anyway for clean rolling deploys:

process.on('SIGTERM', () => {
  server.close(() => process.exit(0));
});

Do both: --init for reaping, explicit handlers for graceful shutdown.

A Real .dockerignore

Without it, COPY . . drags your node_modules (rebuilt anyway), .git history, .env files, and local logs into the image. Secrets in .env baked into a layer persist even if a later layer deletes them.

node_modules
npm-debug.log
.git
.env
.env.*
dist
coverage
*.md

The Dependency Problem npm Cannot Solve

Here is the uncomfortable part. Your image can be tiny, non-root, and perfectly built, and still ship a critical vulnerability, because the risk in a Node app is overwhelmingly in node_modules. A typical service has a handful of direct dependencies and hundreds to thousands of transitive ones. The npm ecosystem has a long history of supply chain incidents, from typosquatting to the event-stream and ua-parser-js compromises, where the malicious code arrived through a dependency of a dependency.

npm audit catches known advisories against your installed tree and is worth wiring into CI, though it is noisy and covers only the npm advisory database. For broader coverage and policy control, software composition analysis resolves the full transitive tree from your lockfile, cross-references multiple vulnerability sources, and can fail the build on severity thresholds, the same gate you use for the image scan. Scan both: the base image for OS CVEs, and node_modules for JavaScript library vulnerabilities.

FAQ

Should I use alpine or slim for a Node.js container?

node:*-slim is the safer default: smaller than the full image and glibc-based, so native modules work. -alpine is smaller still but uses musl libc, which can break native dependencies. Use alpine only when image size is critical and you have verified your native modules build against it.

Why should I use npm ci instead of npm install in Docker?

npm ci installs the exact versions in package-lock.json and fails if the lockfile and package.json disagree, giving you reproducible, tamper-resistant builds. npm install can resolve to newer versions, breaking reproducibility and widening the window for a malicious update.

Why does docker stop take ten seconds on my Node container?

Because Node running as PID 1 ignores SIGTERM by default, so Docker waits for the timeout before sending SIGKILL. Run the container with --init (or Compose init: true) and add explicit SIGTERM handling in your app for graceful shutdown.

Does a small Node image mean it is secure?

No. Image size reduces OS-level attack surface, but most Node.js risk lives in node_modules. Scan your dependency tree for known vulnerabilities with npm audit and an SCA tool, in addition to scanning the base image.

Never miss an update

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