Exercise: Health Endpoint & Docker
You'll:
- implement a
GET /healthendpoint that checks database connectivity - build the Docker image (the Dockerfile is provided)
- run the full stack with
docker compose -f compose.app.yaml up --build - Have Docker use your health endpoint to monitor the running container
Before You Start
Set up the starting state for this exercise:
git fetch
git checkout -b my-ch11 --no-track origin/ch11
uv sync
Files that changed in this chapter:
pyproject.toml(modified, addscoverage,mypy,ruffto the dev group, and[tool.mypy]and[tool.coverage]config)Dockerfile(new, provided, no changes needed).dockerignore(new).env.example(new)compose.app.yaml(new, the multi-service stack with web + db, includes a healthcheck that calls your/healthendpoint).github/workflows/ci.yml(new, sample CI workflow)src/release_tracker/routers/health.py(new, you'll implement this)- minor mypy fixups in
src/release_tracker/main.pyandsrc/release_tracker/routers/tasks.pysouv run mypy .is clean
Step 1: Run the Quality Checks Locally
Run the checks to see their current state:
ruff format, pytest, and coverage should all pass:
uv run ruff format --check .
uv run coverage run -m pytest
uv run coverage report
ruff check and mypy will report errors in health.py because the stub has imports and a function signature but no body yet.
uv run ruff check --no-cache .
uv run mypy .
Those go away once you implement the endpoint in the next step.
Step 2: Implement the Health Endpoint
Open src/release_tracker/routers/health.py. The imports, router setup, and function signature are provided:
from fastapi import APIRouter, status
from fastapi.responses import JSONResponse
from sqlalchemy import text
from release_tracker.dependencies import SessionDep
router = APIRouter(tags=["meta"])
@router.get("/health", response_model=None, status_code=status.HTTP_200_OK)
def healthcheck(session: SessionDep) -> dict[str, str] | JSONResponse:
# TODO: Use session.execute(text("SELECT 1")) to verify the database
# is reachable.
#
# If the query succeeds, return {"status": "healthy"}.
# If it raises an exception, return a JSONResponse with
# {"status": "unhealthy"} and a 503 status code:
#
# return JSONResponse(
# {"status": "unhealthy"},
# status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
# )
...
The endpoint uses SessionDep to get a database session (the same dependency your CRUD endpoints use). You'll replace the ... with a try/except block that runs SELECT 1 against the database and returns the matching response for each case.
Step 3: Build the Image
The Dockerfile is provided on the branch. Build the image:
docker build -t release-tracker:dev .
The first build downloads the python:3.14-slim and uv base images and runs uv sync --frozen --no-dev from scratch, so it might take a minute or two.
Subsequent builds are fast because Docker caches each layer and only re-runs the steps below the first one whose inputs changed.
Confirm the image exists:
docker images release-tracker
Step 4: Run the Stack with Compose
compose.app.yaml brings up both the web container and a Postgres container. The compose file reads JWT_SECRET_KEY from your host shell and passes it into the container. If the variable isn't set, compose refuses to start.
Note your working directory — you'll need it to open a second terminal in Step 5:
pwd
Generate a key and export it, then bring up the stack.
export JWT_SECRET_KEY=$(uv run python -c "import secrets; print(secrets.token_urlsafe(32))")
docker compose -f compose.app.yaml up --build
The compose log shows three things in sequence:
- The
dbcontainer starts and runs through its healthcheck retries untilpg_isreadysucceeds. - The
webcontainer builds (or rebuilds, if Step 3 already cached the layers). webrunsalembic upgrade headagainst the new database, thenfastapi runboots the server.
When you see Uvicorn running on http://0.0.0.0:8000, the stack is up.
Warning
export keeps the key in your shell session. As long as you run docker compose from the same terminal, the container gets the same key. If you open a new terminal or re-run the export, you'll get a different key, which invalidates any tokens issued with the old one.
For this exercise that doesn't matter, but in production the key is stored in a secrets manager so it stays stable across deploys.
Step 5: Verify the Health Endpoint
The compose process is still running in this terminal. Open a new terminal window and cd to the path that pwd printed in Step 4:
cd <path from pwd>
curl http://localhost:8000/health
You should see:
{"status":"healthy"}
Now check the container's health status:
docker compose -f compose.app.yaml ps
The web service shows (healthy) in its status column. Docker calls your /health endpoint every 10 seconds. If the database went down, the endpoint would return a 503 and Docker would mark the container as unhealthy.
When you're done, press Ctrl+C in the original terminal to stop compose, then shut down the stack:
docker compose -f compose.app.yaml down
The postgres_data named volume persists between runs.
WARNING: To reset to a clean database, add -v:
docker compose -f compose.app.yaml down -v
Success
If curl http://localhost:8000/health returns {"status":"healthy"} and docker compose ps shows the web container as (healthy), the exercise is complete.
Troubleshooting Tips
-
docker compose uperrors with "Set JWT_SECRET_KEY before running compose.app.yaml":The
${JWT_SECRET_KEY:?...}form requires the variable in the host shell. Export it first (Step 4) and re-run. -
webcontainer exits immediately withpydantic_core._pydantic_core.ValidationError: jwt_secret_key:The variable is exported but doesn't reach the container. Check
compose.app.yaml'senvironment:block under thewebservice includesJWT_SECRET_KEY: ${JWT_SECRET_KEY:?...}. -
alembic upgrade headfails with "could not connect to server":The web container started before the database was ready. The
depends_on: { db: { condition: service_healthy } }clause should prevent this, but a flaky healthcheck can occasionally let it slip.docker compose -f compose.app.yaml restart webretries the migration step. -
docker buildfails with "lockfile is out of sync":uv sync --frozenrefuses to start whenuv.lockdoesn't matchpyproject.toml. Runuv locklocally, commit the updated lockfile, and rebuild. -
/healthreturns 500 or the container stays(health: starting)indefinitely:Check that
src/release_tracker/routers/health.pyusessession.execute(text("SELECT 1"))inside atry/except. A baresession.execute("SELECT 1")without thetext()wrapper raises aTypeError.
Compare with the Solution
git diff solutions/ch11 -- src/release_tracker/routers/health.py