VOOZH about

URL: https://dev.to/davidtio/docker-compose-dependson-and-health-checks-that-actually-protect-startup-2026-1bc1

โ‡ฑ Docker Compose: depends_on and Health Checks That Actually Protect Startup (2026) - DEV Community


Quick one-liner: restart: unless-stopped brings containers back, but it cannot make startup safe. This episode fixes startup races with healthcheck and depends_on: condition: service_healthy.


๐Ÿค” Why This Matters

In episode 9, we hit a painful failure pattern.

The app started before Postgres was actually ready. Migration failed once, then the app kept restarting into a broken state. docker compose ps looked fine, users still saw failures.

That is the key difference between these two ideas:

  • Restart policy answers: what to do after a container exits.
  • Health check answers: is this service actually ready to serve traffic.

If you want reliable startup, you need both concepts. In this post we focus on readiness.


โœ… Prerequisites

  • Ep 1-9 completed. You are comfortable with Compose files, multi-service stacks, and restart policies.

๐Ÿงจ The Startup Race

Create and use a dedicated project folder so container names stay predictable in this post:

$ mkdir -p ~/appstack
$ cd ~/appstack

Use this minimal stack:

services:
 db:
 image: postgres:16
 environment:
 POSTGRES_DB: app
 POSTGRES_USER: app
 POSTGRES_PASSWORD: app

 app:
 image: gitea.dtio.app/davidtio/noteboard:latest
 ports:
 - "5000:5000"
 volumes:
 - appstate:/app/state
 restart: unless-stopped

volumes:
 appstate:

Bring it up:

$ docker compose up

Typical early logs:

app-1 | First run, setting up...
app-1 | Migration failed: connection to server at "db" (...) Connection refused
app-1 | Already installed, skipping migrations
app-1 | Serving on :5000

Now check the app:

$ curl -i http://localhost:5000

A very common result here is:

curl: (52) Empty reply from server

This is the exact failure pattern we care about in this episode. The container is up, but the app is not actually ready.


โŒ Why Plain depends_on Is Not Enough

A common fix attempt is:

app:
 depends_on:
 - db

This only guarantees start order, not readiness.

Compose starts db first, but Postgres still needs time to initialize and accept queries. Your app can still race and fail.


โœ… Add a Real Database Health Check

Update the db service with pg_isready:

services:
 db:
 image: postgres:16
 environment:
 POSTGRES_DB: app
 POSTGRES_USER: app
 POSTGRES_PASSWORD: app
 healthcheck:
 test: ["CMD-SHELL", "pg_isready-Uapp-dapp"]
 interval: 5s
 timeout: 3s
 retries: 12
 start_period: 10s

What this means:

  • test: command used to check readiness
  • interval: how often to check
  • timeout: max time for one check
  • retries: failures before unhealthy
  • start_period: grace period during startup

Check status:

$ docker compose ps

You should see db move from starting to healthy.


โœ… Gate App Startup on Health, Not Order

Now update app:

services:
 app:
 image: gitea.dtio.app/davidtio/noteboard:latest
 ports:
 - "5000:5000"
 volumes:
 - appstate:/app/state
 restart: unless-stopped
 depends_on:
 db:
 condition: service_healthy

This is the important part of the configuration. This configuration will ensure that app will not start until Compose marks db healthy.

After updating the Compose file, always remove containers and volumes first:

$ docker compose down --volumes
$ docker compose up -d
$ docker compose ps

Then test:

$ curl -i http://localhost:5000

Now you should get a valid HTTP response instead of empty reply or transaction errors.


๐Ÿ“ฆ Full Compose File (Fixed)

The full compose file will be as follow:

services:
 db:
 image: postgres:16
 environment:
 POSTGRES_DB: app
 POSTGRES_USER: app
 POSTGRES_PASSWORD: app
 healthcheck:
 test: ["CMD-SHELL", "pg_isready-Uapp-dapp"]
 interval: 5s
 timeout: 3s
 retries: 12
 start_period: 10s

 app:
 image: gitea.dtio.app/davidtio/noteboard:latest
 ports:
 - "5000:5000"
 volumes:
 - appstate:/app/state
 restart: unless-stopped
 depends_on:
 db:
 condition: service_healthy

volumes:
 appstate:

๐Ÿ” What To Watch During Startup

Useful commands while testing:

