Node.js 78 🟢 Containerizing Node.js Applications with Docker
A Node.js application containerized naively produces an image over a gigabyte, runs as root, ships every dev dependency and the TypeScript compiler into production, and ignores SIGTERM when Docker tries to stop it gracefully. Each of these is fixable with a few Dockerfile lines, and the fixes are not new—multi-stage builds have existed since 2017—yet the majority of Node.js Dockerfiles in the wild still skip them . The cost is measurable: a single-stage build of a typical Node service lands around 1.2 GB, while a properly structured multi-stage build with a slim runtime base lands around 240 MB . The smaller image is not just faster to pull; it has a smaller attack surface because the compiler toolchain and dev dependencies never reach the final layer.
The containerization process has four decisions that matter more than the rest. The base image determines size and compatibility: Alpine is the smallest but breaks native addons compiled against glibc; -slim is the pragmatic default for most services . The multi-stage structure separates the build environment from the runtime environment, so only production dependencies and compiled artifacts reach the final image . The non-root user satisfies Kubernetes’ restricted pod security standard and limits the damage of a container escape . And the signal handling determines whether docker stop waits ten seconds for SIGKILL or shuts the server down cleanly in milliseconds .
Key point: A production Node.js Dockerfile uses a multi-stage build with a -slim or -alpine runtime base, installs dependencies with npm ci --omit=dev, runs as the non-root node user, and uses exec-form CMD with an explicit SIGTERM handler so the container stops gracefully instead of being killed after the timeout.
Why the base image choice matters more than size
The glibc problem. Alpine uses musl libc instead of glibc. Any dependency that ships a native binary compiled against glibc—image processing libraries, cryptography bindings, native database drivers—may fail to load on Alpine or require a from-source rebuild against musl . This is not a rare edge case; it is the most frequently reported friction point in Alpine adoption for Node.js. The -slim variant sticks with Debian and glibc while still stripping the full Debian userland, cutting the image from over 1 GB to a few hundred megabytes . For services without native addons, distroless goes further: no shell, no package manager, no /bin/sh, which means a remote code execution bug cannot be escalated into an interactive reverse shell .
The pinning problem. A base image tag like node:22-alpine can be updated underneath you. The fix is to pin a specific minor version and ideally a digest: node:22.5.1-slim@sha256:... . This ensures that a base-image update cannot silently change what the container ships.
The layer caching problem. Docker caches each layer. If package.json and package-lock.json are copied before the source code, and npm ci runs before COPY . ., then changing a source file does not invalidate the dependency installation layer. The build reuses the cached node_modules and only re-runs the source copy and compile steps . This is the difference between a 30-second rebuild and a 3-minute rebuild during development.
a. The multi-stage Dockerfile
The Dockerfile has three stages: a build stage with full tooling, a production-dependencies stage, and a minimal runtime stage. The build stage installs everything including dev dependencies and runs the build. The deps stage installs only production dependencies. The runtime stage copies the compiled output and the production node_modules from the previous stages.
# syntax=docker/dockerfile:1
# ---- Build stage: full tooling ----
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- Production deps: no dev dependencies ----
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ---- Runtime: minimal ----
FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
The npm ci command is not interchangeable with npm install. npm ci requires a package-lock.json to exist, installs exactly what the lockfile specifies, and fails if package.json and the lockfile disagree . npm install can silently resolve to newer versions, which means the image is not reproducible and a compromised or typosquatted update can slip in. The --omit=dev flag removes test frameworks, linters, and build tools from the production layer.
The USER node instruction switches to the non-root user that ships with the official Node images (UID 1000). This is not optional for production: Kubernetes’ restricted pod security standard enforces runAsNonRoot, and a container running as root will not schedule in a properly configured cluster .
The .dockerignore file is equally important. Without it, COPY . . drags node_modules, .git history, .env files, and local logs into the image. Secrets baked into a layer persist even if a later layer deletes them . A minimal .dockerignore lists node_modules, .git, .env*, dist, coverage, and *.md.
b. Signal handling and graceful shutdown
A Node.js process running as PID 1 in a container does not get the default signal handlers the kernel gives other processes, and it does not reap orphaned child processes . The symptoms are visible: docker stop takes the full 10-second timeout before sending SIGKILL because SIGTERM was ignored, and any child processes become zombies.
The first fix is the exec form of CMD. CMD ["node", "dist/server.js"] makes Node the PID 1 process and the direct recipient of signals. CMD node dist/server.js runs the command as a child of /bin/sh, and the shell does not forward signals to its child by default . The node process never sees SIGTERM, and the container is killed after the timeout.
The second fix is an explicit SIGTERM handler in the application. server.close() stops accepting new connections but does not close idle keep-alive connections, which means the server may hang until the timeout if a client is holding an idle connection . The complete sequence is server.close(), then server.closeIdleConnections() to release the idle keep-alive connections, then process.exit(0) after the server has drained.
const server = app.listen(3000);
process.on('SIGTERM', () => {
server.close(() => {
process.exit(0);
});
server.closeIdleConnections();
});
The third fix is a container init process for cases where the application spawns child processes or uses a shell entrypoint. Docker’s built-in --init flag, or init: true in Compose, inserts tini as PID 1 to forward signals and reap zombies . The bellsoft/hardened-nodejs image includes tini and configures it as the entrypoint by default .
c. Docker Compose and the development workflow
The containerized application is one service in a larger stack. Docker Compose defines the Node service alongside its database, cache, and reverse proxy, with a shared network and persistent volumes for data that must survive container restarts .
services:
api:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://user:pass@db:5432/app
depends_on:
- db
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: secret
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
The depends_on ensures the database container starts before the API container, but it does not wait for the database to be ready to accept connections. The API must implement retry logic on startup, or the first connection attempt will fail . The restart: unless-stopped policy restarts the API if it crashes, but it does not restart it if the container is explicitly stopped.
For development, a separate Dockerfile.dev or a Compose override mounts the source code as a volume and runs the development server with hot reload. The production image is built from the multi-stage Dockerfile and does not include the development tooling.
Complete Example Session
The following session builds a production Dockerfile for a TypeScript Express API, adds the .dockerignore and SIGTERM handler, and verifies the image size and graceful shutdown.
# ============================================
# PART 1: THE MULTI-STAGE DOCKERFILE
# ============================================
# syntax=docker/dockerfile:1
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
# ============================================
# PART 2: THE .dockerignore
# ============================================
# node_modules
# .git
# .env*
# dist
# coverage
# *.md
# ============================================
# PART 3: THE GRACEFUL SHUTDOWN HANDLER
# ============================================
# src/server.ts
const server = app.listen(process.env.PORT || 3000);
process.on('SIGTERM', () => {
server.close(() => {
process.exit(0);
});
server.closeIdleConnections();
});
# ============================================
# PART 4: BUILD AND CHECK SIZE
# ============================================
# docker build -t api:1.0 .
# docker images api:1.0 --format "{{.Size}}"
# Expected: ~250MB (vs ~1.2GB for single-stage)
# ============================================
# PART 5: RUN AND TEST GRACEFUL SHUTDOWN
# ============================================
# docker run -d --name api-test -p 3000:3000 api:1.0
# docker stop api-test
# docker logs api-test
# The logs show the server closing cleanly,
# not a 10-second timeout followed by SIGKILL.
The five parts cover the multi-stage Dockerfile, the .dockerignore, the SIGTERM handler, the size comparison, and the shutdown test. What the session demonstrates is that the image size reduction comes from the multi-stage structure and the slim base, and the graceful shutdown comes from the exec-form CMD and the explicit signal handler.
Quick Reference
Multi-Stage Structure
| Stage | Base | Purpose |
|---|---|---|
| build | node:22-slim | Install all deps, compile |
| deps | node:22-slim | Install production deps only |
| runtime | node:22-slim | Copy artifacts, run |
Base Image Comparison
| Image | Size | Native Addons | Shell |
|---|---|---|---|
node:22 | ~1.1 GB | ✅ glibc | ✅ |
node:22-slim | ~200 MB | ✅ glibc | ✅ |
node:22-alpine | ~150 MB | ⚠️ musl | ✅ |
distroless/nodejs | ~100 MB | ✅ glibc | ❌ |
Critical Dockerfile Instructions
| Instruction | Value |
|---|---|
npm ci | Reproducible install from lockfile |
--omit=dev | Exclude dev dependencies |
USER node | Run as non-root |
CMD ["node", "..."] | Exec form for signal delivery |
Signal Handling
| Problem | Fix |
|---|---|
SIGTERM ignored | Exec-form CMD |
| Idle connections hang | server.closeIdleConnections() |
| Zombie processes | init: true in Compose |
| Hard timeout | stop_grace_period |
Best Practices
✅ Do This:
FROM node:22-slim AS build
RUN npm ci
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
❌ Don’t Do This:
FROM node:22
RUN npm install
COPY . .
CMD npm start
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Image over 1 GB | Single-stage, dev deps included | Multi-stage + --omit=dev |
| Native addon fails on Alpine | musl vs glibc mismatch | Use -slim |
docker stop hangs 10s | Shell-form CMD, no signal handler | Exec form + SIGTERM handler |
| Secrets in image | No .dockerignore | Add .env to ignore |
| Slow rebuilds | COPY . . before npm ci | Copy package files first |
Real-World Examples
1. Multi-stage build
FROM node:22-slim AS build
RUN npm ci && npm run build
FROM node:22-slim AS runtime
COPY --from=build /app/dist ./dist
USER node
2. Production deps only
RUN npm ci --omit=dev
3. Graceful shutdown
process.on('SIGTERM', () => {
server.close(() => process.exit(0));
server.closeIdleConnections();
});
4. Docker Compose with init
services:
api:
build: .
init: true
5. Pin base image
FROM node:22.5.1-slim@sha256:...
6. .dockerignore
node_modules
.env
.git
dist
7. Expose port
EXPOSE 3000
8. Non-root user
USER node
9. Health check
HEALTHCHECK CMD curl -f http://localhost:3000/health || exit 1
10. Stop grace period
stop_grace_period: 30s
Visual
Single-Stage vs Multi-Stage
┌──────────────────────────────────────────────┐
│ SINGLE-STAGE (1.2 GB) │
│ node:22 base (~1.1 GB) │
│ + node_modules (all deps) │
│ + source code │
│ + TypeScript compiler │
│ + test frameworks │
│ → runs as root │
│ │
│ MULTI-STAGE (240 MB) │
│ node:22-slim runtime (~200 MB) │
│ + production node_modules │
│ + compiled dist │
│ → runs as node user │
└──────────────────────────────────────────────┘
Signal Handling
┌──────────────────────────────────────────────┐
│ SHELL FORM: CMD npm start │
│ /bin/sh (PID 1) receives SIGTERM │
│ node (PID 7) never gets it │
│ → 10s timeout → SIGKILL │
│ │
│ EXEC FORM: CMD ["node", "dist/server.js"] │
│ node (PID 1) receives SIGTERM directly │
│ → graceful shutdown → exit 0 │
└──────────────────────────────────────────────┘
Graceful Shutdown Sequence
┌──────────────────────────────────────────────┐
│ SIGTERM received │
│ │ │
│ ▼ │
│ server.close() │
│ (stop accepting new connections) │
│ │ │
│ ▼ │
│ server.closeIdleConnections() │
│ (release idle keep-alive) │
│ │ │
│ ▼ │
│ in-flight requests finish │
│ │ │
│ ▼ │
│ process.exit(0) │
└──────────────────────────────────────────────┘
Base Image Decision
┌──────────────────────────────────────────────┐
│ Native addons? │
│ ├─ YES → node:22-slim (glibc) │
│ └─ NO → Need shell for debugging? │
│ ├─ YES → node:22-slim │
│ └─ NO → distroless/nodejs │
│ (no shell, smallest attack) │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Multi-stage build | Build, deps, runtime stages |
| Base image default | node:22-slim |
| Dependency install | npm ci --omit=dev |
| Non-root user | USER node (UID 1000) |
| Signal form | Exec CMD ["node", "..."] |
| Graceful shutdown | SIGTERM handler + closeIdleConnections |
| Init process | init: true in Compose |
| Image size reduction | ~80% vs single-stage |
Key takeaways:
- Multi-stage builds separate build tooling from runtime. The final image contains only production dependencies and compiled artifacts, reducing a 1.2 GB image to roughly 240 MB .
npm ci --omit=devis mandatory for production. It installs exactly what the lockfile specifies and excludes test frameworks, linters, and compilers .- The base image choice is a compatibility trade-off. Alpine is smallest but breaks native addons compiled against glibc.
-slimis the safe default for most services . - Running as root is a deployment blocker. Kubernetes’ restricted pod security standard enforces
runAsNonRoot, and the official Node images ship anodeuser for this purpose . - Exec-form
CMDis required for signal delivery. Shell-form commands run as a child of/bin/sh, and the shell does not forwardSIGTERMto the Node process . server.close()does not close idle keep-alive connections.server.closeIdleConnections()must be called to release them, or the shutdown will hang until the timeout ..dockerignoreprevents secrets and bloat. Without it,node_modules,.git, and.envfiles are copied into the image, and secrets in layers persist even if deleted later .
Remember: Containerizing a Node.js application is not about wrapping npm start in a Dockerfile. It is about producing an image that is small enough to pull quickly, secure enough to run in a restricted cluster, and correct enough to shut down gracefully when the orchestrator asks it to stop. The multi-stage build handles size. The node user handles security. The exec-form CMD and SIGTERM handler handle correctness. Each of these is a few lines, and each of them is routinely skipped. The difference between an image that works on a developer’s machine and one that works in production is not the application code—it is these four Dockerfile decisions.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!