Safeguard
Containers

How to Write a Secure Node.js Dockerfile

A hardened Node.js Dockerfile starts with a pinned base image, a non-root user, and a multi-stage build. Here is how to write one that survives a real security review.

Karan Patel
Platform Engineer
6 min read

A secure Node.js Dockerfile pins its base image by digest, drops to a non-root user, and uses a multi-stage build so build tooling never ships to production. Most of the images I review fail on at least one of those three, and the fix is usually a dozen lines. This guide walks through a Dockerfile you can actually copy, then explains why each line earns its place.

Start from a pinned, minimal base

The single biggest lever on your attack surface is the base image. node:20 pulls the full Debian-based image with a compiler toolchain, apt, and hundreds of OS packages you will never call. Every one of those is a line item a scanner will flag. The node:20-slim and node:20-alpine variants strip most of that away.

# Bad: floating tag, huge surface
FROM node:20

# Better: slim variant
FROM node:20-slim

# Best: pinned by digest for reproducibility
FROM node:20.15.1-slim@sha256:...

Pinning by digest matters because a mutable tag like node:20-slim points at a different image next week. If you want reproducible builds and a stable vulnerability baseline, pin the digest and bump it deliberately. Alpine images are smaller still, but they use musl libc, which occasionally breaks native modules like bcrypt or sharp. Test before you commit to Alpine.

Use a multi-stage build

Your production image should contain the built application and its runtime dependencies. Nothing else. A multi-stage build gives you a fat "builder" stage with dev dependencies and compilers, then copies only the artifacts into a lean final stage.

# ---- builder ----
FROM node:20.15.1-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

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

The npm ci command installs exactly what the lockfile specifies, which is what you want in CI. It fails loudly if package.json and package-lock.json drift apart, unlike npm install, which will silently rewrite the lockfile.

Drop root privileges

By default, containers run as root. If an attacker finds a remote code execution bug in one of your dependencies, running as root hands them a much larger blast radius. The official Node images ship with a pre-created node user (UID 1000) precisely so you can switch to it.

COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist
USER node

Set USER node after all the COPY and RUN steps that need write access to system paths. If your app needs to bind to a low port, do not run as root to get it. Bind to 3000 inside the container and map it at the host or load balancer.

Handle signals and PID 1 correctly

Node was not designed to run as PID 1. When Docker sends SIGTERM on shutdown, a Node process at PID 1 may ignore it, and orphaned child processes are not reaped. That leads to slow shutdowns and zombie processes. Add a tiny init like tini, or use the --init flag at runtime.

# In the Dockerfile
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "dist/server.js"]

Avoid wrapping your start command in npm start. That adds an extra process layer that swallows signals and adds nothing. Call node directly.

Keep secrets and junk out of the image

A .dockerignore file is not optional. Without one, COPY . . will happily bake your .env, .git history, local node_modules, and CI tokens into a layer that anyone who pulls the image can extract.

node_modules
npm-debug.log
.git
.env
.env.*
Dockerfile
.dockerignore
coverage
*.md

Never use ARG or ENV to pass a real secret at build time. Build args are visible in the image history with docker history, and environment variables persist in every layer. For build-time secrets, use BuildKit secret mounts:

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci

Scan the image you actually ship

A clean Dockerfile does not mean a clean image. The base image and your transitive dependency tree carry known CVEs that appear after your build. Scan the final image in CI and set a policy gate that fails the build on new critical findings.

# Grype is a fast open-source option
grype your-registry/app:latest --fail-on high

# Or check dependencies before the build
npm audit --omit=dev

Container scanning catches the OS-layer CVEs in your base image, while software composition analysis catches the npm dependency tree. An SCA tool such as Safeguard can flag a vulnerable transitive package that npm audit misses because it resolves the full lockfile graph. Rebuilding weekly against a freshly pulled base image is the cheapest way to stay ahead of newly disclosed base-layer CVEs. If you are comparing tooling, our breakdown of scanning approaches covers the tradeoffs.

Put it together

Here is the full Dockerfile with everything above applied:

# ---- builder ----
FROM node:20.15.1-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

# ---- runtime ----
FROM node:20.15.1-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y --no-install-recommends tini \
    && rm -rf /var/lib/apt/lists/*
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/package.json ./
USER node
EXPOSE 3000
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "dist/server.js"]

That is roughly 20 lines and it addresses base image bloat, build/runtime separation, privilege dropping, signal handling, and secret hygiene. Ship it, scan it, and rebuild it on a schedule.

FAQ

Should I use Alpine or slim for a Node.js Dockerfile?

Slim (Debian-based) is the safer default because it uses glibc, so native modules compile without surprises. Choose Alpine only when image size is critical and you have verified your native dependencies build against musl.

Why does my Node.js container ignore Ctrl-C or take 10 seconds to stop?

Node running as PID 1 does not handle SIGTERM by default. Add tini as the entrypoint or run the container with docker run --init so signals are forwarded and child processes are reaped.

Is npm ci really better than npm install in a Dockerfile?

Yes. npm ci installs strictly from the lockfile and errors if it is out of sync, giving reproducible builds. npm install can mutate the lockfile mid-build, which undermines the deterministic images you want in CI.

How often should I rebuild my Node.js image?

Rebuild at least weekly against a freshly pulled base image, and on every dependency change. Newly disclosed CVEs in the base OS layer only disappear from your image when you rebuild, even if your own code has not changed.

Never miss an update

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