Docker Intermediate #4: Compose Deep Dive — depends_on, healthcheck, profiles

8 min read

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:

Where depends_on alone falls short #

The simple form from the basic example:

Plain depends_on
services:
  web:
    depends_on:
      - pg
  pg:
    image: postgres:16

That 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.

postgres healthcheck
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: 10s

Field meanings:

FieldMeaning
testThe command to use for the check
intervalHow often to check
timeoutPer-check timeout
retriesConsecutive failures before marking unhealthy
start_periodBoot grace period — failures during this don’t count toward retries

Two test forms:

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 healthcheck

CMD-SHELL is more flexible and usually what you’ll see.

Common healthcheck patterns #

Healthchecks for various services
# 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. $VAR in compose.yaml substitutes from the host’s environment. To pass through to the container’s environment, escape with $$. So $$POSTGRES_USER becomes $POSTGRES_USER inside the container.

Looking at status #

Check healthcheck 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 startinghealthy 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.

condition usage
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-alpine

Three conditions:

ConditionMeaning
service_startedContainer has started (default)
service_healthyHealthcheck reports healthy
service_completed_successfullyContainer 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.

One-shot job container
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:ro

The 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.

ValueMeaning
no (default)Don’t restart
alwaysAlways — including on Docker restart
on-failure[:N]Only on non-zero exit, up to N times
unless-stoppedAlways, unless the user explicitly stopped it
restart policy
services:
  web:
    image: myapp:latest
    restart: unless-stopped

  migrate:
    image: myapp:latest
    command: migrate
    restart: "no"      # one-shot, restart would be wrong

For 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.

Using profiles
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:
      - test
Activate profiles
docker 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 up

Services 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:

  • dev profile — fake SMTP, DB admin UI
  • test profile — load-testing tools
  • debug profile — 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.

compose.yaml — production base
services:
  web:
    image: ghcr.io/curtis/myapp:1.0
    restart: unless-stopped
    environment:
      DATABASE_URL: ${DATABASE_URL}
compose.dev.yaml — dev override
services:
  web:
    build: .
    image: myapp:dev
    volumes:
      - ./:/app
    environment:
      DEBUG: "1"
Dev setup
docker compose -f compose.yaml -f compose.dev.yaml up

The 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.

Merged automatically
ls
# compose.yaml
# compose.override.yaml

docker compose up
# Both files merge automatically

A 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.

extends pattern
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: 3

You 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 #

anchor / alias
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 priority

Keys 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, or apk add curl / apt-get install curl in the base.
  • Healthcheck too aggressiveinterval: 1s puts strain on the container. Usually 5s ~ 30s is right.
  • depends_on doesn’t seem transitivecondition only 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 config to see the merged result.
See the merged definition
docker compose -f compose.yaml -f compose.dev.yaml config
# Prints the merged config — extremely useful for debugging

docker 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: condition for meaningful startup order — service_healthy, service_completed_successfully.
  • restart: unless-stopped is the safe default for production services.
  • profiles fork dev / test / debug inside one file.
  • Override files (compose.override.yaml, compose.prod.yaml) layer environment-specific differences.
  • docker compose config verifies 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.

X