Recipe: Node.js app
A production Node image, non-root, cached deps.
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.
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.
FROM node:22-alpineWORKDIR /app# deps layer: only rebuilds when the lockfile changesCOPY package.json package-lock.json ./RUN npm ci --omit=dev# app layer: changes on every commit, deps above stay cachedCOPY . .ENV NODE_ENV=production PORT=3000USER nodeEXPOSE 3000HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1CMD ["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.
$ 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-apiREPOSITORY TAG IMAGE ID SIZEpayments-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.
services:api:build: .ports: ["3000:3000"]environment:DATABASE_URL: postgres://app@db:5432/paymentsdepends_on:db: { condition: service_healthy } # wait for db to pass its healthcheckread_only: true # rootfs read-only...tmpfs: ["/tmp"] # ...with an explicit writable /tmpdb:image: postgres:16-alpineenvironment:POSTGRES_DB: paymentsPOSTGRES_USER: appPOSTGRES_PASSWORD_FILE: /run/secrets/db_pwsecrets: [db_pw]volumes: ["pgdata:/var/lib/postgresql/data"]healthcheck:test: ["CMD-SHELL", "pg_isready -U app"]interval: 10svolumes: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.
$ 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 psNAME IMAGE STATUS PORTSpayments-api-1 payments-api Up 9 seconds (healthy) 0.0.0.0:3000->3000/tcppayments-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.
$ curl -i localhost:3000/healthzHTTP/1.1 200 OKContent-Type: text/plainDate: Fri, 17 Jul 2026 09:14:02 GMTConnection: keep-aliveKeep-Alive: timeout=5Content-Length: 2ok$ curl -s localhost:3000/ | jq{"service": "payments","status": "up"}$ docker inspect --format '{{.State.Health.Status}}' payments-api-1healthy
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.
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.
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.
$ 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/healthzok$ docker exec node-demo iduid=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.
package.json and run npm ci before COPY . .?