Prerequisites
- You control the service image (or can add a lightweight wrapper) and can add a healthcheck that exercises the real readiness condition.
- You use Docker Compose v2-style depends_on conditions (support varies by Compose implementation/version).
Implementation decision
Put the readiness check on the dependency, not on the dependent. Define a healthcheck that verifies the real work boundary (for example, a small query or a completed bootstrap signal). Then set the dependent’s depends_on to use condition: service_healthy. This makes Compose wait for the backend’s own claim of readiness instead of relying on timed waits.
Expected result
Compose starts services in right order and then blocks the dependent until the backend reports healthy. This eliminates the startup race where the dependent is running and fails because its backend is still booting. If the backend lacks a healthcheck, Compose can’t honor service_healthy — which surfaces the missing readiness contract rather than silently permitting a race.
One limitation
A healthcheck must be honest and relevant. If it only checks that a TCP port is open, it may still return healthy before the backend can serve the app’s real needs. When the healthcheck is superficial, the gating gives you a false sense of correctness. Keep the check aligned to the actual dependency boundary.
Working example
export function evaluateStartability(services, targetServiceName) {
if (!services || typeof services !== 'object' || Array.isArray(services)) {
throw new TypeError('services must be an object')
}
const target = services[targetServiceName]
if (!target) throw new Error(`service not found: ${targetServiceName}`)
const deps = target.depends_on ?? {}
const normalizedDeps = {}
if (Array.isArray(deps)) {
for (const name of deps) normalizedDeps[name] = { condition: 'service_started' }
} else {
for (const [name, val] of Object.entries(deps)) {
if (val == null || val === 'short') {
normalizedDeps[name] = { condition: 'service_started' }
} else if (typeof val === 'string') {
normalizedDeps[name] = { condition: val }
} else {
normalizedDeps[name] = { condition: val.condition ?? 'service_started' }
}
}
}
const reasons = []
for (const [depName, opts] of Object.entries(normalizedDeps)) {
const dep = services[depName]
if (!dep) {
reasons.push({ dep: depName, ok: false, why: 'missing' })
continue
}
if (dep.state !== 'running') {
reasons.push({ dep: depName, ok: false, why: `not running (${dep.state})` })
continue
}
if (opts.condition === 'service_healthy') {
if (!Object.prototype.hasOwnProperty.call(dep, 'health') || dep.health == null) {
reasons.push({ dep: depName, ok: false, why: 'no healthcheck defined' })
continue
}
if (dep.health !== 'healthy') {
reasons.push({ dep: depName, ok: false, why: `health=${String(dep.health)}` })
continue
}
}
reasons.push({ dep: depName, ok: true, why: 'satisfied' })
}
return { ok: reasons.every((r) => r.ok), reasons }
}
export function makeService(state = 'running', health = null, depends_on = undefined) {
return { state, health, depends_on }
}
Read the full article: https://chriseugenerodriguez.com/blog/compose-wait-for-ready-service