Skip to content
Node.js7 min read

Dockerizing Node.js Applications Properly

Multi-stage builds, layer caching, non-root users and signal handling — the difference between a Dockerfile that works and one that belongs in production.

Most Node Dockerfiles are copied from a tutorial, work on the first try, and quietly carry four or five problems into production: gigabyte images, cache-busting layer ordering, processes running as root, and containers that ignore shutdown signals. Each fix is small; together they are the difference between a container that runs and one you would defend in a security review.

A multi-stage build worth copying

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

FROM node:22-bookworm-slim
ENV NODE_ENV=production
WORKDIR /app
USER node
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
CMD ["node", "dist/server.js"]

The ordering is deliberate. Copying the manifests and running npm ci before copying source means the dependency layer is rebuilt only when the lockfile changes, not on every code edit — the single biggest CI speed win available. The second stage carries only production dependencies and build output, which typically shrinks the image by hundreds of megabytes and, more importantly, removes compilers and dev tooling from the attack surface.

Run as node, not root

The official images ship a non-root node user, and one USER line adopts it. A container escape from a root process is a far worse day than one from an unprivileged user, and every serious platform team will flag root containers on review. The only friction is file ownership, which the --chown flags on COPY solve at build time rather than with a slow recursive chown layer.

Signals: the bug everyone ships once

Write CMD in exec form, as in the example, so node is PID 1 and receives SIGTERM directly. The shell form — CMD node dist/server.js — wraps your process in a shell that swallows signals, which means every stop waits out the full grace period and then kills your app mid-request. Pair this with a real SIGTERM handler in the application, and never launch via npm start in a container: npm is another signal-mangling layer you do not need.

Details that separate fine from good

  • Write a thorough .dockerignore — node_modules, .git, .env, test artefacts — or your build context uploads all of it.
  • Pin the base image to a major version at least; node:latest in production is a time bomb with a friendly name.
  • Set NODE_ENV=production in the image so no orchestrator forgets it.
  • Prefer HEALTHCHECK in orchestrators that honour it, or wire the equivalent probe in Kubernetes.
  • Let the platform restart crashed containers; process managers like pm2 inside a container duplicate the orchestrator badly.

Scanning belongs in the pipeline as well: trivy or docker scout against the built image catches vulnerable OS packages that npm audit never sees, and slim base images keep the finding count reviewable rather than overwhelming. Distroless images push further by dropping the shell and package manager entirely — the trade-off is harder debugging, so decide per service rather than by decree. While you are in the CI file, BuildKit cache mounts on the npm ci step stop even lockfile changes from hurting.

One habit ties it together: run the production image locally before every significant release. docker run with production env vars, hit the health endpoint, send a SIGTERM and watch it drain. Thirty seconds of ritual catches the class of bug that only exists inside the container.

A good Node Dockerfile is finished early and then barely changes. Spend the afternoon once, and deployments become the least interesting part of your week — which is exactly what they should be.

Need a hand getting your Node estate container-ready? This is the kind of groundwork STRCLI does well.

Start your project

Have an idea? Let's ship it together.

Tell us what you're building — we'll reply within one business day with an honest take and a clear next step.