DEV Community

David Tio
David Tio

Posted on โ€ข Originally published at blog.dtio.app

Docker Compose: depends_on and Health Checks That Actually Protect Startup (2026)

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.
  • You can run commands on sysadmin@levellingdocker where rootless Docker is already configured.

๐Ÿงจ The Startup Race

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

$ mkdir -p ~/appstack
$ cd ~/appstack
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

Bring it up:

$ docker compose up
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now check the app:

$ curl -i http://localhost:5000
Enter fullscreen mode Exit fullscreen mode

A very common result here is:

curl: (52) Empty reply from server
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 12
      start_period: 10s
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then test:

$ curl -i http://localhost:5000
Enter fullscreen mode Exit fullscreen mode

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 -U app -d app"]
      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:
Enter fullscreen mode Exit fullscreen mode

๐Ÿ” 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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  1. First run:
$ docker compose up -d
Enter fullscreen mode Exit fullscreen mode
  1. Check behavior:
$ docker compose logs --no-color db app | tail -n 80
$ curl -I http://localhost:2368
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  1. Apply startup-readiness fix:
db:
  healthcheck:
    test: ["CMD-SHELL", "mysqladmin ping -h localhost -ughost -pghostpass --silent"]
    interval: 5s
    timeout: 3s
    retries: 12
    start_period: 10s

depends_on:
  db:
    condition: service_healthy
  mail:
    condition: service_started
Enter fullscreen mode Exit fullscreen mode
  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
Enter fullscreen mode Exit fullscreen mode
  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.


Top comments (0)