Lesson 03 Multi-Service Builds
Theme:

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.

View solutions →