DEV Community

Cover image for Make Docker Compose wait for a backend that’s actually ready
Chris Rodriguez
Chris Rodriguez

Posted on Originally published at chriseugenerodriguez.com

Make Docker Compose wait for a backend that’s actually ready

Problem summary

Docker Compose guarantees startup order but not readiness. A dependency can be “running” long before it’s able to answer the queries or complete the bootstrap your app needs. People often patch this with sleeps or shallow probes — fragile approaches that pass locally and fail in the field.

Implementation approach (high level)

1) Define a healthcheck on the service that knows how to prove it’s truly ready. The check must exercise the actual boundary your dependent relies on (example: a simple query, a bootstrap-complete flag, or an authentication handshake), not merely an open port.

2) Make the dependent wait using depends_on with condition: service_healthy. That moves the readiness contract into the dependency and asks Compose to gate the dependent start on the dependency’s own proof.

Expected behavior

  • If the backend is running and its healthcheck reports healthy, the dependent is allowed to start.
  • If the backend is running but has no healthcheck (or the check fails), Compose cannot honor service_healthy and will block the dependent. This makes the missing signal visible instead of hiding it behind timing guesses.
  • If the backend is still in a starting state, the dependent remains blocked until the backend transitions to healthy.

Limitations and caveats

  • A healthcheck is only effective when it actually verifies the work the dependent needs. Superficial probes (e.g., “is port open?”) can still return success too early and recreate the race.
  • service_healthy is a startup gating mechanism. It reduces early-launch races but does not replace application-level error handling and retry logic for transient failures after startup.
  • This pattern depends on honest healthcheck design. If you’re unable to add meaningful checks to a third-party image, consider a lightweight init wrapper or a sidecar that verifies readiness on that service’s behalf.

Why this is better than sleep loops

Sleep loops are guesses about time and hide the contract. Health-based gating expresses the contract explicitly: the backend must prove readiness. That makes startup behavior observable, debuggable, and consistent across environments.

The article includes a tiny deterministic fixture (no Docker required) that proves the decision boundary between “running” and “healthy” and a checklist for what to put in a real Compose file. The server will append the verified Compose example immediately after this content.

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

Read the full article: https://chriseugenerodriguez.com/blog/compose-wait-for-ready-service

Top comments (0)