The most common Compose gotcha: your application container starts, immediately tries to connect to the database, and gets ECONNREFUSED — because PostgreSQL takes 5–15 seconds to fully initialize on first boot (creating the data directory, running WAL recovery, accepting connections).
Rendering interactive visual diagram...
depends_on without a health check only guarantees the container starts — not that the application inside is ready. This lesson shows how to fix this properly.
The Three depends_on Conditions
services:
api:
depends_on:
# ── Condition 1: service_started (DEFAULT) ───────────────────
# Container process has started — that's ALL it checks.
# Does NOT wait for the app inside to be ready.
# Only appropriate for services that are immediately ready (static file servers).
cache:
condition: service_started
# ── Condition 2: service_healthy ──────────────────────────────
# Container passes its healthcheck before api starts.
# Use for: PostgreSQL, MySQL, Redis, Elasticsearch, RabbitMQ.
db:
condition: service_healthy
restart: true # Restart api if db restarts (maintains the dependency)
# ── Condition 3: service_completed_successfully ───────────────
# Container has exited with code 0.
# Use for: migration scripts, seed scripts, one-off setup tasks.
migrate:
condition: service_completed_successfully
Writing Effective Health Checks
PostgreSQL
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
healthcheck:
# pg_isready checks if postgres is accepting connections on the right user/db
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s # Check every 5 seconds
timeout: 5s # Fail if no response within 5 seconds
retries: 10 # Mark unhealthy after 10 consecutive failures (50s)
start_period: 15s # Give Postgres up to 15s to start before failures count
# (Prevents false failures during first-boot initialization)
MySQL / MariaDB
services:
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DB}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost",
"-u", "${MYSQL_USER}", "-p${MYSQL_PASSWORD}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s # MySQL is slower to initialize than Postgres
Redis
services:
cache:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD}
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 3s
retries: 5
start_period: 5s
MongoDB
services:
mongo:
image: mongo:7
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
healthcheck:
test: >
echo 'db.runCommand("ping").ok' |
mongosh localhost:27017/admin
--username ${MONGO_USER}
--password ${MONGO_PASSWORD}
--quiet
interval: 10s
timeout: 10s
retries: 5
start_period: 30s
Elasticsearch
services:
elasticsearch:
image: elasticsearch:8.12.0
environment:
discovery.type: single-node
ES_JAVA_OPTS: "-Xms512m -Xmx512m"
xpack.security.enabled: "false"
healthcheck:
test: ["CMD-SHELL", "curl -s http://localhost:9200/_cluster/health | grep -q '\"status\":\"green\"\\|\"status\":\"yellow\"'"]
interval: 10s
timeout: 10s
retries: 15
start_period: 60s # ES is very slow to start — give it a full minute
Your Own API / Node.js App
services:
api:
build: .
healthcheck:
# wget is available in alpine images (curl is not always pre-installed)
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
What Makes a Good /health Endpoint
// app/api/health/route.ts — check real dependencies, not just HTTP up
export async function GET() {
const checks: Record<string, "ok" | "error"> = {};
let overall = "ok";
// Check database
try {
await db.raw("SELECT 1");
checks.database = "ok";
} catch {
checks.database = "error";
overall = "degraded";
}
// Check Redis
try {
await redis.ping();
checks.cache = "ok";
} catch {
checks.cache = "error";
overall = "degraded";
}
return Response.json(
{ status: overall, checks, ts: Date.now() },
{ status: overall === "ok" ? 200 : 503 }
);
}
A health endpoint that returns 200 even when the database is down is worse than no health check — it tells Compose the service is ready when it isn't.
The Full Startup Sequence: Correct Implementation
Rendering interactive visual diagram...
Putting It All Together: Full docker-compose.yml
services:
# ── PostgreSQL ───────────────────────────────────────────────────────
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 15s
# ── Redis ─────────────────────────────────────────────────────────────
cache:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD} --loglevel warning
volumes:
- redis-data:/data
networks:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 3s
retries: 5
start_period: 5s
# ── Migration (one-shot) ──────────────────────────────────────────────
migrate:
build:
context: .
target: builder
command: ["npx", "prisma", "migrate", "deploy"]
environment:
DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}"
depends_on:
db:
condition: service_healthy # Must be healthy before running migrations
networks:
- backend
restart: "no" # Never restart — it's a one-shot task
# ── API ───────────────────────────────────────────────────────────────
api:
build:
context: .
target: runner
environment:
NODE_ENV: ${NODE_ENV:-production}
DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}"
REDIS_URL: "redis://:${REDIS_PASSWORD}@cache:6379"
depends_on:
migrate:
condition: service_completed_successfully # Migrations done first
cache:
condition: service_healthy
networks:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
# ── Nginx ─────────────────────────────────────────────────────────────
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
api:
condition: service_healthy # Only serve traffic once API is healthy
networks:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/nginx-health"]
interval: 30s
timeout: 5s
retries: 3
networks:
backend:
driver: bridge
volumes:
postgres-data:
redis-data:
Monitoring Health Status
# View current health status of all containers
docker compose ps
# NAME IMAGE STATUS
# myapp-db-1 postgres:16 Up 5m (healthy) ← ✅ passed healthcheck
# myapp-cache-1 redis:7-alpine Up 5m (healthy)
# myapp-migrate-1 myapp-api Exited (0) 4m ← ✅ completed successfully
# myapp-api-1 myapp-api Up 4m (healthy)
# myapp-nginx-1 nginx:alpine Up 3m (healthy)
# See detailed health check output (last 5 checks)
docker inspect myapp-db-1 | python3 -c "
import sys, json
data = json.load(sys.stdin)
health = data[0]['State']['Health']
print('Status:', health['Status'])
for log in health['Log'][-3:]:
print(f' [{log[\"ExitCode\"]}] {log[\"Output\"].strip()}')"
# Quick health check for a specific service
docker inspect --format='{{.State.Health.Status}}' myapp-db-1
# healthy
# Watch health status change in real time
watch -n 2 'docker compose ps'
Debugging Unhealthy or Failing Services
# === Service stuck in "starting" (healthcheck not passing) ===
# 1. Read the health check logs
docker inspect myapp-api-1 | python3 -c "
import sys, json
h = json.load(sys.stdin)[0]['State']['Health']
for l in h['Log']: print(l['Output'])"
# wget: can't connect to remote host (127.0.0.1): Connection refused
# → App hasn't bound to port 3000 yet — check app logs
# 2. Check the app logs
docker compose logs api --tail=50
# Error: Cannot find module '@prisma/client'
# → Missing dependencies in production image — fix Dockerfile
# === Migration fails (exits non-zero) ===
docker compose logs migrate
# Error: P3009 migrate found failed migrations
# Fix: repair or reset the migration history
# Run migration in interactive mode to see full output
docker compose run --rm migrate npx prisma migrate status
# === Service depends_on isn't being respected ===
# If api is starting before migrations complete, verify:
docker compose config | grep -A 10 "depends_on"
# === Database connection still failing after health check passes ===
# pg_isready checks if Postgres accepts TCP connections, NOT if your user/db exists
# Test the actual connection:
docker compose exec db psql -U ${POSTGRES_USER} -d ${POSTGRES_DB} -c "SELECT 1"
# If this fails: check POSTGRES_DB and POSTGRES_USER match
Restarting and Recovery Behavior
# === What happens when db crashes? ===
# With restart: unless-stopped:
docker compose kill db # Simulate crash
docker compose ps
# myapp-db-1 Restarting ← Docker restarts it automatically
# After db restarts, api might lose its connection pool
# With depends_on.db.restart: true, Compose also restarts api
# Or your app should implement connection retry logic
# === Manual restart workflow ===
# Update just the API without touching DB or cache:
docker compose pull api # Pull new image
docker compose up -d api # Restart only api (brief downtime)
# Or with a rolling-style restart:
docker compose build api
docker compose up --force-recreate --no-deps -d api
Summary
Service dependencies are the foundation of a reliable multi-container stack:
depends_on alone is not enough — it only waits for container start, not service readiness
- Always add
healthcheck to databases and caches so Compose knows when they're truly ready
- Use the right condition:
service_healthy for long-lived services, service_completed_successfully for one-shot migration tasks, service_started only for services that are trivially fast to start
- The
start_period field prevents premature health check failures during first-boot initialization (critical for Postgres and Elasticsearch)
- Monitor with
docker compose ps and docker inspect — the health status and logs tell you exactly what's failing
- Your
/health endpoint must check real dependencies (DB, cache) — a misleading 200 OK breaks the entire health-check dependency chain
In the next lesson, you will learn how to structure your Compose files for different environments and deploy to a production server with zero secrets in Git.