Getting Docker and npm to work together securely comes down to four decisions: reproducible installs with npm ci, multi-stage builds that leave build tooling out of the final image, running as a non-root user, and scanning the result before it ships. The default npm docker tutorial you find online usually gets an app running but produces an image that is large, non-reproducible, and packed with dev dependencies and OS packages you never needed at runtime. This guide walks through building a Node.js image you can actually defend.
The Problem With the Naive Dockerfile
A typical first attempt looks like this:
FROM node:latest
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "index.js"]
Every line here is a small liability. node:latest is an unpinned, full-fat Debian image with a huge package surface and a tag that changes under you. COPY . . before install destroys layer caching and can copy in .env files, .git, and local node_modules. npm install resolves fresh versions that may not match what you tested. And nothing drops root, so a compromised process runs as UID 0 inside the container. Let us fix each of these.
Reproducible Installs: npm ci, Not npm install
In CI and in Docker, use npm ci rather than npm install. npm ci installs exactly what the lockfile specifies, fails if package.json and package-lock.json disagree, and deletes any existing node_modules first. That is what you want in a build: deterministic, lockfile-driven, no surprise version drift between the image you tested and the image you deploy.
Copy the manifest files first so the install layer caches until your dependencies actually change:
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
--omit=dev keeps devDependencies (test frameworks, bundlers, type tooling) out of the runtime image. Those packages are attack surface you do not need in production, and they inflate the image and its vulnerability count.
Multi-Stage Builds
If you need dev dependencies to build (TypeScript, a bundler) but not to run, use a multi-stage build. Compile in a builder stage, then copy only the artifacts and production dependencies into a slim final stage:
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]
The final image contains your compiled code and production dependencies only. The compiler, the dev tooling, and every intermediate layer stay behind in the builder stage.
Pick a Lean, Pinned Base Image
Pin a specific Node.js major version and prefer a slim variant. node:20-slim is far smaller than the default image, and a smaller base means fewer OS packages and fewer CVEs to triage. Distroless or Alpine-based images shrink the surface further, though Alpine's musl libc occasionally causes native-module headaches worth testing for. Whatever you choose, pin it. node:20-slim is defensible; node:latest is a moving target you cannot reason about.
Drop Root
By default a container process runs as root, so a remote code execution in your app becomes root inside the container, one misconfiguration away from the host. The official Node.js images ship a node user for exactly this reason. Switch to it with USER node after you have installed dependencies and copied files. Make sure the app does not need to write outside directories the node user owns.
Keep Secrets and Junk Out
Add a .dockerignore so build context stays clean and secrets never leak into a layer:
node_modules
.git
.env
*.log
Dockerfile
Never bake secrets in with ENV or ARG. They persist in the image history and anyone who pulls the image can read them. Inject secrets at runtime through your orchestrator or a secrets manager instead.
Scan Before You Ship
A tidy Dockerfile still ships whatever CVEs live in your npm dependencies and your base image OS packages. Scan both. In your pipeline, run a dependency audit and an image scan, and fail the build on severities you have decided not to tolerate:
npm audit --omit=dev --audit-level=high
Image and dependency scanning belong in CI, not as an occasional manual chore. Continuous software composition analysis, whether through a dedicated scanner or a platform like Safeguard, catches a newly disclosed CVE in a base layer or a transitive npm package before it rides an image into production. Pair that with rebuilding images regularly so base-image patches actually reach you; a pinned base is good for reproducibility but you still need to bump the pin when upstream ships fixes.
Putting It Together
A secure docker npm workflow is not exotic. Pin a slim base, install with npm ci --omit=dev, split build and runtime with multi-stage, run as node, keep secrets and cruft out with .dockerignore, and scan in CI. Each step removes surface or removes uncertainty, and together they turn the throwaway tutorial Dockerfile into something you can run in production without flinching.
FAQ
Should I use npm install or npm ci in a Dockerfile?
Use npm ci. It installs exactly what the lockfile pins, fails on any mismatch with package.json, and produces reproducible builds. npm install can resolve different versions than you tested, which is the opposite of what you want in a container image.
How do multi-stage builds improve security?
They keep build-time tooling (compilers, bundlers, devDependencies) out of the final image. Less software in the runtime image means a smaller attack surface and fewer CVEs to triage, plus a smaller image to pull and deploy.
Why run the container as a non-root user?
If your app is compromised, the attacker inherits the container process's privileges. Running as the built-in node user instead of root limits what a successful exploit can do and makes escaping to the host materially harder.
Do I still need to scan if I use a slim base image?
Yes. A slim base reduces OS package count but does not eliminate CVEs, and it says nothing about your npm dependencies. Scan both the image and your dependency tree in CI, and rebuild regularly so base-image patches reach production.