Compose the Stack
Once the scorer ran as a service, I wired the dashboard to it the obvious way: two terminals, two docker run commands, each carrying a page of -p and -v and -e flags I retyped from memory every morning. It worked on my machine and nowhere else, because the setup lived in my shell history and nowhere else. When I finally moved it into a compose.yaml, the frontend still could not reach the backend: I had pointed it at http://localhost:8000, and inside the Compose network localhost is the frontend’s own container, not the backend. Then once the address was fixed, the frontend raced the backend on startup and the first prediction failed, because the model had not finished loading into the backend yet. Three separate “works in isolation, breaks together” bugs, and every one of them came from the wiring being commands I remembered rather than a file anyone could run.
In the last lesson the scorer stopped being a script and became a service: its ENTRYPOINT ran uvicorn so PID 1 blocked instead of returning, the port was published with -p and the server bound 0.0.0.0 so a host curl localhost:8000/predict reached it, and outputs lived on a volume that survived docker rm. That single service works. A real product is more than one service. A small frontend, a static page in its own container that calls /predict and shows the result, has to run alongside the backend, and standing both up means running two containers wired together across four shared concerns: a network, ports, volumes, environment, and an order they start in. Doing that by hand is the failure this lesson removes. This lesson replaces the pile of docker run flags with one declarative compose.yaml that brings the frontend and the backend up on a single command, has them find each other by service name rather than localhost, and waits for the backend to be ready (the model loaded) before the frontend depends on it. By the end, docker compose up stands up the whole local app and the dashboard renders a real prediction served by the backend. Three terms carry the lesson and each is grounded the first time it appears: a service is one container’s full definition in the file (its image, ports, volumes, environment, dependencies); the Compose network is the private network Compose creates so those services can address each other; and readiness is the distinction between a container that has started and a service that can actually answer.
Once the scorer ran as a service, I wired the dashboard to it the obvious way: two terminals, two docker run commands, each carrying a page of -p and -v and -e flags I retyped from memory every morning. It worked on my machine and nowhere else, because the setup lived in my shell history and nowhere else. When I finally moved it into a compose.yaml, the frontend still could not reach the backend: I had pointed it at http://localhost:8000, and inside the Compose network localhost is the frontend’s own container, not the backend. Then once the address was fixed, the frontend raced the backend on startup and the first prediction failed, because the model had not finished loading into the backend yet. Three separate “works in isolation, breaks together” bugs, and every one of them came from the wiring being commands I remembered rather than a file anyone could run.
In the last lesson the scorer stopped being a script and became a service: its ENTRYPOINT ran uvicorn so PID 1 blocked instead of returning, the port was published with -p and the server bound 0.0.0.0 so a host curl localhost:8000/predict reached it, and outputs lived on a volume that survived docker rm. That single service works. A real product is more than one service. A small frontend, a static page in its own container that calls /predict and shows the result, has to run alongside the backend, and standing both up means running two containers wired together across four shared concerns: a network, ports, volumes, environment, and an order they start in. Doing that by hand is the failure this lesson removes. This lesson replaces the pile of docker run flags with one declarative compose.yaml that brings the frontend and the backend up on a single command, has them find each other by service name rather than localhost, and waits for the backend to be ready (the model loaded) before the frontend depends on it. By the end, docker compose up stands up the whole local app and the dashboard renders a real prediction served by the backend. Three terms carry the lesson and each is grounded the first time it appears: a service is one container’s full definition in the file (its image, ports, volumes, environment, dependencies); the Compose network is the private network Compose creates so those services can address each other; and readiness is the distinction between a container that has started and a service that can actually answer.
A page of docker run flags is imperative setup no one can reproduce
The model a competent engineer reaches for first is that wiring two services is two docker run commands, and once both work, the app works. The flags are right there in the terminal; copy them into a runbook and the job is done. That model holds exactly until a second person, or the same person a month later, has to stand the same app up. Watch what the actual wiring looks like when it lives as a sequence of commands rather than a file.
# Terminal 1 — the backend, retyped from memory:
$ docker network create app-net
$ docker run -d --name predict --network app-net \
-p 8000:8000 -v models:/app/models -e MODEL_PATH=/app/models/model.pkl \
loan-scorer
# Terminal 2 — the frontend, pointed at the backend by hand:
$ docker run -d --name frontend --network app-net \
-p 3000:3000 -e PREDICT_URL=http://predict:8000 \
loan-frontend
Nothing in that sequence records that the frontend needs the backend, that the network had to exist before either container, which volume holds the model, or that order mattered. The knowledge is entirely in the order the commands were typed and the flags they carried, and the only place that order is written down is one engineer’s shell history. Reproducing the app means remembering it. This is the same class of failure as the unpinned build four lessons back, where the resolved environment was a function of when pip install ran rather than of any versioned artifact: the result is not a function of anything checked in, so it is not reproducible by construction. A second person cannot recreate a state that exists only as remembered commands.
The correct model is to declare the desired end state in a file and let the tool reconcile reality to it. A compose.yaml lists the services (each a container with its image, ports, volumes, environment, and dependencies) and the network they share, and docker compose up creates the network, starts the services, and wires them exactly as the file describes. The wiring that was shell history becomes a versioned, diffable, reviewable artifact. Here is the same two-service app as a file instead of a sequence.
# compose.yaml — the whole app's desired state, version-controlled.
services:
predict:
build: ./backend # or image: loan-scorer
ports:
- "8000:8000"
volumes:
- models:/app/models
environment:
MODEL_PATH: /app/models/model.pkl
frontend:
build: ./frontend
ports:
- "3000:3000"
environment:
PREDICT_URL: http://predict:8000
depends_on:
- predict
volumes:
models:
One command, docker compose up, now stands up everything the two terminals did, and a second person runs the identical command to get the identical app. Because the file is the single source of truth it is idempotent: running up again does not stack a second copy of each container, it reconciles to the state the file already declares, which is the same property that makes declarative infrastructure better than clicking through a console. The diagram below shows the static structure the imperative commands never wrote down anywhere: two services on one Compose-created network, each with its ports and volumes.
package "compose.yaml (declared state)" {
node "predict service" as predict
node "frontend service" as frontend
database "volume: models" as vol
cloud "Compose network" as net
}
frontend --> net : joins
predict --> net : joins
frontend ..> predict : depends_on
predict --> vol : mounts /app/models
predict --> net : published :8000
frontend --> net : published :3000
Read top to bottom, the diagram is the relationship graph the two docker run commands never recorded anywhere: the network is the shared substrate both services join, the depends_on edge is the only place the frontend’s need for the backend is written down, and the volume is bound to exactly one service. Every edge here was previously a flag’s worth of tacit knowledge in shell history; in the file it is a line a reviewer can see and a diff can catch.
The idempotence has a sharp edge that the “declare it and walk away” framing hides, and it is worth naming because it is where the file silently stops matching reality. Compose tracks the resources it owns under a project name, which defaults to the directory the file sits in. Two checkouts in identically named directories can collide over the same tracked resources, and more commonly, a service deleted from the file is not removed by a plain up. The container it created keeps running as an orphaned container, so docker ps shows a service the file no longer mentions: the declared state and the running state have diverged, with nothing to announce it. This is the Compose-level version of state drift, the failure where the source of truth and the live system disagree. The fix is to make removal explicit.
# Delete the `frontend` service from compose.yaml, then:
$ docker compose up -d
[+] Running 1/1
✓ Container predict Started
# ...but the old frontend container is STILL RUNNING — orphaned:
$ docker ps --format '{{.Names}}'
predict
frontend # the file no longer declares it; up did not remove it
$ docker compose up -d --remove-orphans
[+] Running 2/2
✓ Container frontend Removed
✓ Container predict Running
A plain up started the services the file declares and left the deleted one running, because Compose reconciles toward the declaration but does not, by default, reap what the declaration dropped. --remove-orphans is what makes “declarative” actually mean the file is the whole truth. One scope limit before the next section: Compose is the tool for defining and running a multi-service app on one host. It is not a production-scale cluster orchestrator that schedules containers across many machines, that is a different problem with different tools, and it is deliberately out of scope here. On one host, for local development and a single-box deploy, the file is the artifact.
Try It 1
The two-terminal wiring above records the flags but not the relationships. Given a minimal compose-shaped dict, complete the function so it returns each service’s declared dependencies and published ports, proving the file, not shell history, is the source of truth. Fill in the two list comprehensions.
def describe(compose: dict) -> dict[str, dict]:
services = compose["services"]
out: dict[str, dict] = {}
for name, spec in services.items():
# depends_on is a list of service names this one needs (or absent)
deps: list[str] = [] # the services this one declares it depends on
# ports are "host:container" strings; report the host-side numbers
host_ports: list[str] = [] # the host-side number from each "host:container"
out[name] = {"depends_on": deps, "host_ports": host_ports}
return out
compose = {
"services": {
"predict": {"ports": ["8000:8000"]},
"frontend": {"ports": ["3000:3000"], "depends_on": ["predict"]},
}
}
print(describe(compose))Hint
The dependency list may be absent for a service that needs nothing, reach for it with a default of an empty list rather than indexing a key that might not exist. For the ports, each entry is one string shaped "host:container"; the host-side number is the part before the colon. Re-read the paragraph on what a compose.yaml records that two docker run commands do not.Solution
Here is the file read as the source of truth it is: each service resolved to what it depends on and which host ports it publishes, pulled straight from the declaration. Watch that predict has no depends_on key at all and the default keeps it from raising.
def describe(compose: dict) -> dict[str, dict]:
services = compose["services"]
out: dict[str, dict] = {}
for name, spec in services.items():
deps = spec.get("depends_on", [])
host_ports = [p.split(":")[0] for p in spec.get("ports", [])]
out[name] = {"depends_on": deps, "host_ports": host_ports}
return out
compose = {
"services": {
"predict": {"ports": ["8000:8000"]},
"frontend": {"ports": ["3000:3000"], "depends_on": ["predict"]},
}
}
print(describe(compose))The dict carries everything the two docker run commands left in shell history: that frontend depends on predict, and which host ports each publishes. A reviewer reads it, a teammate runs it, and a diff shows exactly what changed between two versions, none of which a remembered command sequence can offer.
Services find each other by service name, not by localhost
The file brings both services up, and the next plausible-but-wrong model takes over from how the services behaved when they ran separately. When the backend ran as its own docker run with -p 8000:8000, the frontend reached it at http://localhost:8000, and that worked. So the natural move is to carry the same URL into the compose.yaml. It refuses every call. The reason is the network namespace from the last lesson, surfacing one level up. Each service is a container with its own network namespace, its own loopback, its own interfaces, so localhost inside the frontend container is the frontend’s own loopback. There is no backend there.
# compose.yaml — the wrong URL, carried over from the separate docker run era:
services:
predict:
build: ./backend
frontend:
build: ./frontend
environment:
PREDICT_URL: http://localhost:8000 # localhost = the frontend itself
depends_on:
- predict
Bring that file up and watch the frontend’s own logs, not the backend’s, the backend is fine, the failure is entirely on the calling side.
$ docker compose up -d
$ docker compose logs frontend
frontend | ConnectionError: HTTPConnectionPool(host='localhost', port=8000):
frontend | Max retries exceeded — Connection refused
The frontend’s request to localhost:8000 went to the frontend’s own port 8000, where nothing is listening, so it refused, even though the backend is up and healthy on the same Compose network. Why did the same URL work before? Because when the services ran as separate docker run processes, the frontend reached localhost:8000 through the host’s published port: localhost resolved on the host, and the host’s -p mapping forwarded it into the backend container. When that URL is carried into a container, the meaning of localhost moves from the host to the calling container, and the forwarding that made it work is gone. The mental-model bug is treating localhost as “the backend” when it has only ever meant “this same machine,” and inside a container “this same machine” is the container itself.
The correct model is that Compose runs an embedded DNS resolver on the project’s private network and registers each service under its service name, so containers address each other by name. The backend’s service name is predict, so http://predict:8000 resolves to the backend container’s address on the Compose network and reaches it directly. The fix is one line.
services:
frontend:
environment:
PREDICT_URL: http://predict:8000 # service name, resolved by Compose DNS
depends_on:
- predict
With only that one word changed, the same up produces a frontend log where the call lands instead of refusing, watch the 200 and the real prediction body come back.
$ docker compose up -d
$ docker compose logs frontend
frontend | GET http://predict:8000/predict -> 200
frontend | {"probability": 0.0731, "label": "fully_paid", "model_version": "lc-default-v3"}
The call resolves now because predict is a real DNS name on the Compose network that points at the backend container, and the request travels container-to-container without ever touching the host. Two consequences fall out of that and both are worth holding onto. The call goes directly between containers, so the backend’s port does not even need to be published to the host for the frontend to reach it, publishing is only for traffic coming from outside the Compose network. And that exclusion is the sharp trap: the user’s browser loading the frontend is outside the Compose network entirely, so any prediction call made from client-side JavaScript in the browser cannot use http://predict:8000, the browser has no route to a name that only exists inside Compose’s DNS. Service-name DNS works container-to-container, never from the browser, which is exactly why an ML frontend proxies prediction calls through its own server (a container on the network) rather than firing them from the browser. The scrolly traces a request from the frontend container to the backend, with the broken localhost track looping back to the frontend’s own empty port and the predict track resolving across the Compose network and through the readiness gate the next section explains.
Compose reads the file and builds the network
docker compose up reads compose.yaml and creates a private network for the project. Every service the file declares will join this one network, which is what lets them address each other at all.
Each service gets a DNS name
Compose starts the predict and frontend services and registers each under a DNS name equal to its service name on that network. predict now resolves to the backend container’s address; frontend resolves to the frontend’s.
localhost loops back to the caller
The frontend calling http://localhost:8000 reaches its own container’s port 8000, where nothing is listening, so the request refuses. localhost is the calling container itself, not the backend, even though the backend is up on the same network.
The service name resolves across the network
Change the URL to http://predict:8000. Compose’s embedded DNS resolves predict to the backend container directly, container-to-container, without going through the host, so the backend’s port need not even be published for this call.
The readiness gate holds the first request
A depends_on with a healthcheck holds the frontend’s start until the backend’s /health passes, the model has finished loading. Only then does the request flow frontend → predict → model → back, and the dashboard renders a real prediction.
The rule is mechanical enough to resolve in plain Python: a URL’s host either names the calling container itself (localhost) or names a peer on the network (a service name), and only the second reaches anything. The resolver below encodes that localhost from inside a container points back at the caller, while a service name points at the named peer.
def resolves_to(caller: str, url: str, network: set[str]) -> str:
# Strip scheme and port to get the host portion of the URL.
host = url.split("://", 1)[-1].split(":", 1)[0]
if host in ("localhost", "127.0.0.1"):
return caller # loopback always means "this same container"
if host in network:
return host # a service name resolves to that peer on the network
return "UNRESOLVABLE"
network = {"predict", "frontend"}
print(resolves_to("frontend", "http://localhost:8000", network)) # the frontend itself
print(resolves_to("frontend", "http://predict:8000", network)) # the backend peer
print(resolves_to("frontend", "http://backend:8000", network)) # not a declared serviceThe first call resolves to frontend (the caller’s own container, which is why localhost refused), and the second resolves to predict, the backend peer Compose’s DNS knows about. The third returns UNRESOLVABLE because backend is not a service the file declared, which is the failure shape behind a typo in a service name: not a connection error but a name that resolves to nothing on the network.
Try It 2
The student’s frontend service still points at http://localhost:8000 and every prediction refuses. Change the PREDICT_URL to address the backend by its service name, and complete the function so it returns the correct in-network URL for a given backend service name and port. The closing prose then explains why a browser fetch could not use that same URL.
def predict_url(service_name: str, port: int) -> str:
# Build the URL the FRONTEND CONTAINER should use to reach the backend.
# It must address the backend by its Compose service name, not localhost.
return "http://localhost" # placeholder -- replace with the service-name URL
print(predict_url("predict", 8000)) # expect http://predict:8000Hint
The whole point of the section is that `localhost` means the calling container itself, so the URL the frontend uses must name the *other* service. What does Compose register each service under on the private network? Build the URL from that name and the port, not from loopback. Re-read the paragraph on the embedded DNS resolver.Solution
Here is the URL built the only way that reaches the backend from inside the frontend container: from the backend’s service name, which Compose’s DNS resolves to the backend container on the network. Watch that localhost never appears, it would have pointed the request back at the frontend.
def predict_url(service_name: str, port: int) -> str:
return f"http://{service_name}:{port}"
print(predict_url("predict", 8000)) # http://predict:8000The frontend now reaches the backend at http://predict:8000, container-to-container across the Compose network. A browser fetch could not use that same URL: the browser is outside the Compose network, so predict is not a name it can resolve, which is why client-side prediction calls have to go to a published host port or be proxied through the frontend’s own server, the one container that is on the network.
depends_on orders start, not readiness
The address is fixed and the frontend can reach the backend, so the remaining model to break is the one about depends_on. It reads like a readiness guarantee: the frontend depends_on the backend, so by the time the frontend runs, the backend is ready to answer. It is not. depends_on controls the order containers are started, and “started” for the backend means the instant uvicorn binds its port, which happens before the model is loaded into memory. The frontend starts the moment the backend container starts, fires its first request into the window where the port is open but the model object does not exist yet, and gets an error.
# compose.yaml — depends_on alone, which orders start but not readiness:
services:
predict:
build: ./backend
frontend:
build: ./frontend
environment:
PREDICT_URL: http://predict:8000
depends_on:
- predict # waits for predict to START, not to be READY
Run it in the foreground and watch the interleaving of the two services’ logs, the order the lines arrive in is the bug, with the frontend’s request landing between the port binding and the model finishing its load.
$ docker compose up
predict | INFO: Uvicorn running on http://0.0.0.0:8000 # port bound — "started"
frontend | GET http://predict:8000/predict -> 503
frontend | {"detail": "model not loaded"} # model still deserializing
predict | INFO: model loaded (3.4s) # ready — too late
The backend container started the instant uvicorn bound :8000, and depends_on released the frontend right then, but the model was still being deserialized from disk into the process heap. That deserialization is the slow part of an ML service’s startup. The frontend’s first request landed in that gap and got a 503. This is an intermittent bug: whether the first request fails depends on whose startup wins the race, so it “works on the second try” and a refresh appears to fix it, which sends people chasing a flaky network when the cause is a deterministic ordering gap. “Started” and “ready” are two different events, and depends_on waits only for the first.
The correct model makes readiness observable and waited-on. A healthcheck is a command Compose runs periodically against a service, typically a request to a /health endpoint, and the service moves through three states as it runs: starting before the first probe passes, healthy once one passes, and unhealthy if probes keep failing past the configured retries. A container is starting, not unhealthy, during the model-load window, which is the distinction that matters here: condition: service_healthy waits out the starting phase, so “wait until healthy” is really “wait until no longer starting.” Declaring depends_on with condition: service_healthy then holds the frontend’s start until the backend reports healthy, not merely started. With the short depends_on syntax Compose does not wait for health; condition: service_healthy is what changes “started” to “ready.”
services:
predict:
build: ./backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 30s # grace window: failing probes here do not count
frontend:
build: ./frontend
environment:
PREDICT_URL: http://predict:8000
depends_on:
predict:
condition: service_healthy # wait until /health passes, not just started
The start_period is the primitive built for exactly the model-load window. During it, a failing probe does not count toward retries and does not flip the container to unhealthy; it only stays starting. Without it, a slow load forces a choice between two bad options: a short retries that marks the backend unhealthy before the model finishes, or a large retries inflated only to cover the load, which then also delays detection of a genuinely dead service later. start_period separates “still booting” from “actually failing” so the two do not have to be traded against each other.
Bring the gated stack up and watch the same logs in a new order, the frontend’s request line no longer appears until after the model-loaded and healthy lines, because the gate held it there.
$ docker compose up
predict | INFO: Uvicorn running on http://0.0.0.0:8000
predict | INFO: model loaded (3.4s)
predict | health: 200 (model loaded) # NOW marked healthy
frontend | GET http://predict:8000/predict -> 200 # first request succeeds
The frontend did not start until the backend’s healthcheck passed, and the healthcheck passed only after the model loaded, so the first request now succeeds instead of racing. The gate closes the window. But it does so only if the /health endpoint is honest about what it reports. A health endpoint that returns 200 the instant uvicorn is up, before the model loads, re-opens the exact gap: service_healthy would mark the backend healthy while it still cannot score, and the frontend would start into the same race. The probe has to verify the model is loaded and can score, not merely that the port is open, which is the readiness-versus-liveness distinction that every orchestrator’s health gate turns on. Compose’s service_healthy is the single-host version of that gate; getting the /health check honest here is the model for getting it right under any orchestrator. The scrolly traces the two startups against the model-load timeline, the bare depends_on releasing the frontend into the not-ready window and the healthcheck holding it until the model is loaded.
The backend container starts
docker compose up starts the backend. uvicorn binds :8000 almost immediately, the container is now Up and the port is open. This is the event depends_on calls “started.”
The model is still loading
Binding the port is fast; loading the classical-ML model from disk into the process heap is the slow part, often several seconds. During this window the container is Up and the port is open, but /predict cannot answer: the model object does not exist yet.
Bare depends_on releases the frontend too early
With plain depends_on, Compose starts the frontend the instant the backend container starts. The frontend’s first request fires into the not-ready window and gets a 503: the started-but-not-ready race.
A healthcheck observes readiness
Add a healthcheck: Compose periodically requests /health against the backend and marks it healthy only when that passes. With an honest /health that checks the model is loaded, the service is healthy only once it can actually score.
service_healthy holds the frontend until ready
condition: service_healthy holds the frontend’s start until the backend reports healthy. The frontend now starts after the model is loaded, so its first request lands on a ready service and succeeds. A /health that returns 200 before the model loads would leave the race in place.
The race is deterministic enough to model from timestamps: a request fails when it is issued after the backend’s port opens but before the model finishes loading, and gating on model_loaded rather than started removes the failure. The block below resolves whether the frontend’s first request lands in the not-ready window under each gating rule.
def first_request_outcome(
started_at: float, model_loaded_at: float, request_at: float, gate: str
) -> str:
# gate "started": frontend may fire as soon as the container starts.
# gate "healthy": frontend waits until the model is loaded.
earliest = started_at if gate == "started" else model_loaded_at
fires_at = max(request_at, earliest)
if fires_at < model_loaded_at:
return "503 -- request landed in the not-ready window"
return "200 -- model was loaded when the request arrived"
# Backend: port open at t=0.0, model loaded at t=3.4. Frontend wants to call at t=0.1.
print(first_request_outcome(0.0, 3.4, 0.1, "started")) # races the load
print(first_request_outcome(0.0, 3.4, 0.1, "healthy")) # waits for the loadUnder the started gate the request fires at 0.1 seconds, well inside the window before the model loads at 3.4, and gets the 503, the exact race from the logs. Under the healthy gate the frontend cannot fire until 3.4, after the model is loaded, so the same request returns 200. The gate did not make the model load faster; it moved the first request to after the load instead of into the middle of it.
Try It 3
The student’s stack has the frontend depends_on the backend, and the first prediction fails intermittently. Add the gate that waits for readiness, and complete the function so it returns whether the frontend’s first request succeeds for a given gate and timeline. Then explain why a /health that returns 200 before the model loads would leave the race in place.
def first_succeeds(
port_open_at: float, model_at: float, req_at: float, gate: str
) -> bool:
# gate "service_started": frontend may call once the container started.
# gate "service_healthy": frontend waits until the model is loaded.
earliest = port_open_at # TODO: pick port_open_at or model_at based on the gate
fires_at = max(req_at, earliest)
return (
fires_at >= model_at
) # True only if the model was loaded by the time it fired
print(first_succeeds(0.0, 3.4, 0.1, "service_started")) # expect False
print(first_succeeds(0.0, 3.4, 0.1, "service_healthy")) # expect TrueHint
Two gates set two different "earliest the frontend may fire" times: one is when the container started, the other is when the model finished loading. The request actually fires at the later of "when it wanted to" and "when the gate allowed it." It succeeds only if that firing time is at or after the model-loaded time. Re-read the paragraph on started-versus-ready and what `service_healthy` waits for.Solution
Here is the outcome resolved by the one fact that decides it: whether the gate releases the frontend before or after the model is loaded. Watch that service_started releases it at port-open and service_healthy releases it at model-loaded, which is the entire difference.
def first_succeeds(
port_open_at: float, model_at: float, req_at: float, gate: str
) -> bool:
earliest = port_open_at if gate == "service_started" else model_at
fires_at = max(req_at, earliest)
return fires_at >= model_at
print(
first_succeeds(0.0, 3.4, 0.1, "service_started")
) # False -- fired at 0.1, model at 3.4
print(first_succeeds(0.0, 3.4, 0.1, "service_healthy")) # True -- held until 3.4service_started lets the request fire at 0.1 seconds, before the model loads at 3.4, so it fails; service_healthy holds the frontend until 3.4 and it succeeds. A /health that returned 200 before the model loaded would make service_healthy mark the backend healthy at port-open (so earliest would effectively become 0.0 again), and the frontend would start back into the not-ready window. The gate is only as honest as the probe behind it.
Summary
- A
compose.yamldeclares a multi-service app’s desired state (services, their ports, volumes, environment, dependencies, and the network they share), anddocker compose upreconciles reality to it on one command. Wiring the same app with a sequence ofdocker runflags leaves the relationships in shell history, reproducible by no one. - Compose
upis idempotent (re-running converges rather than duplicating), but a service removed from the file keeps running as an orphaned container until--remove-orphansis passed, the Compose version of state drift, where the file anddocker psdisagree. - Inside a Compose network,
localhostfrom a container is that container itself, so a frontend callinghttp://localhost:8000reaches its own empty port. Compose’s embedded DNS registers each service under its service name, so containers reach each other by name (http://predict:8000), container-to-container, no published port required. - Service-name DNS works between containers, never from the browser: the user’s browser is outside the Compose network, so client-side prediction calls need a published host port or a proxy through the frontend’s own server.
depends_onorders start, not readiness: it releases the dependent service the instant the dependency’s container starts, which for an ML backend is before the model loads. A healthcheck pluscondition: service_healthyholds the dependent until the dependency reports healthy, and the gate is only honest if/healthverifies the model is loaded rather than that the port is open.
Check your understanding:
- What does a
compose.yamlrecord that a pair ofdocker runcommands does not, and which property letsdocker compose uprun twice without stacking duplicate containers? - Inside a Compose network, what does
http://localhost:8000from the frontend container actually reach, and what address must it use to reach the backend service? - Without looking back:
depends_onis set and the frontend’s first request still fails intermittently. What event doesdepends_onactually wait for, and what must a/healthcheck verify forservice_healthyto mean “can actually score”?
This lesson is part of Pro
The Ship a Machine Learning Product path — every lesson, capstone, and the failure modes free tutorials skip. Sign in if you already have Pro, or unlock it below.
Unlock with Pro Sign in