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.