Docker Intermediate #4: Compose Deep Dive — depends_on, healthcheck, profiles
In #3 we put web + db in one file and ran them. This post layers operational sense on top — healthcheck, depends_on with conditions, profiles, and override files.
This post in the Docker Intermediate series:
- #1 Multi-stage builds and image slimming
- #2 Build cache — layer ordering
- #3 docker compose basics — web + db
- #4 compose deep dive — depends_on, healthcheck, profiles ← this post
- #5 Environment variables and secrets
- #6 Logging and debugging
Where depends_on alone falls short
#
The simple form from the basic example:
services:
web:
depends_on:
- pg
pg:
image: postgres:16That definition just means start web after the pg container has started. Nothing more. pg having started doesn’t mean PostgreSQL is listening — the first boot takes a few seconds to initialize the data directory, and if web comes up and tries to connect during that window, you get connection refused.
In one line: container started ≠ service ready.
The tool that fills that gap is healthcheck.
healthcheck — actually ready?
#
A healthcheck runs a command inside the container periodically, letting Docker decide whether the container is healthy or unhealthy.
services:
pg:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10sField meanings:
| Field | Meaning |
|---|---|
test | The command to use for the check |
interval | How often to check |
timeout | Per-check timeout |
retries | Consecutive failures before marking unhealthy |
start_period | Boot grace period — failures during this don’t count toward retries |
Two test forms:
test: ["CMD", "pg_isready", "-U", "app"] # exec form (run directly)
test: ["CMD-SHELL", "pg_isready -U app"] # via a shell (pipes, $ variables OK)
test: ["NONE"] # disable healthcheckCMD-SHELL is more flexible and usually what you’ll see.
Common healthcheck patterns #
# Postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
# MySQL
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
# Redis
healthcheck:
test: ["CMD", "redis-cli", "ping"]
# Generic HTTP service (when curl exists)
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
# alpine base where wget exists
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:8000/health"]Note the
$$in the Postgres healthcheck.$VARincompose.yamlsubstitutes from the host’s environment. To pass through to the container’s environment, escape with$$. So$$POSTGRES_USERbecomes$POSTGRES_USERinside the container.
Looking at status #
docker compose ps
# NAME STATUS
# myapp-pg-1 Up 12 seconds (healthy)
# myapp-web-1 Up 5 seconds
docker inspect myapp-pg-1 --format '{{json .State.Health}}' | jq
# {
# "Status": "healthy",
# "FailingStreak": 0,
# "Log": [...]
# }Status moves from starting → healthy or unhealthy. If the healthcheck command exits 0, healthy; otherwise the failure count grows toward retries, then it goes unhealthy.
depends_on with conditions — meaningful dependencies
#
Once a healthcheck exists, depends_on can be defined more strictly.
services:
web:
depends_on:
pg:
condition: service_healthy
redis:
condition: service_started
migrate:
condition: service_completed_successfully
pg:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 10
migrate:
image: myapp:latest
command: python manage.py migrate
depends_on:
pg:
condition: service_healthy
restart: "no"
redis:
image: redis:7-alpineThree conditions:
| Condition | Meaning |
|---|---|
service_started | Container has started (default) |
service_healthy | Healthcheck reports healthy |
service_completed_successfully | Container exited with code 0 |
In the example above, web only starts after pg is healthy, migrate has finished successfully, and redis has started. The first-boot migrate-then-start flow falls into place naturally.
The service_completed_successfully pattern
#
Defining one-shot containers for migrations / seeding / building static files is a common pattern.
services:
collectstatic:
image: myapp:latest
command: python manage.py collectstatic --noinput
volumes:
- static:/app/static
restart: "no"
web:
image: myapp:latest
depends_on:
collectstatic:
condition: service_completed_successfully
volumes:
- static:/app/static:roThe collectstatic container collects the static files and exits; its result lives in a named volume that web mounts read-only. Common in production / staging.
restart — bring it back if it dies
#
Production services get an automatic restart policy.
| Value | Meaning |
|---|---|
no (default) | Don’t restart |
always | Always — including on Docker restart |
on-failure[:N] | Only on non-zero exit, up to N times |
unless-stopped | Always, unless the user explicitly stopped it |
services:
web:
image: myapp:latest
restart: unless-stopped
migrate:
image: myapp:latest
command: migrate
restart: "no" # one-shot, restart would be wrongFor production, unless-stopped is the safe default. always even brings things back when you intentionally stop them, which can be annoying; on-failure won’t help when something exits cleanly (e.g. PID 1 deliberately exits).
profiles — fork environments inside one file
#
Same compose.yaml but you want mailhog only in dev, never in prod, or extra services only when testing — historically people split files. Today profiles solves it cleanly.
services:
web:
image: myapp:latest
# No profile → always on
pg:
image: postgres:16
# always on
mailhog:
image: mailhog/mailhog
ports:
- "8025:8025"
profiles:
- dev
pgadmin:
image: dpage/pgadmin4
profiles:
- dev
- debug
load-test:
image: locust
profiles:
- testdocker compose up # default only (web, pg)
docker compose --profile dev up # web, pg, mailhog, pgadmin
docker compose --profile test up # web, pg, load-test
COMPOSE_PROFILES=dev,debug docker compose upServices without a profile always start. Services with one only start when that profile is active. Multiple profiles on one service are OR-ed — any active profile will start it.
That gives you:
devprofile — fake SMTP, DB admin UItestprofile — load-testing toolsdebugprofile — debug proxies, jaeger-style traces
All in one compose.yaml, with no impact on the default flow.
Override files — compose.dev.yaml
#
Some things profiles can’t fix. For example: bind-mount code in dev, don’t in prod. Same service definition, but it needs to differ across environments.
That’s where override files come in.
services:
web:
image: ghcr.io/curtis/myapp:1.0
restart: unless-stopped
environment:
DATABASE_URL: ${DATABASE_URL}services:
web:
build: .
image: myapp:dev
volumes:
- ./:/app
environment:
DEBUG: "1"docker compose -f compose.yaml -f compose.dev.yaml upThe latter file deep-merges into the former. Same keys: the later one wins (scalar / object); lists usually concatenate. The pattern: keep a production base, layer just the dev-time differences.
Auto-detected — compose.override.yaml
#
If the file is named compose.override.yaml (or docker-compose.override.yml), it’s picked up automatically without -f.
ls
# compose.yaml
# compose.override.yaml
docker compose up
# Both files merge automaticallyA common pattern: per-developer settings (e.g. avoiding host port conflicts) in compose.override.yaml, then add it to .gitignore.
extends — reuse service definitions
#
When several services look alike, define a base and extend from it.
services:
worker-base:
image: myapp:latest
environment:
QUEUE_URL: redis://redis:6379/0
depends_on:
redis:
condition: service_started
worker-default:
extends: worker-base
command: python -m worker --queue default
worker-priority:
extends: worker-base
command: python -m worker --queue priority
deploy:
replicas: 3You write the shared worker config once. That said — the same effect is achievable with YAML anchors (& / *), and anchors are the more common idiom in practice.
YAML anchors — lighter reuse #
x-worker-base: &worker-base
image: myapp:latest
environment:
QUEUE_URL: redis://redis:6379/0
depends_on:
- redis
services:
worker-default:
<<: *worker-base
command: python -m worker --queue default
worker-priority:
<<: *worker-base
command: python -m worker --queue priorityKeys starting with x- are ignored by Compose (extension keys), so they’re great for these scratch definitions. &worker-base declares an anchor; <<: *worker-base merges it in.
deploy: and Swarm — one paragraph
#
You’ll see a deploy: key. It’s only meaningful in Docker Swarm mode; with regular docker compose up, most of it (e.g. replicas: 3) is ignored — only one container starts.
Kubernetes and cloud-native have largely displaced Swarm. Compose’s sweet spot is local dev + small single-host operations; beyond that, moving to other tools is the natural next step.
Common pitfalls #
- Healthcheck command lacks the tool — slim/alpine images often don’t include curl. Use
wget --spider, orapk add curl/apt-get install curlin the base. - Healthcheck too aggressive —
interval: 1sputs strain on the container. Usually5s ~ 30sis right. depends_ondoesn’t seem transitive —conditiononly looks at direct dependencies. Express two-hop dependencies explicitly.- List merging in overrides isn’t what you expected — lists aren’t always concatenated. When unsure, use
docker compose configto see the merged result.
docker compose -f compose.yaml -f compose.dev.yaml config
# Prints the merged config — extremely useful for debuggingdocker compose config is a frequent debugging tool — confirm the merged shape matches your intent.
Wrap-up #
The picture from this post:
- healthcheck lets Docker know whether the container is “ready” (
pg_isready,redis-cli ping, HTTP/health). depends_on: conditionfor meaningful startup order —service_healthy,service_completed_successfully.restart: unless-stoppedis the safe default for production services.profilesfork dev / test / debug inside one file.- Override files (
compose.override.yaml,compose.prod.yaml) layer environment-specific differences. docker compose configverifies the final merged definition.
In the next post (#5 Environment variables and secrets), we tackle injecting secret values — DB passwords, API keys — into containers without baking them into images, and the patterns around .env, compose’s secrets:, and BuildKit secrets.