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.
Table of Contents
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?
slimis Debian-based with most dev tools removed — good default for production.alpineuses musl libc which is much smaller but breaks many pip packages that compile against glibc. Stick withslimunless you have very specific size requirements and are prepared to compile everything from source.- How do I handle .env files in Docker?
- Never
COPY .envinto the image — it bakes secrets into the image layer. Pass environment variables at runtime viadocker run --env-file .env, Docker Composeenv_file:, Kubernetes Secrets, or AWS Secrets Manager. Add.envto.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).