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 devDependencies in a production image, .dockerignore as 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.
dockerfile
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:
dockerfile
The three-stage pattern:
deps — installs all node_modules including devDependencies. Cached when package.json doesn'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.
Running your Node process as root inside a container is like handing every visitor to your building the master key because you assume they'll only ever use the lobby. Most of the time nothing happens — but the one time a dependency has a remote code execution bug, a container escape as root becomes root on the host instead of a contained, low-privilege nuisance. USER appuser doesn't stop the escape itself; it stops the escape from mattering as much.
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.
typescript
typescript
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.
PID 1 and Zombie Process Reaping
The Dockerfile above already gets one PID 1 detail right: CMD ["node", "dist/index.js"] uses exec form, so Node itself becomes PID 1 and receives SIGTERM directly instead of a shell swallowing it. That's necessary, but it isn't the whole story — PID 1 has a second job on Linux that has nothing to do with signal forwarding: reaping zombie processes.
If your app ever spawns a child process — child_process.exec, a CLI tool shelled out to for image or PDF conversion, a native binary invoked via execa — that child eventually exits and becomes a zombie (a process-table entry with no more work to do) until its parent calls wait() on it. A real init process (systemd, on a normal host) reaps zombies automatically. Inside a container, Node is PID 1, and Node doesn't implement that reaping behavior. Zombies accumulate silently — harmless at low volume, but on a long-running container that spawns many short-lived children (a worker that shells out to ffmpeg per job, say), the process table eventually fills up and new processes fail to spawn.
tini (or dumb-init) is a small, purpose-built binary that runs as PID 1 instead: it reaps zombies and forwards signals correctly to the real application, which it then runs as its child.
dockerfile
Docker also ships an equivalent built into the engine, usable without touching the Dockerfile at all:
bash
--init runs a bundled tini as PID 1 automatically. It's a docker run flag, though, not something Kubernetes exposes — for a workload that also runs under an orchestrator, baking tini into the image via ENTRYPOINT is the portable choice that works the same way everywhere. If your app never spawns child processes, this is a non-issue; the moment it does, pick one of the two.
.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:
text
Secrets: Never Bake Them Into Images
dockerfile
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 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.
Hardening Further: Dropping Capabilities and a Read-Only Root Filesystem
Non-root user and out-of-image secrets close off the two most common container escapes. Two more flags harden what's left of the runtime attack surface, without touching the Dockerfile:
bash
--cap-drop=ALL strips every Linux capability from the container — CAP_NET_RAW (raw sockets), CAP_SYS_ADMIN, CAP_SETUID, and everything else a compromised process could otherwise use to escalate or pivot. A plain Node HTTP server needs essentially none of the roughly 40 capabilities Docker grants by default; drop them all and add back only what's provably required (--cap-add=NET_BIND_SERVICE, if binding to a port below 1024 — rare when the app already runs as non-root on port 3000).
--read-only mounts the container's root filesystem read-only. Paired with running as non-root, an attacker who gets code execution inside the container can't write a webshell, modify node_modules, or drop a persistence script anywhere on disk — there's nowhere to write. Node still needs a writable /tmp for some native modules and temp files, so pair --read-only with --tmpfs /tmp, an in-memory, container-scoped writable directory that never touches the read-only root.
Kubernetes equivalent, in the pod's securityContext:
yaml
Test --read-only locally before shipping it. Any code path that writes to disk outside /tmp — a log file, a cache directory, an upload staging path — fails loudly the first time it runs, which is exactly the point: it surfaces write paths you didn't know existed before they become a production incident.
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.
dockerfile
For monorepos or apps with many packages, use a cache mount:
dockerfile
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:
yaml
bash
V8's Heap Ceiling vs Your Container's Memory Limit
Setting --memory 512m on docker run (or resources.limits.memory in Kubernetes) caps how much RAM the container's cgroup is allowed to use. It says nothing to V8. By default, V8 sizes its heap ceiling (--max-old-space-size) off the total memory of the machine it thinks it's running on — the host's RAM, not the cgroup limit the container is actually confined to. On a host with 64GB, V8 can happily let the old-space heap grow toward a multi-gigabyte ceiling inside a container that's only allowed 512MB.
An indexer service running in Kubernetes, capped at 512MB via resources.limits.memory, kept getting silently restarted — no error logs, no graceful shutdown sequence, just a gap in indexed blocks that support later found by cross-referencing timestamps. V8's default heap ceiling was sized off the total RAM of the underlying cluster node, not the 512MB cgroup limit the container was actually confined to; heap usage climbed past that ceiling, the kernel's OOM killer sent SIGKILL with zero warning, and none of the SIGTERM handling covered earlier in this module ever ran — SIGKILL can't be caught. The gap was about five minutes each time, until --max-old-space-size was pinned comfortably below the container's memory limit and the restarts stopped.
The fix is to cap V8's heap explicitly, below the container's memory limit, leaving headroom for everything V8 allocates outside the old-space heap plus what the Node process itself needs (typically 100–150MB of buffers, native addon memory, and thread stacks):
dockerfile
Or via environment variable, which is often easier to keep in sync with the orchestrator's memory config:
yaml
Rule of thumb: set --max-old-space-size to roughly 75–80% of the container's memory limit, never 100% — the remaining gap covers new-space, code space, and native buffers that V8 allocates outside the old-space heap you're capping, plus whatever else the Node process needs.
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, not node:22-slim)
Image scanned for known vulnerabilities (Trivy, Docker Scout, or Snyk) before it ships — a clean multi-stage build with pinned versions can still carry unpatched CVEs in the base OS layer
Runtime
No secrets in the image (ENV instructions contain no credentials)
Secrets injected via runtime env vars, env files, or secrets manager
CMD uses exec form (["node", "dist/index.js"]), not shell form
Graceful shutdown handles SIGTERM — drains connections, closes DB
Health
/health endpoint returns 200 when the process is alive
HEALTHCHECK in Dockerfile calls the health endpoint
--start-period accounts for Prisma migration / connection setup time
Process
uncaughtException and unhandledRejection handlers 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
--max-old-space-size set below the container's memory limit — otherwise V8 sizes its heap off host RAM, not the cgroup limit, and the container gets silently SIGKILLed with no graceful shutdown
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
bash
Summary
node:22-slim is 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 like node:22 are 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 useradd and USER instruction fixes this.
HEALTHCHECK tells 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 ENV secrets. Inject at runtime via --env-file, Docker secrets, or a secrets manager.
Layer order matters: copy package.json first, 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.
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.
yaml
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.
docker stop's Default Timeout Is Much Shorter Than This
Everything above assumes an orchestrator where the grace period is configured generously — terminationGracePeriodSeconds: 60 gives a full 60 seconds before SIGKILL. Plain docker stop (and the simpler docker run/docker-compose deploy path used elsewhere in this course, in P-8) defaults to a 10-second timeout between SIGTERM and SIGKILL:
bash
If a deploy path doesn't explicitly override this — a docker-compose down, a plain docker stop in a deploy script, a PaaS that shells out to docker stop under the hood — the shutdown sequence built earlier in this section (HTTP drain, BullMQ worker close, DB pool close, log flush) gets truncated at 10 seconds, regardless of how generous terminationGracePeriodSeconds is set in Kubernetes manifests that aren't actually the deploy path in use. The two settings are not interchangeable, and a project that runs on both Kubernetes in production and plain docker run/docker-compose for staging or single-box deploys needs to set the timeout explicitly on every path it actually uses:
yaml
Check whichever deploy mechanism actually runs in production — a correct shutdown handler is wasted if the platform kills the process before it finishes.
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:
javascript
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
bash
Load test during shutdown to verify no requests are dropped:
bash
Knowledge Check
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.
# Use the exact version — never 'latest' in productionFROM node:22.3.0-slim AS base# Alpine — when image size is critical and you've verified native modules workFROM node:22.3.0-alpine AS base
# Dockerfile# ─── Stage 1: Dependencies ────────────────────────────────────────────────────# Install ALL dependencies (including devDependencies needed for the build)FROM node:22.3.0-slim AS depsWORKDIR /app# Copy manifests first — Docker cache layer# If package.json doesn't change, this layer is cached across buildsCOPY package.json package-lock.json ./# npm ci: clean install from lockfile, no network varianceRUN npm ci# ─── Stage 2: Builder ─────────────────────────────────────────────────────────# Compile TypeScript → JavaScriptFROM node:22.3.0-slim AS builderWORKDIR /app# package.json + lockfile are required here — `npm run build` needs package.json# to resolve the build script, and `npm ci` below refuses to run without bothCOPY package.json package-lock.json ./# Copy all dependencies from the deps stageCOPY--from=deps /app/node_modules ./node_modules# Copy source filesCOPY tsconfig.json ./COPY src ./src# Compile — output to /app/distRUN npm run build# Prune to production-only dependencies AFTER compilation# (devDependencies were needed to compile, not to run)RUN npm ci --omit=dev# ─── Stage 3: Production runtime ─────────────────────────────────────────────FROM node:22.3.0-slim AS productionWORKDIR /app# Security: create a non-root user# Node apps should never run as root — a container escape as root = host rootRUN groupadd --gid 1001 nodejs \ && useradd --uid 1001 --gid nodejs --shell /bin/bash --create-home appuser# Copy production dependencies from builderCOPY--from=builder--chown=appuser:nodejs /app/node_modules ./node_modules# Copy compiled application from builderCOPY--from=builder--chown=appuser:nodejs /app/dist ./dist# Copy any non-compiled assets (email templates, static files, etc.)# COPY --from=builder --chown=appuser:nodejs /app/public ./public# Set ownership and switch to non-root userUSER appuser# Metadata — does not affect runtimeEXPOSE 3000LABEL org.opencontainers.image.title="My API"LABEL org.opencontainers.image.version="1.0.0"# Health check — Docker will mark the container unhealthy if this failsHEALTHCHECK--interval=30s--timeout=10s--start-period=40s--retries=3\CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"# exec form — Node is PID 1, receives SIGTERM directly# shell form would make sh PID 1, SIGTERM would not reach NodeCMD ["node", "dist/index.js"]
// src/routes/health.routes.tsimport{ Router }from'express';import prisma from'../db/prisma.js';import redis from'../db/redis.js';const router =Router();// Liveness — is the process alive?router.get('/health',(req, res)=>{ res.json({ status:'ok', timestamp:newDate().toISOString()});});// Readiness — is the app ready to serve traffic?// Returns 503 if any dependency is unhealthyrouter.get('/health/ready',async(req, res)=>{const checks: Record<string,'ok'|'error'>={};try{await prisma.$queryRaw`SELECT 1`; checks.database ='ok';}catch{ checks.database ='error';}try{await redis.ping(); checks.redis ='ok';}catch{ checks.redis ='error';}const healthy = Object.values(checks).every(v => v ==='ok'); res.status(healthy ?200:503).json({ status: healthy ?'ok':'degraded', checks, uptime: process.uptime(), memory: process.memoryUsage(),});});exportdefault router;
// src/app.ts — register before auth middleware so health checks don't require a tokenapp.use('/health', healthRouter);app.use(authenticate);// auth starts hereapp.use('/users', usersRouter);
FROM node:22.3.0-slim AS production# ...RUN apt-get update && apt-get install -y --no-install-recommends tini \ && rm -rf /var/lib/apt/lists/*USER appuserENTRYPOINT ["/usr/bin/tini", "--"]CMD ["node", "dist/index.js"]
docker run --init myapp:latest
# .dockerignore
# Dependencies — reinstalled inside the image
node_modules
# Build output — rebuilt inside the image
dist
build
.next
# Environment files — NEVER in the image
.env
.env.*
*.env
# Git
.git
.gitignore
# Logs
*.log
npm-debug.log*
# Test files — not needed in production image
**/__tests__
**/*.test.ts
**/*.spec.ts
coverage
jest.config.*
# Documentation
README.md
docs
# IDE
.vscode
.idea
*.swp
# OS
.DS_Store
Thumbs.db
# Docker files themselves
Dockerfile*
docker-compose*
# WRONG — secret ends up in the image layer permanentlyENV DATABASE_URL="postgresql://admin:password@prod-db:5432/myapp"# ALSO WRONG — even with --build-arg, the value is in the image metadataARG DATABASE_URLENV DATABASE_URL=${DATABASE_URL}
docker run \-eDATABASE_URL="$DATABASE_URL"\-eJWT_ACCESS_SECRET="$JWT_ACCESS_SECRET"\ myapp:latest
docker run --env-file /etc/myapp/.env myapp:latest
# Developmentdocker-compose-f docker-compose.dev.yml up
# Production — uses the main Dockerfile's production stagedocker-compose up
# For a container with a 512MB memory limit, cap the heap at ~400MB —# leaves 100MB+ headroom for non-heap memory (buffers, native addons, stacks)CMD ["node", "--max-old-space-size=400", "dist/index.js"]
# Build with a tag matching the git commit SHAdocker build \--target production \-t myapp:$(git rev-parse --short HEAD)\-t myapp:latest \.# Push to a registrydocker tag myapp:latest ghcr.io/myorg/myapp:latest
docker push ghcr.io/myorg/myapp:latest
# Inspect image layers — find what's making it largedockerhistory myapp:latest
docker dive myapp:latest # https://github.com/wagoodman/dive
// src/shutdown.jsimport{ server }from'./server.js'import{ pool }from'./db.js'import{ worker }from'./queue.js'importloggerfrom'./logger.js'let isShuttingDown =falseasyncfunctionshutdown(signal){if(isShuttingDown)return isShuttingDown =true logger.info({ signal },'Shutdown initiated')// Step 1: Stop accepting new connections// server.close() stops accepting new connections but waits for existing ones to finishawaitnewPromise((resolve, reject)=>{ server.close((err)=>{if(err)reject(err)elseresolve()})// Force close after 10s if connections don't drainsetTimeout(()=>{ logger.warn('Forcing connection close after timeout')resolve()},10_000)}) logger.info('HTTP server closed')// Step 2: Stop BullMQ workers (finish current job, don't start new ones)await worker.close() logger.info('BullMQ worker stopped')// Step 3: Close database pool (waits for active queries to complete)await pool.end() logger.info('Database pool closed')// Step 4: Flush logs (Pino batches writes — flush before exit)awaitnewPromise((resolve)=> logger.flush(resolve)) process.exit(0)}process.on('SIGTERM',()=>shutdown('SIGTERM'))process.on('SIGINT',()=>shutdown('SIGINT'))// Ctrl+C in development// Catch unhandled promise rejections — don't silently swallow themprocess.on('unhandledRejection',(reason, promise)=>{ logger.error({ reason, promise },'Unhandled promise rejection')shutdown('unhandledRejection')})
# k8s deployment.yamlspec:template:spec:terminationGracePeriodSeconds:60# give the app 60s to shut down gracefullycontainers:-name: api
lifecycle:preStop:exec:# Optional: sleep 5s before SIGTERM so load balancer removes pod from rotation firstcommand:["/bin/sleep","5"]
# docker-compose.ymlservices:api:stop_grace_period: 60s # compose's equivalent of terminationGracePeriodSeconds
importhttpfrom'node:http'const connections =newSet()server.on('connection',(socket)=>{ connections.add(socket) socket.on('close',()=> connections.delete(socket))})asyncfunctioncloseServer(){// Stop accepting new connections server.close()// Destroy all open keep-alive connectionsfor(const socket of connections){ socket.destroy()} connections.clear()}
# Simulate Kubernetes SIGTERMkill-TERM$(pgrep -f"node src/index.js")# Watch logs for the shutdown sequence# Should see: "Shutdown initiated" → "HTTP server closed" → "Worker stopped" → "DB closed"# Should NOT see: "SIGKILL" or abrupt process exit