A Production Multi-Stage Dockerfile
A Production Multi-Stage Dockerfile
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
npm ci installs exactly what the lockfile says and fails on drift. The two COPY package* steps keep the install layer cached across source edits, and USER node stops the app from running as root inside the container.
Runtime Behavior
Runtime Behavior
Containers have no init process, so signal and zombie handling are your problem:
docker run --init -e NODE_ENV=production app:1.4
# or: CMD ["tini", "--", "node", "dist/server.js"]
Tini forwards SIGTERM so graceful shutdown hooks run — without it, docker stop SIGKILLs after the grace period and in-flight requests die. Handle SIGTERM explicitly: stop accepting connections, drain, exit 0. Memory: Node sizes its heap from the cgroup limit, but set --max-old-space-size (and a matching --memory) so the OOM boundary and GC behavior are predictable.
Pitfalls and Interview Angles
Pitfalls and Interview Angles
Host node_modules. Copying them in drags platform-specific binaries and dev tooling; always install inside the image.
Dev servers in prod. npm run dev with hot reload does not belong in the runtime stage — run the built output with node.
Wrong CMD after a bundler change. A CMD pointing at a missing file exits with code 1 in a restart loop; check docker logs and docker inspect --format '{{.State.ExitCode}}'.
Port mismatch. If Express listens on 3000 but the app binds process.env.PORT, set -e PORT=3000 or EXPOSE and map consistently.
Interview: defend each Dockerfile line, explain the init/signal story, and describe how you would set a memory limit together with Node's heap flag.
Quiz
1. Why add --init (or tini) when running a Node container?
2. Why npm ci instead of npm install in Docker builds?
3. What should CMD execute in a production image?
Flashcards
Question
npm ci
Click to reveal answer
Answer
Clean lockfile-exact install; the only install command that belongs in a Dockerfile.
Question
USER node
Click to reveal answer
Answer
Runs the process as the unprivileged user shipped in the node image instead of root.
Question
Container init
Click to reveal answer
Answer
--init/tini as PID 1: forwards SIGTERM and reaps orphaned children.
Question
Heap vs cgroup
Click to reveal answer
Answer
Node reads the container memory limit; pair --memory with --max-old-space-size for a known OOM point.
Revision Notes
Key Takeaways
- 1. Multi-stage: build with dev deps, run with prod deps only
- 2. Signals need an init; graceful drain needs a SIGTERM handler
- 3. Install inside the image — never copy host node_modules
Interview Tips
- • Defend every line of the Dockerfile as if reviewing a PR
- • Explain what happens on docker stop without tini versus with it
Cheat Sheet
node:20-slim multi-stage; npm ci → build → npm ci --omit=dev; USER node; --init + SIGTERM drain; --memory with --max-old-space-size.
Continue Learning
Before You Start
- No prerequisites listed.