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.
app/— the container build context. Everything the image needs lives here: the Dockerfile, the dependency manifest (pyproject.toml+ committeduv.lock), per-modeconfig/YAMLs, and thefastapi_service_templatepackage (thinmain.pywith a lifespan, a/samplesrouter 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 applydeployment:bootstrap/(the shared ServiceAccount, applied once) plus two flavors,production__internal_facing/andproduction__external_facing/..github/workflows/ci.yaml— lint, tests, and a docker build on every pull request.
Start here if you just created a repository from this template — rename first, then everything below reads as documentation of your project.
-
Create a new repository from this template and clone it. The clone directory's name becomes your project name —
scripts/rename_project.pynormalizes it to snake_case (adocument-api/clone yields thedocument_apipackage); pass--name my_serviceif you want something else. -
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 theapp/fastapi_service_template/directory:Form Template Example ( document_api)Where it appears snake_case fastapi_service_templatedocument_apipackage dir, imports, image name CamelCase FastapiServiceTemplateDocumentApiIAM role name (e.g. FastapiServiceTemplateService→DocumentApiService)SCREAMING_SNAKE FASTAPI_SERVICE_TEMPLATEDOCUMENT_APIenv vars ( FASTAPI_SERVICE_TEMPLATE_MODE)kebab-case fastapi-service-templatedocument-apik8s 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. -
Replace the sample surface with your own: the
/samplesrouter, the schemas, and the tests.
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.
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 .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).
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.
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_templateThe 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.
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.
-
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
-
Create the namespace and apply the shared ServiceAccount once:
kubectl create namespace fastapi-service-template kubectl apply -f k8s/bootstrap/
-
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.
.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.
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.
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 fixed500 {"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 inmain.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 bareraise_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 hitsGET /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.
Apache-2.0 — see LICENSE.