← Back to DevBytes

Docker Compose Depends On: Best Practices and Common Pitfalls

Understanding depends_on in Docker Compose

The depends_on directive in Docker Compose tells Docker to start services in a specific order. When you define a dependency between services, Compose ensures that the dependent service is started before the service that declares the dependency. This is crucial in multi-container applications where, for example, a web server needs a database to be running first.

Here's the simplest possible syntax:

version: '3.8'

services:
  db:
    image: postgres:15
    environment:
      POSTGRES_PASSWORD: secret

  web:
    image: my-web-app:latest
    depends_on:
      - db

In this example, web depends on db. When you run docker compose up, Docker will first create and start the db container, and only then create and start the web container.

Why depends_on Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

In any non-trivial containerized application, services rarely live in isolation. A typical web application stack might include:

Without depends_on, Docker Compose starts all containers simultaneously. This can lead to race conditions where the web server tries to connect to the database before the database container's network socket is even available, causing crashes, connection refused errors, or unpredictable retry loops.

depends_on gives you explicit control over startup sequencing, which is the first step toward reliable container orchestration in development, testing, and CI/CD environments.

The Most Critical Pitfall: Startup vs. Readiness

This is the single most misunderstood aspect of depends_on, and it has bitten countless developers. depends_on only waits for the dependent container to start, not for the service inside it to become ready.

Consider this scenario:

version: '3.8'

services:
  db:
    image: postgres:15
    environment:
      POSTGRES_PASSWORD: secret

  web:
    image: my-web-app:latest
    depends_on:
      - db
    environment:
      DATABASE_URL: postgresql://postgres:secret@db:5432/mydb

Here's what actually happens when you run docker compose up:

  1. Docker creates the db container and starts it.
  2. As soon as the container process (the PostgreSQL server) begins executing, Docker marks the container as "started."
  3. Docker immediately creates and starts the web container.
  4. The web application tries to connect to PostgreSQL — but PostgreSQL is still initializing its data directory, setting up users, and isn't accepting connections yet.
  5. Result: Connection refused or FATAL: the database system is starting up.

The container may be "running" from Docker's perspective, but the application inside is not yet ready to serve. This gap between container startup and service readiness is where most failures occur.

Visualizing the Problem

A simplified timeline makes this clear:

Time 0s:  docker compose up
Time 2s:  db container starts (PID 1 = postgres process)
Time 2s:  Docker sees "container started" → triggers web container start
Time 3s:  web container starts, app tries to connect to db:5432
Time 4s:  PostgreSQL says: "database system is starting up" — connection fails
Time 8s:  PostgreSQL finally accepts connections — but web app already crashed

The web app crashes long before the database is actually ready. This is why simply using depends_on without additional readiness checks is insufficient for production-grade setups.

Docker Compose Version Differences: depends_on with Conditions

Starting with Docker Compose v2 (the plugin version, often invoked as docker compose without a hyphen) and Compose Specification format, depends_on gained powerful conditional forms. These allow you to express not just "start after," but "start after the container is healthy" or "start after the container exits successfully."

Here's the long-form syntax using the condition field:

# Requires Docker Compose v2+ (docker compose, not docker-compose)
# Uses the Compose Specification format (no 'version' top-level key needed)

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

  web:
    image: my-web-app:latest
    depends_on:
      db:
        condition: service_healthy
    environment:
      DATABASE_URL: postgresql://postgres:secret@db:5432/mydb

This syntax tells Docker Compose: "Do not start the web container until the db container's health check passes." This solves the readiness gap completely, because service_healthy means the health check command has succeeded the required number of consecutive times.

Available Conditions

The condition field accepts three values:

Example: Migration Runner with service_completed_successfully

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

  migrations:
    image: my-migration-tool:latest
    depends_on:
      db:
        condition: service_healthy
    environment:
      DATABASE_URL: postgresql://postgres:secret@db:5432/mydb

  web:
    image: my-web-app:latest
    depends_on:
      migrations:
        condition: service_completed_successfully
    environment:
      DATABASE_URL: postgresql://postgres:secret@db:5432/mydb

Here the sequence is: db starts and becomes healthy → migrations runs and exits successfully → web starts. This guarantees that the database schema exists before the web application tries to query it.

Health Checks: The Foundation of Reliable depends_on

