Docker Configuration Guide: Compose, Multi-Stage Builds, and Production Patterns

Docker has become the standard way to package, distribute, and run applications. Whether you are containerizing a Node.js API, a Python data pipeline, or a multi-service architecture, understanding Docker configuration patterns is essential for building images that are small, secure, and fast to deploy. The difference between a production-grade Docker setup and a thrown-together one is often the difference between 50MB images that start in seconds and 2GB images that take minutes to pull.

This guide covers the Docker configuration knowledge every developer needs: Dockerfile best practices, multi-stage builds, docker-compose.yml structure, networking, volumes, environment variables, health checks, and patterns for production versus development. If you need to generate Docker configuration files, try our Docker Compose Generator - it scaffolds production-ready compose files interactively.

Dockerfile Best Practices

A Dockerfile is a recipe for building a container image. Every instruction creates a layer, and layer management directly affects build speed and image size.

Choose the Right Base Image

The base image determines your starting point. Use the smallest image that provides what you need:

# Full image (~900MB) - avoid in production
FROM node:20

# Slim image (~200MB) - Debian-based, good balance
FROM node:20-slim

# Alpine image (~140MB) - smallest, uses musl libc
FROM node:20-alpine

# Distroless (~80MB) - no shell, no package manager
FROM gcr.io/distroless/nodejs20-debian12

For production, prefer slim or distroless images. Alpine is small but uses musl libc instead of glibc, which can cause compatibility issues with some native modules. Distroless images contain only the runtime - no shell, no package manager - which minimizes the attack surface.

Order Instructions for Cache Efficiency

Docker caches each layer. If a layer changes, all subsequent layers are rebuilt. Place instructions that change infrequently (like installing system dependencies) before instructions that change often (like copying application code):

# Good: dependency install is cached separately from code changes
FROM node:20-slim
WORKDIR /app

# These change rarely - cached across builds
COPY package.json package-lock.json ./
RUN npm ci --production

# This changes often - only this layer rebuilds
COPY . .

CMD ["node", "server.js"]

Combine RUN Instructions

Each RUN instruction creates a new layer. Combine related commands and clean up in the same layer to keep images small:

