On September 8, 2025, attackers who had compromised maintainer npm accounts published malicious versions of roughly 18 widely used packages — including foundational ones like chalk and debug — with obfuscated code that intercepted crypto wallet transactions in the browser. Any Node.js image built with a floating npm install during that window could have pulled a poisoned version straight into a production container. That incident is a useful stress test for how most teams still write their Node.js Dockerfiles: a single FROM node:20, an npm install, a COPY . ., and a container that runs as root because nobody added a USER line. None of that is new advice, but the details of doing it correctly have shifted — the official node image has shipped a built-in unprivileged user for years, yet COPY still writes files as root unless you tell it otherwise, which means most "non-root" Node containers aren't actually non-root at the filesystem layer. This post walks through the current best-practice Dockerfile for a Node.js web app: multi-stage builds, npm ci over npm install, ownership-aware COPY --chown, health checks, and where supply-chain scanning fits into the build.
Why does a Dockerfile with USER node still run as root?
Because USER node only changes which user the container process runs as — it does nothing to the ownership of files already copied into the image. The official node Docker image (and its -alpine and -slim variants) has shipped a built-in, least-privileged user named node for years specifically so teams don't have to create one themselves, as documented in the nodejs/docker-node project on GitHub. But a plain COPY . . instruction runs as whatever user is active at that point in the build — root, by default — so every file lands in the image owned by root, with root-only write permissions in the common case. Adding USER node afterward switches the runtime user, but the app process now runs as an unprivileged user against files it may not even be able to write to, or worse, the switch is silently ineffective if a later layer resets the user. The fix is COPY --chown=node:node on every copy instruction that lands application files, or an explicit RUN chown -R node:node /app before the USER node line — either way, ownership has to be set deliberately, not assumed.
What does a multi-stage build actually remove from the final image?
A multi-stage build removes everything the running application doesn't need: devDependencies, compilers, bundlers, and source-control metadata that a single-stage build bakes in by default. The pattern uses a build stage — typically also FROM node:20-alpine — that runs a full npm ci (including devDependencies), executes a TypeScript compile or bundler step, and produces a dist/ directory or compiled output. The final stage then starts fresh from a clean base image and uses COPY --from=build /app/dist ./dist (and a production-only node_modules) to carry over only what's needed to run. This matters for more than image size: a TypeScript compiler, a bundler's plugin ecosystem, and dozens of devDependencies are all attack surface and CVE exposure that has no business existing in a running production container. If a devDependency is later found to contain a vulnerability or malicious code, a properly multi-staged image was never exposed to it at runtime in the first place.
Why does npm ci matter more than npm install in a build stage?
npm ci matters because it installs exactly what package-lock.json specifies — no version resolution, no silent upgrades — which makes builds reproducible and closes a window that floating installs leave open. npm install will happily resolve a dependency range like ^4.1.0 to whatever the latest matching version is at build time, which means the same Dockerfile can produce a different image tomorrow than it does today. npm ci refuses to run if package.json and package-lock.json are out of sync, and it installs strictly from the lockfile, deleting node_modules first to guarantee a clean, deterministic tree. Pairing it with --omit=dev in the production stage skips devDependencies entirely. This determinism is also npm's own documented recommendation for CI and automated environments, and it's the direct defense against exactly the kind of incident the September 2025 npm compromise represented: a pinned lockfile means a build doesn't silently pick up a freshly published, compromised patch version of a transitive dependency.
How should a container defend against a compromised npm package after the fact?
By treating the lockfile as the source of truth and scanning it continuously, not just at build time — because a package that was clean when you built the image can be found malicious afterward. The September 2025 npm account-compromise incident affected packages already sitting in production node_modules trees across the ecosystem; teams that had pinned exact versions via npm ci and a committed lockfile could immediately query which of their images were exposed, while teams on floating installs had no reliable record of what version they'd actually shipped. Generating a Software Bill of Materials (SBOM) in CycloneDX or SPDX format at build time — listing every resolved dependency version — turns that lookup from a grep-and-hope exercise into a query. It also gives you the artifact needed to answer "are we affected" within minutes of a new advisory, rather than re-resolving dependency trees across every running image.
What else belongs in a production-ready Node.js Dockerfile?
A HEALTHCHECK instruction and a patched, current base image tag. HEALTHCHECK lets an orchestrator like Docker Swarm or a Kubernetes readiness probe detect a Node process that's running but unresponsive — for example, hitting GET /healthz on an interval and marking the container unhealthy after repeated failures, so it gets restarted or removed from a load balancer's rotation instead of silently serving errors. Base image freshness matters too: node:20-alpine today isn't the same bytes as node:20-alpine six months ago, since Alpine and Node.js both ship security patches into the same tag, but a cached local image or a pinned digest won't pick those up automatically. It's also worth remembering that container risk isn't confined to the app image — Docker Engine and Compose have had their own recent CVEs, including CVE-2025-9074 (a Docker Desktop flaw allowing container access to the Engine API) and CVE-2025-62725 (a Docker Compose path-traversal issue rated CVSS 8.9), which are reminders to keep the build tooling itself patched, separately from anything inside the Dockerfile.
How Safeguard Helps
Safeguard's self-healing containers capability watches exactly the layers this post covers — the base image, the resolved node_modules tree, and the lockfile — for newly disclosed CVEs, and can rebuild and redeploy a patched image automatically, with a median time-to-heal of 20-45 minutes from CVE publication to production rollout across customer tenants. When a FROM node:20-alpine base picks up a new libcrypto CVE, or a transitive dependency inside node_modules is flagged after the fact — the same scenario the September 2025 npm compromise created — Safeguard resolves the lockfile to the minimum safe version, rebuilds, runs your existing test suite, and writes a lockfile diff into the image as a provenance attestation before promoting it. Every self-healed image also carries an SBOM attestation and a plain-English Griffin explanation of what changed, so a multi-stage, non-root Dockerfile you write correctly today stays correct as the packages inside it change underneath you.