Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
SaaS and CloudDevOpsJavaScriptNode.jsTutorial

How to write a production Dockerfile for a Node.js 24 app

A step-by-step Node.js 24 Dockerfile: multi-stage build, npm ci with a cache mount, a non-root user, a curl-free health check and graceful SIGTERM handling, with the reasoning behind every line.

Cloudsoft Zone

Updated 9 min read

Stacked shipping containers and gantry cranes at the container terminal in Bremerhaven, Germany
Photograph: H. Zell / CC BY-SA 3.0, via Wikimedia Commons
In this article

Most Node.js container images that cause trouble in production were not written badly on purpose. They started as a five-line Dockerfile that copied the whole project, ran npm install and started the app with npm start. That works on a laptop. In production it ships development dependencies and build tools, runs as root, ignores the stop signal from the orchestrator and gives the platform no way to tell whether the app is actually healthy.

This tutorial builds a production Dockerfile for a Node.js 24 web service step by step. Node.js 24 ("Krypton") is the current Active LTS line; the Node.js release schedule moves it to maintenance on 20 October 2026, and Node.js 26 is scheduled to become LTS on 28 October 2026. Everything below works the same on 26 once you change the base image tag.

You will end up with a multi-stage build, a small runtime image that runs as an unprivileged user, a health check that needs no extra packages, and an application that shuts down cleanly when Docker, Kubernetes or a PaaS asks it to stop.

Container cranes and rows of stacked containers at Seaforth Container Terminal in Liverpool
A good image is like a well-packed container: only what the journey needs, sealed and labelled.

The example is a small HTTP service with a TypeScript or bundler build step that outputs JavaScript to dist/. If your project has no build step, delete the build stage and copy your source directly; the rest of the advice is unchanged. The project is assumed to have a committed package-lock.json and "type": "module" in package.json.

GoalHow the Dockerfile achieves it
Reproducible installsnpm ci against the lockfile, with a pinned base image tag
Small runtime imageMulti-stage build; only production dependencies and compiled output reach the final stage
Least privilegeRuns as the built-in node user; application files stay owned by root
Clean shutdownExec-form CMD, a SIGTERM handler and an init process
Observable healthHEALTHCHECK that calls a /healthz endpoint with Node's built-in fetch

Step 1: Choose and pin the base image

The official node image on Docker Hub publishes several variants for the 24 line, including 24-bookworm-slim, 24-trixie-slim and 24-alpine, and fully pinned tags such as 24.21.0-bookworm-slim. The slim Debian variants are a sensible default: they are much smaller than the full image but still use glibc, so native modules built for most Linux distributions behave as expected. Alpine is smaller again but uses musl, which occasionally trips up native dependencies.

Use at least a major-plus-variant tag such as node:24-bookworm-slim so you never jump a major version by accident. Docker's own build best-practices guide goes further and recommends pinning images to a digest for supply-chain safety, with a tool such as Dependabot opening pull requests when a new digest is published. A digest pin looks like node:24-bookworm-slim@sha256:...; get the current value with docker buildx imagetools inspect node:24-bookworm-slim.

Step 2: Keep the build context clean

Before writing the Dockerfile, add a .dockerignore. It keeps local node_modules, build output, Git history and secrets out of the build context, which makes builds faster and stops a stray .env file from being baked into an image layer.

Text
node_modules
dist
.git
.gitignore
.env
.env.*
npm-debug.log*
coverage
Dockerfile
.dockerignore

Step 3: Write the multi-stage Dockerfile

Docker's documentation describes multi-stage builds as a way to make sure the final image "only contains the files that are needed to run the application". Here we use three stages: one installs production dependencies only, one installs everything and runs the build, and the last assembles the runtime image from the other two.

