Module P-14·22 min read

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.

Written by Jatin Jain Saraf · Architect of SupraScan

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.

VariantBaseSize (node:22)When to use
node:22Debian Bookworm~1.1 GBNever in production
node:22-slimDebian Bookworm slim~220 MBMost production apps
node:22-alpineAlpine Linux~60 MBWhen 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.

# Use the exact version — never 'latest' in production
FROM node:22.3.0-slim AS base
 
# Alpine — when image size is critical and you've verified native modules work
FROM node:22.3.0-alpine AS base

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
 
# ─── Stage 1: Dependencies ────────────────────────────────────────────────────
# Install ALL dependencies (including devDependencies needed for the build)
FROM node:22.3.0-slim AS deps
 
WORKDIR /app
 
# Copy manifests first — Docker cache layer
# If package.json doesn't change, this layer is cached across builds
COPY package.json package-lock.json ./
 
# npm ci: clean install from lockfile, no network variance
RUN npm ci
 
 
# ─── Stage 2: Builder ─────────────────────────────────────────────────────────
# Compile TypeScript → JavaScript
FROM node:22.3.0-slim AS builder
 
WORKDIR /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 both
COPY package.json package-lock.json ./
 
# Copy all dependencies from the deps stage
COPY --from=deps /app/node_modules ./node_modules
 
# Copy source files
COPY tsconfig.json ./
COPY src ./src
 
# Compile — output to /app/dist
RUN 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 production
 
WORKDIR /app
 
# Security: create a non-root user
# Node apps should never run as root — a container escape as root = host root
RUN groupadd --gid 1001 nodejs \
    && useradd --uid 1001 --gid nodejs --shell /bin/bash --create-home appuser
 
# Copy production dependencies from builder
COPY --from=builder --chown=appuser:nodejs /app/node_modules ./node_modules
 
# Copy compiled application from builder
COPY --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 user
USER appuser
 
# Metadata — does not affect runtime
EXPOSE 3000
LABEL org.opencontainers.image.title="My API"
LABEL org.opencontainers.image.version="1.0.0"
 
# Health check — Docker will mark the container unhealthy if this fails
HEALTHCHECK --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 Node
CMD ["node", "dist/index.js"]

The three-stage pattern:

  1. deps — installs all node_modules including devDependencies. Cached when package.json doesn't change.
  2. builder — compiles TypeScript, then prunes to production deps.
  3. 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.

// src/routes/health.routes.ts
import { 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: new Date().toISOString() });
});
 
// Readiness — is the app ready to serve traffic?
// Returns 503 if any dependency is unhealthy
router.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(),
  });
});
 
export default router;
// src/app.ts — register before auth middleware so health checks don't require a token
app.use('/health', healthRouter);
app.use(authenticate);  // auth starts here
app.use('/users', usersRouter);

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.

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 appuser
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "dist/index.js"]

Docker also ships an equivalent built into the engine, usable without touching the Dockerfile at all:

docker run --init myapp:latest

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.