Python Multi-Stage Docker Build: Slim Production Images

A naive Python Docker image using python:3.12 as the base starts at 1 GB before you add a single package. Multi-stage builds solve this by separating the build environment (where you compile wheels and install tools) from the runtime environment (where you only need the installed packages and application code). The result is a production image of 80–150 MB that starts faster, pulls faster, and has a smaller attack surface. This guide walks through progressively optimised Dockerfiles for FastAPI, Celery, and script containers.

The Naive Dockerfile (What Not to Do)

# BAD — do not use in production
FROM python:3.12            # 1.0 GB base image
WORKDIR /app
COPY . .                    # copies everything including .git, __pycache__, tests
RUN pip install -r requirements.txt
CMD ["python", "main.py"]

# Problems:
# 1. 1 GB base image with compilers, dev headers, test tools
# 2. COPY . . before pip install — every code change invalidates the pip cache layer
# 3. Runs as root — security risk
# 4. No .dockerignore — copies .git, node_modules, .env files
# 5. Layers not ordered by change frequency

Slim Single-Stage Dockerfile

# Better single-stage using slim base
FROM python:3.12-slim

# Install system deps needed by some pip packages (e.g. psycopg2 needs libpq)
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Copy requirements FIRST so Docker caches the layer
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy app code last (changes most often)
COPY . .

# Non-root user
RUN useradd -m -u 1000 appuser
USER appuser

EXPOSE 8000
CMD ["gunicorn", "app.main:app", "--worker-class", "uvicorn.workers.UvicornWorker", \
     "--workers", "2", "--bind", "0.0.0.0:8000"]

Multi-Stage Build

The builder stage installs build tools and compiles C extensions. The runtime stage starts from a clean slim base and copies only the installed packages — no compilers, no build tools, no intermediate files.

# ---- Stage 1: Builder ----
FROM python:3.12-slim AS builder

# Build dependencies (not needed at runtime)
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    g++ \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

# Install packages into a prefix so we can copy them cleanly
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt


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

# Only runtime system libraries (no dev headers)
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
    curl \
    && rm -rf /var/lib/apt/lists/*

# Copy installed packages from builder
COPY --from=builder /install /usr/local

WORKDIR /app
COPY . .

# Security: non-root user
RUN useradd -m -u 1000 appuser && chown -R appuser /app
USER appuser

EXPOSE 8000
CMD ["gunicorn", "app.main:app", \
     "--worker-class", "uvicorn.workers.UvicornWorker", \
     "--config", "gunicorn.conf.py"]
# Build and inspect size
docker build -t myapp:latest .
docker images myapp

# Compare sizes:
# python:3.12 base naive     ~1.1 GB
# python:3.12-slim single    ~280 MB
# multi-stage (above)        ~130 MB
# distroless (advanced)      ~80 MB

Layer Caching Optimisation

Docker caches each layer until a file it depends on changes. Order layers from least-to-most frequently changed. The --mount=type=cache BuildKit flag caches pip's download cache between builds, making re-installs of unchanged packages nearly instant.

# syntax=docker/dockerfile:1
FROM python:3.12-slim AS builder

RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev \
    && rm -rf /var/lib/apt/lists/*

# Use BuildKit cache mount to persist pip cache between builds
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --prefix=/install -r requirements.txt

FROM python:3.12-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /install /usr/local
WORKDIR /app

# Copy config files that change less often before source code
COPY gunicorn.conf.py pyproject.toml ./

# Copy source — this layer rebuilds on every code change, but pip layer is cached
COPY app/ ./app/

RUN useradd -m -u 1000 appuser && chown -R appuser /app
USER appuser
EXPOSE 8000
CMD ["gunicorn", "app.main:app", "--config", "gunicorn.conf.py"]
# Build with BuildKit (enabled by default in Docker 23+)
DOCKER_BUILDKIT=1 docker build -t myapp:latest .

# Or in newer Docker:
docker buildx build -t myapp:latest .

Non-Root User and Security

FROM python:3.12-slim AS runtime

# Create non-root user with specific UID (avoids conflicts with host user)
RUN groupadd -g 1000 appgroup && \
    useradd -m -u 1000 -g appgroup appuser

WORKDIR /app

# Set ownership before switching user
COPY --chown=appuser:appgroup --from=builder /install /usr/local
COPY --chown=appuser:appgroup . .

# Switch to non-root
USER appuser

# Read-only filesystem (add exceptions for writable paths)
# In Kubernetes, set securityContext.readOnlyRootFilesystem: true
# and mount writable volumes for /tmp and /app/logs

# Drop all Linux capabilities
# In Kubernetes: securityContext.capabilities.drop: [ALL]

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1

EXPOSE 8000
CMD ["gunicorn", "app.main:app", "--config", "gunicorn.conf.py"]

Production FastAPI Dockerfile

# syntax=docker/dockerfile:1
FROM python:3.12-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev \
    && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-cache-dir --prefix=/install -r requirements.txt

FROM python:3.12-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 curl \
    && rm -rf /var/lib/apt/lists/*
COPY --from=builder /install /usr/local
RUN groupadd -g 1000 app && useradd -m -u 1000 -g app app
WORKDIR /app
COPY --chown=app:app . .
USER app
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PORT=8000
EXPOSE 8000
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 \
    CMD curl -f http://localhost:${PORT}/health || exit 1
CMD ["sh", "-c", "gunicorn app.main:app \
    --worker-class uvicorn.workers.UvicornWorker \
    --workers ${WORKERS:-2} \
    --bind 0.0.0.0:${PORT} \
    --timeout 30 \
    --graceful-timeout 20 \
    --access-logfile - \
    --error-logfile -"]

Docker Compose for Local Dev

# docker-compose.yml
version: "3.9"

services:
  api:
    build:
      context: .
      target: runtime       # build only up to runtime stage
      cache_from:
        - myapp:latest      # use previous image as cache source
    image: myapp:dev
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/app
      - REDIS_URL=redis://redis:6379/0
      - SENTRY_DSN=${SENTRY_DSN}
    volumes:
      - ./app:/app/app      # hot reload in dev — override with prod CMD
    command: uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

  worker:
    build:
      context: .
      target: runtime
    command: celery -A app.worker worker -c 4 -l info
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/app
      - REDIS_URL=redis://redis:6379/0
    depends_on: [db, redis]

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "user"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data

volumes:
  postgres_data:
  redis_data:

Frequently Asked Questions

What is the difference between python:3.12-slim and python:3.12-alpine?
slim is Debian-based with most dev tools removed — good default for production. alpine uses musl libc which is much smaller but breaks many pip packages that compile against glibc. Stick with slim unless you have very specific size requirements and are prepared to compile everything from source.
How do I handle .env files in Docker?
Never COPY .env into the image — it bakes secrets into the image layer. Pass environment variables at runtime via docker run --env-file .env, Docker Compose env_file:, Kubernetes Secrets, or AWS Secrets Manager. Add .env to .dockerignore.
How do I build multi-architecture images (arm64 + amd64)?
Use docker buildx build --platform linux/amd64,linux/arm64 --push -t myapp:latest .. This builds both architectures and pushes a multi-arch manifest. Required for Apple Silicon (M-series) development and ARM-based cloud instances (Graviton).