$ docker compose ps
$ docker compose logs -f db app
$ docker inspect --format json appstack-db-1 | jq '.[]|.State.Health'

You are looking for this sequence:

  1. db container starts.
  2. health check runs and turns healthy.
  3. app starts only after db is healthy.

โš  Important Caveat

depends_on: condition: service_healthy controls startup order and readiness at startup time.

It does not restart dependent services automatically later if db becomes unhealthy at runtime.

You still need app-level retry logic, graceful error handling, and observability for runtime incidents.


๐Ÿงช Exercise: Start Ghost, Sign Up, and Switch to Journal

In this exercise, you will:

  1. start Ghost with MySQL
  2. apply startup-readiness fix with healthcheck + depends_on
  3. sign up in Ghost Admin with any email (captured by Mailpit)
  4. switch to the built-in Journal theme

Exercise Walkthrough

  1. Start with this Compose file (plain depends_on first):
services:
 db:
 image: mysql:8
 environment:
 MYSQL_ROOT_PASSWORD: rootpass
 MYSQL_DATABASE: ghost
 MYSQL_USER: ghost
 MYSQL_PASSWORD: ghostpass

 mail:
 image: axllent/mailpit:latest
 ports:
 - "8025:8025"

 app:
 image: ghost:5-alpine
 ports:
 - "2368:2368"
 environment:
 database__client: mysql
 database__connection__host: db
 database__connection__user: ghost
 database__connection__password: ghostpass
 database__connection__database: ghost
 database__connection__port: "3306"
 mail__transport: SMTP
 mail__options__host: mail
 mail__options__port: "1025"
 depends_on:
 - db
 - mail
  1. First run:
$ docker compose up -d
  1. Check behavior:
$ docker compose logs --no-color db app | tail -n 80
$ curl -I http://localhost:2368

On this first run, you should see this failure:

curl: (7) Failed to connect to localhost port 2368 after 0 ms: Couldn't connect to server

Typical broken-state signal:

  • Ghost starts, then fails database connection
  • MySQL is still initializing
  • app goes offline even though containers were started

Real output example (trimmed):

app-1 | [INFO] Ghost server started in 0.228s
app-1 | [ERROR] connect ECONNREFUSED 172.20.0.2:3306
app-1 | Error: connect ECONNREFUSED 172.20.0.2:3306
app-1 | [WARN] Ghost is shutting down
db-1 | [Entrypoint]: Initializing database files
db-1 | [Entrypoint]: Creating database ghost
db-1 | [Entrypoint]: MySQL init process done. Ready for start up.
db-1 | mysqld: ready for connections. ... port: 3306
  1. Apply startup-readiness fix:
db:
 healthcheck:
 test: ["CMD-SHELL", "mysqladminping-hlocalhost-ughost-pghostpass--silent"]
 interval: 5s
 timeout: 3s
 retries: 12
 start_period: 10s

depends_on:
 db:
 condition: service_healthy
 mail:
 condition: service_started
  1. After updating the Compose file, reset fully (including volumes), then run:
$ docker compose down --volumes
$ docker compose up -d
$ docker compose ps
$ curl -I http://localhost:2368
  1. Open Ghost and Mailpit:
  • Ghost site: http://localhost:2368
  • Ghost Admin: http://localhost:2368/ghost
  • Mailpit inbox: http://localhost:8025
  1. Create your Ghost admin user with any email address.

The email does not need to be real for this lab. Ghost email goes to Mailpit, so use the inbox at http://localhost:8025 for any verification link.

  1. In Ghost Admin, switch to the built-in Journal theme:

Settings -> Design -> Change theme -> Journal -> Activate

Exercise complete.

Ghost is now running cleanly, Mailpit is handling local signup flow, and the Journal theme is live.

Most importantly, you removed the startup race by gating app startup on real database readiness.

Next episode, we level this up: a simpler, cleaner, more repeatable Ghost deployment you can rebuild with confidence.


๐Ÿ What You Built

Feature What It Does
mysql health check Confirms the DB is truly ready, not only started
depends_on with service_healthy Delays Ghost startup until MySQL readiness is real
Mailpit in local stack Captures signup/verification emails without external SMTP
reset-and-rerun workflow Reproduces and validates the startup race fix cleanly

Coming up: Startup is stable now. Next, we simplify the Ghost deployment so setup is faster, cleaner, and easier to repeat.