正在加载,请稍候…

Docker Best Practices for Node.js Applications: Production-Ready Containers

Learn how to properly Dockerize Node.js applications. Covers multi-stage builds, security hardening, image optimization, docker-compose setup, and production deployment strategies.

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:

  1. Image is 1GB+ (full Node.js + dev dependencies)
  2. Copies node_modules from host (or rebuilds every time)
  3. Running as root (security risk)
  4. No layer caching optimization
  5. 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.