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:
-
Bloated images: The default
python:3.12tag pulls in hundreds of megabytes of C compilers, headers, and Debian junk. Your tiny API now weighs over 1GB. - 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.
-
Red CI pipelines: Run a scanner like
Trivyand 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 cleanslim-bookwormimage, 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'
Top comments (0)