Docker Compose Best Practices: Complete Guide 2025

Master production-ready Docker Compose configurations. Learn security, performance optimization, configuration management, and deployment strategies for scalable multi-container applications.

Table of Contents

Docker Compose Fundamentals

What is Docker Compose and When to Use It

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:

Core Components: Services, Volumes, Networks

Every Docker Compose application consists of three fundamental components:

Services

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

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

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).

Versioning and Compatibility

Choose the Right Compose File Version

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:

Image Management Best Practices

Use Specific Image Tags, Never 'Latest'

The latest tag is a moving target that can introduce breaking changes and inconsistencies across environments. Always specify exact versions for reproducibility and predictability.

❌ Bad:
services:


db:
image: postgres:latest
# Unpredictable - could upgrade at any time
✅ Good:
services:


db:
image: postgres:15.3-alpine
# Explicit version - reproducible everywhere

Use Lightweight Alpine-Based Images

Alpine Linux images are significantly smaller than standard distributions, reducing download times, storage footprint, and attack surface. Use them whenever available.

Build and Test Images Locally

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

Security Best Practices

Never Hardcode Secrets in Compose Files

Hardcoded passwords, API keys, and tokens expose your infrastructure to security risks. Use environment variables or Docker secrets instead.

❌ Bad:
services:


db:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: “my-secret-password”
MYSQL_USER: “app”
MYSQL_PASSWORD: “app-password”
✅ Good:
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

Use .env Files Properly

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

Use Docker Secrets for Production (Docker Swarm)

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

Set User Permissions

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)

Keep Images Minimal and Updated

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”]

Configuration Management

Separate Development and Production Configurations

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

Use Profiles for Conditional Services

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

Document Your Configuration

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

Performance Optimization

Set Resource Limits

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

Optimize Layer Caching

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”]

Use .dockerignore Files

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

Implement Startup Order

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

Networking Best Practices

Use Custom Networks Instead of Host Network

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

Service Discovery with DNS

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

Expose Ports Carefully

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

Storage and Volumes Best Practices

Named Volumes vs Bind Mounts

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

Define Named Volumes Explicitly

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

Volume Permissions and Ownership

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 and Service Readiness

Implement Health Checks

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

Health Check Parameters

Development vs Production Strategies

Development Configuration

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 Configuration

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

Monitoring and Logging

Configure Logging Drivers

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”

Collect Container Metrics

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}}”

Troubleshooting Common Issues

Port Already in Use

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

Service Cannot Connect to Database

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

Volume Permission Issues

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

Out of Disk Space

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