Homework — Multi-Service Builds
Lesson 03 · Cloud Infrastructure & DevOps · 3 tasks
Task 1 — Add a second API replica behind a load-balancer service
Extend docker-compose.yml to run two API containers (api1 and api2) plus a Caddy (or nginx) service in front that routes port 8000 to a round-robin pool. Verify that consecutive requests hit different replicas.
To make the replicas distinguishable, add a REPLICA_NAME environment variable to each and print it in the /health response:
# In docker-compose.yml:
services:
api1:
build: ./api
env_file: ./.env
environment:
REPLICA_NAME: api1
depends_on:
postgres: { condition: service_healthy }
api2:
build: ./api
env_file: ./.env
environment:
REPLICA_NAME: api2
depends_on:
postgres: { condition: service_healthy }
lb:
image: caddy:2-alpine
ports: ["8000:8000"]
depends_on: [api1, api2]
Create a Caddyfile next to docker-compose.yml that round-robins between the two replicas:
:8000 {
reverse_proxy api1:8000 api2:8000
}
Then mount it into the Caddy service and modify app.py to include REPLICA_NAME in the health response.
Test with:
for i in $(seq 1 6); do curl -s localhost:8000/health; echo; done
Self-check: six requests return six ok responses; the api1 and api2 logs each show approximately three requests. The load balancer service logs show the upstream routing.
Task 2 — Add a healthcheck to the API service and gate the worker on it
Add a Docker healthcheck to the API’s Dockerfile so the container reports itself healthy once /health returns 200. Then configure the worker to depend on the api being healthy — not just started.
Add to the api’s Dockerfile:
HEALTHCHECK --interval=5s --timeout=3s --retries=5 \
CMD curl -f http://localhost:8000/health || exit 1
Note: the slim base does not ship curl. Either install it (RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*) or use a Python one-liner as the CMD.
Then in docker-compose.yml:
worker:
depends_on:
api:
condition: service_healthy
postgres:
condition: service_healthy
Verify the gate by deliberately breaking /health (return a 500), then running docker compose up and observing the worker stays in created state.
Self-check: with a working API, the worker boots normally. With a broken /health, docker compose ps shows the API as unhealthy and the worker stuck in created. Revert the break before moving to Task 3.
Task 3 — Build a dev/prod compose split with docker-compose.override.yml
Create a docker-compose.override.yml that is auto-merged in development but absent in production. The goal: the bind-mount and --reload flag exist only in the dev case.
Base file (docker-compose.yml) — the prod-like definition with no bind-mount and no --reload:
services:
api:
build: ./api
image: url-api:dev
ports: ["8000:8000"]
env_file: ./.env
depends_on:
postgres: { condition: service_healthy }
Override file (docker-compose.override.yml) — dev-only overlays, auto-merged when you run docker compose up without -f:
services:
api:
volumes:
- ./api:/app
command: ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
Show two commands and verify the behaviour difference:
# Dev (auto-merges override.yml):
docker compose up -d
# Prod-like (only reads the base file):
docker compose -f docker-compose.yml up -d
In the dev case, editing app.py should trigger an automatic reload. In the prod-like case, edits are invisible to the running container — the image is the source of truth.
Self-check: docker compose config (dev) shows the bind-mount and --reload in the merged config. docker compose -f docker-compose.yml config (prod) shows neither. Hot-reload works in dev, does not work in prod-like mode.