CoursesDocker in depthRecipe: Node.js app

Recipe: Node.js app

A production Node image, non-root, cached deps.

Intermediate12 min · lesson 21 of 30

A Node.js web service is usually the first thing people reach to containerize, which makes it a good place to build habits you'll reuse on every image afterward. The lazy version works on the first try: FROM node, copy the whole folder, run npm install. Then the bill arrives. The image is close to a gigabyte. Every one-line code change reinstalls all your dependencies from scratch, and the whole thing runs as root inside the container. This recipe builds the image you'd actually ship, wires it to a Postgres database with Compose, then curls the running app to prove it answers.

The app you're shipping

Keep the app small enough to read in one screen but real enough to test. This one uses Node's built-in http module, so there's no framework to explain. It answers two routes: /healthz returns plain ok for Docker to poll, and / returns a little JSON so you get a real response back when you curl it.

server.js
const http = require("http");
const PORT = process.env.PORT || 3000;
const server = http.createServer((req, res) => {
if (req.url === "/healthz") {
res.writeHead(200, { "Content-Type": "text/plain" });
return res.end("ok");
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ service: "payments", status: "up" }));
});
server.listen(PORT, () => console.log(`listening on ${PORT}`));

A Dockerfile you'd actually ship

Four ideas do all the work here: a pinned slim base, a dependency layer that caches separately from your source, a non-root user, and a healthcheck. Read the file first, then the reasons underneath it.

Dockerfile
FROM node:22-alpine
WORKDIR /app
# deps layer: only rebuilds when the lockfile changes
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# app layer: changes on every commit, deps above stay cached
COPY . .
ENV NODE_ENV=production PORT=3000
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1
CMD ["node", "server.js"]

node:22-alpine pins a major version on a tiny base, so you get a predictable image in the low hundreds of megabytes instead of well over a gigabyte. Copying package.json and running npm ci before the source is the caching move: Docker reuses a layer until one of its inputs changes, so as long as your lockfile is untouched, editing server.js skips the reinstall entirely. npm ci installs exactly what the lockfile pins, the same way every time, and --omit=dev leaves your test and build tooling out of the runtime image. USER node switches to the unprivileged account the official image already ships, so if someone breaks out of your app they land as node, not root. EXPOSE 3000 documents the port for humans and tooling (it doesn't publish anything on its own), and the exec-form CMD ["node", "server.js"] runs Node as process ID 1 (PID 1) directly instead of under a shell, so a SIGTERM (the shutdown signal docker stop sends) reaches it and the container stops cleanly.

terminal
$ docker build -t payments-api .
[+] Building 7.8s (12/12) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 438B 0.0s
=> [internal] load metadata for docker.io/library/node:22-alpine 1.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 82B 0.0s
=> [1/5] FROM docker.io/library/node:22-alpine@sha256:6f3c... 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 3.4kB 0.0s
=> [2/5] WORKDIR /app 0.1s
=> [3/5] COPY package.json package-lock.json ./ 0.0s
=> [4/5] RUN npm ci --omit=dev 5.6s
=> [5/5] COPY . . 0.0s
=> exporting to image 0.3s
=> => writing image sha256:9f2c1a7b... 0.0s
=> => naming to docker.io/library/payments-api 0.0s
$ docker images payments-api
REPOSITORY TAG IMAGE ID SIZE
payments-api latest 9f2c1a7b3e4d 182MB

That RUN npm ci ate most of the build, 5.6 of the 7.8 seconds. Change one line in server.js and rebuild: every step above the source copy comes back CACHED instantly, and only COPY . . reruns. That's the entire reason the dependency copy sits above the source copy, and 182MB beats a fat FROM node image by roughly a gigabyte.

Wire it to a database with Compose

In real life the service talks to something. Compose lets you describe the app and its Postgres database together, and depends_on with a condition holds the app back until the database's own healthcheck passes. Without that gate you'd get a burst of connection-refused errors on every startup. Two hardening flags ride along: read_only mounts the container's root filesystem read-only, and tmpfs hands it a small writable /tmp so nothing legitimate breaks.

compose.yaml
services:
api:
build: .
ports: ["3000:3000"]
environment:
DATABASE_URL: postgres://app@db:5432/payments
depends_on:
db: { condition: service_healthy } # wait for db to pass its healthcheck
read_only: true # rootfs read-only...
tmpfs: ["/tmp"] # ...with an explicit writable /tmp
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: payments
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_pw
secrets: [db_pw]
volumes: ["pgdata:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
volumes:
pgdata: {}
secrets:
db_pw:
file: ./secrets/db_pw.txt

Notice the database password never appears in the file. POSTGRES_PASSWORD_FILE points at a path, Compose mounts ./secrets/db_pw.txt there as a secret, and the value stays out of your environment block and out of docker inspect. It's the same secret pattern you'll declare in Swarm, just wired up locally.

terminal
$ docker compose up -d --build
[+] Running 4/4
✔ Network payments_default Created 0.1s
✔ Volume payments_pgdata Created 0.0s
✔ Container payments-db-1 Healthy 11.3s
✔ Container payments-api-1 Started 11.6s
$ docker compose ps
NAME IMAGE STATUS PORTS
payments-api-1 payments-api Up 9 seconds (healthy) 0.0.0.0:3000->3000/tcp
payments-db-1 postgres:16-alpine Up 21 seconds (healthy)

See how payments-api-1 only reports Started after the db line flips to Healthy at 11.3 seconds? That's depends_on doing its job. The api waited for a database that could actually accept a connection.

Prove it works

Up (healthy) is Docker's opinion. Get your own by hitting the port you published. Use curl -i so you see the status line and headers, not just the body, and confirm the app is really answering on 3000.

terminal
$ curl -i localhost:3000/healthz
HTTP/1.1 200 OK
Content-Type: text/plain
Date: Fri, 17 Jul 2026 09:14:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 2
ok
$ curl -s localhost:3000/ | jq
{
"service": "payments",
"status": "up"
}
$ docker inspect --format '{{.State.Health.Status}}' payments-api-1
healthy

If that first curl had come back with Connection refused instead, you'd know the process never bound the port. A 200 here proves three things at once: the published port maps through, the app inside is serving, and it all runs under the non-root node user. The docker inspect line is a bonus check that Docker's own probe agrees with you.

How Compose brings the stack up healthy
1docker compose up
one command, whole stack
2postgres starts
pg_isready loops
3db reports healthy
the gate opens
4api starts as node
depends_on satisfied
5curl /healthz
HTTP 200 ok

One small file keeps all of this reproducible: a .dockerignore. List node_modules, .git, and .env in it. That keeps host junk out of the build context, so the image installs its own Linux-native dependencies instead of copying whatever your laptop happened to compile. A native module built for macOS won't load on the Alpine image, and that mismatch is the classic 'works on my machine' container crash.

Your healthcheck is only as portable as your base image
The wget in that HEALTHCHECK line works because node:22-alpine bundles BusyBox, and BusyBox includes wget. Swap the base to node:22-slim (Debian) to dodge the occasional Alpine musl-libc compatibility issue, and wget is suddenly gone. The check can't run, every probe fails, and Docker parks the container as unhealthy forever while the app serves traffic perfectly. On a slim or Debian base, run the probe through Node itself, which you know is installed: HEALTHCHECK CMD node -e "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))".

Production habits for Node images

Pin the base, copy package manifests before source for cache, run npm ci, drop to a non-root USER, and keep the final image free of devDependencies. Multi-stage helps when you build assets. Healthchecks should hit a cheap /healthz. Compose with a database network that does not publish the DB port.

The trade-off of alpine versus debian-slim is size versus native module friction. Prefer what your native addons support; do not chase smallest at the cost of broken bcrypt builds. In CI, fail on npm audit for critical issues and scan the image before push.

Runtime: set NODE_ENV=production, use dumb-init or the image's init when PID 1 signal handling matters, and cap memory so a leak becomes a restart instead of a node-wide OOM.

When you apply this in a real environment for r-node, write down the before/after digests, the host that ran the commands, and what you would reverse if the change misbehaves. Operators who keep that short paper trail recover faster than those who rely on chat memory. Prefer a boring, reversible step with sample output over a clever one-liner nobody can audit. If a teammate cannot replay your steps from the ticket alone, the runbook is not done — expand it with the exact flags and the expected STATUS lines you saw on a healthy system.

Try this

Run these on a lab engine (Docker 24+ is fine). Read the sample output so you know what success looks like before you depend on the command in production.

terminal
$ docker build -t node-demo:1 .
$ docker run -d --name node-demo -p 3000:3000 node-demo:1
$ curl -s http://127.0.0.1:3000/healthz
ok
$ docker exec node-demo id
uid=1000(node) gid=1000(node) groups=1000(node)
# STATUS: READY — non-root, healthz 200

Takeaway

Ship Node with cached dependency layers, npm ci, non-root USER, healthchecks, and a small final image. Pair with private networks for backends and scan before the registry.

Quick check
01Why does the Dockerfile copy package.json and run npm ci before COPY . .?
Correct — The layer inputs above the source copy stay unchanged between commits, so Docker reuses the installed-deps layer and only reruns COPY . . plus the metadata after it.
Incorrect — No. npm ci only reads package.json and the lockfile. It runs the same whether or not your source is in the image.
Incorrect — No. Docker caches every instruction whose inputs are unchanged, not just the first COPY. Order controls which ones stay valid.
Incorrect — No. Layer order affects cache reuse, not image size. The same bytes end up in the image regardless of order.
02The Dockerfile ends with CMD ["node", "server.js"] (the exec form) rather than CMD node server.js (the shell form). Why does that choice matter when the container is stopped?
Correct — with the shell form a shell would be PID 1 and might swallow the signal; running Node as PID 1 lets it receive SIGTERM and exit.
Incorrect — the form of CMD doesn't change image size; it changes which process becomes PID 1.
Incorrect — it's the shell form that runs through a shell and expands variables; the exec form does not.
Incorrect — they differ at shutdown, because only the exec form makes your process PID 1 and signal-reachable.
03You switch the base image from node:22-alpine to node:22-slim (Debian) to dodge a musl-libc issue and leave the HEALTHCHECK line (wget -qO- .../healthz) unchanged. The app serves traffic fine, but docker ps shows the container unhealthy. Why?
Incorrect — Debian doesn't block loopback probes; the probe fails because the tool it runs is missing.
Incorrect — the port is set by your app and ENV, not the base image; the probe binary is what changed.
Correct — the healthcheck depends on wget being present; on a Debian base run the probe through Node, which you know is installed.
Incorrect — a slow boot would only delay the first healthy result; here the probe can never succeed because wget isn't there.

Related