Docker Compose Explained: depends_on, Healthchecks and Networks
8 min readBytePatterns
Docker Compose explained: service names on the default network, container vs host ports, depends_on vs service_healthy, and what docker compose down keeps.
Docker Compose is how most teams run an application with its database on a laptop or in CI: one YAML file, one command, several containers. It is also a reliable source of interview questions, because three of its behaviours surprise people. Services find each other by name, not localhost. depends_on waits for a container to run, not to be ready. And docker compose down keeps your data unless you ask otherwise. Everything below comes from the Docker documentation pages listed at the end, as of September 2026.
The problem it solves
A real application is rarely one container. An API needs a database, maybe a cache and a worker. Starting each with its own docker run, remembering the flags, creating a network by hand and starting things in the right order is tedious and error-prone.
Compose replaces that with a declarative file. The Compose application model describes services (the containers to run), networks, volumes, configs and secrets. The default file is compose.yaml in the working directory, and a project name groups the resources the file creates so they stay isolated from other applications. docker compose up creates everything; docker compose down removes it.
The intuition
Three rules explain almost every Compose question.
Names are addresses. Compose creates a network named after the project, <project>_default, and attaches every service to it. Each service registers its name with an internal DNS server, so the API reaches Postgres at db. Inside the API container, localhost is the API container itself.
Container ports inside, host ports outside. In ports: ["8080:8000"], 8080 is the host port and 8000 the container port. Service-to-service traffic uses the container port, so the API connects to db:5432. The host port is only for reaching a service from outside the network, such as your browser.
Started is not ready. By default, depends_on makes Compose start the dependency first, but Compose does not wait until a container is ready, only until it is running. A Postgres container can be running while still initialising, and an API that connects immediately fails. The fix is condition: service_healthy plus a healthcheck on the dependency, such as pg_isready. A third condition, service_completed_successfully, waits for a one-off job like a migration to finish. On the way down, Compose removes services in dependency order, dependents first.
And one limit: Compose drives a single Docker Engine. Spreading replicas across machines and rescheduling them when a machine dies is an orchestrator's job, which is what Kubernetes adds.
Watch it run
One file describes the whole app: two services, a volume, and who waits for whom. docker compose up first creates the project's default network, and every service joins it. api depends on db, so db is started first. But plain depends_on only waits for running, and Postgres may not accept connections yet. With condition: service_healthy, Compose waits for db's healthcheck, pg_isready, to pass, and only then does api start. api reaches the database by its service name, on the container port: db:5432. From your laptop, the published host port 8080 maps to api's container port 8000. Rows land in the named volume, and docker compose down removes the containers and the network but keeps the volume. Names for discovery, healthchecks for readiness, volumes for data, all on one engine; many machines is Kubernetes' job.
Docker Compose for Local Dev
Step 1 of 10
One file describes the whole app: two services, a volume, and who waits for whom.
The same interactive animation as the lesson — step through it with the controls.
The code
The lesson's file, with a healthcheck-gated dependency and a named volume:
# illustrative — compose.yaml for local development
services:
api:
build: .
ports: ["8080:8000"]
environment: { DATABASE_URL: "postgres://postgres:dev@db:5432/postgres" }
depends_on:
db: { condition: service_healthy }
db:
image: postgres:17
environment: { POSTGRES_PASSWORD: dev }
healthcheck: { test: ["CMD-SHELL", "pg_isready -U postgres"], interval: 5s }
volumes: ["pgdata:/var/lib/postgresql/data"]
volumes:
pgdata: {}
A toy model of those rules, not Docker: a service waits for each dependency to start or to become healthy, names resolve only on the project network and only on the container port, and down keeps named volumes unless told otherwise. ready_after stands in for how long the healthcheck takes to pass:
class ToyCompose:
"""Toy model of `docker compose up` and `down` on one host, not Docker."""
def __init__(self, project, services, volumes=()):
self.project, self.services = project, services
self.named_volumes = set(volumes)
self.network, self.containers, self.log = None, {}, []
def up(self):
self.network = f"{self.project}_default" # every service joins it
self.log.append(f"Network {self.network} Created")
start, ready, events = {}, {}, []
pending = dict(self.services)
while pending:
progress = False
for name, svc in list(pending.items()):
deps = svc.get("depends_on", {})
if any(d not in start for d in deps):
continue # a dependency has not started yet
waits = [start[d] if cond == "service_started" else ready[d]
for d, cond in deps.items()]
start[name] = max(waits, default=0)
ready[name] = start[name] + svc.get("ready_after", 0)
box = self.containers[name] = f"{self.project}-{name}-1"
events.append((start[name], len(events), f"Container {box} Started"))
if "ready_after" in svc: # it has a healthcheck
events.append((ready[name], len(events), f"Container {box} Healthy"))
del pending[name]
progress = True
if not progress:
raise ValueError("dependency cycle")
self.start, self.ready = start, ready
self.log += [text for _, _, text in sorted(events)]
def connect(self, client, host, port): # at the moment client starts
svc = self.services.get(host)
if svc is None or port != svc["port"]:
return "not reachable"
return "connected" if self.ready[host] <= self.start[client] else "connection refused"
def from_laptop(self, host_port): # published ports only
for name, svc in self.services.items():
if svc.get("publish") == host_port:
return f"{name}:{svc['port']}"
return "nothing published"
def down(self, volumes=False):
self.containers, self.network = {}, None
if volumes: # docker compose down -v
self.named_volumes = set()
return sorted(self.named_volumes)
The lesson's app. The healthy condition orders the log exactly as the animation's console shows it; the host port, localhost and a plain depends_on all fail the way they do for real:
services = {
"api": {"port": 8000, "publish": 8080, "depends_on": {"db": "service_healthy"}},
"db": {"port": 5432, "ready_after": 4}, # healthcheck: pg_isready
}
app = ToyCompose("myapp", services, volumes=["pgdata"])
app.up()
print(*app.log, sep="\n")
# Network myapp_default Created
# Container myapp-db-1 Started
# Container myapp-db-1 Healthy
# Container myapp-api-1 Started
print(app.connect("api", "db", 5432)) # connected
print(app.connect("api", "db", 15432)) # not reachable
print(app.connect("api", "localhost", 5432)) # not reachable
print(app.from_laptop(8080)) # api:8000
plain = dict(services, api=dict(services["api"], depends_on={"db": "service_started"}))
app2 = ToyCompose("myapp", plain)
app2.up()
print(app2.connect("api", "db", 5432)) # connection refused
print(app.down()) # ['pgdata'] named volume kept
print(app.down(volumes=True), app.network) # [] None
The model's start times against a brute force that relaxes every wait until nothing changes, on 2,000 random dependency graphs listed in random order, plus a check that the log never shows a service before what it waits for:
import random
random.seed(21)
ok = True
for _ in range(2000):
names = [f"s{i}" for i in range(random.randint(1, 7))]
svcs = {}
for i, n in enumerate(names):
deps = {d: random.choice(["service_started", "service_healthy"])
for d in random.sample(names[:i], random.randint(0, i))}
svcs[n] = {"port": 80, "depends_on": deps}
if random.random() < 0.7:
svcs[n]["ready_after"] = random.randint(0, 5)
for n in names: # healthy needs a healthcheck: add one if missing
for d, cond in svcs[n]["depends_on"].items():
if cond == "service_healthy":
svcs[d].setdefault("ready_after", 1)
order = names[:]
random.shuffle(order) # the file lists services in any order
c = ToyCompose("p", {n: svcs[n] for n in order})
c.up()
s = {n: 0 for n in names} # brute force: relax every wait until stable
for _ in names:
for n in names:
for d, cond in svcs[n]["depends_on"].items():
need = s[d] if cond == "service_started" else s[d] + svcs[d]["ready_after"]
s[n] = max(s[n], need)
ok &= s == c.start
at = {text: i for i, text in enumerate(c.log)}
for n in names: # the log never shows a service before what it waits for
for d, cond in svcs[n]["depends_on"].items():
what = "Started" if cond == "service_started" else "Healthy"
ok &= at[f"Container p-{d}-1 {what}"] < at[f"Container p-{n}-1 Started"]
ok &= c.log[0] == "Network p_default Created" and len(c.containers) == len(names)
print(ok) # True
The complexity
The costs here are startup time and blast radius rather than steps:
- Startup: the slowest chain of healthchecks sets how long
uptakes, which is what the model's start times measure. - Scope: one Docker Engine. If that machine goes down, every service goes with it.
- Data: named volumes outlive
down;down -vremoves named volumes declared in the file and anonymous volumes attached to the containers. Networks and volumes marked external are never removed.
Where it goes wrong
- Connecting to
localhost. Inside a container that is the container itself; use the service name. - Using the host port between services.
db:5432is the container port; a mapping like15432:5432only matters from outside. - Trusting plain
depends_on. It orders startup, not readiness. Add a healthcheck andcondition: service_healthy, and still let the application retry. - Running
down -vout of habit. It deletes the database volume along with the containers. - Treating Compose as production orchestration. It does not reschedule containers onto another machine when a host fails.
When it shows up in interviews
It appears in Docker and platform rounds as "why can't my API reach the database?" or "what is the difference between Compose and Kubernetes?" The isolation underneath is covered in containers vs virtual machines, readiness in a cluster in Kubernetes liveness, readiness and startup probes, and a wider question bank in Docker and Kubernetes interview questions.
How to say it in an interview
"Compose declares an app's services, networks and volumes in one file, and docker compose up creates them on one Docker Engine. Every service joins the project's default network and is reachable by its service name, on its container port, so the API uses db:5432; host ports are only for access from outside. depends_on starts the database first but only waits for it to be running, so I add a healthcheck and condition: service_healthy, and keep retries in the app. docker compose down removes containers and the network but keeps named volumes unless I pass -v. For several machines and self-healing, I would move to Kubernetes."
Sources
- How Compose works — Docker Docs
- Networking in Compose — Docker Docs
- Control startup and shutdown order — Docker Docs
- docker compose down — Docker Docs