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_onaccepts three conditions:-
service_started(default) — waits for the container to simply be started.
-service_healthy— waits for the container's healthcheck to returnhealthy.
-service_completed_successfully— for short-lived containers (jobs, migrations).For any database,
service_healthyis the only condition that guarantees the service accepts connections.Write the PostgreSQL healthcheck in the `db` service
Add the
healthcheckblock directly in thedbservice 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
testfield receives a list: the first element isCMD(Docker executes the command directly), followed by the arguments.pg_isreadyreturns0if PostgreSQL is ready to accept connections for the given user and database, and a non-zero code otherwise — which Docker interprets ashealthyorunhealthy.Understand the role of `start_period`
start_periodis the grace window given to the container to initialize before healthcheck failures start counting towardretries. 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 gounhealthybefore 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.
intervalandstart_periodare distinct:intervalpaces checks under normal operation,start_periodprotects 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_onwith 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
dbservice healthcheck to returnhealthybefore startingapp. Ifdbgoesunhealthyafterretriesfailures,appdoes 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,startingorunhealthy, and lists the last check outputs.Redis use case: adapted healthcheck
Redis does not have
redis-isready, but its equivalent isredis-cli ping:redis: image: redis:7-alpine healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 5 start_period: 10sredis-cli pingreturnsPONGand exit code0if Redis accepts connections. Since Redis starts faster than PostgreSQL,start_period: 10sis 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
-pwith no space (-psecret), which is the expectedmysqladminbehavior. This command appears indocker 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 officialpostgresimage (or a derived image that includes it). Check withdocker compose exec db which pg_isready.Healthcheck looping, never
healthy— thetestnever returns0. Test manually:docker compose exec db pg_isready -U app -d appdb. If the command fails, check thePOSTGRES_USERandPOSTGRES_DBenvironment variables.start_periodtoo short — on a first start with an empty volume, PostgreSQL can take more than 30 seconds. Increase to60sor observe the logs:database system was shut down at … LOG: database system is ready to accept connectionsindicates the actual delay.Container permanently
unhealthy— afterretriesfailures, Docker marks the containerunhealthybut does not restart it (that is the role ofrestart). Checkdocker inspectto see the output of the last checks and identify the failing command.
Default behavior vs with healthcheck
Scroll the table
| 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.