Docker Best Practices: Building Production-Ready Containers
Best Practices

Docker Best Practices: Building Production-Ready Containers

February 5, 2026
6 min read

Why Best Practices Matter

Docker makes containerization easy, but production-ready containers require careful planning. Following best practices ensures your images are:

  • Secure - Protected from vulnerabilities
  • Efficient - Small size, fast builds
  • Maintainable - Easy to update and debug
  • Reliable - Consistent across environments

Multi-Stage Builds

Multi-stage builds dramatically reduce image size by separating build and runtime dependencies:

# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Remove devDependencies after build
RUN npm prune --production
 
# Runtime stage
FROM node:18-alpine
WORKDIR /app
# Use the built-in 'node' user (included in official Node images)
USER node
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --chown=node:node package.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]

Pro Tip

Multi-stage builds can significantly reduce image sizes—often by 50% or more depending on your application. Only production dependencies end up in the final image.

Use Specific Tags

Never use latest in production:

# Bad - unpredictable
FROM node:latest
 
# Good - specific and reproducible
FROM node:18.19.0-alpine3.19

Specific tags ensure:

  • Reproducibility - Same image every build
  • Predictability - No surprise updates
  • Security - You control when to update

Minimize Layers

Each RUN, COPY, and ADD creates a layer. Combine commands to reduce layers:

# Bad - multiple layers
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
 
# Good - single layer
RUN apt-get update && \
    apt-get install -y \
        curl \
        git \
    && rm -rf /var/lib/apt/lists/*

Information

Clean up package manager caches in the same layer to avoid bloating the image.

Leverage Build Cache

Order Dockerfile instructions from least to most frequently changed:

FROM node:18-alpine
 
# Dependencies change rarely - cache these layers
COPY package*.json ./
RUN npm ci
 
# Source code changes often - do this last
COPY . .

This ordering maximizes cache hits and speeds up builds.

Use .dockerignore

Just like .gitignore, use .dockerignore to exclude unnecessary files:

node_modules
npm-debug.log
.git
.env
*.md
tests/

Benefits:

  • Smaller context - Faster builds
  • Better security - Prevents .env files and secrets from leaking into image layers
  • Cleaner images - Only production files
  • No history leaks - Excluding .git prevents source history from being exposed

Run as Non-Root User

Never run containers as root in production:

FROM node:18-alpine
 
WORKDIR /app
COPY --chown=node:node . .
 
# Use the built-in 'node' user (included in official Node images)
USER node
 
CMD ["node", "server.js"]

Pro Tip

Official Node.js images already include a node user (UID 1000). No need to create a new user—just use USER node.

Security Risk

Running as root exposes your host system if the container is compromised. Always use a non-root user.

Health Checks

Add health checks to ensure container reliability:

# Using wget (available in Alpine)
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
  CMD wget -q --spider http://localhost:3000/health || exit 1

Or in docker-compose:

services:
  web:
    image: myapp:latest
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

Security Scanning

Scan images for vulnerabilities regularly:

# Using Docker Scout
docker scout quickview myapp:latest
docker scout recommendations myapp:latest
 
# Using Trivy
trivy image myapp:latest
 
# Using Snyk
snyk container test myapp:latest

Pro Tip

Integrate security scanning into your CI/CD pipeline to catch vulnerabilities before production.

Optimize for Size

Consider Alpine-based or slim images for smaller footprint:

# Standard Debian image (larger, ~300-900MB depending on version)
FROM node:18
 
# Alpine image (smaller, typically under 200MB)
FROM node:18-alpine
 
# Slim image (good balance of size and compatibility)
FROM node:18-slim

Alpine Compatibility

Alpine uses musl instead of glibc, which can cause issues with some native Node modules. If you encounter problems, try -slim images or verify your dependencies work with Alpine first.

Additional size optimization:

FROM node:18-alpine
 
# Remove unnecessary files
RUN npm ci --omit=dev && \
    npm cache clean --force && \
    rm -rf /tmp/*
 
# Use multi-stage builds
# Copy only necessary files

Environment Variables

Handle environment variables securely:

# Use ARG for build-time variables
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
 
# Never hardcode secrets
# Use runtime environment or secrets management

Better approach with docker-compose:

services:
  web:
    image: myapp:latest
    env_file:
      - .env.production
    environment:
      - NODE_ENV=production

Logging Best Practices

Log to stdout/stderr, not files:

# Good - logs go to Docker logging drivers
CMD ["node", "server.js"]
 
# Bad - logs trapped in container (requires shell form for redirection)
CMD node server.js > /var/log/app.log

Information

The exec form (CMD ["node", "..."]) doesn't support shell features like redirection. That's actually a good thing—it encourages proper logging to stdout.

Configure log drivers:

services:
  web:
    image: myapp:latest
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

Complete Example

Here's a production-ready Dockerfile incorporating all best practices:

# Multi-stage build
FROM node:18-alpine AS builder
 
WORKDIR /app
 
# Copy dependency files
COPY package*.json ./
 
# Install ALL dependencies (including devDependencies for build)
RUN npm ci
 
# Copy source
COPY . .
 
# Build application
RUN npm run build
 
# Remove devDependencies after build
RUN npm prune --production
 
# Production stage
FROM node:18-alpine
 
# Install tini for proper signal handling (PID 1)
RUN apk add --no-cache tini
 
WORKDIR /app
 
# Copy from builder with correct ownership
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/package.json ./
 
# Use the built-in 'node' user
USER node
 
# Expose port
EXPOSE 3000
 
# Health check using wget (available in Alpine)
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
  CMD wget -q --spider http://localhost:3000/health || exit 1
 
# Use tini as entrypoint for proper signal handling
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/server.js"]

Why tini?

Node.js doesn't handle signals properly when running as PID 1. Without tini, docker stop may hang for 10 seconds before force-killing your container. Tini ensures graceful shutdowns.

Monitoring and Observability

Add labels for better organization:

LABEL maintainer="team@devopspath.io"
LABEL version="1.0.0"
LABEL description="Production API server"

Use consistent labeling in docker-compose:

services:
  web:
    image: myapp:latest
    labels:
      - "io.devopspath.environment=production"
      - "io.devopspath.version=1.0.0"

Next Steps

Ready to apply these practices?

  • Try our Docker lessons - Docker track for hands-on practice
  • Build a project - Apply these to a real application
  • Learn Kubernetes - Take containers to the next level
  • Explore CI/CD - Automate your Docker workflows

Learn by Doing

The best way to master Docker is through practice. Our Docker learning track provides step-by-step lessons with real commands.

Conclusion

Following these best practices ensures your containers are production-ready, secure, and efficient. Start with the fundamentals—specific tags, multi-stage builds, non-root users—and build from there.

Remember: good practices today prevent problems tomorrow. Invest time in building containers correctly, and you'll save countless hours in production.

DT

DevOpsPath Team

Teaching DevOps practices through hands-on, real-world examples

Share: