Lesson 03 Multi-Service Builds
Theme:

Lab Guide — docker compose up with API + Worker + Postgres + Redis

Lesson 03 · Cloud Infrastructure & DevOps · ~60 min

Prerequisites

Step 1 — Review docker-compose.yml

The compose file has already been added to the repo at cloud-devops/sample-app/docker-compose.yml. Open it and read through it before running anything. It defines:

# Lesson 03 — full local stack for the URL shortener.
services:
  api:
    build: ./api
    image: url-api:dev
    ports:
      - "8000:8000"
    env_file:
      - ./.env
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
    volumes:
      - ./api:/app
    command: ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

  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:

Key things to notice:

Step 2 — Create .env

Copy the example file and set a dev password:

cd cloud-devops/sample-app
cp .env.example .env

Edit .env so it contains:

POSTGRES_PASSWORD=devpassword
DATABASE_URL=postgresql://urls:devpassword@postgres:5432/urls
REDIS_URL=redis://redis:6379/0
BASE_URL=http://localhost:8000

Verify .env is not tracked by git:

grep -n "^\.env$\|^\.env\b" ../../.gitignore || echo "WARNING: .env not in .gitignore — add it before going further"

Step 3 — Bring the stack up

cd cloud-devops/sample-app
docker compose up -d --build
docker compose ps

Expected output from docker compose ps: four services listed, all in state running (postgres should show running (healthy) after its healthcheck passes, which takes ~10–20 s on first run).

Tail the logs to confirm startup:

docker compose logs -f api
# Look for: INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
# ...and: WARNING: WatchFiles detected changes in 'app.py'. Reloading... (from the --reload flag)

# In a second terminal:
docker compose logs -f worker
# Look for: sweep tick: 0 records expired

Step 4 — Smoke-test the API

curl -s localhost:8000/health
# {"status":"ok"}

curl -s -X POST localhost:8000/shorten \
  -H 'content-type: application/json' \
  -d '{"url": "https://anthropic.com"}'
# {"code":"...","short_url":"http://localhost:8000/..."}

# Follow the redirect:
CODE=$(curl -s -X POST localhost:8000/shorten \
  -H 'content-type: application/json' \
  -d '{"url": "https://anthropic.com"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['code'])")
curl -s -L "localhost:8000/${CODE}"
# Should reach anthropic.com (or show the redirect destination)

Step 5 — Service-to-service DNS

From inside the api container, prove that postgres and redis resolve as hostnames on the internal network:

docker compose exec api /bin/sh -c "getent hosts postgres"
# 172.x.x.x  postgres

docker compose exec api /bin/sh -c "getent hosts redis"
# 172.x.x.x  redis

docker compose exec api /bin/sh -c \
  "python -c \"import urllib.request; print(urllib.request.urlopen('http://api:8000/health').read())\""
# b'{\"status\":\"ok\"}'

Each getent hosts resolves to the bridge-network IP of the service. The final Python one-liner shows the api can talk to itself by its own service name — useful for inter-service calls.

Step 6 — Verify hot-reload works

With docker compose up -d still running, edit cloud-devops/sample-app/api/app.py and add a new route at the end:

@app.get("/version")
def version() -> dict:
    return {"version": "0.3-dev"}

Save the file. In the api logs you should see uvicorn auto-reload:

docker compose logs -f api
# WARNING: WatchFiles detected changes in 'app.py'. Reloading...

Then hit the new endpoint without rebuilding:

curl -s localhost:8000/version
# {"version":"0.3-dev"}

Revert the edit (or keep it):

cd cloud-devops/sample-app/api && git checkout app.py && cd -

Step 7 — Verify Postgres survives compose down

Write something into Postgres, then cycle the stack and verify the data persists:

docker compose exec postgres psql -U urls -d urls -c \
  "CREATE TABLE test_persistence (id int); INSERT INTO test_persistence VALUES (42);"

docker compose down              # containers removed, volumes kept
docker compose up -d
docker compose exec postgres psql -U urls -d urls -c "SELECT * FROM test_persistence;"
# Should print: 42

docker compose exec postgres psql -U urls -d urls -c "DROP TABLE test_persistence;"

Now compare to down -v:

docker compose down -v           # also drops the pgdata volume
docker compose up -d
docker compose exec postgres psql -U urls -d urls -c "SELECT * FROM test_persistence;"
# ERROR:  relation "test_persistence" does not exist
# The table is gone because the volume was destroyed

Step 8 — Inspect the implicit network

docker network ls | grep sample-app
# sample-app_default   bridge   local

docker network inspect sample-app_default
# Look for "Containers": api, worker, postgres, redis all attached to the same network

The network name is <directory-name>_default. Since the compose file is in sample-app/, the network is sample-app_default. Every service is on it, sharing the same DNS namespace.

Step 9 — Tear down cleanly

# Stop containers, keep volumes (safe for next lab):
docker compose down

# Or wipe everything including the pgdata volume:
docker compose down -v

What you should leave with

Troubleshooting