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
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
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-SHELLinstead ofCMD. The shell form handles pipes and redirects. The array form doesn't — which is whycurloften fails silently when your endpoint returns a redirect.
2. Profiles: One File, Two Environments
I used to maintain two separate compose files:
-
docker-compose.ymlfor production -
docker-compose.dev.ymlfor 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
Now I just flip a flag:
# Production
docker compose up -d
# Development (all dev tools)
docker compose --profile dev up -d
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
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
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
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)