Engineering in practice

Docker for Python, practically

13 October 2026 · 7 min read

Sizing for Production: The “Smallest Image” Fallacy

In the containerization community, there is an obsession with stripping Docker images down to the absolute smallest byte count, often resorting to minimal Alpine images with stripped C runtimes (musl libc).

For Python applications with native C-extensions (such as pydantic-core, cryptography, asyncpg, or selectolax), Alpine frequently causes unexpected headaches: wheels must be compiled from source on every build, resulting in 10x slower build times and mysterious memory allocation bugs.

The optimal approach is a multi-stage build based on Debian Slim:

  • Build stage: Full compiler toolchain to install and wheel dependencies via uv.
  • Runtime stage: Clean Debian Slim image containing only Python, compiled bytecode, and necessary shared libraries.

Production Multi-Stage Dockerfile

# Stage 1: Build & Dependency Resolution
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder

WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy

# Install dependencies using cached mounts
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-install-project --no-dev

# Stage 2: Minimalist Runtime Environment
FROM python:3.12-slim-bookworm AS runtime

WORKDIR /app

# 1. Security: Create an unprivileged application user
RUN groupadd -g 1000 appgroup && \
    useradd -u 1000 -g appgroup -s /bin/bash -m appuser

# 2. Copy the pre-built virtualenv from the builder stage
COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"

# 3. Copy application source code
COPY --chown=appuser:appgroup src/ /app/src/

# 4. Drop privileges
USER appuser

EXPOSE 8000

# 5. Native Container Healthcheck
HEALTHCHECK --interval=15s --timeout=3s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health/ready')" || exit 1

ENTRYPOINT ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

Why Non-Root Users are Non-Negotiable

Running containers as root (UID 0) inside a container gives any remote code execution (RCE) attacker root privileges inside the container boundary. If a container escape or kernel vulnerability occurs, the attacker immediately gains full root control over the host server.

By creating appuser (UID 1000) and dropping root capabilities via USER appuser, you enforce defense-in-depth isolation.


True Health Dependency in Docker Compose

A frequent configuration flaw in docker-compose.yml is relying on standard depends_on:

# Flawed: Launches 'app' immediately when Redis container boots,
# NOT when Redis is actually accepting socket connections!
app:
  depends_on:
    - redis

When the application boots faster than Redis can initialize its network listener, the app crashes with connection refused errors.

Use condition: service_healthy:

version: "3.8"

services:
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 3s
      timeout: 2s
      retries: 5

  app:
    build: .
    restart: unless-stopped
    depends_on:
      redis:
        condition: service_healthy
    ports:
      - "8000:8000"

Now, Docker Compose guarantees that redis is actively responding with PONG before the web application process is initialized.