Deployment9 min read

depends_on is not enough: PostgreSQL healthcheck in Compose

Your Docker stack starts, the database moves to `running` state — and your application crashes immediately with `connection refused` or `FATAL: role does not exist`. The cause is almost always the same: `depends_on` by default waits for the container to start, not for the service to be operational. This guide shows you how to configure a reliable PostgreSQL healthcheck in `docker-compose.yml` to eliminate this race condition once and for all.

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-stopped relaunches 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_on chain 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 version

The 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

01

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.

02

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: 30s

The 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.

03

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.

04

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/appdb

With 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.

05

Test with `docker compose up`

Launch the stack and observe the sequencing:

docker compose up

You 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 server

To 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.

06

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: 10s

redis-cli ping returns PONG and exit code 0 if Redis accepts connections. Since Redis starts faster than PostgreSQL, start_period: 10s is generally sufficient.

07

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: 30s

Note: 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.

08

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

CaseDefault behavior (`service_started`)With `service_healthy`
First start, empty volumeApp starts before the database is ready → crash loopApp waits for PostgreSQL to be initialized and accepting connections
Restart after clean shutdownApp may start during PostgreSQL recovery phaseApp waits until recovery is complete
Slow database (extensions, heavy init)Race condition depending on host speedNo race condition: healthcheck validates actual state
Cascading dependencies (app → worker → db)Each component must handle reconnection retries on its ownThe condition chain guarantees startup order
Integration tests in CIIntermittent results depending on runner speedDeterministic results
Redis or MySQL instead of PostgreSQLSame problem, default `depends_on` makes no distinctionSame 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.

Host your Docker stack on a dedicated VPS

A ServOrbit VPS gives you root access, a dedicated IPv4 and the resources needed to run your Docker Compose stacks in production. Deploy in minutes.

Need help?

Browse our help center and FAQ, or reach our team — callback, WhatsApp or email. Support in French, English and Arabic.