Dockerized App + CI/CD
Take the FastAPI Task API from module 07
and make it production-deployable, end to end: a uv-based multi-stage Dockerfile
that lands a ~130 MB image, a .dockerignore, a docker-compose.yml that runs the
built image (not the dev server) against Postgres, a GitHub Actions workflow that
lints, type-checks, tests, builds, and pushes the image, and a set of Kubernetes
manifests with health probes wired to the app.
If you’ve Dockerized a Node or Go service, the multi-stage shape is the same idea:
a fat builder stage that installs the whole toolchain, and a slim runtime stage that
copies out only the artifact. The Python twist is that “the artifact” is a .venv
built by uv sync --frozen, and the runtime image carries the interpreter but no
uv and no build tools.
What you’ll practice
Section titled “What you’ll practice”- A multi-stage
Dockerfilewith theghcr.io/astral-sh/uvbuilder and BuildKit cache mounts. - Lockfile-first layer caching:
uv sync --frozen --no-devas its own cached layer. - A slim, non-root runtime image running
uvicorn. - A
.dockerignorethat keeps the context lean and.venvout. - A
docker-compose.ymlthat runs the production image against Postgres with a health-gateddepends_on. - A GitHub Actions pipeline using
astral-sh/setup-uv(lint + type + test + build + push). - Kubernetes
Deployment/Service/ConfigMap/Secretwith liveness/readiness probes.
Requirements
Section titled “Requirements”- Multi-stage
Dockerfilebuilding a.venvwithuv sync --frozen --no-dev, final image non-root anduvicorn-served. .dockerignoreexcluding.venv, caches, and secrets.docker-compose.ymlrunning the built image + Postgres, app waits for DB health./healthz(liveness) and/readyz(readiness) endpoints on the app.- A GitHub Actions workflow:
ruff check,ruff format --check,ty check,pytest, then build + push to GHCR. - K8s manifests:
Deployment(with probes),Service,ConfigMap,Secret.
The worked solution
Section titled “The worked solution”We start from the module 07 Task API and add the deployment layer around it. The
app gains a tiny health.py and a settings.py (pydantic-settings); everything
else is the deploy scaffolding.
Directorytask-api/
- pyproject.toml uv project + deps (fastapi, uvicorn, pydantic-settings)
- uv.lock the reproducibility contract (committed)
- Dockerfile multi-stage uv build
- .dockerignore keep the context lean
- docker-compose.yml prod-like: built image + Postgres
Directoryapp/
- init .py
- main.py FastAPI app, lifespan, router wiring
- models.py Pydantic models (from module 07)
- repository.py in-memory repo (from module 07)
- routes.py the CRUD APIRouter (from module 07)
- settings.py pydantic-settings config
- health.py /healthz + /readyz probes
Directory.github/
Directoryworkflows/
- ci.yml lint + type + test + build + push
Directoryk8s/
- deployment.yaml Deployment + probes
- service.yaml ClusterIP Service
- config.yaml ConfigMap + Secret template
The app additions
Section titled “The app additions”The CRUD code (models.py, repository.py, routes.py) is exactly what you built
in module 07. Two small files make it production-shaped.
settings.py reads config from the environment, typed and validated — a missing
required value crashes the process at boot instead of serving errors later.
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_")
log_level: str = "info" # In module 09 this becomes a required PostgresDsn; in-memory for now. database_url: str = "memory://"
settings = Settings()health.py gives the platform two endpoints — a cheap liveness check and a
readiness check that would verify the DB in module 09. They map straight to the K8s
probes.
from fastapi import APIRouter
router = APIRouter(tags=["meta"])
@router.get("/healthz")async def healthz() -> dict[str, str]: """Liveness: the process is up and the event loop is responsive.""" return {"status": "ok"}
@router.get("/readyz")async def readyz() -> dict[str, str]: """Readiness: dependencies are reachable. (Add a DB ping in module 09.)""" return {"status": "ready"}main.py mounts the health router alongside the tasks router. The lifespan is
where a real DB pool would open at startup and close on SIGTERM.
from contextlib import asynccontextmanagerfrom fastapi import FastAPI
from app.health import router as health_routerfrom app.repository import InMemoryTaskRepositoryfrom app.routes import router as tasks_router
@asynccontextmanagerasync def lifespan(app: FastAPI): app.state.repository = InMemoryTaskRepository() yield # Shutdown runs on SIGTERM — close the DB pool / Redis client here (module 09+).
app = FastAPI(title="Task API", version="1.0.0", lifespan=lifespan)app.include_router(tasks_router)app.include_router(health_router)pyproject.toml
Section titled “pyproject.toml”uvicorn[standard] pulls in the production extras (uvloop, httptools). The dev
group holds tooling that never reaches the image.
[project]name = "task-api"version = "0.1.0"requires-python = ">=3.13"dependencies = [ "fastapi>=0.115", "uvicorn[standard]>=0.34", "pydantic-settings>=2.5",]
[dependency-groups]dev = ["ruff>=0.9", "ty>=0.0.1", "pytest>=8.3", "pytest-asyncio>=0.24", "httpx>=0.27"]The Dockerfile
Section titled “The Dockerfile”The production default: a uv builder stage that installs deps and the project into
a .venv, and a slim runtime stage that copies only that venv and the source. No
uv, no build tools in the final image.
# --- Stage 1: build the venv with uv ---FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builderENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copyWORKDIR /app
# Layer 1: dependencies only (lockfile-driven, caches independently of source).# Bind-mount the lock + manifest so they don't bust later layers when source changes.RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ uv sync --frozen --no-install-project --no-dev
# Layer 2: project source, then install the project itself into the venv.COPY . /appRUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-dev
# --- Stage 2: slim runtime, no uv, no build tools ---FROM python:3.13-slim-bookwormWORKDIR /app
# Non-root user — root-by-default is the #1 container security finding.RUN groupadd -r app && useradd -r -g app app
# Copy the built venv + source from the builder, owned by the app user.COPY --from=builder --chown=app:app /app /app
# Put the venv on PATH so `uvicorn` resolves to it without activation.ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 8000USER app
# slim has a shell, so a shell-form HEALTHCHECK works (Compose / docker run use it).HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/healthz').status==200 else 1)"
# One worker per container — scale with replicas, not in-process workers.CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]The four ideas doing the heavy lifting:
ghcr.io/astral-sh/uv:python3.13-...builder ships uv and the interpreter, so there’s nopip, no manual venv, no installing uv.--mount=type=cache,target=/root/.cache/uvkeeps uv’s download/wheel cache between builds without baking it into a layer — rebuilds re-download nothing.- Split sync (
--no-install-projectfirst) installs the slow dependency layer separately from your fast-changing source, so a code-only change reuses the cached dep layer. This isCOPY package.jsonbeforeCOPY src/, the uv way. - Two stages — the runtime image is plain
python:3.13-slimcarrying only the.venvand your code, run by a non-rootappuser.
.dockerignore
Section titled “.dockerignore”.git.github.venv__pycache__/**/*.pyc.pytest_cache.ruff_cache.ty_cache.mypy_cachek8s/*.md!README.mddocker-compose*.yml.env.env.*.dockerignoreDockerfiledocker-compose.yml — prod-like local run
Section titled “docker-compose.yml — prod-like local run”This Compose file runs the built image (not fastapi dev) so local matches
production. It wires Postgres in for module 09’s DB swap and gates the app on the
database being healthy, not merely started.
services: app: build: context: . dockerfile: Dockerfile image: task-api:local ports: - "8000:8000" environment: - APP_LOG_LEVEL=info - APP_DATABASE_URL=postgresql+asyncpg://dev:dev@db:5432/app depends_on: db: condition: service_healthy restart: unless-stopped
db: image: postgres:17-alpine environment: POSTGRES_USER: dev POSTGRES_PASSWORD: dev POSTGRES_DB: app ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U dev -d app"] interval: 5s timeout: 5s retries: 5
volumes: pgdata:The Postgres credentials match the guide’s shared-infra
convention (dev/dev/app on 5432), so the
same DSN works across every module.
GitHub Actions — lint, type, test, build, push
Section titled “GitHub Actions — lint, type, test, build, push”One workflow, two jobs. check runs on every push and PR; build-and-push runs
only on main and needs check to pass. astral-sh/setup-uv installs uv and
caches the dependency download keyed on uv.lock.
name: CI
on: push: branches: [main] pull_request:
env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }}
jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
- name: Install uv uses: astral-sh/setup-uv@v6 with: enable-cache: true cache-dependency-glob: "uv.lock"
- name: Install dependencies run: uv sync --frozen
- name: Lint run: uv run ruff check .
- name: Format check run: uv run ruff format --check .
- name: Type check run: uv run ty check # or: uv run mypy .
- name: Test run: uv run pytest
build-and-push: needs: check runs-on: ubuntu-latest if: github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: contents: read packages: write steps: - uses: actions/checkout@v4
- name: Log in to GHCR uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Image metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=sha type=ref,event=branch
- name: Build and push uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=maxKubernetes manifests
Section titled “Kubernetes manifests”A FastAPI service in K8s is refreshingly plain — Python starts fast, so no
JVM-style startupProbe, no heap-sizing flags. One uvicorn worker per pod; scale
with replicas.
apiVersion: apps/v1kind: Deploymentmetadata: name: task-api labels: app: task-apispec: replicas: 3 selector: matchLabels: app: task-api strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 template: metadata: labels: app: task-api spec: securityContext: runAsNonRoot: true runAsUser: 1000 containers: - name: app image: ghcr.io/example/task-api:latest ports: - containerPort: 8000 name: http env: - name: APP_LOG_LEVEL valueFrom: configMapKeyRef: { name: task-api-config, key: log-level } - name: APP_DATABASE_URL valueFrom: secretKeyRef: { name: task-api-secrets, key: database-url } resources: requests: { cpu: "100m", memory: "128Mi" } limits: { cpu: "500m", memory: "256Mi" } livenessProbe: httpGet: { path: /healthz, port: http } initialDelaySeconds: 5 periodSeconds: 10 readinessProbe: httpGet: { path: /readyz, port: http } initialDelaySeconds: 5 periodSeconds: 5 terminationGracePeriodSeconds: 30apiVersion: v1kind: Servicemetadata: name: task-apispec: type: ClusterIP selector: app: task-api ports: - name: http port: 80 targetPort: httpapiVersion: v1kind: ConfigMapmetadata: name: task-api-configdata: log-level: "info"---# Template only — base64 here is encoding, NOT encryption.# Real values: kubectl create secret generic task-api-secrets \# --from-literal=database-url="postgresql+asyncpg://dev:dev@db:5432/app"# Better: External Secrets Operator / cloud secret manager.apiVersion: v1kind: Secretmetadata: name: task-api-secretstype: Opaquedata: database-url: cG9zdGdyZXNxbCthc3luY3BnOi8vZGV2OmRldkBkYjo1NDMyL2FwcA==Run it
Section titled “Run it”-
Generate the lockfile (commit
uv.lockafterward) and verify locally:Terminal window uv syncuv run uvicorn app.main:app --port 8000 --proxy-headers -
Build the production image and run it standalone:
Terminal window docker build -t task-api:local .docker run -p 8000:8000 task-api:local -
Or bring up the prod-like stack (built image + Postgres, app waits for DB health):
Terminal window docker compose up --build -
Exercise the running container:
Terminal window curl http://localhost:8000/healthzcurl http://localhost:8000/readyzcurl -X POST http://localhost:8000/tasks \-H "Content-Type: application/json" \-d '{"title": "Ship it", "description": "Dockerize the API"}'curl http://localhost:8000/tasks -
Confirm the image is lean and non-root:
Terminal window docker image ls task-api:local # ~130MBdocker run --rm task-api:local whoami # -> app (not root) -
Apply the manifests to a cluster and watch the rollout:
Terminal window kubectl create namespace task-apikubectl apply -f k8s/ -n task-apikubectl rollout status deployment/task-api -n task-apikubectl port-forward svc/task-api 8000:80 -n task-api
Lint and type-check
Section titled “Lint and type-check”uv run ruff check .uv run ruff format --check .uv run ty check # mypy works here too: uv run mypy .