Skip to content
Field Notes
Go back

Dependencies, Healthchecks, and Startup Order in Compose

Your API container starts before the database is ready to accept connections. The app crashes, the restart policy brings it back, the database still is not ready, the app crashes again. Eventually the database finishes initializing and the app stays up — but the first 30 seconds of logs are a mess of connection errors. This is the most common Compose startup problem, and the solution is not retry logic in your application — it is telling Compose to wait until the database is actually ready.


TL;DR

depends_on without conditions only waits for the container to start — not for the application inside to be ready. Use depends_on with condition: service_healthy and define healthchecks on your dependencies. For one-time setup tasks (database migrations, schema creation), use condition: service_completed_successfully. These conditions turn Compose startup from “start everything and hope” into a deterministic sequence.


Context

Docker Compose starts services roughly in parallel. depends_on introduces ordering, but the default behavior — condition: service_started — only waits for the container to exist, not for the application to be functional. A PostgreSQL container is “started” the moment the process launches, but it may take several seconds to initialize the data directory and begin accepting connections. The gap between “container started” and “application ready” is where most startup failures live.


Analysis / Key Findings

The Three Conditions

depends_on:
  db:
    condition: service_started              # Default — container is running
  db:
    condition: service_healthy              # Container's healthcheck passes
  migrations:
    condition: service_completed_successfully  # Container exited with code 0
ConditionWaits forUse case
service_startedContainer process existsServices that start instantly
service_healthyHealthcheck returns healthyDatabases, caches, APIs
service_completed_successfullyContainer exits with code 0Migrations, seed scripts, init tasks

service_healthy in Practice

The most useful pattern — wait for the database to actually accept connections:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s

  api:
    build: .
    depends_on:
      db:
        condition: service_healthy
    environment:
      DATABASE_URL: postgres://postgres:secret@db:5432/appdb

The api service will not start until pg_isready confirms PostgreSQL is accepting connections. No more connection refused errors on startup.

service_completed_successfully: The Init Container Pattern

Some tasks need to run once before the main application starts — database migrations, schema creation, cache warming. Compose handles this with a service that runs to completion:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  migrate:
    build: .
    command: npx prisma migrate deploy
    depends_on:
      db:
        condition: service_healthy
    environment:
      DATABASE_URL: postgres://postgres:secret@db:5432/appdb

  api:
    build: .
    depends_on:
      db:
        condition: service_healthy
      migrate:
        condition: service_completed_successfully
    environment:
      DATABASE_URL: postgres://postgres:secret@db:5432/appdb

Startup sequence:

  1. db starts and becomes healthy (PostgreSQL accepting connections)
  2. migrate runs and exits with code 0 (migrations applied)
  3. api starts (database is ready and schema is current)

If migrate fails (exit code != 0), api will not start. This prevents the application from running against an unmigrated database.

Healthcheck Configuration in Compose

Healthchecks defined directly in the compose file:

services:
  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 5s

  mysql:
    image: mysql:8
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s  # MySQL can be slow to initialize
    environment:
      MYSQL_ROOT_PASSWORD: secret

  api:
    build: .
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 15s

The Common Trap

# This does NOT wait for the database to be ready
services:
  api:
    depends_on:
      - db
  db:
    image: postgres:16

The short form depends_on: [db] is equivalent to condition: service_started. It waits for the PostgreSQL container to exist — not for PostgreSQL to be accepting connections. The API will likely start before the database is ready, causing connection errors.

Always use the long form with condition: service_healthy for databases and services that have a startup delay.

Restart Behavior Interaction

Restart policies and dependencies interact in important ways:

For single-server deployments, combining healthchecks with restart policies provides a reasonable level of resilience: if the API crashes because the database is down, the restart policy brings the API back, and eventually the database recovers and the API reconnects.

Tuning start_period and retries

start_period gives a service time to boot before failed healthchecks count. Set it to at least the expected startup time:

retries determines how many consecutive failures trigger an unhealthy state. Too few (1-2) causes false alarms from momentary slowdowns. Too many (10+) delays detection of real problems. 3-5 is a reasonable default.

healthcheck:
  test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
  interval: 10s
  timeout: 5s
  start_period: 45s   # JVM app needs time to boot
  retries: 3

Conclusion

The difference between service_started and service_healthy is the difference between “container exists” and “application is ready.” Use service_healthy for anything that has a startup delay — databases, caches, APIs. Use service_completed_successfully for one-time init tasks. Define healthchecks on every service that other services depend on. The investment is a few lines of YAML; the payoff is a stack that starts cleanly every time instead of racing through connection errors until things stabilize.


References


Share this post on:

Previous Post
Open Source Server Monitoring: Resources, Services, Dashboards, and Alerts
Next Post
Compose Profiles and Environment Management