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.
Updated 9 min read

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.

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.
| Goal | How the Dockerfile achieves it |
|---|---|
| Reproducible installs | npm ci against the lockfile, with a pinned base image tag |
| Small runtime image | Multi-stage build; only production dependencies and compiled output reach the final stage |
| Least privilege | Runs as the built-in node user; application files stay owned by root |
| Clean shutdown | Exec-form CMD, a SIGTERM handler and an init process |
| Observable health | HEALTHCHECK 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.
node_modules
dist
.git
.gitignore
.env
.env.*
npm-debug.log*
coverage
Dockerfile
.dockerignoreStep 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.
# 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:1asks BuildKit to use the latest stable Dockerfile frontend, which providesRUN --mount.- Copying
package.jsonandpackage-lock.jsonbefore 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/.npmkeeps npm's download cache between builds without storing it in an image layer. The Dockerfile reference liststargetas the only required option;iddefaults to the target path andsharingdefaults toshared.npm ci --omit=devinstalls exactly what the lockfile says and skips devDependencies, so compilers, test runners and linters never reach production.ENV NODE_ENV=productionis the setting the Node.js Docker image maintainers recommend; many libraries switch off development-only behaviour when they see it.- Plain
COPYwithout--chownleaves application files owned by root (the Dockerfile reference notes files are created with UID and GID 0 by default). The process runs asnode, so it can read its code but cannot overwrite it if it is compromised. Only add--chown=node:nodeto a directory the app genuinely writes to. USER nodeuses 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 throughnpm startadds a wrapper process, and the Node.js image documentation recommends callingnodedirectly so thatSIGTERMandSIGINTreach 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:
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
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 lineThe --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.

Production checklist
- Base image tag pinned to a major version and variant, ideally to a digest, with automated update pull requests.
.dockerignoreexcludes.envfiles,.gitand localnode_modules.- No secrets in
ENV,ARGor 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
CMDthat startsnodedirectly. - App handles
SIGTERMand 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).

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-slimare 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 --initorinit: truein 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
FROMlines plus a full test run.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Building best practices — Docker
- Dockerfile reference — Docker
- docker container stop — Docker
- Docker and Node.js Best Practices — Node.js Docker team
- Node.js release schedule (schedule.json) — Node.js Release Working Group
- HTTP | Node.js v24 documentation — OpenJS Foundation
- 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.
More from Cloudsoft Zone

Architecture guideAI and Automation
How to build a safe AI content pipeline with human fact review
An architecture for AI-assisted publishing where people stay responsible: untrusted source snapshots, claim checking with citations, a review queue AI cannot skip, labels and rollback.
By Cloudsoft Zone

TutorialNode.js
How to test a Node.js API with the built-in test runner (node:test)
Write, run, mock and measure coverage for a small Node.js HTTP API using only node:test and node:assert. Covers port-0 servers, fetch(), spies, t.mock.method(), fake dates and coverage thresholds.
By Cloudsoft Zone

TutorialNode.js
Node.js streams in practice: pipeline(), backpressure and async generators
Process files of any size in Node.js with flat memory use. A hands-on guide to stream/promises pipeline(), backpressure, async generator transforms, AbortSignal timeouts and streaming downloads over HTTP.
By Cloudsoft Zone
