Look at almost any Python backend moving from a laptop to production and the first Dockerfile looks like this:

FROM python:3.12 WORKDIR /app COPY . . RUN pip install -r requirements.txt CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] 

It builds locally. It passes a basic smoke test. Someone merges it.
And you just created three problems in production:

  1. Bloated images: The default python:3.12 tag pulls in hundreds of megabytes of C compilers, headers, and Debian junk. Your tiny API now weighs over 1GB.
  2. Root execution: Docker runs containers as root (UID 0) by default. Catch an RCE in your web framework, and the attacker owns the container namespace as root.
  3. Red CI pipelines: Run a scanner like Trivy and your build triggers a flood of HIGH and CRITICAL CVEs sitting in base OS packages.

Fixing this takes 40 lines of config. Here is the base setup I use for FastAPI services with uv, multi-stage isolation, and automated Trivy gates.

What You Need

  • Docker (24.0+) on Linux or macOS
  • A Python 3.12 project managed with uv

Step 1. The Service

Create a minimal network health-check endpoint in main.py:

from fastapi import FastAPI import psutil app = FastAPI(title="Hardened Telemetry Service") @app.get("/health") async def health(): return { "status": "ok", "interfaces": list(psutil.net_if_stats().keys()) } 

Why psutil? Because on Linux, if a pre-built wheel isn't matched, psutil compiles C extensions from source. That is the exact moment developers give up, install build-essential and gcc straight into the production image and leave them there forever (Recognized yourself? I did :) ).

Don't do that. Split the build.

Step 2. The Multi-Stage Dockerfile (THE BASE)

Shipping compilers to production is lazy. We split the Dockerfile into two stages:

  • builder: Installs dependencies and compiles wheels into an isolated /app/.venv.
  • runtime: Starts from a clean slim-bookworm image, patches the OS, drops root privileges and copies only the compiled /app/.venv.
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder WORKDIR /app # Enable bytecode compilation and copy mode for cleaner layer transfers ENV UV_COMPILE_BYTECODE=1 \ UV_LINK_MODE=copy COPY pyproject.toml uv.lock* ./ RUN uv sync --no-install-project --no-dev COPY . . RUN uv sync --no-dev # Hardened production runtime FROM python:3.12-slim-bookworm AS runtime WORKDIR /app # 1. Patch base Debian packages to resolve OS-level CVEs # 2. Create a dedicated, non-root system user and group RUN apt-get update \  && apt-get upgrade -y --no-install-recommends \  && rm -rf /var/lib/apt/lists/* \  && groupadd --system --gid 10001 appgroup \  && useradd --system --uid 10001 --gid appgroup --no-create-home appuser # Copy only the pre-built virtualenv and application entry point COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv COPY --from=builder --chown=appuser:appgroup /app/main.py /app/main.py ENV PATH="/app/.venv/bin:$PATH" \ PYTHONUNBUFFERED=1 USER appuser EXPOSE 8000 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] 

apt-get upgrade -y is mandatory here

People assume pulling python:3.12-slim-bookworm gives them a fully patched OS. It doesn't. Official Docker Hub images are rebuilt periodically, not on every single Debian security advisory. Running apt-get update && apt-get upgrade -y in the runtime stage pulls the latest patched C libraries right now, and wiping /var/lib/apt/lists/* in the same layer keeps the image slim.

Step 3. Build & Check UID

Build it:

docker build -t telemetry-service:hardened . 

Verify you aren't running as root:

docker run --rm --entrypoint id telemetry-service:hardened 
uid=10001(appuser) gid=10001(appgroup) groups=10001(appgroup) 

Step 4. Automate the Paranoia with Trivy

Run Trivy against the image to check both the Debian layer and your uv.lock packages:

docker run --rm \ -v /var/run/docker.sock:/var/run/docker.sock \ aquasec/trivy:latest image \ --severity HIGH,CRITICAL \ telemetry-service:hardened 

To make sure nobody breaks this on the next PR, wire it into .github/workflows/ci.yml:

name: Container Security & CI on: [push, pull_request] jobs: build-and-scan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Build Hardened Image run: docker build -t telemetry-service:${{ github.sha }} . - name: Run Trivy Vulnerability Scanner uses: aquasecurity/trivy-action@master with: image-ref: 'telemetry-service:${{ github.sha }}' format: 'table' exit-code: '1' ignore-unfixed: true severity: 'CRITICAL,HIGH'