Docker 80 🐳 Containerizing Node.js, Express, and Fastify Applications for Production
A Node.js application containerized naively produces an image that is both enormous and insecure. A single-stage build with COPY . . followed by npm install bakes every devDependency—TypeScript, Jest, ESLint, Webpack—into the runtime image, along with the shell and package manager that an attacker can use after a breach. The official node:20 image is built on Debian with a full toolchain, and a typical project using it ships several hundred megabytes of packages the application will never invoke . The multi-stage pattern is the structural fix: a fat build stage compiles and installs, and a minimal runtime stage copies only the artifacts and production dependencies. The second fix is the non-root user, which the official images provide but do not activate by default . The third is the signal handling, because a Node.js process running as PID 1 does not respond to SIGTERM the way an init system expects .
Express and Fastify differ in one production detail that matters for containerization: Fastify’s listen method requires an explicit host: '0.0.0.0' in a container, because the default binds to localhost and the port mapping cannot reach it . Express’s app.listen(port) binds to all interfaces by default, but the same 0.0.0.0 explicit binding is the correct practice for both. The rest of the containerization discipline is identical: a multi-stage Dockerfile, a -slim or distroless runtime base, npm ci for reproducible installs, a non-root USER, and a health check endpoint.
Key point: The production Node.js Dockerfile has three stages—build, production-deps, and runtime—uses npm ci --omit=dev for the runtime dependencies, switches to the non-root node user with COPY --chown, and runs the app with CMD ["node", "dist/server.js"] behind a dumb-init or --init process so that SIGTERM reaches Node.js. Fastify must bind to 0.0.0.0 explicitly; Express binds there by default but should be explicit.
Why the runtime image is not the build image
The devDependency problem. A TypeScript project’s devDependencies—the compiler, the test runner, the linter, the bundler, and their transitive trees—routinely outweigh the production dependencies several times over . A single-stage build ships all of it. The multi-stage build runs npm ci (with devDependencies) and npm run build in the build stage, then starts a fresh runtime stage and copies only the compiled output and a production-only node_modules . The final image never contains the compiler or the test suite.
The shell problem. The full node:20 image includes bash, apt, and build-essential. Every one of those is a tool an attacker can use after gaining code execution. The -slim variant drops much of the tooling while staying on glibc, which avoids the native-addon compatibility problems that Alpine’s musl libc causes . The distroless variant drops the shell and package manager entirely, which means a remote code execution bug cannot be escalated into an interactive reverse shell . The trade-off is debugging: distroless has no shell, so kubectl exec -it -- sh does not work, and the :debug variant is needed for non-production troubleshooting .
The npm ci problem. npm install resolves dependency ranges at build time, which means the same Dockerfile can produce a different image tomorrow. npm ci installs exactly what the lockfile specifies, deletes node_modules first, and fails if package.json and the lockfile disagree . This is the defense against a compromised package being published between builds—the September 2025 incident where malicious versions of chalk and debug were published is the concrete example . The production stage uses npm ci --omit=dev so the devDependencies never land in the runtime layer.
The PID 1 problem. When a Node.js process runs as PID 1 in a container, the kernel treats it differently: SIGTERM does not invoke the default termination behavior unless the process installs a handler . The Docker documentation’s guidance is explicit: use dumb-init or the --init flag so that an init process runs as PID 1 and forwards signals to Node.js . The CMD ["node", "server.js"] exec form is also required—the shell form (CMD node server.js) wraps the process in /bin/sh, which does not forward signals to its child .
a. The multi-stage Dockerfile for Express and Fastify
The Dockerfile is the same for both frameworks. The difference is the entry point file and the listen call inside it.
# syntax=docker/dockerfile:1
# ---- Stage 1: build ----
# The full node image has the toolchain needed to compile TypeScript
FROM node:22-slim AS build
WORKDIR /app
# Copy the manifests first so the dependency layer is cached
# A source change does not invalidate the npm ci layer
COPY package.json package-lock.json ./
# Install all dependencies, including devDependencies
RUN npm ci
# Copy the source and compile
COPY . .
RUN npm run build
# ---- Stage 2: production dependencies ----
# A separate stage installs only the runtime dependencies
# This keeps the devDependencies out of the final image
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ---- Stage 3: runtime ----
# The runtime stage is the smallest that can run the app
FROM node:22-slim AS runtime
# The production environment variable
ENV NODE_ENV=production
WORKDIR /app
# Copy the production node_modules with the correct ownership
# --chown sets the ownership during the copy, avoiding a separate chown layer
COPY --chown=node:node --from=deps /app/node_modules ./node_modules
# Copy the compiled output with the correct ownership
COPY --chown=node:node --from=build /app/dist ./dist
# Copy the package.json for the start script and metadata
COPY --chown=node:node --from=build /app/package.json ./package.json
# Switch to the non-root user that ships with the official image
USER node
# Expose the port the app listens on
EXPOSE 3000
# The health check probes the /health endpoint
# The --spider flag makes wget send a HEAD request
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# The exec form ensures Node.js receives signals directly
# dumb-init is not in the slim image, so the --init flag on docker run is the alternative
CMD ["node", "dist/server.js"]
The COPY --chown=node:node is the detail that makes the USER node line actually effective. A plain COPY runs as root and writes files owned by root. The USER node line switches the runtime user, but the app process then runs as an unprivileged user against files it may not be able to write to, and the ownership is inconsistent . The --chown flag sets the ownership during the copy, and the USER node line switches the user after the files are in place.
The .dockerignore is the other half of the build context. Without it, COPY . . drags node_modules, .git, .env files, and local build artifacts into the build stage, and the .env files can leak into a layer that is later copied to the runtime stage .
node_modules
.git
.env*
dist
coverage
*.md
Dockerfile*
b. The application binding: 0.0.0.0 and the health check
The Express and Fastify applications must bind to all interfaces, not to localhost. In a container, the port mapping forwards from the host to the container’s network namespace. If the app binds to 127.0.0.1, the port mapping cannot reach it, and the request fails with a connection refused or a 502 from the proxy .
// server.js for Express
import express from 'express'
const app = express()
const PORT = process.env.PORT || 3000
// The health check endpoint the Docker HEALTHCHECK probes
// It returns 200 and a small JSON body
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok' })
})
app.get('/', (req, res) => {
res.json({ message: 'hello' })
})
// Bind to 0.0.0.0 so the container's port mapping can reach the server
// Binding to localhost would make the app unreachable from outside the container
app.listen(PORT, '0.0.0.0', () => {
console.log(`Listening on 0.0.0.0:${PORT}`)
})
The Fastify version has the same requirement, and the Fastify documentation is explicit about it: containers do not default to exposing mapped ports to localhost, and the 0.0.0.0 address must be set explicitly with the --address flag or the FASTIFY_ADDRESS environment variable when the container is not detected automatically .
// server.js for Fastify
import Fastify from 'fastify'
const app = Fastify({ logger: true })
const PORT = process.env.PORT || 3000
// The health check endpoint
app.get('/health', async () => {
return { status: 'ok' }
})
app.get('/', async () => {
return { message: 'hello' }
})
// Bind to 0.0.0.0 explicitly
// Fastify defaults to 127.0.0.1, which is unreachable from the port mapping
await app.listen({ port: PORT, host: '0.0.0.0' })
The graceful shutdown is the other production requirement. When the orchestrator sends SIGTERM, the application must stop accepting new connections, finish the in-flight requests, close the database connections, and exit. Express does not have a built-in graceful shutdown; the application must listen for the signal and call server.close(). Fastify provides app.close() for the same purpose, and the close-with-grace package wraps the signal handling .
// Graceful shutdown for Express
const server = app.listen(PORT, '0.0.0.0')
process.on('SIGTERM', () => {
// Stop accepting new connections
server.close(() => {
// Close the database connection
db.end().then(() => process.exit(0))
})
// Release idle keep-alive connections
server.closeIdleConnections()
})
The server.closeIdleConnections() call is the detail that prevents the shutdown from hanging on an idle keep-alive connection, which is the same mechanism from the Node.js graceful shutdown chapter.
c. The production image verification and the --init flag
The verification sequence checks the image size, the non-root user, the health check, and the signal handling.
# Build the image
docker build -t myapp:1.0 .
# Check the image size
docker images myapp:1.0 --format "{{.Size}}"
# Expected: ~250MB for the -slim base, ~150MB for Alpine, ~100MB for distroless
# Verify the user is not root
docker run --rm myapp:1.0 id
# Expected: uid=1000(node) gid=1000(node)
# Verify the health check
docker run -d --name myapp-test myapp:1.0
docker inspect --format='{{.State.Health.Status}}' myapp-test
# Expected: healthy after the start period
# Test the graceful shutdown
docker stop myapp-test
# The logs should show the server closing cleanly, not a 10-second timeout
The --init flag is the alternative to dumb-init when the base image does not include it. The flag inserts tini as PID 1, which forwards signals to the Node.js process and reaps orphaned child processes . The docker run --init form is the simplest fix when the runtime image is a distroless or slim image without dumb-init.
# Run with the --init flag to get signal forwarding
docker run --init -d --name myapp myapp:1.0
The signal handling test is the one that catches the PID 1 problem. Without --init or dumb-init, docker stop waits the full 10-second timeout before sending SIGKILL, because the Node.js process as PID 1 ignores the SIGTERM. With the init process, the shutdown completes in milliseconds.
Complete Example Session
The following session builds and verifies a production image for a Fastify application.
# ============================================
# PART 1: THE DOCKERFILE
# ============================================
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 --chown=node:node --from=deps /app/node_modules ./node_modules
COPY --chown=node:node --from=build /app/dist ./dist
COPY --chown=node:node --from=build /app/package.json ./package.json
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]
// ============================================
// PART 2: THE FASTIFY SERVER
// ============================================
import Fastify from 'fastify'
const app = Fastify({ logger: true })
const PORT = process.env.PORT || 3000
// The health check endpoint
app.get('/health', async () => ({ status: 'ok' }))
app.get('/', async () => ({ message: 'hello' }))
// Bind to 0.0.0.0 so the container port is reachable
await app.listen({ port: PORT, host: '0.0.0.0' })
# ============================================
# PART 3: BUILD AND VERIFY
# ============================================
docker build -t myapp:1.0 .
# Check the size
docker images myapp:1.0 --format "{{.Size}}"
# Expected: ~250MB
# Check the user
docker run --rm myapp:1.0 id
# Expected: uid=1000(node)
# Run with the init flag
docker run --init -d --name myapp-test -p 3000:3000 myapp:1.0
# Check the health
docker inspect --format='{{.State.Health.Status}}' myapp-test
# Expected: healthy
# Test the graceful shutdown
docker stop myapp-test
# The logs show the server closing, not a timeout
The three parts cover the Dockerfile, the Fastify server with the 0.0.0.0 binding, and the build and verification commands. What the session demonstrates is that the runtime image is small, the process runs as a non-root user, the health check passes, and the shutdown is clean.
Quick Reference
Dockerfile Stages
| Stage | Purpose | Base |
|---|---|---|
| build | Compile, install all deps | node:22-slim |
| deps | Production deps only | node:22-slim |
| runtime | Run the app | node:22-slim or distroless |
Base Image Choice
| Image | Size | Native Addons | Shell |
|---|---|---|---|
node:22 | ~1.1 GB | ✅ glibc | ✅ |
node:22-slim | ~200 MB | ✅ glibc | ✅ |
node:22-alpine | ~150 MB | ⚠️ musl | ✅ |
distroless/nodejs22 | ~100 MB | ✅ glibc | ❌ |
Production Checklist
| Item | Value |
|---|---|
| Install | npm ci --omit=dev |
| User | USER node + COPY --chown |
| Signal | CMD ["node", "dist/server.js"] + --init |
| Health check | HEALTHCHECK on /health |
| Binding | 0.0.0.0 for Fastify, explicit for Express |
Framework Differences
| Aspect | Express | Fastify |
|---|---|---|
| Default bind | 0.0.0.0 | 127.0.0.1 |
| Explicit bind | app.listen(port, '0.0.0.0') | app.listen({ host: '0.0.0.0' }) |
| Graceful close | server.close() | app.close() |
Best Practices
✅ Do This:
# Use multi-stage builds
FROM node:22-slim AS build
RUN npm ci && npm run build
FROM node:22-slim AS runtime
COPY --chown=node:node --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
❌ Don’t Do This:
# Single-stage build with dev dependencies
FROM node:22
COPY . .
RUN npm install
CMD npm start
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Container unreachable | Fastify binds to localhost | host: '0.0.0.0' |
| Image over 1 GB | Single-stage, dev deps included | Multi-stage + --omit=dev |
| Docker stop hangs | Node as PID 1 ignores SIGTERM | --init or dumb-init |
| Permission denied | COPY as root, then USER node | COPY --chown=node:node |
| Native addon fails | Alpine musl vs glibc | Use -slim |
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
2. Production deps only
RUN npm ci --omit=dev
3. Non-root user
COPY --chown=node:node --from=build /app/dist ./dist
USER node
4. Signal handling
CMD ["node", "dist/server.js"]
5. Fastify binding
await app.listen({ port: PORT, host: '0.0.0.0' })
6. Express binding
app.listen(PORT, '0.0.0.0')
7. Health check
HEALTHCHECK CMD wget --spider http://localhost:3000/health || exit 1
8. Init flag
docker run --init -d myapp:1.0
9. Verify user
docker run --rm myapp:1.0 id
10. Verify size
docker images myapp:1.0 --format "{{.Size}}"
Visual
Multi-Stage Build
┌──────────────────────────────────────────────┐
│ BUILD STAGE (node:22-slim) │
│ - npm ci (all deps) │
│ - npm run build │
│ - output: dist/ + node_modules/ │
│ │ │
│ ▼ │
│ RUNTIME STAGE (node:22-slim) │
│ - COPY --from=build dist/ │
│ - COPY --from=deps node_modules/ │
│ - USER node │
│ - CMD ["node", "dist/server.js"] │
└──────────────────────────────────────────────┘
PID 1 and Signal Handling
┌──────────────────────────────────────────────┐
│ WITHOUT --init │
│ PID 1: node server.js │
│ SIGTERM → ignored (Node not designed for 1) │
│ docker stop waits 10s → SIGKILL │
│ │
│ WITH --init │
│ PID 1: tini │
│ SIGTERM → forwarded to node │
│ node closes cleanly │
└──────────────────────────────────────────────┘
Fastify Binding
┌──────────────────────────────────────────────┐
│ Fastify default: 127.0.0.1 │
│ → port mapping cannot reach it │
│ │
│ Fastify container: 0.0.0.0 │
│ → port mapping reaches the server │
│ │
│ await app.listen({ port, host: '0.0.0.0' }) │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Multi-stage | build, deps, runtime |
| Production install | npm ci --omit=dev |
| Non-root | USER node + COPY --chown |
| Signal handling | --init or dumb-init |
| Fastify binding | 0.0.0.0 explicitly |
| Express binding | 0.0.0.0 explicitly |
| Health check | /health endpoint |
| Base image | -slim for native addons, distroless for hardened |
Key takeaways:
- The multi-stage build is the largest single win. The build stage installs everything and compiles; the runtime stage copies only the compiled output and the production dependencies. The compiler and the test suite never reach the final image .
npm ci --omit=devis mandatory for reproducibility. It installs exactly what the lockfile specifies and excludes the devDependencies that are pure attack surface in production .COPY --chown=node:nodemakesUSER nodeeffective. A plainCOPYwrites root-owned files, and theUSERline switches the runtime user without changing the ownership. The--chownflag sets both at once .- Node.js as PID 1 ignores
SIGTERM. The--initflag or adumb-initentrypoint inserts an init process that forwards the signal, and theCMDexec form ensures the signal reaches Node.js . - Fastify must bind to
0.0.0.0. The default is127.0.0.1, which the container’s port mapping cannot reach. Express binds to0.0.0.0by default, but the explicit binding is the correct practice for both . - The base image choice is a compatibility trade-off.
-slimis the safe default for native addons. Distroless is the hardened choice for pure-JavaScript applications. Alpine is for projects verified against musl .
Remember: Containerizing a Node.js application for production 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. The multi-stage build handles size. The node user handles security. The --init flag and the exec-form CMD handle correctness. The 0.0.0.0 binding handles reachability. 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 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!