Best Practice #
Docker Compose is a powerful tool, but without good conventions, the docker-compose.yml file can become messy and hard to maintain. This article covers best practices that will keep your Compose files clean, secure, and production-ready.
1. Use Concise, Modular Files #
Large, complex Compose files are hard to maintain. Split them with several strategies.
Separate per Environment #
# docker-compose.yml — base
services:
api:
build: ./api
image: myapp/api:${VERSION}
db:
image: postgres:16-alpine
# docker-compose.override.yml — development (auto-loaded)
services:
api:
command: npm run dev
volumes:
- ./api/src:/app/src
db:
ports:
- "5432:5432"
# docker-compose.prod.yml — production
services:
api:
command: node dist/server.js
environment:
- NODE_ENV=production
deploy:
replicas: 2
Use per environment:
# Development
docker compose up
# Production (no override, prod only)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Modular with Include (Docker Compose V2.20+) #
# docker-compose.yml
include:
- path: ./compose/api.yml
- path: ./compose/db.yml
- path: ./compose/redis.yml
Split per concern (database, cache, application) into separate files. Include merges them at runtime.
2. Use Stable Image Tags #
Always pin image versions. Avoid latest, which can change without you noticing.
# GOOD — pinned versions
image: postgres:16-alpine
image: redis:7.2-alpine
image: nginx:1.25-alpine
# BAD — can change at any time
image: postgres
image: postgres:latest
image: nginx
# BAD — too loose
image: postgres:16 # can get minor/patch updates
Best patterns:
- Major.minor.patch + variant tag:
postgres:16.2.1-alpine - Or major.minor + variant:
postgres:16.2-alpine
3. Avoid Hardcoded Secrets #
Secrets (passwords, API keys, certificates) must not be in YAML files committed to Git.
# BAD
services:
db:
environment:
- POSTGRES_PASSWORD=supersecret # EXPOSED in Git!
# GOOD — via .env
services:
db:
environment:
- POSTGRES_PASSWORD=${DB_PASSWORD} # from .env, gitignored
# GOOD — via secrets
services:
api:
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
The .gitignore file:
.env
.env.local
.env.*.local
secrets/
4. Healthchecks for Services with Dependents #
Healthchecks are the only reliable way to make depends_on: service_healthy work.
services:
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
api:
depends_on:
db:
condition: service_healthy # meaningless without a healthcheck
5. Named Volumes for Persistent Data #
Always use named volumes for data that must persist.
# GOOD
volumes:
db-data:
services:
db:
volumes:
- db-data:/var/lib/postgresql/data
# AVOID for production
services:
db:
volumes:
- /var/lib/postgresql/data:/var/lib/postgresql/data
Named volumes are portable, Docker-managed, and backup-supported. Bind mounts to absolute paths are fragile.
6. Resource Limits #
Set limits so one service doesn’t consume all resources.
services:
api:
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G
reservations:
cpus: "0.5"
memory: 512M
deploy.resources is supported in Compose V2 and Docker Swarm. For plain Docker daemons, mem_limit and cpus at the service level are alternatives.
7. Restart Policies #
Set a restart policy so services auto-restart on crashes.
services:
api:
restart: unless-stopped # always restart unless manually stopped
worker:
restart: on-failure # restart only on crashes
unless-stopped is safest for production. no (default) for development.
8. Network Isolation #
Split services across networks by role.
services:
nginx:
networks:
- frontend
api:
networks:
- frontend
- backend
db:
networks:
- backend # not exposed to the host
networks:
frontend:
backend:
Databases aren’t on a public network, reducing the attack surface.
9. Avoid Running Containers as Root #
Many images run as root by default. Override with user:.
services:
api:
user: "1000:1000" # non-root UID:GID
Or in the Dockerfile:
RUN useradd -m -u 1000 app
USER app
Container root ≠ host root. Namespace isolation means root in a container has no direct host access. But defense in depth requires non-root in containers too — if there’s a container escape, attackers don’t immediately get root.
10. Logging Configuration #
Configure a logging driver to control log size and rotation.
services:
api:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
The default json-file driver can fill the disk if unlimited. Set max-size and max-file for automatic rotation.
For production, consider an external logging driver:
services:
api:
logging:
driver: fluentd
options:
fluentd-address: localhost:24224
tag: myapp.api
Send logs to centralized logging (ELK, Loki, CloudWatch) for retention and searchability.
11. Efficient Images #
Choose small, secure base images.
# GOOD — alpine variants
image: postgres:16-alpine
image: node:20-alpine
image: python:3.12-slim
# NOT GREAT — full images
image: postgres:16
image: node:20
image: python:3.12
# BAD — very large
image: ubuntu:22.04 # 70MB+ just for the OS
Small images = fast downloads, fast scans, small attack surfaces.
For high-security production, consider distroless:
image: gcr.io/distroless/nodejs20-debian12
image: gcr.io/distroless/python3-debian12
Distroless has only the runtime + dependencies, with no shell, package manager, or other utilities.
12. Validate Compose Files #
Always validate before deploying.
# Show the parsed config
docker compose config
# Strict validation (no output if OK)
docker compose config --quiet
Integrate into CI/CD:
# .github/workflows/ci.yml
- name: Validate docker-compose
run: docker compose config --quiet
13. Consistent Naming Conventions #
# Service names
services:
web: # or frontend
api: # or backend
db: # or database, postgres
cache: # or redis
worker: # or job-processor
# Volume names
volumes:
db-data: # for data
cache-data: # for cache state
uploads: # for user uploads
shared-config: # for shared configuration
# Network names
networks:
frontend: # for services exposed to users
backend: # for internal services
management: # for monitoring, debugging
Avoid generic names like data, app, container1.
14. Document with Comments #
Comments explain WHY, not WHAT.
services:
api:
# Pool size tuned for a 4-core CPU.
# Too high causes context-switch overhead.
deploy:
resources:
limits:
cpus: "1.0"
# 1GB heap is enough for normal workloads.
# For > 10K concurrent requests, raise to 2GB.
memory: 1G
15. Directory Structure #
project/
├── docker-compose.yml
├── docker-compose.override.yml
├── docker-compose.prod.yml
├── .env
├── .env.example
├── .dockerignore
├── api/
│ ├── Dockerfile
│ ├── src/
│ └── package.json
├── web/
│ ├── Dockerfile
│ └── src/
├── db/
│ └── init/
│ └── 01-schema.sql
└── secrets/ # gitignored
└── db_password.txt
A clear structure makes onboarding new developers easy.
Anti-Patterns to Avoid #
1. latest Images
#
# BAD
image: nginx:latest
Can change at any time, causing surprise bugs.
2. Hardcoded Secrets #
# BAD
environment:
- API_KEY=sk-test123
Will be committed to Git history. Use .env or secrets:.
3. Bind Mounts for Production Data #
# BAD for production
volumes:
- /var/lib/postgresql/data:/var/lib/postgresql/data
Not portable, fragile. Use named volumes.
4. No Healthchecks #
# NOT GREAT
services:
api:
depends_on:
- db
depends_on alone is unreliable without healthchecks. Dependent services can start before the dependency is ready.
5. Excessive Port Exposure #
# BAD — every port exposed to the host
services:
db:
ports:
- "5432:5432"
cache:
ports:
- "6379:6379"
# GOOD — internal ports not exposed
services:
db:
expose:
- "5432" # only accessible on the internal network
Only expose services that genuinely need host reachability.
6. Containers Running as Root #
The default for most images. Override with user: "1000:1000".
7. Invalid YAML Files #
# WRONG — inconsistent indentation
services:
api:
image: nginx
ports:
- "80:80"
2-space indentation, consistent.
8. Generic Service Names #
# BAD
services:
app1:
app2:
service:
# GOOD
services:
web:
api:
worker:
Review Checklist #
Before a Compose file is considered production-ready, run this checklist.
Front matter & structure:
- File version-controlled (Git)
-
.envin.gitignore,.env.examplecommitted - Override files separated for dev/prod
- README with a quickstart
Images:
- All images use stable tags (not
latest) - Images as small as possible (alpine/slim/distroless)
- Dockerfiles in the repo, multi-stage builds
Security:
- No hardcoded secrets
- Containers don’t run as root
- Healthchecks for every service with dependents
- Network isolation by role
- Resource limits set
Data:
- Named volumes for persistent data
- Backup strategy defined
-
.dockerignoreexcludes unneeded files
Operational:
- Restart policies set
- Logging configuration
- Validated with
docker compose config - Tested in an environment matching production
Maintenance:
- Consistent naming conventions
- Comments explain WHY
- No dead config (commented-out services, unused env)
A Production-Ready File Example #
# docker-compose.yml (base for all environments)
services:
nginx:
image: nginx:1.25-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
web:
condition: service_healthy
networks:
- frontend
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
user: "101:101"
web:
build:
context: ./web
dockerfile: Dockerfile
image: myapp/web:${VERSION:-latest}
environment:
- NODE_ENV=production
- API_URL=http://api:8080
depends_on:
api:
condition: service_healthy
networks:
- frontend
- backend
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
api:
build:
context: ./api
dockerfile: Dockerfile
image: myapp/api:${VERSION:-latest}
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://app:pass@db:5432/myapp
- REDIS_URL=redis://cache:6379
env_file:
- .env.production
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
networks:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
deploy:
resources:
limits:
cpus: "2.0"
memory: 2G
user: "1000:1000"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
db:
image: postgres:16.2-alpine
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=myapp
volumes:
- db-data:/var/lib/postgresql/data
networks:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
interval: 10s
timeout: 5s
retries: 5
user: "999:999"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
cache:
image: redis:7.2-alpine
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- cache-data:/data
networks:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 3s
retries: 3
user: "999:999"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
db-data:
cache-data:
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # not exposed to the host
This file implements every best practice discussed:
- Stable image tags
- Healthchecks on every service
- Network isolation
- Resource limits
- Restart policies
- Logging configuration
- Non-root users
- Secrets via env_file
- Named volumes
Migrating from Old Practices #
For existing projects, migrate gradually:
Step 1: Stable image tags
Replace latest with specific versions. Test, deploy, monitor.
Step 2: Healthchecks
Add healthchecks to services with dependents. Enable condition: service_healthy.
Step 3: Resource limits Start with conservative limits. Tune based on monitoring.
Step 4: Network isolation Separate frontend/backend networks. Test that all flows still work.
Step 5: Logging configuration
Add logging.options. Monitor disk usage.
Step 6: Non-root users The most invasive — some images may break. Test thoroughly.
Step 7: Secret management
Migrate from hardcoded to .env or secrets:. Rotate passwords.
Migration doesn’t have to happen all at once. Prioritize by risk and impact.
Summary #
Docker Compose best practices:
- Concise, modular files — separate per environment.
- Stable image tags — pin versions.
- Avoid hardcoded secrets — use
.envorsecrets:. - Healthchecks for dependents.
- Named volumes for data.
- Resource limits for every service.
- Restart policies for resilience.
- Network isolation by role.
- Non-root users for security.
- Logging configuration for size control.
- Efficient images — alpine/slim.
- Validation before deploying.
- Consistent naming conventions.
- Clear documentation.
- Checklists before production.
Summary #
- Use concise, modular files — separate per environment with override files, or modularize with include.
- Stable image tags — always pin versions, avoid
latest.- Avoid hardcoded secrets — use
.envorsecrets:. The.envfile goes in.gitignore.- Healthchecks for dependent services — without healthchecks,
depends_on: service_healthyisn’t reliable.- Named volumes for persistent data, bind mounts only for development source code.
- Resource limits so one service doesn’t disturb the others.
- Restart policies for auto-recovery.
- Network isolation by role (frontend, backend, management).
- Non-root users for defense in depth.
- Logging configuration for size control and rotation.
- Efficient images — alpine/slim/distroless for small size and small attack surfaces.
- Validate with
docker compose configbefore deploying.- Consistent naming conventions — services, volumes, and networks follow clear patterns.
- Documentation — comments explain WHY, READMEs have quickstarts.
- Review checklists before production: structure, security, data, operational, maintenance.
- Anti-patterns:
latestimages, hardcoded secrets, bind mounts for production data, no healthchecks, excessive ports, root users.- Best practices are about consistency — pick patterns and apply them across every Compose file in the project.