Dockerfile
# syntax=docker/dockerfile:1
# 1. Production dependencies only
FROM node:24-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev
# 2. Full install and build
FROM node:24-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci
COPY . .
RUN npm run build
# 3. Runtime image
FROM node:24-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json ./
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
  CMD ["node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
CMD ["node", "dist/server.js"]

Why each line is there

  • # syntax=docker/dockerfile:1 asks BuildKit to use the latest stable Dockerfile frontend, which provides RUN --mount.
  • Copying package.json and package-lock.json before the source means the expensive install layer is reused until your dependencies change, not every time you edit a file.
  • RUN --mount=type=cache,target=/root/.npm keeps npm's download cache between builds without storing it in an image layer. The Dockerfile reference lists target as the only required option; id defaults to the target path and sharing defaults to shared.
  • npm ci --omit=dev installs exactly what the lockfile says and skips devDependencies, so compilers, test runners and linters never reach production.
  • ENV NODE_ENV=production is the setting the Node.js Docker image maintainers recommend; many libraries switch off development-only behaviour when they see it.
  • Plain COPY without --chown leaves application files owned by root (the Dockerfile reference notes files are created with UID and GID 0 by default). The process runs as node, so it can read its code but cannot overwrite it if it is compromised. Only add --chown=node:node to a directory the app genuinely writes to.
  • USER node uses the unprivileged user (UID 1000) that ships in the official image. The image's best-practices guide points out that running as root "violates the Principle of Least Privilege".
  • Exec-form CMD ["node", "dist/server.js"] starts Node directly. Starting through npm start adds a wrapper process, and the Node.js image documentation recommends calling node directly so that SIGTERM and SIGINT reach your process.

Step 4: Add a health endpoint and graceful shutdown

The HEALTHCHECK above calls /healthz with the fetch function that is built into Node.js, so the slim image does not need curl or wget. Docker treats exit code 0 as healthy and 1 as unhealthy; code 2 is reserved. Without explicit options Docker would check every 30 seconds with a 30-second timeout and three retries, and --start-period defaults to zero, so a slow-starting app could be marked unhealthy before it is ready. That is why the example sets a 5-second timeout and a 20-second start period.

When a container is stopped, docker stop sends SIGTERM and, after a grace period that defaults to 10 seconds for Linux containers, SIGKILL. Orchestrators behave similarly. Your app should stop accepting new connections, finish in-flight requests and exit within that window. Here is a minimal src/server.ts (or .js) that does both jobs with only the standard library:

JavaScript
import http from 'node:http';
const port = Number(process.env.PORT ?? 3000);
const server = http.createServer((req, res) => {
  if (req.url === '/healthz') {
    res.writeHead(200, { 'content-type': 'text/plain' });
    res.end('ok');
    return;
  }
  res.writeHead(200, { 'content-type': 'application/json' });
  res.end(JSON.stringify({ message: 'hello' }));
});
server.listen(port, '0.0.0.0', () => {
  console.log(`listening on ${port}`);
});
function shutdown(signal) {
  console.log(`${signal} received, closing server`);
  // Stop accepting connections; idle keep-alive sockets are closed too.
  server.close((err) => {
    if (err) {
      console.error(err);
      process.exit(1);
    }
    process.exit(0);
  });
  // Force exit before the orchestrator's SIGKILL if something hangs.
  setTimeout(() => process.exit(1), 8000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

Since Node.js 19, server.close() also closes idle connections, so keep-alive sockets no longer hold the process open. Requests that are still running are allowed to finish. Close your database pools and queue consumers inside the same handler before calling process.exit. If your health endpoint checks dependencies, keep it cheap; a health check that runs a heavy query every 30 seconds becomes load of its own.

Step 5: Build, run and inspect

Shell
docker build -t my-service:1.0.0 .
docker run --rm --init \
  --name my-service \
  -p 3000:3000 \
  --memory 512m \
  my-service:1.0.0
# In another terminal
docker inspect --format '{{.State.Health.Status}}' my-service
docker stop my-service   # watch the "SIGTERM received" log line

The --init flag runs a tiny init process as PID 1 and starts Node as its child. The Node.js image maintainers recommend it because "a Node.js process running as PID 1 will not respond to SIGINT (CTRL-C) and similar signals" unless it installs handlers, and an init also reaps zombie processes if your app spawns children. In Docker Compose the equivalent is init: true on the service. Setting a memory limit, as the same guide suggests, stops one container from starving everything else on a shared host.

Technician with a laptop working at an open server rack in a computing centre
Containers still run on real servers; clean shutdown and health checks help the platform manage them.

Production checklist

  • Base image tag pinned to a major version and variant, ideally to a digest, with automated update pull requests.
  • .dockerignore excludes .env files, .git and local node_modules.
  • No secrets in ENV, ARG or copied files; pass them at runtime from your platform's secret store.
  • Final stage contains only production dependencies and build output.
  • Runs as a non-root user; code is not writable by that user.
  • Exec-form CMD that starts node directly.
  • App handles SIGTERM and finishes within the platform's grace period.
  • HEALTHCHECK (or the platform's own probe) points at a cheap endpoint, with a start period.
  • Image scanned for known vulnerabilities in CI and rebuilt regularly so base-image security fixes are picked up.
  • A plan for moving from Node.js 24 to 26 once 26 reaches LTS; 24 remains supported until 30 April 2028 according to the release schedule.

Common mistakes to avoid

Running npm install instead of npm ci

npm install may update the lockfile and resolve different versions than you tested. npm ci fails if package.json and the lockfile disagree, which is what you want in a build.

Copying the whole project before installing

Putting COPY . . before the install step invalidates the dependency layer on every code change, so every rebuild reinstalls all packages.

Using a health check that needs tools the image does not have

The common curl -f http://localhost/ example from the Dockerfile reference fails silently in slim images that do not include curl: the check itself errors and the container is reported unhealthy. Using Node to perform the request avoids adding packages.

Ignoring the grace period

If shutdown work takes longer than the platform waits, the process is killed mid-request. Either make shutdown faster or raise the grace period (docker stop --timeout, stop_grace_period in Compose, or the equivalent setting in your orchestrator).

Overhead view of a large IBM TS3500 tape library with rows of storage cartridges
Reproducible builds depend on stored, versioned artefacts: lockfiles, pinned base images and registries.

Next steps

With this Dockerfile you have a small, reproducible image that runs without root privileges, reports its own health and stops cleanly. From here, add image scanning to CI, push to a private registry, and let your platform's own readiness and liveness probes use the same /healthz endpoint. When Node.js 26 becomes LTS at the end of October 2026, the upgrade is a one-line change to the three FROM lines followed by your normal test run.

Frequently asked questions

  • Should I use the Alpine or the slim Debian Node.js image?

    Slim Debian variants such as node:24-bookworm-slim are a safe default because they use glibc, so native modules usually work unchanged. Alpine images are smaller but use musl, which some native dependencies do not support well. Test your app on whichever you choose.

  • Why not start the container with npm start?

    npm adds a wrapper process between the container runtime and Node.js. The Node.js Docker image guidance recommends CMD ["node", "index.js"] so that SIGTERM and SIGINT reach your application directly and it can shut down cleanly.

  • Do I need tini or the --init flag?

    It is recommended. Without an init process, Node.js runs as PID 1, which does not respond to signals such as SIGINT unless handlers are installed and does not reap zombie processes. Use docker run --init or init: true in Docker Compose.

  • When should I move from Node.js 24 to Node.js 26?

    The Node.js release schedule lists Node.js 26 becoming LTS on 28 October 2026, while Node.js 24 is supported until 30 April 2028. Plan the upgrade after 26 reaches LTS and your dependencies support it; in this Dockerfile it is a change to the FROM lines plus a full test run.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Building best practices — Docker
  2. Dockerfile reference — Docker
  3. docker container stop — Docker
  4. Docker and Node.js Best Practices — Node.js Docker team
  5. Node.js release schedule (schedule.json) — Node.js Release Working Group
  6. HTTP | Node.js v24 documentation — OpenJS Foundation
  7. node official image tags — Docker Hub

Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.

Comments

No comments yet. Be the first to join the conversation.

Leave a comment

Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.