DEV Community

Sir Max
Sir Max

Posted on

4 Docker Compose Patterns That Saved Our Team Hours of Debugging

4 Docker Compose Patterns That Saved Our Team Hours of Debugging

I spent most of last year running a half-dozen services on a single VPS. Database, API gateway, a few workers, Redis, the usual stack. Docker Compose was the obvious choice — but my early configs were a mess of trial and error.

Here are four patterns I learned the hard way. Each one solved a real problem that cost me at least an evening of frustrated debugging.


1. Healthchecks That Actually Work

My first healthcheck looked like this:

# ❌ Too aggressive — restarts the container before it's ready
healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
  interval: 5s
  timeout: 3s
  retries: 3
  start_period: 5s
Enter fullscreen mode Exit fullscreen mode

The problem: my app took 15–20 seconds to warm up (DB migrations, cache loading). Compose gave it 5 seconds, then killed it. I'd get a cascade of depends_on failures and every container restarting in a loop.

The fix is simpler than you think — make start_period match reality:

# ✅ Give the app time to actually start
healthcheck:
  test: ["CMD-SHELL", "wget -q -O- http://localhost:3000/health || exit 1"]
  interval: 10s
  timeout: 5s
  retries: 5
  start_period: 30s  # This is the key line
Enter fullscreen mode Exit fullscreen mode

The start_period is how long Compose waits before counting failures. Set it to your app's actual warmup time. For a Node.js app with migrations, 30 seconds is a safe default. For a Go binary, 5 seconds might be enough.

Pro tip: Use CMD-SHELL instead of CMD. The shell form handles pipes and redirects. The array form doesn't — which is why curl often fails silently when your endpoint returns a redirect.


2. Profiles: One File, Two Environments

I used to maintain two separate compose files:

  • docker-compose.yml for production
  • docker-compose.dev.yml for development

Every change had to be synced. I missed a port mapping once and spent an hour wondering why my local debugger couldn't connect.

Docker Compose profiles solve this cleanly:

services:
  api:
    build: .
    ports: ["3000:3000"]
    environment:
      - NODE_ENV=production

  # Dev-only services
  adminer:
    image: adminer
    profiles: ["dev"]
    ports: ["8080:8080"]

  mailhog:
    image: mailhog/mailhog
    profiles: ["dev"]
    ports: ["1025:1025", "8025:8025"]

  # Prod-only overrides
  api-dev:
    extends:
      service: api
    profiles: ["dev"]
    volumes: ["./src:/app/src"]
    environment:
      - NODE_ENV=development
Enter fullscreen mode Exit fullscreen mode

Now I just flip a flag:

# Production
docker compose up -d

# Development (all dev tools)
docker compose --profile dev up -d
Enter fullscreen mode Exit fullscreen mode

All the core services start in both environments. Dev tools (Adminer, MailHog, hot-reload) only come up with --profile dev. One file, no duplication, no drift.


3. extends for Dry Compose Files

Here is a pattern I wish I'd discovered sooner. Instead of repeating environment variables across services:

# ❌ Repeated env vars everywhere
services:
  worker-1:
    environment:
      - DB_HOST=postgres
      - DB_PORT=5432
      - REDIS_HOST=redis
      - REDIS_PORT=6379
      - LOG_LEVEL=info
  worker-2:
    environment:
      - DB_HOST=postgres
      - DB_PORT=5432
      - REDIS_HOST=redis
      # ... same thing again
Enter fullscreen mode Exit fullscreen mode

Use extends with a base service:

# ✅ Define once, reuse everywhere
x-base-env: &base-env
  DB_HOST: postgres
  DB_PORT: 5432
  REDIS_HOST: redis
  REDIS_PORT: 6379
  LOG_LEVEL: info

services:
  worker-1:
    environment:
      <<: *base-env
      WORKER_NAME: processor-1

  worker-2:
    environment:
      <<: *base-env
      WORKER_NAME: processor-2
Enter fullscreen mode Exit fullscreen mode

This is YAML anchors, not a Docker feature — but Docker Compose supports them fully. I use anchors for environment blocks, volume mounts, and even entire service definitions when I need multiple similar containers.


4. Wait-For-It Without Custom Scripts

The classic problem: your API starts before the database is ready. Connection refused. Container restarts. Log spam.

The old solution was a wait-for-it.sh script in every container. The modern solution is built into Compose:

services:
  api:
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

  postgres:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

  redis:
    image: redis:7
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
Enter fullscreen mode Exit fullscreen mode

The condition: service_healthy tells Compose: "Do not start this container until that container passes its healthcheck." No custom scripts, no sleep loops, no guesswork.


What These Patterns Share

Looking back, all four patterns solve the same underlying problem: making startup order predictable. Most Docker Compose debugging sessions start with "which container failed first?" Once you can answer that reliably, the rest is just configuration.

I still keep a docker-compose.debug.yml override file for those 2 a.m. incidents — it cranks up log levels, exposes extra ports for profiling, and adds a tty: true so I can docker attach when things really go sideways. But the four patterns above eliminated about 80% of my late-night debugging sessions.

If you have your own Compose patterns that saved you, drop them in the comments. I am always looking for new tricks.

Top comments (0)