Why `depends_on` by default fails
By default, depends_on uses the service_started condition. This means Docker only waits for the target container to be launched — in other words, for its main process to have started. This says nothing about the internal state of the service.
PostgreSQL, like most databases, goes through several phases during initialization: the official image runs bootstrap scripts, creates roles, initializes extensions and positions the cluster before it starts accepting connections. This sequence can take anywhere from a few seconds to more than thirty seconds on a VPS with a warm disk, an unprepared volume or a large set of extensions.
During this time, your application — which correctly respects the depends_on directive — is already trying to connect, and receives a hard refusal.
The practical consequences of a startup race condition
connection refused— PostgreSQL's TCP socket is not yet open, the application fails on the first PDO or SQLAlchemy call.FATAL: role does not exist— PostgreSQL is listening, but the initialization scripts (docker-entrypoint-initdb.d) have not yet created the role or database.FATAL: the database system is starting up— the cluster is recovering from a clean shutdown; connections are temporarily refused.- Silent crash loop — Docker
restart: unless-stoppedrelaunches the application indefinitely, logs repeat, and the problem looks like an application error. - False positives in CI — integration tests fail intermittently depending on the runner startup speed.
- Cascading dependencies — an API that depends on an app that depends on the database inherits the same problem if the
depends_onchain is not uniformly correct.
Prerequisites: Docker Compose v2 and the official plugin
The service_healthy condition is not available in Docker Compose v1 (the Python docker-compose binary, now obsolete). It is supported since Docker Compose v2, distributed as a Go plugin under the docker compose command (no hyphen).
To check your version:
docker compose versionThe output must show Docker Compose version v2.x.x or higher. On Debian 12 and Ubuntu 22.04+, the plugin is available from the official Docker repositories. If you still have docker-compose (v1), migrate: the project is archived and no longer receives security fixes.
No additional dependency is required for PostgreSQL: pg_isready is a native tool of the official postgres image, present in all tags for years.
Configure a reliable PostgreSQL healthcheck, step by step
Understand service_started vs service_healthy
depends_on accepts three conditions:
- service_started (default) — waits for the container to simply be started.
- service_healthy — waits for the container's healthcheck to return healthy.
- service_completed_successfully — for short-lived containers (jobs, migrations).
For any database, service_healthy is the only condition that guarantees the service accepts connections.
Write the PostgreSQL healthcheck in the `db` service
Add the healthcheck block directly in the db service definition:
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
healthcheck:
test: ["CMD", "pg_isready", "-U", "app", "-d", "appdb"]
interval: 5s
timeout: 5s
retries: 5
start_period: 30sThe test field receives a list: the first element is CMD (Docker executes the command directly), followed by the arguments. pg_isready returns 0 if PostgreSQL is ready to accept connections for the given user and database, and a non-zero code otherwise — which Docker interprets as healthy or unhealthy.
Understand the role of `start_period`
start_period is the grace window given to the container to initialize before healthcheck failures start counting toward retries. During this window, Docker does run the healthcheck, but a failure does not increment the counter.
Without start_period, a PostgreSQL that takes 15 seconds to initialize would fail its first 5 checks (interval: 5s × 5 attempts = 25 seconds) and go unhealthy before it was even operational.
The recommended value is 30 seconds for a standard PostgreSQL: long enough to absorb slow initializations (first start with empty volume, heavy extensions) without unnecessarily delaying startup under normal conditions. interval and start_period are distinct: interval paces checks under normal operation, start_period protects the bootstrap phase.
Write the `depends_on` with `condition: service_healthy`
In each service that depends on the database, replace the short form of depends_on with the long form with condition:
services:
app:
image: myapp:latest
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql://app:secret@db:5432/appdbWith this configuration, Docker waits for the db service healthcheck to return healthy before starting app. If db goes unhealthy after retries failures, app does not start.
Test with `docker compose up`
Launch the stack and observe the sequencing:
docker compose upYou will see in the logs lines such as:
db | database system is ready to accept connections
app | Waiting for db to be healthy...
app | Starting application serverTo check the healthcheck state at any time:
docker inspect <db_container_name> | grep -A 5 '"Health"'The output shows Status: healthy, starting or unhealthy, and lists the last check outputs.
Redis use case: adapted healthcheck
Redis does not have redis-isready, but its equivalent is redis-cli ping:
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10sredis-cli ping returns PONG and exit code 0 if Redis accepts connections. Since Redis starts faster than PostgreSQL, start_period: 10s is generally sufficient.
MySQL / MariaDB use case: `mysqladmin ping`
For MySQL or MariaDB, use mysqladmin ping:
mysql:
image: mariadb:11
environment:
MYSQL_ROOT_PASSWORD: secret
MYSQL_DATABASE: appdb
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-psecret"]
interval: 5s
timeout: 5s
retries: 5
start_period: 30sNote: the password is concatenated directly to -p with no space (-psecret), which is the expected mysqladmin behavior. This command appears in docker inspect, so prefer a Compose secret or environment variable if confidentiality is a constraint.
Troubleshooting: four common errors
pg_isready: command not found — you are not using the official postgres image (or a derived image that includes it). Check with docker compose exec db which pg_isready.
Healthcheck looping, never healthy — the test never returns 0. Test manually: docker compose exec db pg_isready -U app -d appdb. If the command fails, check the POSTGRES_USER and POSTGRES_DB environment variables.
start_period too short — on a first start with an empty volume, PostgreSQL can take more than 30 seconds. Increase to 60s or observe the logs: database system was shut down at … LOG: database system is ready to accept connections indicates the actual delay.
Container permanently unhealthy — after retries failures, Docker marks the container unhealthy but does not restart it (that is the role of restart). Check docker inspect to see the output of the last checks and identify the failing command.
Default behavior vs with healthcheck
| Case | Default behavior (`service_started`) | With `service_healthy` |
|---|---|---|
| First start, empty volume | App starts before the database is ready → crash loop | App waits for PostgreSQL to be initialized and accepting connections |
| Restart after clean shutdown | App may start during PostgreSQL recovery phase | App waits until recovery is complete |
| Slow database (extensions, heavy init) | Race condition depending on host speed | No race condition: healthcheck validates actual state |
| Cascading dependencies (app → worker → db) | Each component must handle reconnection retries on its own | The condition chain guarantees startup order |
| Integration tests in CI | Intermittent results depending on runner speed | Deterministic results |
| Redis or MySQL instead of PostgreSQL | Same problem, default `depends_on` makes no distinction | Same solution, check command adapted to each engine |
`pg_isready` or `SELECT 1`: which to choose?
Two variants of PostgreSQL healthcheck are commonly seen in the wild:
- ["CMD", "pg_isready", "-U", "postgres"]
- ["CMD-SHELL", "psql -U postgres -c 'SELECT 1'"]"
pg_isready is more reliable for a simple reason: it only tests the server's ability to accept TCP connections, without opening a SQL session. It returns 0 as soon as the server is listening and accepting the handshake, which is exactly what an application needs to attempt its own connection.
SELECT 1 via psql opens a real SQL session and executes a query. It is a deeper test, but it can fail for reasons unrelated to server availability (connection quota reached, misconfigured pg_hba.conf). For a healthcheck, the minimal and direct test is preferable.
Adapting the healthcheck for Redis and MySQL
For Redis, replace pg_isready with CMD redis-cli PING — it returns PONG as soon as the server accepts connections. For MySQL or MariaDB, use CMD mysqladmin ping -h localhost -u root --password=$$MYSQL_ROOT_PASSWORD: raise start_period to 60s since MySQL initialization takes longer than PostgreSQL. The condition: service_healthy pattern is identical regardless of the target service.
Next steps
The service_healthy healthcheck is one of the robustness settings to enable in production. Several other points deserve the same attention before a sustainable deployment: restart policy restart: unless-stopped, resource limits deploy.resources.limits, and log rotation logging.options. Find the complete checklist in Docker Compose in Production: 10-Point Checklist.
If your stack grows — multiple services, multiple hosts — a reverse proxy like Caddy or Traefik is essential for managing HTTPS routing. The Caddy, Traefik or Nginx Proxy Manager guide details the selection criteria by profile.
To automate the deployment of your entire infrastructure (VPS, Docker, configuration) in a reproducible way, Ansible for automating your VPS servers will guide you step by step.