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:
- A frontend web server
- A backend API service
- A database like PostgreSQL or MySQL
- A caching layer like Redis
- A message broker like RabbitMQ
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:
- Docker creates the
dbcontainer and starts it. - As soon as the container process (the PostgreSQL server) begins executing, Docker marks the container as "started."
- Docker immediately creates and starts the
webcontainer. - 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.
- Result:
Connection refusedorFATAL: 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:
service_started— Equivalent to the old short-formdepends_on. Waits only for the container to start (default if condition is omitted).service_healthy— Waits for the dependent container's health check to pass. This is the one you want for databases, APIs, and any service that needs to be truly ready.service_completed_successfully— Waits for the dependent container to exit with exit code 0. Useful for one-shot setup tasks like database migrations or seed scripts.
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:
interval: How often to run the check (e.g., every 2 seconds).timeout: Maximum time each check attempt can take before being considered failed.retries: Number of consecutive failures needed before the container is marked unhealthy.start_period: Grace period during startup where failures don't count toward the retry limit. Critical for services with slow initialization.
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
-
Assuming
depends_onguarantees readiness. Without health checks and conditions, it only guarantees container start order. Always verify what "started" means. -
Forgetting to define health checks when using
condition: service_healthy. Docker Compose will produce an error or silently fall back, depending on the version. Explicit is better. -
Setting
start_periodtoo short. If your database takes 20 seconds to initialize and yourstart_periodis 5 seconds, it will be marked unhealthy before it ever becomes ready. -
Using a TCP port check instead of an application-level readiness check. A port being open does not mean the service is ready. PostgreSQL opens port 5432 early but isn't ready for queries until later. Use
pg_isreadyor equivalent. - Creating implicit circular dependencies. If service A needs B's health check, and B's health check somehow depends on A (e.g., through a shared volume or network call), you've created a deadlock.
-
Not handling dependency service crashes.
depends_ononly affects startup order. Ifdbcrashes at runtime,webwon't automatically restart unless you've configuredrestartpolicies and connection retry logic in your application. -
Over-dependence on
depends_onfor production. Docker Compose is primarily designed for development, testing, and single-host deployments. In Kubernetes or other orchestrators, startup ordering is handled differently (init containers, readiness probes, pod priority). Don't assume Compose patterns directly translate.
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:
- PostgreSQL and Redis start first and must pass their health checks before anything else proceeds.
- Database migrations run once PostgreSQL is healthy and must complete successfully.
- The web application and background worker only start after migrations succeed and both infrastructure services are healthy.
- The web application itself exposes a health check endpoint so orchestrators or reverse proxies can verify its readiness.
- All long-running services use
restart: unless-stoppedfor resilience. - The migration service uses
restart: "no"so it doesn't loop infinitely if it fails.
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.