Skip to content

Exercise: Health Endpoint & Docker

You'll:

  • implement a GET /health endpoint 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:

shell
git fetch
git checkout -b my-ch11 --no-track origin/ch11
uv sync
Files that changed in this chapter:
  • pyproject.toml (modified, adds coverage, mypy, ruff to 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 /health endpoint)
  • .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.py and src/release_tracker/routers/tasks.py so uv 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:

shell
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.

shell
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:

src/release_tracker/routers/health.py
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:

shell
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:

shell
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:

shell
pwd

Generate a key and export it, then bring up the stack.

shell
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:

  1. The db container starts and runs through its healthcheck retries until pg_isready succeeds.
  2. The web container builds (or rebuilds, if Step 3 already cached the layers).
  3. web runs alembic upgrade head against the new database, then fastapi run boots 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:

shell
cd <path from pwd>
shell
curl http://localhost:8000/health

You should see:

JSON
{"status":"healthy"}

Now check the container's health status:

shell
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:

shell
docker compose -f compose.app.yaml down

The postgres_data named volume persists between runs.

WARNING: To reset to a clean database, add -v:

shell
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 up errors 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.

  • web container exits immediately with pydantic_core._pydantic_core.ValidationError: jwt_secret_key:

    The variable is exported but doesn't reach the container. Check compose.app.yaml's environment: block under the web service includes JWT_SECRET_KEY: ${JWT_SECRET_KEY:?...}.

  • alembic upgrade head fails 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 web retries the migration step.

  • docker build fails with "lockfile is out of sync":

    uv sync --frozen refuses to start when uv.lock doesn't match pyproject.toml. Run uv lock locally, commit the updated lockfile, and rebuild.

  • /health returns 500 or the container stays (health: starting) indefinitely:

    Check that src/release_tracker/routers/health.py uses session.execute(text("SELECT 1")) inside a try/except. A bare session.execute("SELECT 1") without the text() wrapper raises a TypeError.

Compare with the Solution

shell
git diff solutions/ch11 -- src/release_tracker/routers/health.py