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@levellingdockerwhere 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
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 -U app -d app"]
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 -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:
๐ 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:
-
dbcontainer starts. - health check runs and turns
healthy. -
appstarts only afterdbis 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:
- start Ghost with MySQL
- apply startup-readiness fix with
healthcheck+depends_on - sign up in Ghost Admin with any email (captured by Mailpit)
- switch to the built-in Journal theme
Exercise Walkthrough
- Start with this Compose file (plain
depends_onfirst):
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
- First run:
$ docker compose up -d
- 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
- 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
- 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
- Open Ghost and Mailpit:
- Ghost site:
http://localhost:2368 - Ghost Admin:
http://localhost:2368/ghost - Mailpit inbox:
http://localhost:8025
- 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.
- 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)