Lab Guide — docker compose up with API + Worker + Postgres + Redis
Lesson 03 · Cloud Infrastructure & DevOps · ~60 min
Prerequisites
- Docker Desktop 24+ with Compose v2 — verify with
docker compose version(should printv2.x) - Both L02 images buildable from the sample-app Dockerfiles (
url-api:dev,url-worker:dev) — compose will build them automatically - Free ports on the host:
8000(api). Ports 5432 (postgres) and 6379 (redis) only need to be free if you want external access; compose does not expose them to the host by default - The repo checked out with
cloud-devops/sample-app/present
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:
apiandworkerusebuild:paths; postgres and redis useimage:to pull published imagespostgreshas ahealthcheck:;apiandworkerusecondition: service_healthyto wait for it- Only
apihas aports:mapping — postgres and redis are reachable from the other containers by service name on the internal network - The bind-mount
./api:/app+--reloadcommand enables hot-reload during development
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
docker-compose.ymland.env.exampleinsample-app/- The four-service stack working end-to-end from a single
docker compose up -d --build - Hands-on evidence that service names act as DNS hostnames on the internal network
- Hot-reload working: save a file on your laptop, see it live in the container
- Volume persistence verified:
compose downkeeps data;down -vwipes it
Troubleshooting
port is already allocatedon 8000 — something else is using port 8000 on the host. Stop it or change theports:mapping indocker-compose.ymlto e.g."8080:8000".pg_isreadyhealthcheck never passes — check thatPOSTGRES_USER,POSTGRES_DBin the compose file match the values in the healthcheck command (pg_isready -U urls -d urls). They must be identical.COPY failedduring build — compose builds with the context set to the directory specified inbuild: ./api. Your Dockerfile mustCOPYpaths relative to that directory, not the repo root.- api container restarts in a loop — usually a
DATABASE_URLtypo. Rundocker compose logs apito see the traceback. Note: in Plan A,DATABASE_URLsits in the env but the api does not use it yet — Lesson 07 wires Postgres into the app code. .envis in version control — fix immediately. Rungit rm --cached cloud-devops/sample-app/.envto untrack it, addcloud-devops/sample-app/.envto.gitignore, then commit the removal.- Hot-reload does not trigger — the bind mount may not be active. Confirm
docker compose psshows the api as running, then check that thevolumes:entry and the--reloadflag are present in the compose file.