To use condition: service_healthy, you must define a health check on the dependent service. Without a health check, Docker Compose cannot evaluate the service_healthy condition, and the dependency will fail or behave unpredictably.

A well-crafted health check is service-specific and lightweight. Here are patterns for common services:

PostgreSQL

healthcheck:
  test: ["CMD-SHELL", "pg_isready -U postgres"]
  interval: 2s
  timeout: 5s
  retries: 10
  start_period: 10s

MySQL / MariaDB

healthcheck:
  test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
  interval: 3s
  timeout: 5s
  retries: 10
  start_period: 15s

Redis

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

HTTP-based Application

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
  interval: 5s
  timeout: 3s
  retries: 5
  start_period: 10s

Custom Script (when no CLI tool exists)

healthcheck:
  test: ["CMD-SHELL", "/usr/local/bin/check-readiness.sh"]
  interval: 5s
  timeout: 10s
  retries: 5
  start_period: 20s

Key parameters explained:

The start_period is especially important. Without it, a database that takes 20 seconds to initialize its data directory will accumulate failures during those 20 seconds and get marked unhealthy before it even has a chance to become ready.

Handling Legacy Compose Files: The wait-for-it Pattern

If you're using an older version of Docker Compose (the standalone docker-compose v1) or a format that doesn't support conditions, you need an in-container wait strategy. The most common approach is a wrapper script like wait-for-it or dockerize.

Here's how to use wait-for-it inside your application container:

# Dockerfile
FROM python:3.11-slim

# Install wait-for-it script
ADD https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh /usr/local/bin/wait-for-it
RUN chmod +x /usr/local/bin/wait-for-it

COPY app/ /app/
WORKDIR /app

# Use wait-for-it as the entrypoint wrapper
CMD ["wait-for-it", "db:5432", "--", "python", "app.py"]

In your Compose file, you still declare depends_on for ordering:

version: '3.8'

services:
  db:
    image: postgres:15
    environment:
      POSTGRES_PASSWORD: secret

  web:
    build: .
    depends_on:
      - db
    # wait-for-it handles the readiness check at runtime

Here depends_on handles container startup ordering (coarse), and wait-for-it handles service readiness (fine). Note that wait-for-it only checks TCP port availability, not full service readiness. For PostgreSQL, the port may be open before the database is ready to accept authenticated connections, so a more robust script might retry with actual queries.

Custom Wait Script with Retry Logic

For maximum reliability, you can write a custom entrypoint script:

#!/bin/bash
# entrypoint.sh

echo "Waiting for PostgreSQL to be ready..."

MAX_RETRIES=30
RETRY_INTERVAL=2

for i in $(seq 1 $MAX_RETRIES); do
    if python -c "import psycopg2; psycopg2.connect('postgresql://postgres:secret@db:5432/mydb')" 2>/dev/null; then
        echo "PostgreSQL is ready!"
        break
    fi
    echo "Attempt $i/$MAX_RETRIES — PostgreSQL not ready yet, retrying in ${RETRY_INTERVAL}s..."
    sleep $RETRY_INTERVAL
done

if [ "$i" = "$MAX_RETRIES" ]; then
    echo "PostgreSQL did not become ready in time. Exiting."
    exit 1
fi

exec python app.py

Then use it in your Dockerfile:

COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

This pattern gives you full control over what "ready" means for your specific application, and it works regardless of Docker Compose version.

Best Practices Summary

1. Always Use Health Checks When Possible

If you're on Docker Compose v2+ (which you almost certainly should be by now), define health checks on every service that other services depend on, and use condition: service_healthy. This is the cleanest, most declarative approach.

depends_on:
  db:
    condition: service_healthy
  redis:
    condition: service_healthy

2. Set Realistic start_period Values

The start_period must be longer than your service's worst-case cold-start initialization time. Monitor your service's startup time in development and double it for safety. For PostgreSQL, 10-15 seconds is common; for services that download models or warm caches, it might be minutes.

healthcheck:
  test: ["CMD-SHELL", "pg_isready -U postgres"]
  interval: 3s
  timeout: 5s
  retries: 5
  start_period: 30s  # Generous for slow disk or large data dirs

3. Avoid Circular Dependencies

Circular dependencies (A depends on B and B depends on A) will cause Docker Compose to refuse to start. If two services genuinely need each other to start, reconsider the architecture — perhaps they should be a single service, or communicate through a shared intermediary like a message queue.

