Compose: depends_on, Healthchecks & Profiles

Docker & DevOps · Lesson 17 of 26 7 min read Updated

The notice board from Docker Compose Basics had a small flaw: for the first 20 seconds the PHP page showed a connection error, because depends_on only waited for the MySQL container to start, not for MySQL to be ready. In development that is annoying. In production, an app that crashes on start-up because its database is still booting is a real outage.

This lesson fixes that with healthchecks and depends_on conditions, then adds the features that make Compose files ready for real projects: restart policies, optional services with profiles, override files and watch mode for development.

Healthchecks in Compose

A healthcheck is a command Docker runs inside the container again and again to check that the service works. You met the HEALTHCHECK instruction in Dockerfiles; Compose has the same feature with a healthcheck key, which also works for images you did not build, such as mysql:8.4:

YAML
services:
  db:
    image: mysql:8.4
    environment:
      MYSQL_DATABASE: notices
      MYSQL_USER: meera
      MYSQL_PASSWORD: Meera-Change-Me
      MYSQL_ROOT_PASSWORD: Root-Change-Me
    volumes:
      - dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 30s
  • test is the command. CMD runs it directly; CMD-SHELL runs a string through the shell.
  • interval is how often it runs, and timeout is how long one check may take.
  • After retries failures in a row, the container is marked unhealthy.
  • During start_period, failures do not count, which gives slow services time to boot.

mysqladmin ping succeeds once the MySQL server accepts connections. Using -h 127.0.0.1 forces a network connection, which matters for the MySQL image: during its first-start setup it runs a temporary server without networking, and the check must not pass until the real server is up.

Waiting for healthy dependencies

The long form of depends_on lets you say what to wait for:

YAML
services:
  web:
    build: .
    ports:
      - "8080:80"
    environment:
      DB_HOST: db
      DB_NAME: notices
      DB_USER: meera
      DB_PASSWORD: Meera-Change-Me
    depends_on:
      db:
        condition: service_healthy
        restart: true
      migrate:
        condition: service_completed_successfully
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost/"]
      interval: 30s
      timeout: 3s
      retries: 3

  migrate:
    build: .
    command: ["php", "/var/www/html/migrate.php"]
    environment:
      DB_HOST: db
      DB_NAME: notices
      DB_USER: meera
      DB_PASSWORD: Meera-Change-Me
    depends_on:
      db:
        condition: service_healthy

The three conditions are:

ConditionCompose waits until the dependency...
service_startedhas started (the short form's behaviour)
service_healthyreports healthy from its healthcheck
service_completed_successfullyhas run to the end with exit code 0

Here migrate is a one-off job that creates the tables and exits. web starts only after the database is healthy and the migration has finished successfully. restart: true under db tells Compose to restart web when db is restarted by a Compose command, so it reconnects cleanly.

Now up waits for health before starting the app:

Terminal
docker compose up -d
docker compose ps --format 'table {{.Service}}\t{{.Status}}'
Output
[+] Running 4/4
 ✔ Network notice-board_default     Created                                           0.1s
 ✔ Volume "notice-board_dbdata"     Created                                           0.0s
 ✔ Container notice-board-db-1      Healthy                                          21.4s
 ✔ Container notice-board-web-1     Started                                          21.7s
SERVICE   STATUS
db        Up 2 minutes (healthy)
web       Up 2 minutes (healthy)

(The migrate lines are left out for space.) The app never sees a missing database. In scripts and CI, docker compose up -d --wait goes one step further and only returns when every service is running and healthy, or fails if one is not.

Restart policies

restart takes the same values as docker run --restart: no, always, on-failure and unless-stopped. For long-running services on a server, unless-stopped is the usual choice. Do not put a restart policy on one-off jobs such as migrate, or Compose will run them again and again.

Optional services with profiles

Some services are only needed sometimes: a database admin tool, a mail catcher for testing emails, a load-testing script. Give them a profile, and Compose leaves them out unless that profile is switched on:

YAML
adminer:
  image: adminer
  ports:
    - "127.0.0.1:8081:8080"
  profiles: [tools]
Terminal
docker compose up -d                    # web, db and migrate only
docker compose --profile tools up -d    # also starts adminer
COMPOSE_PROFILES=tools docker compose up -d

Services without profiles always start. Naming a profiled service directly, such as docker compose up -d adminer, also starts it. Profiles let one file serve developers, testers and production without copies.

Override files

When Compose finds compose.override.yaml next to compose.yaml, it merges the two automatically. Teams use this to keep production settings in compose.yaml and development extras in the override:

YAML
# compose.override.yaml (development only)
services:
  web:
    volumes:
      - ./src:/var/www/html
    environment:
      APP_DEBUG: "1"

For production, name the files explicitly so the override is not used: docker compose -f compose.yaml -f compose.prod.yaml up -d. Later files override earlier ones, and docker compose config shows the merged result.

Watch mode for development

Instead of a bind mount, Compose can watch your files and update containers when they change. Add a develop section to a service:

YAML
develop:
  watch:
    - action: sync
      path: ./src
      target: /var/www/html
    - action: rebuild
      path: Dockerfile

Run docker compose watch (or docker compose up --watch). Editing a PHP file copies it into the running container at once (sync); editing the Dockerfile rebuilds the image and replaces the container (rebuild). A third action, sync+restart, copies the files and then restarts the container, which suits apps that read their configuration only at start-up. Watch avoids the slow file sharing and hidden node_modules problems that bind mounts can cause on Windows and macOS.

Common mistakes

  • Using short depends_on and expecting the database to be ready. It only orders container start-up. Add a healthcheck and condition: service_healthy.
  • A healthcheck command that does not exist in the image. curl is missing from many slim and Alpine images, so the check fails forever. Use a tool the image has.
  • Restart policies on one-off jobs. A migration with restart: always runs in a loop.
  • Forgetting that compose.override.yaml loads automatically. Development bind mounts can end up on a server by accident. Use explicit -f files in production.

Key points

  • Add a healthcheck with test, interval, timeout, retries and start_period so Docker knows when a service is really working.
  • depends_on with condition: service_healthy waits for readiness; service_completed_successfully waits for one-off jobs.
  • docker compose up -d --wait returns only when everything is healthy, which is ideal for scripts and CI.
  • profiles keep optional services out of a normal up; enable them with --profile or COMPOSE_PROFILES.
  • compose.override.yaml merges automatically; develop.watch syncs or rebuilds on file changes.

Exercise

Add a cache service using the redis:8-alpine image to the notice board. Give it a healthcheck that runs redis-cli ping every 5 seconds, and make web wait until it is healthy. Then add a mailpit service (image axllent/mailpit, web interface on container port 8025) that only starts with a profile called mail.

Show solution
YAML
cache:
  image: redis:8-alpine
  healthcheck:
    test: ["CMD", "redis-cli", "ping"]
    interval: 5s
    timeout: 3s
    retries: 5

mailpit:
  image: axllent/mailpit
  ports:
    - "127.0.0.1:8025:8025"
  profiles: [mail]
YAML
web:
  depends_on:
    db:
      condition: service_healthy
    migrate:
      condition: service_completed_successfully
    cache:
      condition: service_healthy

redis-cli ping exits with 0 when Redis answers PONG, so the container becomes healthy. The web block shows only the changed depends_on part. docker compose --profile mail up -d starts Mailpit as well.

Quick Quiz

1. What does the short form depends_on: [db] guarantee?

2. Which condition waits for a one-off job such as a migration to finish without errors?

3. A service has profiles: [tools]. When does docker compose up -d start it?

4. Why is docker compose up -d --wait useful in CI pipelines?

Learn Docker & DevOps with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call