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
- 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. - 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. - Pin base image versions. Use
node:20.11.1-sliminstead ofnode:latest. Unpinned tags can change without warning and break your builds or introduce vulnerabilities. - Scan images for vulnerabilities. Use
docker scout, Trivy, or Snyk to scan your images as part of your CI pipeline. - 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.