A secure Node.js Dockerfile pins its base image to a specific digest, installs dependencies from a lockfile, drops to a non-root user, and uses a multi-stage build so build tooling never ships to production. Most Node.js Dockerfiles you find in tutorials do none of these things, which is how teams end up running containers as root on a latest base image bloated with compilers. This guide walks through a hardened template and explains the reasoning behind each decision so you can adapt it rather than copy it blindly.
Start from a pinned, minimal base image
The base image is the largest single factor in your container's attack surface. Two rules matter most.
First, pick a slim variant. The full node image is built on a full Debian userland with a large package set you will never use in production. The node:22-slim and node:22-alpine variants strip that down dramatically. Alpine is smallest but uses musl libc, which occasionally breaks native modules; the Debian-based slim images are a safer default unless you have tested Alpine with your dependencies.
Second, pin to a digest, not a floating tag. A tag like node:22 moves over time, so a rebuild can silently pull a different image than the one you tested. Pinning by digest makes the build reproducible and stops a mutated upstream tag from entering your pipeline:
FROM node:22-slim@sha256:<digest>
You still need to rotate that digest deliberately when you patch, but that is the point — updates become an explicit, reviewable change rather than an accident.
Use a multi-stage build
Build-time needs and runtime needs are different. Compiling native modules or a TypeScript project requires toolchains that have no business being in the shipped image, where they only add vulnerable packages an attacker could use. Multi-stage builds solve this cleanly:
# ---- build stage ----
FROM node:22-slim@sha256:<digest> AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- runtime stage ----
FROM node:22-slim@sha256:<digest> AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
The runtime stage carries only production dependencies and the compiled output. Devy tooling, source, and the full dependency tree stay behind in the build stage and never ship.
Install from the lockfile with npm ci
Use npm ci, not npm install, in the image. npm ci installs exactly what the lockfile specifies and fails if package.json and the lockfile disagree, which gives you reproducible builds and stops a drifting or tampered transitive dependency from sneaking in during a rebuild. Pair it with --omit=dev in the runtime stage so devDependencies never land in production. The known-vulnerable package you do not install cannot hurt you.
Run as a non-root user
By default containers run as root, and a process that is compromised as root inside the container has a much easier path to escaping it or abusing mounted resources. The official Node images ship a pre-created node user, so dropping privilege is one line:
USER node
Make sure any files the process needs to write are owned by or writable to that user, and that the working directory is set up before the USER switch. Never run production Node containers as root without a specific, documented reason.
Keep secrets and context out of the image
Two common leaks. First, a missing .dockerignore lets COPY . . pull your .git directory, local .env files, and node_modules into the image — sometimes baking credentials right into a layer. Add a .dockerignore:
node_modules
npm-debug.log
.git
.env
Dockerfile
Second, never pass secrets with ENV or ARG, because they persist in the image history and anyone who pulls the image can read them. Use BuildKit secret mounts for build-time credentials and inject runtime secrets through the orchestrator's secret mechanism, not the image.
Scan the image, and keep scanning it
A hardened Dockerfile reduces your starting exposure, but base images accumulate CVEs over time as new vulnerabilities are disclosed in packages you froze months ago. Two scanning habits close that gap. Scan at build time in CI so a newly introduced vulnerable layer fails the pipeline, and scan published images on a schedule so you learn when a previously clean image has aged into a known-vulnerable one. A container-aware scanner reports both operating-system packages from the base image and the Node dependencies you added — the two live in different layers and both matter. Our SCA overview covers how dependency scanning fits alongside image scanning.
A note on image size and rebuild cadence
Order your COPY and RUN steps so that dependency installation is cached separately from source changes — copy package.json and the lockfile, install, then copy the rest. This keeps rebuilds fast and, more importantly, means a routine base-image patch does not force a full reinstall you might be tempted to skip. Fast, painless rebuilds are what make it realistic to rebuild often, and rebuilding often is how you actually pick up upstream security fixes. The Safeguard Academy has more on container supply chain hygiene.
FAQ
What base image should a Node.js Dockerfile use?
Prefer a slim variant such as node:22-slim for a good balance of size and compatibility, or node:22-alpine if you have verified your native modules work under musl. Pin the image to a specific digest rather than a floating tag so builds are reproducible and a mutated upstream tag cannot enter your pipeline.
Why should a Node.js container not run as root?
A process compromised while running as root inside the container has a far easier path to escaping isolation or abusing mounted volumes. The official Node images include a node user, so adding USER node before the CMD drops privilege with almost no effort.
What is the benefit of a multi-stage build for Node.js?
It separates build tooling from the runtime image. Compilers, dev dependencies, and source stay in the build stage, while the final image ships only production dependencies and compiled output. That shrinks both the image size and the attack surface.
Should I use npm install or npm ci in a Dockerfile?
Use npm ci. It installs exactly what the lockfile pins and fails on any mismatch with package.json, giving reproducible builds and blocking drifting or tampered dependencies. Add --omit=dev in the runtime stage so devDependencies stay out of production.