Safeguard
Software Supply Chain•

How do you containerize a Node.js web app with Docker safely?

Use multi-stage builds, a small pinned base image, a non-root user, direct node startup and regular rebuilds. Docker and Node.js maintainers agree on this.

Safeguard Security Team
5 min read

To containerize a Node.js web app safely, build in stages so the final image has only what runs, pin a small trusted base image, run as the non-root node user, start the process directly with node, exclude junk with a dockerignore file, and rebuild often so patches arrive. These come from Docker's and the Node.js image maintainers' own guidance.

Why use a multi-stage build?

Docker's building best practices say multi-stage builds reduce the size of the final image by separating building from the final output. Split the Dockerfile into stages so the result contains only the files needed at runtime.

For Node.js that means compilers, dev dependencies and source maps stay in the build stage. The runtime stage gets production dependencies and built code. The Node.js image maintainers describe the same idea for native add-ons: install the build toolchain in one stage, compile, and copy only the built modules into the final stage, so the toolchain is not shipped.

A smaller image also has a smaller attack surface, since fewer packages means fewer places for a vulnerability to sit.

How do you pick and pin the base image?

Docker advises starting from a trusted source and keeping the image small. It names Docker Official Images and Verified Publisher images as curated starting points.

Then pin. Docker explains that image tags are mutable: a publisher can point a tag at a new image. FROM alpine:3.21 resolves to the latest patch of 3.21, which is convenient but means your build can change without any edit from you. Pinning to a digest makes builds repeatable, at the cost of updating the pin on purpose. Pair pinning with regular rebuilds.

Why run as a non-root user?

By default containers run as root. The Node.js maintainers note this violates the principle of least privilege when superuser rights are not needed, and that the official node images include a node user with uid 1000 for this purpose. Docker's guide likewise says to use USER to switch to a non-root user if the service can run without privileges, and suggests an explicit UID and GID because automatically assigned ones can change between builds.

FROM node:22-slim AS runtime
WORKDIR /home/node/app
COPY --chown=node:node . .
USER node
CMD ["node", "index.js"]

This is a constructed sketch; choose a node version that is currently supported.

What about signals, memory and startup?

The Node.js image guidance has several practical points:

  • Set NODE_ENV to production.
  • Node.js was not designed to run as PID 1, so a process there may ignore SIGINT and similar signals. Use the init option of docker run, or include a small init program such as Tini.
  • Start with CMD set to node and your file, not npm start, so SIGTERM and SIGINT reach Node.js instead of being swallowed by npm.
  • Limit memory for containers sharing a host, since by default a container can use as much CPU and RAM as the host allows.

Clean shutdown is a reliability feature and a security one: stale containers that hang on shutdown stay up and keep old code running.

What should stay out of the image?

Docker recommends a .dockerignore file to exclude files not relevant to the build, using patterns similar to a .gitignore file. Its own example excludes every Markdown file with the pattern *.md. Add local environment files and the git directory to your own list. Never copy secrets into an image layer. Docker's guide shows that setting and then unsetting an environment variable in separate steps still leaves the value in an earlier layer, so use build secrets or runtime configuration instead.

Also avoid installing unnecessary packages. Docker's ephemeral container advice is that a container should be easy to stop, destroy, rebuild and replace with minimal setup.

How do you keep the image patched?

Images are snapshots. Docker says to rebuild regularly to pick up updated dependencies, and to use the pull option so the build fetches a fresh base image instead of a cached one. The Node.js maintainers also point to docker-bench-security, a tool that checks running containers for common security issues.

Do not forget the packages inside your app. A tidy image can still carry a vulnerable npm dependency. Safeguard's CLI and Gold's free in-browser scan at gold.safeguard.sh/scan can check a project's dependencies, up to 100 in the browser scan.

How to check or apply this

  1. Use a multi-stage Dockerfile, with production dependencies only in the last stage.
  2. Choose a small, trusted base image and pin its version or digest.
  3. Add USER node (or your own non-root user) before CMD.
  4. Use CMD with node directly, set NODE_ENV to production and run with an init process.
  5. Add a .dockerignore, and keep secrets out of build layers.
  6. Set memory limits when running.
  7. Rebuild on a schedule with fresh base images, and scan both the image and the npm dependencies.

Quick check

Is the node user created in every Node.js image?

The official Node.js images provide a node user with uid 1000 for this purpose, according to the image maintainers. Check the tag you use.

Why not start with npm start?

npm can swallow termination signals. Starting node directly lets SIGTERM and SIGINT reach your process, so it shuts down cleanly.

Sources

Last reviewed: 18 October 2026.

Never miss an update

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

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.