Multi-stage builds, Alpine vs slim images, node_modules inside containers, non-root users, health checks, docker-compose, SIGTERM graceful shutdown (draining in-flight requests, closing DB pools, flushing log buffers), and the production readiness checklist.
Module P-14 — Dockerizing Node.js Applications for Production
What this module covers: A Docker image is a portable, reproducible build of your application. If it runs in your container, it runs in production — no more "works on my machine." This module goes deeper than the basics: Alpine vs slim base images and when each is right, why you never install
devDependenciesin a production image,.dockerignoreas a security and performance tool, running as a non-root user, health checks that integrate with orchestrators, handling secrets without baking them into images, and a production readiness checklist that covers the most common containerisation mistakes. This module extends P-8's introduction and covers the remaining depth for production deployments.
The Node.js Image Landscape
The official Node.js Docker images come in three variants. Choosing the wrong one adds hundreds of megabytes and attack surface.
| Variant | Base | Size (node:22) | When to use |
|---|---|---|---|
node:22 | Debian Bookworm | ~1.1 GB | Never in production |
node:22-slim | Debian Bookworm slim | ~220 MB | Most production apps |
node:22-alpine | Alpine Linux | ~60 MB | When size matters most |
Alpine trade-offs: Alpine uses musl libc instead of glibc. Most npm packages are fine. Some packages with native bindings (bcrypt, sharp, canvas) need compilation flags. Alpine also uses ash not bash — shell scripts may need adjustments.
Slim is the default production choice. It's Debian-based (glibc, familiar tooling), small enough, and has far fewer compatibility issues than Alpine.
Pin the exact version, not the major tag. node:22-slim will change when Node releases a patch. node:22.3.0-slim will not.
Multi-Stage Build: The Complete Pattern
Building on P-8, here is the complete production Dockerfile with every best practice applied:
The three-stage pattern:
- deps — installs all
node_modulesincluding devDependencies. Cached whenpackage.jsondoesn't change. - builder — compiles TypeScript, then prunes to production deps.
- production — only the compiled JS and production
node_modules. No TypeScript source, no devDependencies, no build tools.
The Health Check Endpoint
Your container orchestrator (Docker Swarm, Kubernetes, ECS) needs to know if your container is healthy before routing traffic to it. Without a health check, a container is assumed healthy the moment it starts — even if the app crashed during startup.
The Dockerfile HEALTHCHECK calls /health (liveness only) — it should be fast and never fail unless the Node process itself is broken. The /health/ready endpoint with dependency checks is for orchestrator readiness probes, not the Docker health check.
.dockerignore: Security and Speed
.dockerignore prevents files from entering the build context. Every file sent to the Docker daemon gets hashed for layer caching. Large node_modules (200MB+) without .dockerignore adds seconds to every build:
Secrets: Never Bake Them Into Images
Secrets in image layers are visible to anyone who can pull the image. They persist even after a docker run that overwrites them. The correct approaches:
Option 1: Runtime environment variables (simplest):
Option 2: .env file mounted at runtime:
Option 3: Docker secrets (for Docker Swarm):
Read the secret file in your app:
Option 4: Secrets manager (production best practice):
AWS Secrets Manager, HashiCorp Vault, or Google Secret Manager. Fetch secrets at startup, cache in memory. Never in environment variables, never in files.
Layer Caching: Fast Builds
Docker caches each layer. A layer is invalidated when its instruction or any of its inputs change. The rule: least frequently changed content first.
For monorepos or apps with many packages, use a cache mount:
The --mount=type=cache persists the npm cache across builds without it being part of the layer — best of both worlds.
docker-compose for Local Development
The local development compose file from P-8, extended with named volumes and proper dependency ordering:
Production Readiness Checklist
Before shipping a containerised Node.js app to production:
Image
- Multi-stage build — no TypeScript source or devDependencies in production image
- Pinned base image version (
node:22.3.0-slim, notnode:22-slim) - Non-root user (
USER appuser) -
.dockerignoreexcludes.env,node_modules,dist, tests - Image size is reasonable (< 300MB for most apps)
Runtime
- No secrets in the image (
ENVinstructions contain no credentials) - Secrets injected via runtime env vars, env files, or secrets manager
-
CMDuses exec form (["node", "dist/index.js"]), not shell form - Graceful shutdown handles
SIGTERM— drains connections, closes DB
Health
-
/healthendpoint returns 200 when the process is alive -
HEALTHCHECKin Dockerfile calls the health endpoint -
--start-periodaccounts for Prisma migration / connection setup time
Process
-
uncaughtExceptionandunhandledRejectionhandlers log the error and exit with code 1 - Exits cleanly on fatal errors — let the orchestrator restart it
- Memory limit set in container config (
--memory 512m) — prevents one container from OOMing the host
Observability
- Structured JSON logs (Pino) — not
console.log - Logs go to stdout/stderr — Docker captures them automatically
- No log files written inside the container (ephemeral filesystem)
- Correlation IDs on all log entries
Building and Tagging for CI/CD
Summary
node:22-slimis the default production base — Debian-based, glibc-compatible, 220MB. Use Alpine only when size is critical and you've verified native modules compile.- Pin exact versions (
node:22.3.0-slim) — tags likenode:22are mutable and will change under you. - Three-stage build: deps (install all) → builder (compile + prune) → production (compiled JS + prod deps only). Source and devDependencies never reach production.
- Non-root user is mandatory. Container escape as root = host root. One
useraddandUSERinstruction fixes this. HEALTHCHECKtells Docker (and orchestrators) when your app is actually ready. Without it, traffic routes to containers that are still initialising or have crashed post-startup.- Never
ENVsecrets. Inject at runtime via--env-file, Docker secrets, or a secrets manager. - Layer order matters: copy
package.jsonfirst,npm ci, then source code. Keeps the dependency layer cached across most builds. - Logs to stdout — Docker handles rotation, aggregation, and shipping to your log platform. No log files inside containers.
This completes Phase 2: The Practitioner. All 14 modules cover the complete production Node.js application stack — from architecture and auth through TypeScript, testing, security, caching, real-time, REST, GraphQL, background jobs, serialisation, and containerisation.
Next: Phase 3: The Architect begins with A-0 — the mental model reset that bridges everything you've built as a Practitioner to the performance engineering and distributed systems thinking required at the senior/principal level.
Graceful Shutdown — The Deploy Bug Nobody Talks About
Every Kubernetes rolling deploy, every Docker container restart, every PM2 reload sends SIGTERM to your Node.js process before killing it. If your process doesn't handle SIGTERM, here's what happens: Kubernetes waits for terminationGracePeriodSeconds (default 30s), then sends SIGKILL. SIGKILL is immediate and unhandled — active HTTP connections are severed mid-response, database transactions are abandoned, BullMQ jobs are marked as failed even though they were halfway done, Pino's buffer might not flush to stdout before the process dies.
This happens on every single deploy. The bugs are intermittent and hard to reproduce.
The Shutdown Sequence
Kubernetes terminationGracePeriodSeconds Alignment
Kubernetes's default terminationGracePeriodSeconds is 30 seconds. Your application's total shutdown time must fit within this window. If your shutdown sequence takes 35 seconds (slow database drain), Kubernetes sends SIGKILL after 30 seconds, cutting your shutdown short.
The preStop hook runs before SIGTERM. The sleep gives the load balancer (which polls health checks every few seconds) time to mark the pod as "not ready" and stop routing traffic to it before the shutdown begins. Without this, in-flight requests can still arrive during shutdown.
Draining Active HTTP Connections
server.close() stops accepting new connections but waits for existing keep-alive connections to close on their own. Keep-alive connections can stay open indefinitely. Add explicit connection draining:
For graceful HTTP/2 draining (Fastify, h2), use the framework's built-in close method which handles HTTP/2 GOAWAY frames properly.
Testing Your Shutdown Handler
Load test during shutdown to verify no requests are dropped:
Why is handling the SIGTERM signal critical for Node.js applications deployed on container orchestrators like Kubernetes?
Which instruction is essential in a production Dockerfile to prevent severe security vulnerabilities related to host access?
What is the correct way to handle in-flight HTTP connections during a graceful shutdown?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
Sign in & RegisterDiscussion
0Join the discussion