Module P-14·25 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.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

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.

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:

  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.

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

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.