Docker Compose describes a graph of services and can start them according to declared dependencies. Process startup is only one possible condition in that graph. A database or API may be running before it can accept the operation another service needs. An explicit service_healthy condition lets Compose wait for a dependency’s configured healthcheck before starting the dependent service, provided that check represents meaningful readiness.
When it breaks
The short depends_on form establishes startup ordering without establishing application readiness. Replacing it with an arbitrary sleep is fragile because initialization time changes with storage, cache state, migrations, or machine load. A sleep can be unnecessarily long on one run and too short on another. The condition should come from observable dependency behavior rather than an estimated elapsed time.
A health-based dependency condition is also not a continuous availability guarantee. After the dependent starts, the dependency can fail or restart. Compose does not automatically stop or restart an already-running dependent merely because the dependency’s health later changes. Applications still need a runtime policy for connection loss, bounded retries, and operations that may have partially completed.
The restart: true option nested under a dependency has a separate meaning from a service’s restart policy. It concerns dependent restarts after explicit Compose operations that update or restart the dependency. It should not be read as a general subscription to every health transition or every engine-level crash. These similarly named settings solve different lifecycle problems.
Check it quickly
Run the script using host Python, Docker Compose, and the pinned Python image. The recorded run used Compose 5.1.1, Docker 29.3.1, and Linux arm64 Python 3.11.16. The script writes a Compose document using JSON syntax, which is accepted as YAML, creates a unique project, and keeps all HTTP traffic on its project network. Cleanup removes only that project:
from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,time,uuid
BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def run(args,check=True):
result=subprocess.run(args,text=True,capture_output=True,timeout=120)
if check:assert result.returncode==0,result.stderr
return result
def docker(*args,check=True):return run(['docker',*args],check)
def inspect(name):return json.loads(docker('inspect',name).stdout)[0]
name='xbcatm-depends-'+uuid.uuid4().hex[:10]
with TemporaryDirectory(prefix=name+'-') as directory:
file=Path(directory)/'compose.yaml';base=['docker','compose','-p',name,'-f',str(file)]
def compose(*args,check=True):return run(base+list(args),check)
def service_id(service):
return docker('ps','-aq','--filter','label=com.docker.compose.project='+name,'--filter','label=com.docker.compose.service='+service).stdout.strip()
def wait(predicate):
deadline=time.monotonic()+20
while time.monotonic()<deadline:
result=predicate()
if result:return result
time.sleep(.1)
raise AssertionError('condition did not become true')
server='from http.server import BaseHTTPRequestHandler,HTTPServer\nfrom pathlib import Path\nclass H(BaseHTTPRequestHandler):\n def do_GET(self):self.send_response(200 if Path("/ready").exists() else 503);self.end_headers()\n def log_message(self,*args):pass\nHTTPServer(("0.0.0.0",8080),H).serve_forever()\n'
health=['CMD','python','-c','import urllib.request;urllib.request.urlopen("http://127.0.0.1:8080",timeout=.5).read()']
application='import urllib.request,time;urllib.request.urlopen("http://dependency:8080",timeout=2).read();print("started-after-ready",flush=True);time.sleep(180)'
file.write_text(json.dumps({'services':{'dependency':{'image':BASE,'command':['python','-c',server],'healthcheck':{'test':health,'interval':'1s','timeout':'1s','retries':2,'start_period':'30s'}},'application':{'image':BASE,'command':['python','-c',application],'depends_on':{'dependency':{'condition':'service_healthy'}}}}},indent=2))
process=None
try:
process=subprocess.Popen(base+['up','-d'],text=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
dependency=wait(lambda:service_id('dependency'))
wait(lambda:inspect(dependency)['State']['Running'])
assert inspect(dependency)['State']['Health']['Status']=='starting'
application_id=service_id('application')
assert not application_id or not inspect(application_id)['State']['Running']
assert process.poll() is None
docker('exec',dependency,'python','-c','from pathlib import Path;Path("/ready").touch()')
stdout,stderr=process.communicate(timeout=30);assert process.returncode==0,stderr
application_id=service_id('application');wait(lambda:'started-after-ready' in docker('logs',application_id).stdout)
original=inspect(application_id);assert original['State']['Running']
assert inspect(dependency)['State']['Health']['Status']=='healthy'
docker('exec',dependency,'python','-c','from pathlib import Path;Path("/ready").unlink()')
wait(lambda:inspect(dependency)['State']['Health']['Status']=='unhealthy')
after=inspect(application_id);assert after['State']['Running'] and after['State']['StartedAt']==original['State']['StartedAt'] and after['RestartCount']==0
print('service_healthy keeps the dependent application stopped until the dependency probe succeeds')
print('later dependency health failure does not stop or restart the already-running dependent application')
finally:
if process is not None and process.poll() is None:process.terminate();process.communicate(timeout=10)
compose('down','--volumes','--remove-orphans',check=False)
The dependency runs an HTTP server whose response remains 503 until /ready exists. Its healthcheck tests that real endpoint. A thirty-second startup grace period gives the controller time to inspect the initial state without early failures immediately exhausting the two-failure threshold. The application declares condition: service_healthy and performs its own HTTP request when it eventually starts.
The controller launches compose up -d asynchronously and observes the dependency running with health starting. At that point the application is either not yet created or not running, and the Compose command is still waiting. The test allows either creation detail because the required contract is the dependent’s startup gate, not a particular intermediate container-allocation sequence.
Creating /ready changes the dependency’s HTTP response to 200. Compose completes startup successfully, and the application prints started-after-ready only after its own request succeeds. This connects the declared condition to real reachable behavior. A green health label alone would be weaker evidence if the dependent could not actually access the dependency over its network path.
Next, the controller removes /ready. After enough failed probes, the dependency becomes unhealthy. The application remains running with the same start timestamp and zero restarts. This is the central negative case: a satisfied startup condition is not a promise that Compose will continuously synchronize the dependent’s lifecycle with later dependency health.
The application intentionally sleeps after its initial request so that its process lifetime can be observed independently. It is not a production client implementation, and the fixture makes no claim that its sleeping state proves successful business operations during the outage. The observed result is precisely that Compose leaves the existing dependent process alone.
Design startup and runtime handling together
Use service_started when process startup is the actual requirement, service_healthy when a meaningful healthcheck defines readiness, and a successful-completion condition for an appropriate one-shot prerequisite. A migration job’s successful exit and a long-running database’s readiness answer different questions. Choose the condition that matches the prerequisite, and retain useful failure output when it is not satisfied.
Make readiness checks cheap and specific. A TCP listener may exist before authentication, schema initialization, or required data is ready. Conversely, checking every optional downstream system can prevent otherwise useful services from starting. The check should represent the minimum dependency contract needed by the service being gated.
Once running, handle dependency changes in the application and its operational controls. Bound retries, avoid duplicating non-idempotent work, and test reconnection after replacement. Compose can coordinate a reproducible startup sequence, but ongoing correctness still depends on the services’ actual communication and failure semantics.
Sources
- Control startup order in Compose, started, healthy, and completed dependency conditions, checked 2026-09-12.
- Compose services reference, depends_on conditions and explicit Compose restart behavior, checked 2026-09-12.
- Dockerfile HEALTHCHECK, probe state, startup grace, and consecutive failure counting, checked 2026-09-12.