Docker Compose Healthchecks That Actually Work
13 Nov 2025A healthcheck that just confirms the process is running isn’t a healthcheck — it’s a heartbeat. docker compose ps will happily show a container as “healthy” while it’s returning 500s to every request, because the process technically didn’t crash. A real healthcheck confirms the app can actually do its job.
The default problem
Without a healthcheck, Docker only knows if the container’s main process is alive. A Node app can be running, fully deadlocked on a database connection pool, accepting zero new requests — and Docker reports it as up, because the process never exited.
1. Write a healthcheck endpoint that checks real dependencies
app.get('/health', async (req, res) => {
try {
await db.query('SELECT 1')
res.status(200).json({ status: 'ok' })
} catch (err) {
res.status(503).json({ status: 'error', detail: err.message })
}
})
This confirms the app can reach its database, not just that the HTTP server is listening. Extend it to check other hard dependencies — a Redis connection, a required external API — but keep it fast. A healthcheck that itself times out or does expensive work becomes part of the problem it’s meant to catch.
2. Wire it into Compose
services:
app:
build: .
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 10s
timeout: 3s
retries: 3
start_period: 30s
interval— how often to checktimeout— how long a single check gets before it’s considered failedretries— consecutive failures before the container is marked unhealthystart_period— a grace window where failures don’t count, for slow-starting apps
Get start_period wrong — too short — and a container that just needs 15 seconds to warm up its connection pool gets marked unhealthy and potentially restarted before it ever had a chance to become ready. This is one of the most common self-inflicted outages in a fresh Compose setup.
3. Make dependent services actually wait
A healthcheck alone doesn’t stop another service from starting too early. You need depends_on with a condition:
services:
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
app:
build: .
depends_on:
db:
condition: service_healthy
Without condition: service_healthy, depends_on only waits for the container to start, not for Postgres inside it to actually be accepting connections — which can be several seconds behind the container starting. The app connects, fails, and either crashes or enters a retry loop depending on how your connection code handles it.
4. Don’t use curl or wget if they’re not in the image
This is the failure mode that quietly breaks healthchecks on slim/alpine images: the CMD runs inside the container, using whatever’s installed there. A node:20-alpine image with no curl installed will fail the healthcheck command itself — not because the app is unhealthy, but because the tool you’re shelling out to doesn’t exist. The healthcheck reports failure, Compose calls the container unhealthy, and the actual app might be fine.
Two fixes: install a minimal curl in the image, or skip the shell-out entirely and use the runtime itself:
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"]
This uses Node itself to make the request, no external binary required — one less thing to install just for a healthcheck.
5. Verify what Compose actually sees
docker compose ps
docker inspect --format='{{json .State.Health}}' your-container | jq
The second command shows the actual healthcheck log — the last few check results and their output — which is what you want when a container is stuck “unhealthy” and you need to know why, rather than guessing from the Compose summary alone.
The takeaway
A healthcheck that only confirms the process didn’t crash is worse than no healthcheck, because it gives false confidence. Check the thing that actually matters — can this container do its job — set start_period generously for anything with real startup work, and confirm the check command exists inside the image you’re actually running, not just the one you tested locally.