Docker Best Practices for Node.js Applications: Production-Ready Containers
Containerizing a Node.js app seems simple — until you hit issues with large images, security vulnerabilities, or slow builds. These best practices will help you build Docker images that are fast, small, and secure.
The Basic Dockerfile (and Why It's Wrong)
# ❌ Naive approach — don't do this in production
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "src/index.js"]
Problems:
- Image is 1GB+ (full Node.js + dev dependencies)
- Copies node_modules from host (or rebuilds every time)
- Running as root (security risk)
- No layer caching optimization
- Dev dependencies included in production image
Best Practice #1: Multi-Stage Builds
# ✅ Multi-stage build — separates build from runtime
# Stage 1: Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy only dependency files first (cache optimization)
COPY package*.json ./
RUN npm ci --include=dev # Install including devDependencies for build
# Copy source and build
COPY . .
RUN npm run build # TypeScript compilation, bundling, etc.
# Stage 2: Production image
FROM node:20-alpine AS production
WORKDIR /app
# Copy only production dependencies
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Copy built artifacts from builder stage
COPY --from=builder /app/dist ./dist
# Security: Don't run as root
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
Result: ~150MB image instead of 1GB+
Best Practice #2: Use Alpine Images
# Image size comparison:
# node:20 ~ 1.1GB
# node:20-slim ~ 247MB
# node:20-alpine ~ 133MB ← Use this
FROM node:20-alpine
# Alpine uses apk instead of apt
RUN apk add --no-cache dumb-init
# dumb-init handles signals properly (for graceful shutdown)
CMD ["dumb-init", "node", "dist/index.js"]
Best Practice #3: Optimize Layer Caching
Docker caches each layer. Changing a layer invalidates all subsequent layers.
# ❌ Bad: Copying everything before npm install
COPY . . # Changes every time any file changes
RUN npm install # Always reinstalls!
# ✅ Good: Copy dependencies first, source code last
COPY package*.json ./
COPY tsconfig.json ./ # If needed for build
RUN npm ci # Cached until package.json changes!
COPY src/ ./src/ # Only invalidates cache when src changes
RUN npm run build
Best Practice #4: Security Hardening
FROM node:20-alpine
# Create non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nodeapp -u 1001 -G nodejs
WORKDIR /app
COPY --chown=nodeapp:nodejs package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY --chown=nodeapp:nodejs dist/ ./dist/
# Switch to non-root user
USER nodeapp
# Expose non-privileged port
EXPOSE 3000
# Read-only filesystem (optional, very secure)
# Add in docker run: --read-only --tmpfs /tmp
CMD ["node", "dist/index.js"]
Best Practice #5: .dockerignore
# .dockerignore — similar to .gitignore
node_modules/
npm-debug.log
dist/
.git/
.gitignore
README.md
*.md
*.test.ts
*.spec.ts
coverage/
.env
.env.*
docker-compose*.yml
Dockerfile*
Health Checks
FROM node:20-alpine
# ... other instructions ...
# Health check — Docker restarts container if unhealthy
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
# Or with curl if available
# HEALTHCHECK CMD curl -f http://localhost:3000/health || exit 1
EXPOSE 3000
CMD ["node", "dist/index.js"]
// Add a /health endpoint in your Express app
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || '1.0.0',
});
});
Docker Compose for Development
# docker-compose.yml
version: '3.9'
services:
api:
build:
context: .
target: builder # Use builder stage for dev (has devDeps)
ports:
- "3000:3000"
volumes:
- ./src:/app/src # Mount source for hot reload
- /app/node_modules # Don't mount node_modules from host
environment:
- NODE_ENV=development
env_file:
- .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
command: npm run dev # nodemon for hot reload
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: user
POSTGRES_PASSWORD: password
volumes:
- postgres_data:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d myapp"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
volumes:
postgres_data:
Docker Compose for Production
# docker-compose.prod.yml
version: '3.9'
services:
api:
build:
context: .
target: production
ports:
- "3000:3000"
environment:
- NODE_ENV=production
env_file:
- .env.production
restart: unless-stopped
deploy:
replicas: 2
resources:
limits:
memory: 512M
cpus: '0.5'
depends_on:
- db
- redis
networks:
- app-network
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- api
networks:
- app-network
networks:
app-network:
driver: bridge
Complete Production Dockerfile
# syntax=docker/dockerfile:1.4
FROM node:20-alpine AS base
RUN apk add --no-cache dumb-init
WORKDIR /app
# ── Install dependencies ──────────────────────────────────────────
FROM base AS deps
COPY package*.json ./
RUN npm ci
# ── Build ─────────────────────────────────────────────────────────
FROM deps AS builder
COPY . .
RUN npm run build && npm prune --production
# ── Production image ──────────────────────────────────────────────
FROM base AS production
# Security: non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nodeapp -u 1001
# Copy only what's needed
COPY --from=builder --chown=nodeapp:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodeapp:nodejs /app/dist ./dist
COPY --from=builder --chown=nodeapp:nodejs /app/package.json ./
USER nodeapp
ENV NODE_ENV=production PORT=3000
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["dumb-init", "node", "dist/index.js"]
Useful Docker Commands
# Build with tags
docker build -t myapp:latest -t myapp:1.2.0 .
docker build --target production -t myapp:prod .
# Check image size breakdown
docker history myapp:latest
# Scan for vulnerabilities
docker scout cves myapp:latest
# OR
trivy image myapp:latest
# Run with limited resources
docker run \
--memory=512m \
--cpus=0.5 \
--read-only \
--tmpfs /tmp \
-p 3000:3000 \
myapp:latest
# View container logs
docker logs -f container-name
docker logs --tail=100 container-name
# Execute command in running container
docker exec -it container-name sh
docker exec -it container-name node -e "console.log(process.env)"
Summary
| Practice | Impact |
|---|---|
| Multi-stage builds | 80% smaller images |
| Alpine base image | 90% smaller than full Node |
| Layer caching | 10x faster rebuilds |
| Non-root user | Security hardening |
| .dockerignore | Faster builds, no leaks |
| Health checks | Automatic recovery |
| dumb-init | Proper signal handling |
→ Convert docker run commands to docker-compose with the Docker Compose Converter.