CoursesDocker in depthPractical recipes

Recipe: Node.js app

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

Intermediate12 min · lesson 21 of 30

A Node.js service is the canonical first thing to containerize, and doing it well touches every image habit at once: a pinned slim base, dependency layer caching, a non-root user, and a healthcheck. The naive Dockerfile — FROM node, COPY everything, npm install — works and is also large, slow to rebuild, and running as root. This recipe builds the version you would actually ship.

Dockerfile
FROM node:22-alpine AS base
WORKDIR /app
# deps layer: only re-runs when the lockfile changes
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# app layer: changes every commit, but deps above stay cached
COPY . .
ENV NODE_ENV=production PORT=3000
USER node # the alpine image ships a non-root "node" user
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1
CMD ["node", "server.js"]

Why each line is there

node:22-alpine pins a specific major on a tiny base. Copying package.json and running npm ci before the source is the caching trick from the image-management section — a code edit no longer reinstalls dependencies. npm ci (not install) installs exactly the lockfile, reproducibly, and --omit=dev drops build/test packages. USER node runs the app as the unprivileged user the official image already provides, so a compromise is not root. The HEALTHCHECK lets Docker and Compose know when the app is actually ready, not merely started.

Run it, or wire it with Compose

For local development you want your source live-mounted and the container’s own node_modules preserved — the anonymous-volume trick from the storage section. In Compose that is a few lines, and depends_on with a condition waits for a database’s healthcheck before starting the app.

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’s 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 } }
terminal
$ docker compose up -d --build
$ docker compose ps
NAME IMAGE STATUS
api recipe-api Up (healthy)
db postgres:16 Up (healthy)
$ curl -s localhost:3000/healthz
ok
Do not COPY node_modules from the host
A .dockerignore that lists node_modules (and .git, .env) keeps host artifacts out of the build context, so the image installs its own dependencies for the right architecture. Copying host node_modules is a classic source of “works on my laptop, crashes in the container” — native modules built for macOS do not run on the Linux image.