Skip to content

Latest commit

 

History

9 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

FastAPI Service Template

A production-container skeleton for FastAPI services: uv-managed dependencies, YAML-per-mode configuration, real tests, a slim non-root Docker image, and plain Kubernetes manifests for two deployment flavors.

What's inside

  • app/ — the container build context. Everything the image needs lives here: the Dockerfile, the dependency manifest (pyproject.toml + committed uv.lock), per-mode config/ YAMLs, and the fastapi_service_template package (thin main.py with a lifespan, a /samples router demonstrating current FastAPI/Pydantic v2 idioms, and tests).
  • app/observability_tools/ — a general utility package for logging/observability constructs, deliberately segregated so it can be deleted (or lifted out) in one directory. First resident: unified HTTP error logging — server middleware + httpx client helpers; see HTTP error logging.
  • k8s/ — plain manifests, kubectl apply deployment: bootstrap/ (the shared ServiceAccount, applied once) plus two flavors, production__internal_facing/ and production__external_facing/.
  • .github/workflows/ci.yaml — lint, tests, and a docker build on every pull request.

Creating your own project from this template

Start here if you just created a repository from this template — rename first, then everything below reads as documentation of your project.

  1. Create a new repository from this template and clone it. The clone directory's name becomes your project name — scripts/rename_project.py normalizes it to snake_case (a document-api/ clone yields the document_api package); pass --name my_service if you want something else.

  2. Preview and then execute the rename — from app/, with uv installed (see Getting started for toolchain notes):

    uv run python scripts/rename_project.py           # dry-run: reports every planned change
    uv run python scripts/rename_project.py --apply   # rewrites files, renames the package dir
    uv sync                                           # reinstall under the new name
    uv run pytest                                     # verify: suite green under the new name

    The script replaces all four mechanical forms of the template name everywhere in the repo (including uv.lock, so frozen installs stay reproducible) and renames the app/fastapi_service_template/ directory:

    Form Template Example (document_api) Where it appears
    snake_case fastapi_service_template document_api package dir, imports, image name
    CamelCase FastapiServiceTemplate DocumentApi IAM role name (e.g. FastapiServiceTemplateServiceDocumentApiService)
    SCREAMING_SNAKE FASTAPI_SERVICE_TEMPLATE DOCUMENT_API env vars (FASTAPI_SERVICE_TEMPLATE_MODE)
    kebab-case fastapi-service-template document-api k8s resource names, hostnames

    Prose stays yours to rewrite: this README's title and descriptions still describe the template after the rename — replace them as you make the project your own.

    The script is one-time tooling: once the rename is applied and verified, delete scripts/rename_project.py — it has done its job and doesn't belong in your project.

  3. Replace the sample surface with your own: the /samples router, the schemas, and the tests.

Getting started

uv is the local toolchain (it provisions Python 3.13 itself if needed). git must be on your PATH — uv uses it to fetch the simple_config dependency. Docker and kubectl are only needed for images and deploys.

cd app
uv sync              # create .venv and install everything from uv.lock
uv run fastapi dev   # serve on http://localhost:8000 (entrypoint comes from pyproject.toml)

Interactive API docs: http://localhost:8000/docs. Liveness probe: /health.

Tests, coverage, lint

Run from app/:

uv run pytest
uv run pytest --cov=fastapi_service_template --cov=observability_tools --cov-report=term
uv run pytest --cov=fastapi_service_template --cov=observability_tools --cov-report=html   # then open htmlcov/index.html
uv run ruff check .
uv run ruff format --check .

Working with uv

If your muscle memory is pip + requirements.txt + a virtualenv bootstrap script, this section is the translation guide.

Mental model. uv replaces virtualenv, pip, pip-tools, and pyenv here. The project is defined by three files: app/pyproject.toml (what you depend on), the committed app/uv.lock (exact pinned versions of everything, resolved), and app/.python-version (the interpreter). The environment lives at app/.venv — a completely normal venv, just created and managed by uv; it is gitignored. Commands run from app/ (or use uv --project app … from the repo root — there is no root pyproject.toml).

Old → new commands:

pip-era habit uv equivalent
python3 -m venv .venv + pip install -r requirements.txt uv sync
pip install X uv add X
pip uninstall X uv remove X
pip install --upgrade X uv lock --upgrade-package X && uv sync
pip freeze uv.lock (generated; never hand-edited)
pyenv / interpreter wrangling .python-version (uv auto-installs 3.13 if absent)

Two equally supported workflows. Either prefix commands with uv run (no activation needed — it auto-syncs the environment first, so you can't run against stale deps), or activate once with source .venv/bin/activate and use plain python/pytest/etc. exactly as you always have.

Rebuilds. uv sync is an exact reconcile: it creates the venv if missing, installs the runtime and dev groups precisely per the lockfile, and removes anything that doesn't belong. In other words, uv sync is the rebuild. Reach for rm -rf .venv && uv sync only when you suspect venv corruption.

Alias translations. If you carry pip-era aliases (a revenv nuke-and-rebuild and a vact activator), the uv equivalents — run from app/:

alias urevenv='deactivate 2>/dev/null; rm -rf .venv && LDFLAGS="-L$(brew --prefix openssl@3)/lib" CPPFLAGS="-I$(brew --prefix openssl@3)/include" uv sync --no-cache'
alias vact='source .venv/bin/activate 2>/dev/null || source venv/bin/activate'

Two deliberate choices here. First, the compilation flags stay: this template's dependencies all ship wheels, but real projects derived from it usually end up compiling at least one dependency — uv passes the environment through to build backends exactly like pip does, unused flags are harmless, and the $(brew --prefix openssl@3) form resolves correctly on both Intel and Apple Silicon Homebrew (substitute whatever build flags your dependencies actually need). Second, --no-cache makes the rebuild fully isolated: uv neither reads from nor writes to its shared cache, using a temporary directory instead, so stale cached build artifacts can never leak into (or out of) a fresh environment. The cost is re-downloading and rebuilding on each urevenv — which is the point of a nuke alias; day-to-day uv sync and uv run stay cached and fast.

The lockfile contract. uv.lock is committed. Docker and CI install with uv sync --frozen (plus --no-dev in the image), which refuses to re-resolve — builds are exactly reproducible. Dependency changes go through uv add / uv remove, which update the lockfile as they go; commit pyproject.toml and uv.lock together.

The simple_config git source. One dependency is installed straight from GitHub (github.com/stevelautus/simple_config, wired up in [tool.uv.sources]), which is why uv sync needs the git CLI. It is locked to an exact commit in uv.lock like everything else; refresh it to newest upstream with uv lock --upgrade-package simple_config.

Poking the running app. app/scripts/probe.py is a deliberately informal scratchpad for hitting a running instance: start the dev server, tweak the flat list of probe calls in probe.py, run uv run python scripts/probe.py, and read the pretty-printed status/body output (plumbing lives in scripts/probe_common.py; point it elsewhere with --base-url or PROBE_BASE_URL). Editing the script directly is the intended workflow — it is a scratchpad, not a framework. Developer scripts in general live in app/scripts/ (top level or topic-specific subdirectories).

Configuration

Config is YAML-per-mode, handled by simple_config (installed from its public GitHub source, pinned in uv.lock). app/config/base.yaml holds the defaults; development.yaml, staging.yaml, production.yaml, and test.yaml are overlays merged on top of it. The mode is selected by the FASTAPI_SERVICE_TEMPLATE_MODE environment variable (default: development); the k8s deployments set it to production. Values support {config.*} interpolation, and the fastapi_app_settings block is splatted directly into the FastAPI(...) constructor. For local experiments, simple_config also reads developer overrides from ~/.simple_config/fastapi_service_template/ — outside the repo, so they can never be committed. The Env singleton (environment.py) loads all of this once at import time and exposes env.config and env.logger.

Docker

Build and run locally, from app/:

docker build -t fastapi_service_template .
docker run --rm -e FASTAPI_SERVICE_TEMPLATE_MODE=production -p 8000:8000 fastapi_service_template

The image is python:3.13-slim + uv: dependencies install from the lockfile in their own cached layer (uv sync --frozen --no-dev), the app runs as non-root user apprunner (uid 999) under a single uvicorn process — one process per container; Kubernetes replicas do the scaling. Tests ship in the image on purpose, so any pulled image can smoke-test itself. If the service runs behind a load balancer and real client IPs/scheme matter, add --proxy-headers (and a FORWARDED_ALLOW_IPS env var) to the uvicorn CMD.

Deployment (Kubernetes)

One-time infrastructure, via your platform's usual process: a container registry repository for the image, and (on EKS) an IAM role for the pod ServiceAccount. Then substitute the placeholders in the manifests with real values: <container-registry> (both deployments), <aws-account-id> (k8s/bootstrap/service-account.yaml), and — external flavor only — <certificate-arn>, <security-group-id>, <subnet-id-a>, <subnet-id-b> (ingress). The placeholders fail loudly on kubectl apply, so nothing deploys half-configured.

  1. Build and push the image to your registry:

    docker build -t <container-registry>/fastapi_service_template:latest app/
    docker push <container-registry>/fastapi_service_template:latest
  2. Create the namespace and apply the shared ServiceAccount once:

    kubectl create namespace fastapi-service-template
    kubectl apply -f k8s/bootstrap/
  3. Pick one flavor — internal-facing (private ALB) or external-facing (internet-facing ALB with TLS). The two flavors share resource names and will overwrite each other if both are applied:

    kubectl apply -f k8s/production__internal_facing/
    # or
    kubectl apply -f k8s/production__external_facing/

The deployments run :latest with imagePullPolicy: Always, so a rollout picks up whatever was most recently pushed — verify your image build/push finished before deploying. For real projects, prefer immutable tags (or digests) over :latest so rollouts and rollbacks are deterministic.

CI

.github/workflows/ci.yaml runs on every pull request and on pushes to main: ruff check, ruff format --check, and pytest (via uv, against the locked dependencies), plus a docker build of the image. A registry-push step is included but commented out — wire it to your registry credentials when you're ready to publish images from CI.

Extending this template

The lifespan block in main.py is the seam for app-lifetime resources — an HTTP client, a DB pool, or an event producer would be constructed there and torn down after the yield.

HTTP error logging (observability_tools)

app/observability_tools/ is a general home for logging/observability utilities, kept apart from the app package on purpose: submodules are imported directly, __init__.py re-exports nothing, and nothing in the package imports the app's Env — every entrypoint takes a logging.Logger (services pass env.logger). Its first residents implement unified HTTP error logging — trap, fully log, raise — on both sides of the wire:

  • Server side (http_error_middleware): when an endpoint raises an unhandled exception, the middleware logs the full request context (method, URL, path, query params, headers, body) plus the traceback, then answers a fixed 500 {"detail": "Internal server error"} — the service log is complete while the caller still fails loudly. FastAPI's own handled responses (HTTPException, validation 422s) never reach it. The single wiring line, already in main.py:

    app.middleware("http")(make_request_context_exception_middleware(env.logger))
  • Client side (http_error_client, httpx — sync and async responses alike): when an outbound call fails, log the original request AND the full response (status, reason, headers, cookies, redirect history, elapsed, body) AND the traceback, then let the exception bubble. Scripts and services replace a bare raise_for_status() with:

    raise_for_status_with_context(env.logger, response)

    To watch both halves fire: start the dev server, then run scripts/probe.py — it hits GET /samples/boom (a demo endpoint that deliberately raises) and prints the client-side exchange block while the server log shows the middleware banner.

Redaction and truncation defaults (http_error_rendering): values of sensitive headers (Authorization, Cookie, Set-Cookie, API-key variants), sensitive query params (redacted in both rendered dicts and URL strings), basic-auth passwords embedded in URLs, and response cookies are replaced with [REDACTED] — client-side error messages and tracebacks get the URL redaction pass too, since httpx embeds the request URL in them; bodies are capped at 50,000 characters with a truncation marker stating the total length; binary and undecodable bodies render as byte-count placeholders. The helpers expect a completed (read) request/response pair — the normal non-streaming httpx usage; streaming callers pass what they safely can.

Extending: build_exchange_record(...) returns a JSON-serializable dict, so persisting a failed exchange to a file is one json.dump(...) line. A service that makes real outbound calls should promote httpx from the dev group to the main dependencies (the Docker image installs with --no-dev).

Removing just HTTP error logging: delete observability_tools/http_error_*.py and their tests, the middleware import and wiring line in main.py, the /samples/boom endpoint and its test, and the probe additions (show_exchange in probe_common.py plus the boom probes in probe.py).

Removing the whole package: everything above, plus delete the observability_tools/ directory and its entry in pyproject.toml's testpaths.

License

Apache-2.0 — see LICENSE.

About

FastAPI service template: uv toolchain, YAML-per-mode config, Docker, Kubernetes manifests, CI

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages