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

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
| Piece | What |
|---|---|
| healthcheck: | a command that returns 0 when the container is ready |
| depends_on: db: condition: service_healthy | wait for that check before starting |
| restart: unless-stopped | come back after reboots and crashes |
| docker ps | shows (healthy) / (unhealthy) / (health: starting) |
| profiles: [optional] | services that only start with --profile optional |
| docker compose up -d --wait | returns 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
- unless-stopped — the default for services: restarts on crash and after reboot, unless you stopped it on purpose.
- on-failure:5 — try five times, then give up; for a one-shot job that shouldn't loop forever.
- no — for tools you run by hand.
- always — like unless-stopped but also restarts after you docker stop it; rarely what you want.
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.
