Docker Compose is the tool everyone recommends and nobody does correctly. You start with three services in a YAML file. Six months later you have thirty, and your docker-compose.yml is a file that nobody fully understands but everybody fears touching.
I’ve been through this pattern enough times that I’ve developed a structure that actually holds up as projects grow. What follows isn’t official Docker guidance — it’s the lessons I learned from breaking things repeatedly.
Pattern 1: Separate Compose Files for Different Concerns
The single docker-compose.yml is a trap. It works until it doesn’t.
Instead of one monolithic file, I use this structure:
- docker-compose.base.yml — shared config (networks, volumes, base images)
- docker-compose.dev.yml — overlays for local development
- docker-compose.prod.yml — overlays for production
- docker-compose.yml — imports the above via the cascade
The extends keyword is limited. The include directive (Compose 2.20+) lets you do proper composition without hacks.
Pattern 2: Health Checks for Every Service
The docker-compose up command treats containers as healthy the moment they start, not the moment they’re ready. A database that started but isn’t accepting connections looks healthy to Compose.
services:
postgres:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
Then use service_healthy dependency ordering so your app doesn’t start until the database actually is.
Pattern 3: Named Volumes for Everything That Persists
Anonymous volumes (volumes: ['/data']) get recreated on every deploy. Named volumes (volumes: ['postgres_data:/data']) persist until you explicitly destroy them.
Always use named volumes for databases, Redis, uploaded files, and any state your app depends on.
Pattern 4: Separate Networks for Service Isolation
Default bridge network puts all services in the same flat network — a compromise that works nowhere.
networks:
frontend:
backend:
monitoring:
Put your reverse proxy in frontend. Put your database and cache in backend. Put your metrics scraper in monitoring. Only expose what needs to be exposed.
Pattern 5: Resource Limits as Contract
Without explicit limits, Docker will let any container consume all CPU and memory. Without limits, one bad container kills everything.
services:
api:
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
reservations:
cpus: '0.1'
memory: 128M
Reservations aren’t guaranteed — they’re the minimum. Limits are the ceiling. Set both.
Pattern 6: Don’t Embed Configuration
Environment variables belong in .env files (for local) or secrets (for production), not baked into images.
Build the configuration retrieval INTO your app, not INTO your Docker setup. Twelve-factor app applies here.
Pattern 7: Use a Makefile as the Interface
docker-compose commands are long. The solution isn’t aliases — it’s a Makefile:
dev:
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
build-prod:
docker-compose -f docker-compose.yml -f docker-compose.prod.yml build
deploy:
docker-compose -f docker-compose.yml -f docker-compose.prod.yml pull
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
The Makefile makes the workflow self-documenting. Anyone can make help and see what the actual ops procedure is.