# Bad: three layers, apt cache persists
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# Good: one layer, cache cleaned in same layer
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl && \
    rm -rf /var/lib/apt/lists/*

Multi-Stage Builds

Multi-stage builds are the single most important technique for producing small, secure production images. You use one stage to build the application and a separate stage that contains only the runtime and the compiled output:

# Stage 1: Build
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production && npm cache clean --force
COPY --from=builder /app/dist ./dist

USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

The final image contains only production dependencies and compiled code. Build tools (TypeScript compiler, Webpack, test frameworks) and source code are left behind in the builder stage. This typically reduces image size by 50-80%.

Docker Compose: Multi-Service Configuration

Docker Compose defines multi-container applications in a single YAML file. Here is a production-ready example for a web app with a database and cache:

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: production
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgres://user:pass@db:5432/myapp
      - REDIS_URL=redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=myapp
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d myapp"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  cache:
    image: redis:7-alpine
    volumes:
      - redisdata:/data
    restart: unless-stopped

volumes:
  pgdata:
  redisdata:

Networking

Docker Compose creates a default network for all services in a compose file. Services can reach each other by service name (DNS resolution). The app service above connects to PostgreSQL via db:5432 and Redis via cache:6379. For more complex setups, define custom networks to isolate groups of services:

services:
  app:
    networks: [frontend, backend]
  db:
    networks: [backend]
  nginx:
    networks: [frontend]

networks:
  frontend:
  backend:

In this setup, nginx cannot directly reach db because they are on different networks. This provides network-level isolation between your reverse proxy and your database.

Volumes

Volumes persist data across container restarts and rebuilds. There are two types:

  • Named volumes (pgdata:/var/lib/postgresql/data) - managed by Docker, ideal for databases and persistent state. Portable and easy to back up.
  • Bind mounts (./src:/app/src) - map a host directory into the container. Ideal for development, where you want file changes to appear immediately inside the container.

Environment Variables

Never hardcode secrets in your Dockerfile or compose file. Use environment variables and .env files:

# .env file (never commit to version control)
POSTGRES_PASSWORD=secretpassword
API_KEY=sk-abc123

# docker-compose.yml
services:
  app:
    env_file: .env
    environment:
      - NODE_ENV=production

The env_file directive loads variables from a file. The environment directive sets individual variables. Variables in environment override those in env_file.

Health Checks

Health checks tell Docker (and orchestrators like Kubernetes) whether a container is functioning correctly. Without health checks, Docker only knows if the process is running - not whether it is actually serving requests:

# In Dockerfile
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \
  CMD curl -f http://localhost:3000/health || exit 1

# Or in docker-compose.yml (as shown above)
healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
  interval: 30s
  timeout: 10s
  retries: 3
  start_period: 40s

The start_period gives the application time to initialize before health checks begin counting failures. The depends_on directive with condition: service_healthy ensures services start in the right order.

Production vs Development Configurations

Use separate compose files or build targets for development and production:

# docker-compose.yml (base)
services:
  app:
    build:
      context: .
    environment:
      - NODE_ENV=production
    restart: unless-stopped

# docker-compose.override.yml (development, auto-loaded)
services:
  app:
    build:
      target: development
    volumes:
      - ./src:/app/src
    environment:
      - NODE_ENV=development
      - DEBUG=app:*
    ports:
      - "9229:9229"  # Node.js debugger
    restart: "no"

Docker Compose automatically merges docker-compose.yml with docker-compose.override.yml in development. For production, use docker compose -f docker-compose.yml -f docker-compose.prod.yml up to skip the override file.

Security Best Practices

  1. Run as a non-root user. Add USER node (or any non-root user) near the end of your Dockerfile. Running as root inside containers is a significant security risk.
  2. Use .dockerignore. Exclude .git, node_modules, .env, test files, and documentation from the build context. This speeds up builds and prevents secrets from leaking into images.
  3. Pin base image versions. Use node:20.11.1-slim instead of node:latest. Unpinned tags can change without warning and break your builds or introduce vulnerabilities.
  4. Scan images for vulnerabilities. Use docker scout, Trivy, or Snyk to scan your images as part of your CI pipeline.
  5. Do not store secrets in images. Use build secrets (--mount=type=secret) for build-time secrets and runtime environment variables or secret managers for runtime secrets.

Generate Your Docker Configuration

Our tools help you scaffold Docker and CI/CD configurations:

  • Docker Compose Generator - generate production-ready docker-compose.yml files with service dependencies, volumes, and health checks.
  • Dockerfile Generator - scaffold optimized Dockerfiles with multi-stage builds for your language and framework.
  • GitHub Actions Generator - create CI/CD workflows that build, test, and deploy your Docker images.

Frequently Asked Questions

A multi-stage build uses multiple FROM statements in a single Dockerfile. Each FROM begins a new build stage. You can copy artifacts from one stage to the next while leaving behind build tools, source code, and intermediate files. This produces much smaller final images because the production image only contains the runtime and the compiled application, not the compiler, package manager caches, or source code.
COPY simply copies files from the build context into the image. ADD does the same but also supports two additional features: it can extract tar archives automatically and it can download files from URLs. Best practice is to use COPY for most cases because its behavior is more transparent. Only use ADD when you specifically need tar extraction. For downloading files, prefer curl or wget in a RUN instruction so you can verify checksums.
Docker volumes are managed by Docker and stored in a dedicated area on the host filesystem (/var/lib/docker/volumes/). Bind mounts map a specific host directory into the container. Volumes are preferred for production because they are portable, can be backed up and migrated, and work on all platforms. Bind mounts are better for development because they provide real-time file synchronization between your editor and the container.
Docker Compose V2 is the current standard. It is built into the Docker CLI as 'docker compose' (with a space, no hyphen). The older V1 used 'docker-compose' (with a hyphen) and was a separate Python binary. V2 is faster, supports Docker BuildKit by default, and is actively maintained. The compose file format is the same for both. Use 'docker compose' for all new projects.
Use multi-stage builds to separate build dependencies from the runtime. Start from slim or alpine base images instead of full Ubuntu/Debian. Combine RUN instructions to reduce layers. Add a .dockerignore file to exclude node_modules, .git, and other unnecessary files from the build context. Remove package manager caches in the same RUN layer that installs packages. Pin dependency versions to avoid unexpected bloat from new transitive dependencies.