DEV Community

Edem Khadiev
Edem Khadiev

Posted on

Stop Shipping 1GB Python Containers Running as Root (Multi-Stage Builds + uv + Trivy)

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"]
Enter fullscreen mode Exit fullscreen mode

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())
    }
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

Verify you aren't running as root:

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

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

Top comments (0)