SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Postgres healthcheck in Docker Compose

The pg_isready healthcheck block for the official postgres image, why a socket probe lies on first boot, the TCP fix, and depends_on with service_healthy.

What a Postgres healthcheck in Docker Compose has to prove

A Postgres healthcheck in Docker Compose is one pg_isready command on the db service, plus depends_on with condition: service_healthy on the app that uses it. The detail that decides whether it works is -h localhost. On the very first boot, the official postgres image runs a temporary, socket-only server to load its init scripts, then shuts that server down and starts the real one. A pg_isready with no -h probes the Unix socket, reports ready while the temporary server is up, and your app starts into Connection refused. Probing over TCP makes the check fail until the real server is listening, which is the only moment your app cares about.

This guide covers the Postgres side only. For what interval, timeout, retries and start_period mean in general, read how Docker Compose healthchecks work first. Nothing below re-explains them.

The Postgres healthcheck block for the official image

Put the credentials in one env file and let both services read it. The probe and both services then use the same user and database name, and there is nothing to keep in sync by hand.

mkdir -p ~/shop && cd ~/shop
cat > .env <<'EOF'
POSTGRES_USER=app
POSTGRES_PASSWORD=change-me-to-a-long-random-string
POSTGRES_DB=appdb
EOF
chmod 600 .env

Then compose.yaml:

services:
  db:
    image: postgres:17
    env_file: .env
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h localhost -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 30s
    restart: unless-stopped

  app:
    image: postgres:17
    env_file: .env
    command: ["sh", "-c", "PGPASSWORD=$$POSTGRES_PASSWORD psql -h db -U $$POSTGRES_USER -d $$POSTGRES_DB -c 'SELECT now()'"]
    depends_on:
      db:
        condition: service_healthy

volumes:
  pgdata:

The app service here is a stand-in. It reuses the postgres:17 image only for its psql client, connects to db once, and exits after printing the time. That is the shape of app that dies on first boot, so it is a useful test subject. Replace it with your real image and keep the depends_on block as written.

Five details in the db block carry the weight.

-h localhost forces a TCP connection instead of the Unix socket. The next section explains why that is the whole fix.

-U and -d name the real role and database. pg_isready does not log in. The libpq documentation says so directly: the status comes back whether or not the user name or database name is right, but a wrong value makes the server log a failed connection attempt. Without -U, the probe connects as the operating system user it runs as, which is root inside this image because the Dockerfile sets no USER. The check returns 0 and Postgres writes FATAL: role "root" does not exist to its log every five seconds for the life of the container. Without -d, the database name defaults to the user name, and the log fills with FATAL: database "app" does not exist instead. Both are noise that buries real errors.

$$ is how you write a literal $ in a Compose file. A later section covers what a single $ does instead.

start_period: 30s gives initdb and your init scripts room. Failures inside that window do not count toward retries, so a slow first boot does not flip the container to unhealthy and abort docker compose up. If you load a large dump from /docker-entrypoint-initdb.d, raise it to cover the load. It does not fix the false positive on its own: a success during the start period ends the start period and marks the container healthy at once, so a probe that lies still lies.

With interval: 5s and retries: 10, a server that stops answering after startup is marked unhealthy about fifty seconds later. Ten retries is generous on purpose. A short restart from a config change should not condemn the whole container. On Docker Engine 25 or newer you can add start_interval: 2s to probe faster during the start period without shortening the steady-state interval.

Bring it up and watch:

docker compose up -d
docker compose ps

While db is initialising, look for (health: starting) in the STATUS column and no app row at all, because Compose has not created it yet:

NAME        IMAGE         COMMAND                  SERVICE   CREATED         STATUS                            PORTS
shop-db-1   postgres:17   "docker-entrypoint.s…"   db        8 seconds ago   Up 7 seconds (health: starting)   5432/tcp

Run docker compose ps -a again after half a minute. db should now read (healthy), and app should be listed as Exited (0) under it (-a is needed because plain ps hides stopped containers). docker compose logs app should show the now column with the current timestamp and (1 row). If app shows Exited (2) instead and its log says connection to server at "db" followed by Connection refused, the check passed before the server was ready. That is the next section.

Why a socket-only pg_isready lies on first boot

Read the image's docker-entrypoint.sh and the mechanism is plain. On start it tests $PGDATA/PG_VERSION. If that file is missing or empty, the volume is fresh, and the script runs initdb, then starts a temporary server with pg_ctl -w start and the flags -c listen_addresses='' -p 5432. An empty listen_addresses means no TCP sockets at all. This server answers only on the Unix socket in /var/run/postgresql. The script uses it to create POSTGRES_DB, runs every file in /docker-entrypoint-initdb.d, stops it with pg_ctl -m fast -w stop, prints PostgreSQL init process complete; ready for start up. and finally replaces itself with the real postgres process.

Now follow a probe with no -h. pg_isready defaults to the Unix socket, the same way psql does. Its output is /var/run/postgresql:5432 - accepting connections and its exit code is 0. That first 0 arrives while the temporary server is up. Docker marks the container healthy on the first success. Compose sees service_healthy satisfied and creates app, which dials db:5432 over the Compose network. Nothing is listening on TCP, so the kernel refuses the connection and libpq reports:

connection to server at "db" (172.18.0.2), port 5432 failed: Connection refused
        Is the server running on that host and accepting TCP/IP connections?

Depending on timing you can also land in the gap after the temporary server stops, which gives the same error, or in the first moments of the real server, which gives FATAL: the database system is starting up. All of these have one cause: the probe answered a different question from the one the app asks.

The second boot behaves. PG_VERSION now exists, the entrypoint prints PostgreSQL Database directory appears to contain a database; Skipping initialization and goes straight to postgres. There is no temporary server, so the socket probe is accurate. That is why the failure looks random. It happens once per fresh volume, on the server you just set up, and never in the reproduction you attempt afterwards on a volume that already exists. To see it again on purpose, run docker compose down -v (the -v deletes the named volume) and up once more. Read what docker compose down deletes that stop does not before you run that on a database you care about.

-h localhost closes the hole. pg_isready treats a -h value that starts with / as a socket directory and anything else as a TCP host, so localhost means the loopback address inside the db container. Against the temporary server that gets localhost:5432 - no response and exit code 2, which is a failed check. Against the real server it succeeds, because the image patches postgresql.conf.sample so that initdb writes listen_addresses = '*', and the real server listens on loopback and on the container's network address alike. The healthcheck now turns green at the same moment the app can connect. -h 127.0.0.1 works too, if you prefer to skip the name lookup.

Check what Docker really holds after the $$ escaping, and see each probe's result:

docker inspect --format '{{json .Config.Healthcheck.Test}}' shop-db-1
docker inspect --format '{{json .State.Health}}' shop-db-1

The first line should print ["CMD-SHELL","pg_isready -h localhost -U ${POSTGRES_USER} -d ${POSTGRES_DB}"], with single dollars. The second prints the current Status and a Log list holding the last few probes with their ExitCode and Output. On a fresh volume, look for early entries with exit code 2 and no response, followed by exit code 0 and accepting connections. A log whose very first entry is exit code 0 with a /var/run/postgresql path is the socket probe you just replaced.

Why the test needs $$ and not $

Compose interpolates ${VAR} and $VAR while it parses the file, from your shell environment and from a file named .env in the project directory. That happens on the host, before any container exists. A healthcheck needs the opposite. The container's own shell should expand the variable at probe time, from the environment that env_file gave the container. $$ is the Compose escape for a literal $. It survives parsing as $, and the CMD-SHELL form runs the string through /bin/sh -c inside the container, where ${POSTGRES_USER} resolves to app.

Write a single $ and one of two things happens. If your file is literally .env beside compose.yaml, Compose finds the value and substitutes it at parse time, and the check works by accident, with the user name baked into the container definition. Rename that file to db.env and point env_file at it, and Compose no longer reads it for interpolation. up then prints WARN[0000] The "POSTGRES_USER" variable is not set. Defaulting to a blank string. and the probe runs pg_isready -h localhost -U -d. The -U option swallows -d as its value, so the probe connects as a role literally named -d, Postgres logs FATAL: role "-d" does not exist on every probe, and the check still passes, because pg_isready never authenticates. docker compose ps shows nothing wrong. Only the Postgres log does. $$ behaves the same in both layouts, which is the point. The wider rules for which file Compose reads when are in Docker Compose env files and secrets.

When pg_isready is not strict enough

pg_isready proves the server accepts connections. It does not prove that the role can log in or that the database exists, and it never runs a query. A psql probe does all of that:

    healthcheck:
      test: ["CMD-SHELL", "psql -h localhost -U $${POSTGRES_USER} -d $${POSTGRES_DB} -tAc 'SELECT 1' >/dev/null"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 30s

Keep -h localhost here for the same reason as before, since a psql with no -h also uses the socket and also believes the temporary server. No password is needed from inside the container: initdb writes a pg_hba.conf that trusts connections from 127.0.0.1 and ::1, and the image only appends a host all all all scram-sha-256 line for everything else. If you changed that with POSTGRES_INITDB_ARGS, the probe log shows fe_sendauth: no password supplied, and the fix is to prefix the command with PGPASSWORD=$${POSTGRES_PASSWORD}.

The cost is one full backend process per probe instead of a lightweight ping, plus a psql error in the health log whenever it fails. For most stacks pg_isready over TCP is enough. Reach for psql when you need the probe to prove that a specific role and database exist, for example after an init script that creates them.

service_healthy versus service_started

The short form, depends_on: [db], means condition: service_started. Compose waits only until the db container has been started, which is the moment the entrypoint script begins to run. On a fresh volume the server that accepts your connection is initdb, one temporary server and every init script away from that moment. The app loses that race every time. service_healthy waits for Docker to report the container healthy, which with the block above is the moment TCP connections succeed. It needs a healthcheck on db to read. Without one, up stops with dependency failed to start: container shop-db-1 has no healthcheck configured.

If the check never passes, up waits through the start period and then ten failed retries, about eighty seconds with the values above, and ends with dependency failed to start: container shop-db-1 is unhealthy. Read docker compose logs db at that point. A wrong POSTGRES_PASSWORD on a volume that already holds a database does not stop the server, so that error usually means the server itself failed to start, and the log says why.

Two more keys sit next to condition. restart: true makes Compose restart app whenever docker compose up recreates db, for example after you bump the image tag, so the app does not keep a pool of dead connections to a container that no longer exists. service_completed_successfully is the condition for a one-shot job. A migrate service that depends on db being healthy, and an app that depends on migrate completing, gives you an ordered boot with no sleep loops anywhere.

Why the app still needs restart: unless-stopped

service_healthy orders the first docker compose up and nothing else. Two events bypass it. When the Docker daemon restarts, on a host reboot or a Docker upgrade, the daemon restarts every container that has a restart policy in whatever order it reaches them, and Compose is not running to enforce depends_on. And when Postgres restarts later on its own, after an out-of-memory kill or a docker compose restart db, the app is already running with a pool of connections that no longer work.

For an app that connects once at boot and exits when it cannot, restart: unless-stopped is the safety net. Docker restarts it after an increasing delay, starting at 100 milliseconds and doubling, until a start succeeds and stays up. Put it on the real app, not on the stand-in above, which exits 0 on purpose and would be restarted forever.

  app:
    build: .
    env_file: .env
    depends_on:
      db:
        condition: service_healthy
        restart: true
    restart: unless-stopped

The better fix lives in the app: retry the first connection for a minute, and validate pooled connections before use, so a restarted database costs a few failed queries instead of a crash. Where that logic belongs, and what a pooler in front of Postgres does to the reconnect story, is covered in Postgres connection pooling on a VPS. If your Postgres runs on the host rather than in Compose, none of the above applies, because there is no container for depends_on to wait on, and the app's own retry is the only guard. The trade-offs of running the database in Docker or on the host are worth reading before you commit either way.

FAQ

Why does my app get "Connection refused" when depends_on already lists db?

The short form of depends_on means condition: service_started, which only waits for the db container to start, not for Postgres to accept connections. Add a healthcheck to db and change the app's dependency to condition: service_healthy. Compose then waits until pg_isready succeeds before it creates the app container.

The healthcheck reports healthy, but the app still fails on the very first docker compose up. Why?

On a fresh volume the official image starts a temporary server with listen_addresses='' to run initdb and your init scripts, then stops it and starts the real server. A pg_isready with no -h probes the Unix socket, which that temporary server answers, so the container turns healthy while nothing listens on TCP. Add -h localhost to the probe. It then fails with no response until the real server is listening, which is what your app needs.

Should I write $POSTGRES_USER or $$POSTGRES_USER in the healthcheck test?

Use $$. Compose replaces a single $VAR on the host while it parses the file, using your shell and a file named .env, and warns The "POSTGRES_USER" variable is not set when it cannot find it. $$ survives as a literal $, so the container's shell expands the variable at probe time from the environment that env_file provided.

Why is my Postgres log full of FATAL: role "root" does not exist?

The healthcheck runs pg_isready with no -U, so it connects as the operating system user of the probe, which is root in the official image. pg_isready does not log in, so the check still passes, but the server logs the failed attempt on every probe. Pass -U $${POSTGRES_USER} and -d $${POSTGRES_DB} so the probe names a role and database that exist.