Docker Compose Health Checks and Startup Order (Stop Apps Crashing Because the Database Wasn't Ready)

Compose done properly: health checks on the database, depends_on that waits for healthy, restart policies that fit — no more 'restart it again'.

Several tutorials' troubleshooting sections say "the database wasn't ready — restart the app". That's a symptom of depends_on doing less than people think: it starts containers in order, but doesn't wait for the database to accept connections. A healthcheck tells Docker what "ready" means, and depends_on with condition: service_healthy waits for it. Ten lines per stack, and the reboot-and-pray ritual stops.

Quick reference

PieceWhat
healthcheck:a command that returns 0 when the container is ready
depends_on: db: condition: service_healthywait for that check before starting
restart: unless-stoppedcome back after reboots and crashes
docker psshows (healthy) / (unhealthy) / (health: starting)
profiles: [optional]services that only start with --profile optional
docker compose up -d --waitreturns when everything is healthy

1. Health checks for the usual suspects

PostgreSQL

db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s

MariaDB / MySQL

healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5

Redis

healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3

A web app (anything with an HTTP endpoint)

healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:3000/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s

If the image lacks curl, use wget -qO- …, or the app's own check command. start_period is grace time for slow starters (Nextcloud, Immich) so early failures don't count. The double $$ escapes compose's variable substitution.

2. depends_on that actually waits

app:
image: ghcr.io/example/app
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped

Now docker compose up -d starts db, waits for pg_isready to pass, then starts app. No more "connection refused" on first boot. The short form (depends_on: [db]) only orders the start.

docker compose up -d --wait blocks until every service with a healthcheck is healthy — useful in scripts and the Ansible apps role (140).

3. Restart policies

A container in a restart loop shows Restarting (1) 5 seconds ago in docker ps; Dozzle (125) or docker compose logs has the reason.

4. What unhealthy does — and doesn't do

Docker marks the container (unhealthy) and that's all: it doesn't restart it. For auto-recovery, the autoheal container watches for unhealthy and restarts them:

autoheal:
image: willfarrell/autoheal
environment:
AUTOHEAL_CONTAINER_LABEL: all
volumes:
- /var/run/docker.sock:/var/run/docker.sock
restart: unless-stopped

Uptime Kuma's Docker monitor (35) and Homepage's status dots (94) also read health status, so a sick container shows red before anyone notices.

5. Profiles for optional services

pgadmin:
image: dpage/pgadmin4
profiles: [tools]

docker compose up -d skips it; docker compose --profile tools up -d includes it. Right for admin tools, debug containers, the Hub cache registry (163) you only want sometimes.

6. A tidy stack, all together

services:
db: # healthcheck, restart
redis: # healthcheck, restart
app: # depends_on healthy, healthcheck, restart, mem_limit (68), logging (67)
worker: # depends_on app healthy
pgadmin: # profiles: [tools]

Copy that shape into every compose file in ~/docker over a quiet evening; it's also what the Ansible playbook deploys (140).

Troubleshooting

Container stays (health: starting) forever. The test command fails inside the container — run it by hand with docker compose exec app curl …. Missing tool, wrong port, wrong path.

depends_on ignored. You used the list form, not the condition: form; or the dependency has no healthcheck (then service_healthy can never be true — use service_started).

Healthcheck floods the logs. Lengthen interval; make the endpoint quiet.

$$ confusion. Inside test: use $$VAR for a container variable; a single $ is substituted by compose from .env.

Wrapping up

A healthcheck on every database and app, condition: service_healthy on everything that needs them, unless-stopped as the default, autoheal for the rare sick container, profiles for tools. Stacks come up in the right order on every reboot, and "try up -d again" disappears from your vocabulary.

Call us