| |

Node.js 78 🟢 Containerizing Node.js Applications with Docker

⏱️ Estimated read: 12 min | |

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

StageBasePurpose
buildnode:22-slimInstall all deps, compile
depsnode:22-slimInstall production deps only
runtimenode:22-slimCopy artifacts, run

Base Image Comparison

ImageSizeNative AddonsShell
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

InstructionValue
npm ciReproducible install from lockfile
--omit=devExclude dev dependencies
USER nodeRun as non-root
CMD ["node", "..."]Exec form for signal delivery

Signal Handling

ProblemFix
SIGTERM ignoredExec-form CMD
Idle connections hangserver.closeIdleConnections()
Zombie processesinit: true in Compose
Hard timeoutstop_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

PitfallWhy It HappensFix
Image over 1 GBSingle-stage, dev deps includedMulti-stage + --omit=dev
Native addon fails on Alpinemusl vs glibc mismatchUse -slim
docker stop hangs 10sShell-form CMD, no signal handlerExec form + SIGTERM handler
Secrets in imageNo .dockerignoreAdd .env to ignore
Slow rebuildsCOPY . . before npm ciCopy 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

ItemValue
Multi-stage buildBuild, deps, runtime stages
Base image defaultnode:22-slim
Dependency installnpm ci --omit=dev
Non-root userUSER node (UID 1000)
Signal formExec CMD ["node", "..."]
Graceful shutdownSIGTERM handler + closeIdleConnections
Init processinit: 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=dev is 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. -slim is 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 a node user for this purpose .
  • Exec-form CMD is required for signal delivery. Shell-form commands run as a child of /bin/sh, and the shell does not forward SIGTERM to 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 .
  • .dockerignore prevents secrets and bloat. Without it, node_modules, .git, and .env files 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!