Understanding service readiness, healthchecks, and depends_on in Docker Compose

One of the most common problems when developing applications with Docker Compose looks deceptively simple:
Connection refused
You check your containers:
docker compose ps
Your database container is running.
Your application container is running.
Everything appears to be fine.
Yet your application still can’t connect to the database.
What’s going on?
The answer is an important distinction that every developer working with containers should understand:
A container being started does not necessarily mean the service inside it is ready.
This article explains why this happens and how Docker Compose healthchecks can solve it cleanly.
The Problem
Imagine a simple application architecture:
Docker Compose
│
├── application
│
└── PostgreSQL
The application needs PostgreSQL during startup, perhaps to:
- Run database migrations
- Initialize tables
- Load initial data
- Establish a connection pool
A typical Compose configuration might look like:
services:
postgres:
image: postgres:18-alpine
api:
build: .
depends_on:
- postgres
At first glance, this seems reasonable.
You might expect Docker Compose to do this:
Start PostgreSQL
↓
Wait until PostgreSQL is ready
↓
Start API
But that isn’t what the basic dependency declaration guarantees.
The API may start while PostgreSQL is still initializing.
The actual sequence can look like this:
Start PostgreSQL
↓
Start API
↓
API tries to connect
↓
❌ Connection refused
↓
PostgreSQL finishes initialization
↓
PostgreSQL is ready
By then, your API may already have crashed.
depends_on Is Not the Same as "Ready"
This is the key concept.
Consider:
depends_on:
- postgres
This expresses a dependency:
Start the
postgresservice before theapiservice.
But there is a difference between:
Container lifecycle
and
Application readiness
A PostgreSQL container can exist and be running while PostgreSQL is still:
- Creating its database cluster
- Applying initialization scripts
- Starting the database server
- Opening its network socket
So these two states are not equivalent:
Container is running
and:
PostgreSQL is ready to accept connections
That distinction is the source of many startup race conditions.
What Is a Healthcheck?
Docker provides a mechanism specifically designed to answer a simple question:
Is this service actually ready?
That’s a healthcheck.
For PostgreSQL, the standard tool is:
pg_isready
It checks whether PostgreSQL is accepting connections.
We can add a healthcheck to our Compose configuration:
services:
postgres:
image: postgres:18-alpine
environment:
POSTGRES_USER: root
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
healthcheck:
test: ["CMD-SHELL", "pg_isready -U root -d myapp"]
interval: 5s
timeout: 5s
retries: 5
Now Docker can track the database’s health.
Conceptually:
PostgreSQL container
│
▼
Healthcheck
│
├── unhealthy
│
└── healthy
That’s much more useful than simply knowing whether the container process exists.
Combining Healthchecks With depends_on
Now comes the important part.
Instead of:
depends_on:
- postgres
we can use:
depends_on:
postgres:
condition: service_healthy
Our application service becomes:
api:
build: .
depends_on:
postgres:
condition: service_healthy
Now the dependency has more meaning:
Start PostgreSQL
↓
Run healthcheck
↓
Is PostgreSQL healthy?
│
├── No → Keep waiting
│
└── Yes
↓
Start API
This eliminates the startup race condition in a much cleaner way.
A Complete Example
Here’s a generic Docker Compose configuration:
services:
postgres:
image: postgres:18-alpine
environment:
POSTGRES_USER: root
POSTGRES_PASSWORD: secret
POSTGRES_DB: myapp
healthcheck:
test: ["CMD-SHELL", "pg_isready -U root -d myapp"]
interval: 5s
timeout: 5s
retries: 5
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
DB_SOURCE: postgres://root:secret@postgres:5432/myapp?sslmode=disable
depends_on:
postgres:
condition: service_healthy
Notice another important detail:
postgres
is used as the hostname.
Inside a Docker Compose network, services can communicate using their service names.
So the application connects to:
postgres:5432
rather than:
localhost:5432
That’s because localhost inside the API container refers to the API container itself, not the PostgreSQL container.
What About Database Migrations?
This pattern becomes particularly useful when your application runs migrations during startup.
For example, you might have a startup script:
#!/bin/sh
set -e
echo "Running database migrations..."
migrate \
-path /app/migrations \
-database "$DB_SOURCE" \
-verbose up
echo "Starting application..."
exec "$@"
The startup sequence becomes:
PostgreSQL starts
↓
PostgreSQL initializes
↓
Healthcheck passes
↓
Application starts
↓
Migration runs
↓
Migration succeeds
↓
Application starts serving requests
This is significantly more reliable than having the application immediately attempt a database connection while the database is still starting.
What About wait-for.sh?
You may have seen another popular solution:
wait-for.sh
wait-for-it.sh
dockerize
custom retry loops
These approaches are often used to solve service startup ordering.
For example:
Application starts
↓
wait-for.sh
↓
Wait for PostgreSQL
↓
Run migration
↓
Start application
This can work.
But there is an important question to ask:
Should the application be responsible for understanding Docker service readiness?
When you’re using Docker Compose, Compose already has mechanisms for expressing service dependencies and health.
For a straightforward Compose setup, using:
healthcheck:
...
and:
depends_on:
postgres:
condition: service_healthy
is generally cleaner and more declarative.
Instead of writing additional shell logic, you’re describing the relationship directly in your infrastructure configuration.
Declarative vs. Imperative
This is a useful way to think about the difference.
A shell script approach says:
“Run this command repeatedly until the database works.”
That’s imperative.
A Compose healthcheck approach says:
“PostgreSQL is healthy when this check succeeds, and the API depends on PostgreSQL being healthy.”
That’s declarative.
The second approach describes what the system requires, rather than embedding the orchestration logic inside the application startup process.
But Does service_healthy Solve Everything?
No.
This is an important distinction.
A healthcheck solves service readiness at startup.
It doesn’t automatically solve every possible database connectivity problem.
For example, after the application has started, PostgreSQL could still:
- Restart
- Become temporarily unavailable
- Lose its network connection
- Experience resource exhaustion
Your application should still have appropriate database error handling and, depending on the architecture, retry logic.
So don’t think of:
condition: service_healthy
as a replacement for robust application behavior.
Think of it as solving a specific problem:
Don’t start this dependent service until its dependency is ready.
Healthchecks Are Useful Beyond Databases
The same concept applies to many services.
Redis
A healthcheck can verify that Redis is responding.
HTTP services
You can check an endpoint such as:
/health
Message queues
You can verify that the broker is accepting connections.
Custom applications
You can expose a health endpoint:
GET /health
and let Docker use it to determine whether the service is healthy.
The general architecture becomes:
Service starts
↓
Healthcheck
↓
Service ready?
│
├── No
│
└── Yes
↓
Dependent services
A Small Detail That Matters: Healthcheck Design
A healthcheck should test something meaningful.
For PostgreSQL:
test: ["CMD-SHELL", "pg_isready -U root -d myapp"]
is better than simply checking whether a process exists.
Why?
Because we’re interested in:
“Can PostgreSQL accept connections?”
not:
“Does a PostgreSQL process exist?”
The closer your healthcheck is to the actual dependency requirement, the more useful it becomes.
Don’t Ignore Application-Level Readiness
There’s another subtle distinction worth mentioning.
A service can be:
Process running
but not necessarily:
Application ready
For example, a web application might start its process before:
- Loading configuration
- Connecting to dependencies
- Loading models
- Warming caches
- Initializing background workers
That’s why production systems often expose explicit readiness endpoints.
For example:
GET /health
might answer:
{
"status": "ok"
}
The healthcheck then represents the actual state that other services care about.
The Mental Model to Remember
Whenever you build a multi-container system, think about three different concepts:
1. Created
The container exists.
CREATED
2. Running
The container’s main process is running.
RUNNING
3. Ready
The service is actually capable of handling requests.
HEALTHY
These aren’t the same thing.
A common mistake is assuming:
RUNNING = READY
But in real systems:
RUNNING ≠ READY
That’s the lesson behind many “connection refused” errors during container startup.
Key Takeaways
If your application depends on another container:
- Don’t assume that a running container means the service is ready.
- Use a meaningful
healthcheckfor the dependency. - Use:
depends_on:
service:
condition: service_healthy
when you need Compose to wait for that dependency.
- Use service names for container-to-container communication:
postgres:5432
rather than:
localhost:5432
Keep application-level retry and error handling where appropriate.
Treat startup ordering and runtime resilience as two separate problems.
Final Thoughts
Containerization doesn’t eliminate distributed-system problems.
It often makes them more visible.
A database and an API may run on the same machine, but once they’re placed in separate containers, they have separate lifecycles. The API cannot simply assume that the database is ready because its container has started.
That’s why healthchecks are so useful.
Instead of:
"PostgreSQL container started, so let's connect."
we can express the actual requirement:
"Start the API when PostgreSQL is healthy."
That small change can turn a fragile startup sequence into a predictable one.
And sometimes, the best solution to a startup race condition isn’t another script.
It’s describing the dependency correctly.