Solutions — Multi-Service Builds
Lesson 03 · Cloud Infrastructure & DevOps
Solution to Task 1 — Load-balancer with two API replicas
The compose snippet for the three-service front end:
services:
api1:
build: ./api
image: url-api:dev
env_file: ./.env
environment:
REPLICA_NAME: api1
depends_on:
postgres: { condition: service_healthy }
api2:
build: ./api
image: url-api:dev
env_file: ./.env
environment:
REPLICA_NAME: api2
depends_on:
postgres: { condition: service_healthy }
lb:
image: caddy:2-alpine
ports: ["8000:8000"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
depends_on: [api1, api2]
The Caddyfile at cloud-devops/sample-app/Caddyfile:
:8000 {
reverse_proxy api1:8000 api2:8000
}
The app.py change to surface which replica served the request:
import os
REPLICA = os.environ.get("REPLICA_NAME", "unknown")
@app.get("/health")
def health() -> dict:
print(f"health served by {REPLICA}")
return {"status": "ok", "replica": REPLICA}
ports: ["8000:8000"] on both api1 and api2. Port conflicts — only one process can bind a host port. Only the load balancer exposes the host port; api1 and api2 are reachable from the lb on the internal network without any port mapping.
Solution to Task 2 — API healthcheck gating the worker
Addition to the api’s Dockerfile (after installing packages and before the CMD):
# Need curl for the healthcheck (slim doesn't ship it):
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
HEALTHCHECK --interval=5s --timeout=3s --retries=5 \
CMD curl -f http://localhost:8000/health || exit 1
And the compose change to gate the worker:
worker:
build: ./worker
env_file: ./.env
depends_on:
api:
condition: service_healthy
postgres:
condition: service_healthy
curl, use the Python runtime that is already in the image:
HEALTHCHECK --interval=5s --timeout=3s --retries=5 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
Saves ~4 MB and avoids the apt install layer.
Solution to Task 3 — Dev/prod compose split
docker-compose.yml (prod-like base — no bind-mount, no --reload):
services:
api:
build: ./api
image: url-api:dev
ports: ["8000:8000"]
env_file: ./.env
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
worker:
build: ./worker
image: url-worker:dev
env_file: ./.env
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: urls
POSTGRES_USER: urls
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U urls -d urls"]
interval: 5s
timeout: 3s
retries: 10
redis:
image: redis:7-alpine
volumes:
pgdata:
docker-compose.override.yml (dev-only, auto-merged):
services:
api:
volumes:
- ./api:/app
command: ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
Run commands:
# Dev — Compose auto-merges override.yml:
docker compose up -d
# Prod-like — only reads the base file:
docker compose -f docker-compose.yml up -d
# Inspect merged config in dev:
docker compose config | grep -A5 "command\|volumes"
docker-compose.override.yml and dev tweaks in docker-compose.yml. The override convention is the opposite: the base file is the prod default, the override is the dev overlay. Compose auto-merges override.yml for the developer’s convenience — so if you get this backwards, developers silently run a production config and the “dev” config requires an explicit -f flag every time.
The bigger lesson: by Module 4 you stop maintaining a separate prod compose file and use Helm with per-environment values.yaml instead. The override pattern is the “minimum viable” version of that idea — good for local development, not scalable beyond a handful of services.
You have now containerised a real two-service Python app and wired it into a full local stack. Module 2 starts shipping it: GitHub Actions builds the images on every commit, scans them for CVEs, signs them, and pushes to a registry.