Dockerizing a Node.js App in 2026: The Practical Guide
Not another "what is Docker" post. This is the exact setup I use for every production Node.js service.
The Goal
Take a typical Node.js app and make it:
- Reproducible — same environment everywhere
- Portable — runs on any cloud provider
- Efficient — small images, fast builds
- Production-ready — health checks, logging, security
Starting Point
// server.js
const express = require('express');
const app = express();
app.get('/health', (req, res) => res.json({ status: 'ok', time: Date.now() }));
app.get('/api/hello', (req, res) => res.json({ message: 'Hello World' }));
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Running on :${PORT}`));
{
"name": "my-app",
"version": "1.0.0",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.21.0"
}
}
The Dockerfile (Final Version)
I'll show the final version first, then break it down:
# ---- Build Stage ----
FROM node:22-alpine AS builder
WORKDIR /app
# Install dependencies first (layer caching)
COPY package.json package-lock.json ./
RUN npm ci --only=production && npm cache clean --force
# Copy source code
COPY . .
# ---- Production Stage ----
FROM node:22-alpine AS runner
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
adduser -S myuser -u 1001 -G nodejs
WORKDIR /app
# Copy built files from builder stage
COPY --from=builder --chown=myuser:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=myuser:nodejs /app/server.js .
# Switch to non-root user
USER myuser
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "server.js"]
Image size: ~180MB (vs ~1GB without optimization)
Line-by-Line Breakdown
Why Multi-Stage?
Stage 1 (builder): Installs all deps + copies source → produces /app with node_modules
Stage 2 (runner): Copies only what's needed → final image is tiny
The builder stage's npm install, package-lock.json, even npm itself — none of it ends up in the final image.
Why Alpine?
node:22 → 1.05 GB (full Debian)
node:22-slim → 220 MB (stripped Debian)
node:22-alpine → 180 MB (musl libc + busybox)
Alpine uses musl libc instead of glibc. Smaller, but has some compatibility quirks with native modules.
Why Non-Root User?
# If your container is compromised...
# Running as root = attacker has root access to the container
# Running as myuser = attacker only has user-level access
This is a requirement for many Kubernetes deployments and a security best practice everywhere.
The HEALTHCHECK
This is the most overlooked part:
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
Without this:
- Docker doesn't know if your app crashed
- Kubernetes won't restart unhealthy pods
- Load balancers will send traffic to dead containers
Always expose a /health endpoint.
docker-compose.yml for Local Development
version: '3.8'
services:
app:
build: .
ports:
- '${PORT:-3000}:3000'
environment:
- NODE_ENV=production
- PORT=3000
restart: unless-stopped
healthcheck:
test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:3000/health']
interval: 30s
timeout: 3s
retries: 3
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
# Optional: Redis for sessions/cache
redis:
image: redis:7-alpine
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
volumes:
redis_data:
Building and Running
# Build the image
docker build -t my-app:latest .
# Run it
docker run -d \
--name my-app \
-p 3000:3000 \
-e NODE_ENV=production \
--restart unless-stopped \
my-app:latest
# Check logs
docker logs -f my-app
# Check health
docker inspect --format='{{json .State.Health.Status}}' my-app
# Enter running container for debugging
docker exec -it my-app sh
Common Pitfalls (I Made All of These)
1. Forgetting .dockerignore
# .dockerignore
node_modules
npm-debug.log
.git
.env
coverage
.DS_Store
*.md
Without this, COPY . . copies EVERYTHING including node_modules you just installed. Your builds will be slow and images huge.
2. Using COPY Before RUN npm install
# ❌ BAD: Changes code → invalidates npm install cache
COPY . .
RUN npm install
# ✅ GOOD: Only re-runs npm install when deps change
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
Docker caches each layer. If package.json doesn't change, it skips npm install entirely.
3. Not Pinning Node Version
# ❌ Could break when 23 releases
FROM node:alpine
# ✅ Predictable builds
FROM node:22-alpine
4. Exposing Secrets via ENV in Image
# ❌ Secret baked into image layers
ENV DATABASE_URL=postgres://user:pass@host/db
# ✅ Pass at runtime
# docker run -e DATABASE_URL=... my-app
# Or use docker secrets / Kubernetes secrets
Use docker-compose.yml or runtime env vars. Never put secrets in the Dockerfile.
Optimizing for CI/CD
# .github/workflows/docker.yml
name: Build & Push Docker Image
on:
push:
tags: ['v*']
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
Monitoring Resource Usage
# Real-time stats
docker stats my-app
# One-time snapshot
docker stats --no-stream my-app
# Expected output for our app:
CONTAINER CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
my-app 0.15% 45.2MiB / 512MiB 8.83% 1.2kB / 0.8kB 0B / 0B 11
A well-behaved Node.js container should use < 100MB RAM at idle.
When Things Go Wrong
# Container keeps restarting?
docker logs --tail 50 my-app
# Usually: missing env vars, wrong port, dependency crash
# Can't access from host?
docker port my-app
# Check port mapping matches
# Image too big?
docker history my-app:latest
# Find which layer is bloated
# Permission denied errors?
# You're probably running as root inside the container
# Fix: add USER directive to Dockerfile
The Complete Checklist
Before shipping to production:
- [ ]
.dockerignoreexists and coversnode_modules,.git,.env - [ ] Multi-stage build (separate builder and runner)
- [ ] Non-root user in final stage
- [ ] HEALTHCHECK configured
- [ ] No secrets in Dockerfile or image layers
- [ ] Node version pinned (
node:22-alpine) - [ ]
npm ciinstead ofnpm install - [ ] Memory limits set in compose/deployment
- [ ] Logs go to stdout/stderr (not files)
- [ ] Graceful shutdown handled (
SIGTERM)
Docker doesn't have to be scary. Start with this template, adapt as needed.
Follow @armorbreak for more practical DevOps guides.
Top comments (0)