# This will fail — circular dependency
services:
  service-a:
    depends_on:
      service-b:
        condition: service_healthy
  service-b:
    depends_on:
      service-a:
        condition: service_healthy

4. Use service_completed_successfully for One-shot Tasks

Database migrations, seed scripts, and initialization tasks should use service_completed_successfully so that dependent services only start after the task completes. This prevents race conditions where the application starts before the schema exists.

services:
  db:
    image: postgres:15
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 2s
      timeout: 5s
      retries: 10
      start_period: 10s

  migrate:
    image: my-app:latest
    command: ["python", "manage.py", "migrate"]
    depends_on:
      db:
        condition: service_healthy
    restart: "no"

  app:
    image: my-app:latest
    command: ["python", "manage.py", "runserver"]
    depends_on:
      migrate:
        condition: service_completed_successfully

5. Combine depends_on with Restart Policies

Even with perfect startup ordering, services can crash at runtime. Use the restart policy so that if a dependency fails after startup, dependent services don't linger in a broken state forever. Docker Compose v2+ supports restart: always, unless-stopped, and on-failure.

services:
  web:
    image: my-web-app:latest
    depends_on:
      db:
        condition: service_healthy
    restart: always

6. Keep Dependencies Shallow and Explicit

Only declare dependencies that your service directly needs at startup. If web needs db but db doesn't need web, only web should declare depends_on: db. Avoid transitive dependency chains where possible — let each service declare only its direct dependencies.

7. Test Startup Sequences in CI

Startup ordering bugs are often discovered late because they depend on timing. Write integration tests that verify your Compose stack starts reliably by actually running docker compose up --wait in CI. The --wait flag (available in Docker Compose v2+) waits for all containers to become healthy or exit successfully before returning, which is perfect for automated testing.

# In CI pipeline
docker compose up --wait --timeout 120
curl -f http://localhost:8080/health || exit 1

Common Pitfalls Checklist

Putting It All Together: A Complete Example

Here's a production-quality development Compose file that demonstrates all the best practices discussed:

services:
  # PostgreSQL database with health check
  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: apppassword
      POSTGRES_DB: appdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 3s
      timeout: 5s
      retries: 10
      start_period: 15s
    restart: unless-stopped
    volumes:
      - postgres_data:/var/lib/postgresql/data

  # Redis cache with health check
  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 2s
      timeout: 3s
      retries: 10
      start_period: 5s
    restart: unless-stopped

  # Database migration runner
  migrations:
    build:
      context: .
      dockerfile: Dockerfile
    command: ["python", "manage.py", "migrate"]
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      DATABASE_URL: postgresql://appuser:apppassword@postgres:5432/appdb
    restart: "no"

  # Main web application
  web:
    build:
      context: .
      dockerfile: Dockerfile
    command: ["gunicorn", "app.wsgi:application", "--bind", "0.0.0.0:8000"]
    ports:
      - "8000:8000"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      migrations:
        condition: service_completed_successfully
    environment:
      DATABASE_URL: postgresql://appuser:apppassword@postgres:5432/appdb
      REDIS_URL: redis://redis:6379/0
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 15s
    restart: unless-stopped

  # Background worker
  worker:
    build:
      context: .
      dockerfile: Dockerfile
    command: ["celery", "-A", "app.tasks", "worker", "--loglevel=info"]
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      migrations:
        condition: service_completed_successfully
    environment:
      DATABASE_URL: postgresql://appuser:apppassword@postgres:5432/appdb
      REDIS_URL: redis://redis:6379/0
    restart: unless-stopped

volumes:
  postgres_data:

This Compose file guarantees:

Conclusion

depends_on is deceptively simple: its basic form is easy to write but easy to misunderstand. The fundamental insight is that container startup and service readiness are two distinct events, and confusing them leads to flaky, timing-dependent failures that are hard to debug. By combining depends_on with properly configured health checks and using the condition syntax (service_healthy, service_completed_successfully), you transform a coarse ordering hint into a precise, reliable startup contract. For legacy environments, in-container wait scripts bridge the gap. The best practices outlined here — realistic start periods, avoiding circular dependencies, testing startup in CI, and layering restart policies — turn Docker Compose from a simple development tool into a robust, predictable orchestration platform suitable for staging, testing, and even single-host production deployments.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles