← Knowledge hub
Tutorialby Uptime Room· September 24, 2026· 6 min read

Postgres Health Check: Three Layers That Matter

A postgres health check needs three layers: pg_isready in the container, a real query behind your health route, and a port check from outside.

A postgres health check answers one question: can the database accept a connection and run a query right now. Most setups only test half of that. A container probe proves the process started, an application probe proves the query path still works, and an external probe proves both are reachable from where your users sit.

What a postgres health check should prove

Three different audiences ask three different questions of the same database. The container orchestrator wants to know whether it can start dependent services. Your application wants to know whether to serve traffic. You want to know whether customers are affected. One probe cannot answer all three, which is why layering them is worth the small amount of extra work.

LayerRuns whereProvesFails when
pg_isreadyInside the containerThe server accepts connectionsStartup stalls, port not bound
Health routeIn your applicationThe pool works and a query returnsCredentials wrong, pool exhausted, migrations locked
Port checkOutside your networkThe database path is reachableSecurity group change, network partition

Each layer catches faults the layer above it cannot see. Skipping the middle one is the most common gap, and it is the one that hides bad credentials after a secret rotation.

Layer 1: pg_isready inside the container

pg_isready opens a connection to the server and reports whether it is accepting them. It exits 0 for accepting, 1 for rejecting, 2 for no response and 3 for a bad invocation. That exit code is exactly what a container healthcheck wants.

services:
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: appdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb -h 127.0.0.1"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 30s
  api:
    build: .
    depends_on:
      db:
        condition: service_healthy

Two details matter here. Pass -U and -d explicitly, because a bare pg_isready connects as the default user against the default database and will happily report success even when your application's role cannot log in. Set start_period generously too, so a slow first boot with migrations does not burn all the retries. The Compose healthcheck reference documents how those fields interact.

Layer 2: a health route that runs a real query

The container probe proves the server is up. It says nothing about whether your application can talk to it. Those are separate failures, and after a rotated password only the second one is true.

Expose a route that borrows a connection from the real pool and runs a cheap query.

-- Cheap, no locks, proves the pool and the role both work
SELECT 1;

-- Slightly stronger: proves the schema is the one you expect
SELECT count(*) FROM pg_catalog.pg_tables WHERE schemaname = 'public';

Return 200 when the query succeeds and 503 when it does not, and keep the handler free of caching. We tested a route that cached its result for 60 seconds. It reported healthy for a full minute after the database went away, which is exactly the minute you needed the alert.

Keep this route cheap. A health endpoint that runs an expensive aggregate becomes a load source once you point a probe at it every 30 seconds.

Decide deliberately what the route reports when a read replica is lagging. Returning 503 pulls the instance out of the load balancer, which is right if stale reads would break your product and wrong if they would not. We prefer a separate route for readiness and liveness, so the orchestrator and the pager can disagree without arguing.

One more detail worth getting right: return the failure reason in the body. A 503 with {"db":"connect timeout"} saves whoever is on call from guessing whether the database, the pool or the handler itself broke.

Layer 3: watching the port from outside

The outer layer is a tcp monitor against the database port, or an HTTP check against the health route from the previous layer. Prefer the second where you can, since it exercises the whole path rather than just the socket.

FieldValueWhy
TypetcpConfirms 5432 accepts a connection
Port5432Or your pooler's port, if traffic goes through one
Interval60sFast enough to matter, slow enough to be quiet
RegionsTwo minimumOne network path is not evidence
ConfirmationSecond region must agreeA single failed check is a blip

Never expose 5432 to the public internet just to monitor it. If the port is private, and it should be, watch the application health route instead and allowlist the probe egress ranges published on the probe IPs page. The same argument for external vantage points applies here as in blackbox monitoring, and the assertion design carries over from monitoring an API endpoint.

How often should each layer run?

Match the interval to how fast the layer's consumer needs an answer. The container probe runs every 10 seconds because Compose is waiting on it to start other services. Anything slower makes every deploy feel broken.

The application route and the external check can be slower. We prefer 60 seconds for both, which gives a worst case of two minutes from failure to confirmed alert. A 1-minute interval is included on the free plan as of September 2026, and the 30-second interval on the paid plans is listed on the pricing page.

Going faster than 30 seconds is rarely worth it for a database. The recovery path for a down Postgres is measured in minutes. Shaving 15 seconds off detection changes nothing, while the extra connection churn lands on a server that is already struggling.

What an outside check cannot read

Reachability is not health. A server that answers on 5432 can still be minutes from a transaction ID wraparound shutdown. It can be carrying 40 percent table bloat. It can be holding a replication slot that has pinned WAL since last Tuesday. Those numbers live in system catalogs and need a session inside the database to read.

Keep a scheduled diagnostic job for them. A cron script that queries pg_stat_activity, pg_stat_user_tables and the age of the oldest frozen transaction covers most of it, and the routine vacuuming documentation explains which thresholds actually matter. Wire that job to an inbound heartbeat so a silent failure of the script itself gets noticed.

The split is the familiar one from the four golden signals. Latency and errors are visible from outside. Saturation is not.

Common mistakes

Trusting a bare pg_isready. Without -U and -d it validates the wrong credentials and the wrong database. It will report a healthy server your application cannot use.

Pointing the health route at a second connection. A handler that opens its own connection bypasses the pool, so pool exhaustion never shows up. Borrow from the pool the application actually uses.

Alerting on the first failed check. One timeout during a checkpoint is normal. Require a second region or a second consecutive failure before anyone gets paged.

Monitoring the primary only. A replica that has fallen hours behind is a real incident that every primary-side check reports as fine. Give each replica its own check and alert on lag separately.

A layered postgres health check costs about twenty minutes to set up and removes most of the ambiguity from a database incident. Start with pg_isready in Compose, add a health route that runs a real query, and put an external check in front of it from two regions. You can set up the external layer free with the 10 monitors included as of September 2026.

FAQ

How do I check the health of PostgreSQL in Docker Compose?

Add a healthcheck block to the database service that runs pg_isready with an explicit user and database, then have dependent services wait on condition: service_healthy. An interval of 10 seconds with five retries and a generous start_period handles slow first boots. Without the explicit -U and -d flags the probe tests the default role rather than the one your application uses.

How do you check PostgreSQL health?

Run pg_isready for connection acceptance, then run a cheap query such as SELECT 1 through your application's own connection pool to prove the role and the pool both work. For deeper health, query the system catalogs for table bloat, replication lag and the age of the oldest frozen transaction on a schedule, since none of that is visible from outside.

Does Docker Compose have a health check?

Yes. Any service can declare a healthcheck with a test command, an interval, a timeout, a retry count and a start_period. Compose runs the command inside the container and marks the service healthy on exit code 0. Other services can then use depends_on with condition: service_healthy so they only start once the dependency is genuinely ready.

Is SELECT 1 enough for a database health check?

It is enough to prove the connection, the role and the pool are working, which covers the most common application-side failures. It will not detect bloat, transaction ID wraparound risk, replication lag or a stuck autovacuum. Pair the cheap query in your health route with a scheduled diagnostic job that reads the system catalogs.

Should the database port be exposed so it can be monitored?

No. Keep the port private and monitor the application health route instead, since that path exercises the pool and the credentials as well as the socket. If an external port check is genuinely needed, restrict access to the published probe egress ranges rather than opening 5432 to the internet.

Related reading