Docker & Containers
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences
of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were
checked against the official documentation on 2026-10-04, and every Docker example below was built and run with Docker Desktop 29.8.1 on the
node:26-slim image, and the server was also run on the node:24 and node:22 images. The Kubernetes manifest was parsed but not applied,
because no cluster was available; it is labelled as such. A container is not a Node.js feature: the runtime does not know it is in one, and
everything it does (listening, reading process.env, handling signals, writing to stdout) is the same as on a laptop. What changes is the
environment around the process: it is started by a runtime that sends it signals, it is usually process number 1 in its own namespace, it sees a
memory limit, and an orchestrator decides when it is ready and when it must die. This page covers only those Node-specific consequences. The
general material lives in the Docker and Kubernetes sections and is linked, not repeated. The running scenario is the Bookshelf application
described in Introduction & Architecture; the server used here is a small variant of the
one in HTTP Server Fundamentals, extended with health endpoints.
The version and image map
| Topic | Status | Version notes |
|---|---|---|
Official |
Maintained by the Node.js Docker team |
Tags exist per major line and per exact release. |
|
Stable (npm 10 and 11) |
|
Corepack in images |
Removed from the distribution |
Not bundled from Node.js 25, so |
|
Documented CLI option (no stability label in the CLI page) |
Added in v22.0.0; runs a |
|
Stable |
Works on every line. Node.js installs no default SIGTERM handler, so the kernel’s behaviour for PID 1 decides what happens without one. |
|
Documented V8 option (CLI page) |
Listed in the Node.js 26, 24 and 22 CLI documentation, and accepted by all three images in the runs below. |
|
Stable |
Close idle keep-alive connections; used by the shutdown code below on 26, 24 and 22. See HTTP Server Fundamentals. |
Choosing the base image
The official image is published in three families. Choose by what the application needs at build time and run time, not by size alone.
| Tag | What it is | Use it when |
|---|---|---|
|
The full Debian-based image with compilers and common build tools. About 1.8 GB on disk here. |
Building native add-ons in a build stage; never as the final image. |
|
Debian with only what Node.js needs to run. About 375 MB on disk here, with the app and its production dependencies. |
The default recommendation for the runtime stage: glibc, so prebuilt native binaries from npm work. |
|
Alpine Linux with musl libc. |
Smallest images, when every dependency ships a musl build or compiles cleanly; native add-ons that assume glibc are the usual failure. |
Tags such as node:26 float: they move to each new patch release, which is convenient for security fixes and surprising for reproducibility.
Pin the major (node:26-slim) for a rolling patch, or an exact version for a pinned build, and let a bot such as Renovate or Dependabot propose
updates. Digest pinning (node:26-slim@sha256:…) gives the strongest guarantee. Image Best Practices and Supply-Chain Security
covers tags, digests and scanning in general.
The Node.js multi-stage Dockerfile
The generic mechanics of multi-stage builds, layers and the build cache are in
Dockerfile Fundamentals and
Build Cache and Multi-Stage Builds. For Node.js the recipe is short: install only
production dependencies in one stage with npm ci --omit=dev, copy the result into a slim runtime stage, and drop root with USER node.
# syntax=docker/dockerfile:1
ARG NODE_VERSION=26
FROM node:${NODE_VERSION}-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
FROM node:${NODE_VERSION}-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --chown=node:node package.json ./
COPY --chown=node:node src ./src
USER node
EXPOSE 3000
CMD ["node", "src/server.js"]
Source: Docker Docs: Containerize a Node.js application, Docker Docs: Optimize cache usage in builds
Built with docker build -t bookshelf ., the image ran as uid 1000 with NODE_ENV=production, and its node_modules held the 13 production
packages and no prettier (the dev dependency). A single-stage image that copies everything, runs npm install and keeps FROM node:26
(root user, dev dependencies included, full Debian) was 1.81 GB against 377 MB for the multi-stage one in this experiment. Each line has a reason.
| Line | Reason |
|---|---|
|
One place to change the runtime line. |
|
Dependencies change less often than code. Copying only the manifests first keeps the |
|
Installs exactly what the lockfile says and fails if |
|
A BuildKit cache mount keeps npm’s download cache between builds without storing it in a layer. It needs the |
Two |
Anything installed in the first stage (compilers for native add-ons, a TypeScript build, dev dependencies) stays there. Only the |
|
Frameworks and libraries branch on it (Express, for example, enables view caching and terser errors). Set it in the runtime stage. Setting it before |
|
Files copied by |
|
The official image predefines this unprivileged user. Kubernetes' |
|
Documentation only; it publishes nothing. Publishing happens with |
|
Exec form, so Node.js is the container’s main process and receives signals directly. See PID 1, signals and shutdown. |
If the project is TypeScript or needs a bundler, add a build stage between the two, run npm ci there (dev dependencies included), build,
and copy only dist/ into the runtime stage. On Node.js 24 and 26 plain .ts files can also run through type stripping (see
TypeScript in Node.js), which removes the build stage for projects that stay within erasable syntax.
The .dockerignore file
docker build sends the build context (the directory) to the builder. COPY . . would then copy everything in it, including a developer’s
node_modules, which may hold binaries built for macOS, and files that must never reach an image such as .env or .git. A .dockerignore
file in the context root lists what to leave out.
.dockerignore for a Node.js projectnode_modules
npm-debug.log*
.git
.gitignore
.env
.env.*
Dockerfile
compose.yaml
.dockerignore
coverage
test
*.md
node_modules is the important line: with it ignored, the deps stage is the only place that creates it, inside the container’s own
operating system. The .env lines keep local secrets out of every layer, and exclusion also keeps the context small (12 MB of host
node_modules in the scratch project would otherwise be uploaded on every build). Because the Dockerfile in this page copies only package.json,
the lockfile and src, the file is a safety net here; with a COPY . . style Dockerfile it becomes essential.
PID 1, signals and shutdown
Inside a container the first process, whatever it is, is PID 1 of the container’s PID namespace. Two things are special about PID 1 on Linux:
the kernel does not apply the default action for a signal that the process has not installed a handler for (so an unhandled SIGTERM is ignored
instead of terminating the process), and PID 1 is expected to reap orphaned child processes. docker stop sends SIGTERM, waits (10 seconds by
default, -t changes it) and then sends SIGKILL. Node.js does not install a SIGTERM handler of its own, so an application without one
ignores the polite request and is killed after the grace period. The Node.js side of signals is in
Process, Signals & Exit Codes.
let draining = false;
function shutdown(signal) {
if (draining) return;
draining = true;
log.info({ signal }, 'shutting down');
server.close(() => {
log.info('closed');
process.exit(0);
});
server.closeIdleConnections(); // do not wait for idle keep-alive sockets
setTimeout(() => process.exit(1), 8_000).unref(); // hard stop below the orchestrator's grace period
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Four ways of starting the same image were stopped with docker stop -t 5 and compared. The exit code is 128 plus the signal number, so
143 means the process died of SIGTERM and 137 means SIGKILL.
| Entrypoint | Time to stop | Exit | What happened |
|---|---|---|---|
|
0.1 s |
0 |
Node.js is PID 1, receives |
|
5.1 s (the whole grace period) |
137 |
PID 1 ignored |
|
0.1 s |
143 |
|
Shell form |
5.1 s |
137 |
The shell stayed PID 1 and did not pass the signal on; the server never logged "shutting down". |
Two lessons follow. Always use the exec (JSON array) form of CMD and ENTRYPOINT, so that no shell sits between Docker and Node.js, and install
a signal handler for SIGTERM (and SIGINT, for Ctrl-C under docker run -it) in every long-running server. The second lesson is
about wrappers. Running npm start as CMD made npm the main process: docker stop ended the server through the process tree, but npm reported a
failure (signal SIGTERM, exit 1) and the application’s own handler never logged anything, so its drain code did not run (observed). node --run start
as PID 1 behaved like the shell form and ended with exit 137 (observed). Start node directly.
An init process solves the second PID 1 problem too: it reaps zombie processes. A Node.js application that uses child_process can leave
terminated children unreaped if Node.js, as PID 1, never waits for processes it did not spawn itself; see
Child Processes. Docker bundles tini as docker-init, enabled by docker run --init and by init: true for a Compose service
(used below). Docker’s own reference documents the flag.
docker run --init --rm -p 3000:3000 bookshelf
An orchestrator such as Kubernetes has no --init flag for a container, so when the image itself must carry an init, install tini in the runtime
stage, before USER node (installing packages needs root), and make it the entrypoint. The deps stage is unchanged from the Dockerfile above.
# syntax=docker/dockerfile:1
ARG NODE_VERSION=26
FROM node:${NODE_VERSION}-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
FROM node:${NODE_VERSION}-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --chown=node:node package.json ./
COPY --chown=node:node src ./src
USER node
EXPOSE 3000
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "src/server.js"]
Built as bookshelf-tini on Node.js 26 and stopped with docker stop -t 5, the container showed /usr/bin/tini — node src/server.js as PID 1
(/proc/1/cmdline), the server ran as uid 1000 and logged shutting down and closed, the stop took 0.1 s and the exit code was 0: tini
forwarded SIGTERM to the server, whose own handler drained and exited.
The official Node.js Docker best-practices document recommends handling signals in the application or using an init; the two are complementary.
An application that already handles SIGTERM correctly, as the server above does, does not need tini to shut down; it needs it only to reap
children. Between the two the safest default is the handler, because it works the same under Docker, Compose, Kubernetes and a plain
terminal. When the process ignores SIGTERM for another reason (for example it is blocked in a synchronous loop, see
Don’t Block the Event Loop), neither helps.
Logging follows the same rule as signals: write to stdout and stderr and let the runtime collect them (the pino output above is plain JSON lines
that docker logs shows), never to files in the container. See Diagnostic Reports, Logging & Observability.
Listening inside a container
A server bound to 127.0.0.1 listens on the container’s own loopback interface, which -p 3000:3000 cannot reach. The same one-line server
was started twice with -p: bound to 127.0.0.1 the request got no reply, bound to 0.0.0.0 it answered. The Bookshelf server therefore binds
0.0.0.0 and reads the port from process.env.PORT; Node.js’s default for listen(port), with no host, is already all interfaces, so the
mistake is usually an explicit 'localhost' copied from a development setup. On the host, publish to loopback only
(-p 127.0.0.1:3000:3000) when the service should not be reachable from the network. See
HTTP Server Fundamentals and Docker networking.
Memory limits and the V8 heap
A container’s memory limit is enforced by the kernel’s cgroup: when the process exceeds it, the kernel kills the process (exit code 137, reported
as out-of-memory), without any JavaScript exception. The V8 heap is only one part of a Node.js process’s memory; buffers, native code, the
thread pool and the V8 young generation live beside it, so the heap limit has to be set below the container limit. In this experiment a
container limited to 256 MiB (--memory 256m) reported a V8 heap limit of 262 MiB on Node.js 26 (259 MiB on 24 and 22): there is no headroom,
and a heap that grows to its limit is killed by the kernel before V8 reports a JavaScript heap out-of-memory error. The percentage option or an explicit size fixes that.
docker run --rm --memory 256m node:26 \
node --max-old-space-size-percentage=75 -p "require('v8').getHeapStatistics().heap_size_limit"
# or explicitly, in the image: ENV NODE_OPTIONS=--max-old-space-size=192
With 75 percent the same container reported 198 MiB on Node.js 26 and 195 MiB on 24 and 22; an explicit --max-old-space-size=192 gave the same
numbers (observed). Pick the share by measuring: native memory and buffers count against the container limit too. The
Memory & Leaks page explains the heap spaces, and
Resources, Logging and Monitoring covers limits from the Docker side.
Compose for local dependencies
During development the application usually runs on the host (with node --watch, see
Running Scripts: CLI & REPL) and only its dependencies (database, cache, queue)
run in containers; that is what the Bookshelf scratch project does. A Compose file can also run the application next to them to rehearse
the container itself, which is what the file below does. The general feature set is in
Compose for Local Development and
Compose in Practice. The Node.js-specific points are that the application must retry or wait for its
dependencies, and that a health check makes Compose wait for them.
services:
app:
build:
context: .
args:
NODE_VERSION: "26"
init: true
stop_grace_period: 15s
ports:
- "127.0.0.1:3000:3000"
environment:
DB_HOST: db
DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 5s
timeout: 3s
retries: 3
start_period: 5s
db:
image: postgres:17
environment:
POSTGRES_DB: bookshelf
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d bookshelf"]
interval: 3s
timeout: 3s
retries: 10
volumes:
pgdata:
Source: Docker Docs: Control startup and shutdown order in Compose, Docker Docs: Compose services, healthcheck
The password is not in the file. Compose reads .env next to it, so a local .env containing a throw-away line such as DB_PASSWORD=change-me
(listed in both .gitignore and .dockerignore) is enough, and the :? form makes docker compose up fail with a clear message when it is missing
instead of starting a database with an empty password. The Compose file format has no version: key any more; it is obsolete and Compose warns about it.
docker compose up -d --build started the database first and the app only after pg_isready passed (depends_on with condition: service_healthy); a few
seconds later docker compose ps showed both services healthy. Three more observations from the same run. The container health check has no
curl to call, because the slim image does not ship one, so it runs node -e with the built-in fetch instead. Stopping the database made
GET /readyz return 503 (the server tries a TCP connection to DB_HOST) and starting it again returned 200, without restarting the app.
docker compose stop app took 0.16 s and the exit code was 0, because the handler ran. The project was then removed with docker compose down -v.
Two cautions apply. depends_on only orders startup: the application must still tolerate a dependency that restarts later, by retrying or by
reporting not-ready as above. And a database in a volume keeps its first-start password: changing DB_PASSWORD later has no effect until the
volume is removed. The database drivers themselves are in Databases from Node.js. For tests that need
real databases the Testcontainers approach is described in Testcontainers Fundamentals.
For fast feedback inside the container, Compose can sync edits into a running service with develop.watch, which pairs with node --watch as the command; see the Docker file-watch
documentation.
Kubernetes probes and termination
An orchestrator asks the application three questions through probes, and the answers need three different endpoints (or one endpoint with different rules). The mechanics, timing fields and failure behaviour are in Health Probes and Container Lifecycle; this section shows what each means for a Node.js server.
| Probe | Question | What the Bookshelf server does |
|---|---|---|
|
Has the process finished starting? |
Reuses |
|
Is the process stuck beyond recovery? |
|
|
Should this replica receive traffic now? |
|
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookshelf
spec:
replicas: 2
selector:
matchLabels: { app: bookshelf }
template:
metadata:
labels: { app: bookshelf }
spec:
terminationGracePeriodSeconds: 30
containers:
- name: app
image: registry.example.com/bookshelf:1.0.0
ports:
- containerPort: 3000
resources:
requests: { cpu: 250m, memory: 256Mi }
limits: { memory: 256Mi }
securityContext:
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
lifecycle:
preStop:
exec:
command: ["sleep", "5"]
startupProbe:
httpGet: { path: /healthz, port: 3000 }
periodSeconds: 2
failureThreshold: 15
livenessProbe:
httpGet: { path: /healthz, port: 3000 }
periodSeconds: 10
timeoutSeconds: 2
readinessProbe:
httpGet: { path: /readyz, port: 3000 }
periodSeconds: 5
failureThreshold: 2
Source: Kubernetes Docs: Configure liveness, readiness and startup probes, Kubernetes Docs: Pod termination
This manifest was parsed as YAML and checked against the structure above but was not applied, because the experiment had no cluster. When a pod is
deleted, the kubelet runs the preStop hook, sends SIGTERM to the container’s main process, and sends SIGKILL after
terminationGracePeriodSeconds (30 s here, counted from the start of termination). The removal of the pod from Service endpoints happens in
parallel with those steps, so for a moment a load balancer can still send a request to a pod that has begun shutting down. A short preStop sleep and
a readiness endpoint that turns 503 on SIGTERM, as in the server above, shrink that window. The application’s own hard-stop timer (8 s here) sits below the grace period, and the
grace period sits above preStop plus the longest request you are willing to finish. The same layering for a framework is in
Production Deployment.
With the memory limit of 256 Mi above, set the heap as described in Memory limits and the V8 heap. The readOnlyRootFilesystem line works for this server because
it writes nothing to disk; an application that needs a scratch directory (uploads, temporary files from
File System code) mounts an emptyDir volume there. A manifest that runs the Bookshelf service end to end, with
Services, configuration and secrets, is in Bookshelf End to End; configuration and credentials are covered in
ConfigMaps and Secrets, and resource requests and limits in
Resource Management.
One process per container
Inside a container, scale with replicas, not with a process manager. The cluster module and PM2 solve a problem (using several cores of one
machine) that an orchestrator solves one level up by running more containers, each with a CPU request and an independent health check. A
container whose PID 1 is a process manager hides crashes from the orchestrator, because the manager restarts the child and the container never
exits. Use cluster or worker threads inside one container only when one replica must use several cores for a single workload; see
Cluster & Scaling and Worker Threads.
What changed recently
| Version | Change |
|---|---|
Node.js 26 (LTS from 2026-10-28) |
The |
Node.js 25 (EOL 2026-06-01) |
Corepack stopped being distributed, so images no longer contain |
Node.js 24.10 and 22.21 |
|
npm 11 and 12 |
|
Docker Compose |
The |
Where the books are outdated
Both reference books for this section touch containers briefly, and both predate the current baseline. Node.js Projects by Jonathan Wexler
(O’Reilly, August 2025) has an appendix with a Dockerfile and Compose setup that targets the Node.js 20 line. Node.js 20 reached end of life on
2026-04-30, so the base image should be node:26-slim (or a 24 or 22 tag) instead, and the image should add what the appendix leaves out: a
non-root USER node, npm ci --omit=dev from a lockfile, a multi-stage build and a .dockerignore. Its tooling advice (Corepack for Yarn, nodemon for reload)
is superseded: Corepack is no longer bundled since Node.js 25, and node --watch is stable.
Efficient Node.js by Samer Buna (O’Reilly, 2025) is about the runtime itself and treats scaling with cluster, restarts and process
managers such as PM2. That advice remains correct on a single machine, but the container model adds a layer it does not discuss: PID 1 signal
behaviour, the memory limit and an orchestrator that does the restarting and scaling. Prefer one process per container with the probes and
shutdown handling shown above, and treat clustering as a special case described in
Cluster & Scaling.
Pitfalls and good practice
-
Do not start the application with the shell form of
CMD, withnpm start, or withnode --run; useCMD ["node", "src/server.js"](observed: 137 or an npm error instead of a clean exit). -
Install a
SIGTERMhandler that stops accepting connections, closes idle sockets and exits, and keep its hard-stop timer below the orchestrator’s grace period. -
Bind to all interfaces inside the container and decide on the host side, with
-p 127.0.0.1:…, what is exposed. -
Use
npm ci --omit=devfrom a lockfile in a build stage; nevernpm installin the runtime stage. -
Do not run as root:
USER node, plusrunAsNonRootwith a numericrunAsUserin Kubernetes. -
Set the V8 heap below the container memory limit with
--max-old-space-size-percentageor an explicit size, and keep other native memory in the budget. -
Keep secrets out of images,
.dockerignoreand Compose files: read them fromprocess.env, supplied by an.envfile that is not committed, a Kubernetes Secret, or a secret mount. See Crypto, Passwords & Secrets. -
Make liveness independent of dependencies and readiness dependent on them.
-
Pin the base image major (or digest) and rebuild regularly to pick up OS and Node.js security patches; see Security Best Practices.
-
Log to stdout and stderr, not to files.
Related material
| Topic | Page |
|---|---|
Dockerfile instructions, layers, the cache |
|
Running, networking, storage, debugging containers |
Running Containers, Networking, Storage: Volumes and Bind Mounts, Debugging Containers |
Image hardening and scanning |
Image Best Practices and Supply-Chain Security, Docker Security |
Continuous delivery of images |
|
Pods, Deployments, rollout strategies |
|
Logging and metrics in a cluster |
|
The same runtime behaviour in a framework |
NestJS, whose lifecycle and deployment pages cover shutdown hooks and container images |