Master production-ready Docker Compose configurations. Learn security, performance optimization, configuration management, and deployment strategies for scalable multi-container applications.
Docker Compose is a tool for defining and running multi-container Docker applications using a declarative YAML configuration file.
Instead of managing containers individually with complex Docker CLI commands, you define your entire application stack in a
docker-compose.yml file and orchestrate it with simple commands.
Docker Compose is ideal for:
Docker Compose is not suitable for:
Every Docker Compose application consists of three fundamental components:
Services are containers that make up your application. Each service definition includes the image to run, ports to expose, volumes to mount, environment variables, and networking configuration. Services communicate with each other using their service names as hostnames within the Compose network.
Volumes persist data beyond container lifecycle. Named volumes are managed by Docker; bind mounts connect to host directories. Essential for stateful services like databases, caches, and application data storage.
Networks enable service-to-service communication. Docker Compose creates a default network automatically, but custom networks provide better isolation and segmentation (e.g., frontend networks separate from backend).
Docker Compose file format has evolved through multiple versions. Each version introduces new features and maintains backward compatibility. Choose based on your Docker Engine version and required features.
| Version | Docker Engine | Release Year | Key Features |
|---|---|---|---|
| 3.8 | 19.03+ | 2019 | Profiles, service secrets |
| 3.9 | 20.10+ | 2021 | Extension fields, depends_on conditions |
| 3.x (latest) | 20.10+ | Current | Stable, recommended for most users |
Recommendation: Use version 3.8 or 3.9 for new projects. Avoid version 2.0 unless you have legacy constraints. The 3.x series is stable, widely supported, and includes modern features without experimental functionality.
version: '3.9' # Specify version explicitly
services:
web:
image: nginx:1.25-alpine
ports:
- ‘80:80’
networks:
- frontend
networks:
frontend:
The latest tag is a moving target that can introduce breaking changes and inconsistencies across environments.
Always specify exact versions for reproducibility and predictability.
services:
db:
image: postgres:latest
# Unpredictable - could upgrade at any time
services:
db:
image: postgres:15.3-alpine
# Explicit version - reproducible everywhere
Alpine Linux images are significantly smaller than standard distributions, reducing download times, storage footprint, and attack surface. Use them whenever available.
postgres:15.3-alpine (~150MB) vs postgres:15.3 (~330MB)node:18-alpine (~170MB) vs node:18 (~990MB)redis:7-alpine (~28MB) vs redis:7 (~114MB)
Always build and test custom images in your Compose setup before pushing to production. Use
docker-compose build to build images defined in your Compose file.
services:
api:
build:
context: ./api
dockerfile: Dockerfile
args:
- NODE_ENV=development
image: myapp-api:1.0.0
Hardcoded passwords, API keys, and tokens expose your infrastructure to security risks. Use environment variables or Docker secrets instead.
services:
db:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: “my-secret-password”
MYSQL_USER: “app”
MYSQL_PASSWORD: “app-password”
services:
db:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
Then create a .env file (never commit to Git):
MYSQL_ROOT_PASSWORD=secure-root-password
MYSQL_USER=appuser
MYSQL_PASSWORD=app-secure-password
Environment files allow you to separate configuration from code. Add .env to .gitignore
to prevent accidental commits of sensitive data.
# .gitignore
.env
.env.local
.env.*.local
secrets/
Create .env.example to document required variables without sensitive values:
# .env.example
MYSQL_ROOT_PASSWORD=change_me
MYSQL_USER=appuser
MYSQL_PASSWORD=change_me
DB_HOST=db
API_PORT=3000
For production deployments using Docker Swarm, use Docker Secrets for secure secret management. Secrets are encrypted at rest and in transit.
version: '3.9'
services:
db:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
secrets:
- db_root_password
secrets:
db_root_password:
file: ./secrets/db_root_password.txt
Run containers with non-root users to limit damage if a container is compromised.
services:
app:
image: node:18-alpine
user: “1000:1000”
# Run as UID 1000, GID 1000 (non-root)
Remove unnecessary files and tools from your custom images. Regularly update base images to include security patches.
# Dockerfile - minimal and clean
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci –only=production
FROM node:18-alpine
WORKDIR /app
COPY –from=builder /app/node_modules ./node_modules
COPY –chown=node:node . .
USER node
CMD [“node”, “app.js”]
Use base and override Compose files to manage environment-specific configurations. This prevents duplicating configuration and reduces errors.
# Structure
docker-compose.yml # Base configuration (shared)
docker-compose.dev.yml # Development overrides
docker-compose.prod.yml # Production overrides
docker-compose.test.yml # Test environment overrides
Run with multiple files:
# Development
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
# Production
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up
Profiles allow you to selectively enable/disable services without removing them from the file.
version: '3.9'
services:
web:
image: nginx:latest
profiles:
- prod
- dev
testing-db:
image: postgres:15-alpine
profiles:
- test
cache:
image: redis:7-alpine
# No profile = always active
Start with specific profiles:
docker-compose --profile prod up
docker-compose –profile test up
Add comments explaining non-obvious settings and configuration choices. This helps team members understand the setup.
version: '3.9'
services:
# Main web application - Node.js 18 LTS
# Exposed on port 3000 for development
web:
image: myapp:1.0.0
ports:
- ‘3000:3000’
depends_on:
db:
condition: service_healthy
environment:
NODE_ENV: ${NODE_ENV:-development}
DATABASE_URL: postgresql://user:pass@db:5432/myapp
# PostgreSQL 15 - database layer
# Uses named volume for persistence across restarts
db:
image: postgres:15.3-alpine
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: [“CMD-SHELL”, “pg_isready -U postgres”]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
driver: local
Define CPU and memory limits to prevent containers from consuming all system resources and causing other services to fail.
services:
web:
image: nginx:latest
deploy:
resources:
limits:
cpus: ‘1’ # Maximum 1 CPU core
memory: 512M # Maximum 512MB RAM
reservations:
cpus: ‘0.5’ # Guaranteed 0.5 CPU
memory: 256M # Guaranteed 256MB RAM
Order Dockerfile instructions to maximize layer caching. Put frequently-changing instructions (like COPY and RUN) after stable instructions.
# Good - caching order optimized
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./ # Stable, cache this
RUN npm ci –only=production # Cache dependencies
COPY . . # Frequently changes
CMD [“node”, “app.js”]
Reduce build context size by excluding unnecessary files from Docker builds.
# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
.env
.env.local
README.md
.vscode
.idea
dist
build
Use depends_on with condition: service_healthy to ensure services start in the correct order
and are ready before dependents start.
services:
web:
image: node:18-alpine
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
db:
image: postgres:15-alpine
healthcheck:
test: [“CMD-SHELL”, “pg_isready -U postgres”]
interval: 10s
timeout: 5s
retries: 5
cache:
image: redis:7-alpine
healthcheck:
test: [“CMD”, “redis-cli”, “ping”]
interval: 5s
timeout: 3s
retries: 5
Docker Compose creates a default network, but custom networks provide better isolation and explicit control over which services can communicate.
version: '3.9'
services:
web:
image: nginx:latest
networks:
- frontend
api:
image: node:18-alpine
networks:
- frontend
- backend
# Can communicate with both web (via frontend) and db (via backend)
db:
image: postgres:15-alpine
networks:
- backend
# Isolated from web - no direct connection
networks:
frontend:
driver: bridge
backend:
driver: bridge
Docker's embedded DNS server allows services to communicate by service name. Service names resolve to the container's IP address.
# Inside a container:
$ curl http://db:5432 # ‘db’ resolves to the database service’s IP
$ curl http://cache:6379 # ‘cache’ resolves to Redis service IP
Only expose ports that need to be accessed externally. Use expose for internal service-to-service communication.
services:
web:
image: nginx:latest
ports:
- ‘80:80’ # Expose to host
api:
image: node:18-alpine
expose:
- ‘3000’ # Only expose to other services via network
networks:
- backend
db:
image: postgres:15-alpine
expose:
- ‘5432’ # No host port - internal only
Named volumes are managed by Docker and persist independently. Bind mounts connect to host directories. Use named volumes for production data, bind mounts for development.
| Type | Use Case | Example |
|---|---|---|
| Named Volume | Production data, databases, persistence | - postgres_data:/var/lib/postgresql/data |
| Bind Mount | Development, hot reload, source code | - ./src:/app/src |
| Tmpfs | Temporary data, cache, performance | - type: tmpfs target: /tmp |
Always define named volumes at the top level to ensure they're created and can be managed independently.
version: '3.9'
services:
db:
image: postgres:15-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
volumes:
postgres_data:
driver: local
# Volume persists beyond container lifecycle
Be careful with volume ownership. Use chown in Dockerfile or specify user permissions to avoid permission issues.
FROM node:18-alpine
WORKDIR /app
COPY –chown=node:node . .
USER node
volumes:
app_data:
# Created with root ownership - potential permission issues
driver: local
Health checks allow Docker to detect if a container is healthy and ready to serve traffic.
Use them with depends_on condition to ensure proper startup order.
services:
web:
image: nginx:latest
healthcheck:
test: [“CMD”, “curl”, “-f”, “http://localhost/health”]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
db:
image: postgres:15-alpine
healthcheck:
test: [“CMD-SHELL”, “pg_isready -U postgres”]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
cache:
image: redis:7-alpine
healthcheck:
test: [“CMD”, “redis-cli”, “ping”]
interval: 5s
timeout: 3s
retries: 5
Development setups prioritize convenience, fast feedback, and ease of debugging.
# docker-compose.dev.yml
services:
app:
build:
context: .
dockerfile: Dockerfile
volumes:
- .:/app # Hot reload on file changes
- /app/node_modules # Prevent node_modules override
ports:
- ‘3000:3000’
environment:
NODE_ENV: development
DEBUG: app:*
db:
image: postgres:15-alpine
# Loose security for dev - easier troubleshooting
environment:
POSTGRES_PASSWORD: dev_password
ports:
- ‘5432:5432’ # Direct access for debugging
Production setups prioritize security, performance, and reliability.
# docker-compose.prod.yml
services:
app:
image: myapp:1.0.0 # Pre-built image, never build in prod
user: “1000:1000” # Non-root user
deploy:
replicas: 3
resources:
limits:
cpus: ‘1’
memory: 512M
environment:
NODE_ENV: production
restart: unless-stopped
db:
image: postgres:15.3-alpine
user: “999:999”
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
deploy:
resources:
limits:
cpus: ‘2’
memory: 1G
volumes:
postgres_data:
driver: local
Docker supports multiple logging drivers. Configure them to route logs to your monitoring system.
version: '3.9'
services:
app:
image: node:18-alpine
logging:
driver: json-file
options:
max-size: ‘10m’
max-file: ‘3’
labels: “service=app,env=production”
db:
image: postgres:15-alpine
logging:
driver: awslogs
options:
awslogs-group: “/ecs/myapp-db”
awslogs-region: “us-east-1”
awslogs-stream-prefix: “ecs”
Monitor CPU, memory, and network usage:
# View real-time stats
docker stats
# View specific container
docker stats app_db_1
# Format output
docker stats –format “table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}”
Error: "address already in use"
# Find process using port
lsof -i :8080
# Kill process
kill -9
# Or change port in compose file
ports:
- ‘8081:8080’ # Changed from 8080:8080
Check service names, networks, and health status.
# Inspect networks
docker network ls
docker network inspect
# Check DNS resolution
docker-compose exec app nslookup db
# Test connectivity
docker-compose exec app curl http://db:5432
Permission denied when writing to mounted volumes.
# Check volume permissions
docker inspect
# Fix ownership
docker-compose exec db chown -R postgres:postgres /var/lib/postgresql/data
Docker images and volumes consume disk space.
# See disk usage
docker system df
# Clean up unused images and containers
docker system prune -a
# Remove unused volumes
docker volume prune