•9 min read

FastAPI Docker Multistage Builds: Cut Your Image Size by 70%

FastAPI Docker Multistage Builds: Cut Your Image Size by 70%

Most Python developers start with a straightforward Dockerfile. You pull python:3.12, install your requirements, copy your FastAPI app, and ship it.

The problem? Your image is now 1.2 GB. It contains build tools, C compilers, and development headers your production server never uses. It takes forever to pull during deployments and expands your attack surface.

If you deploy Python applications, multistage Docker builds are mandatory.

This guide covers three versions of the same Dockerfile, each building on the previous:

  1. Classic multistage: the baseline fix
  2. BuildKit cache mounts: fast rebuilds in CI
  3. uv-based: the fastest modern approach
FastAPI Docker Multistage Builds
Audio Briefing
0:00 / 0:00
Interactive Dev Tool
100% Client-Side & Private

Docker Compose to Kubernetes Converter

Generate Deployments, Services, ConfigMaps & Ingress

Convert multi-service Docker Compose v2/v3 YAML into production-grade Kubernetes manifests with custom replicas, health probes, resource limits, and PVC storage.

K8s DeploymentsConfigMapsPVC StorageIngress YAML

Why Single-Stage Python Builds Fail

Python needs a compiler to build C-extensions for popular libraries like psycopg2, cryptography, or lxml.

When you run pip install, it often downloads source code and compiles it. To make this work, your base Docker image needs build-essential and python3-dev. If you ship that same stage to production, you are shipping the compiler.

Hackers love finding compilers on production web servers. It makes compiling exploits trivially easy.

Why single-stage Python builds fail
Advertisement

Version 1: The Classic Multistage Build

A multistage build splits your Dockerfile into discrete stages:

  1. Builder Stage: Heavy base image. Installs compilers, builds the virtual environment.
  2. Runtime Stage: Lightweight slim image. Copies only the compiled venv and application code.

The compilers never reach production.

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

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

# Create a self-contained virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Stage 2: Runtime
FROM python:3.12-slim

RUN groupadd -r appuser && useradd -r -g appuser appuser

WORKDIR /app

# Runtime-only native lib (no compiler)
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
    && rm -rf /var/lib/apt/lists/*

# Transfer the fully-built venv - no build tools come with it
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY . .
RUN chown -R appuser:appuser /app
USER appuser

EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Result: ~150 MB image instead of ~1.2 GB.

Alpine vs. Slim | Don't Use Alpine for Python

Alpine uses musl libc instead of glibc. Many Python packages (numpy, pandas, cryptography) ship pre-compiled wheels for glibc only. With Alpine, pip must compile everything from source - drastically slower builds and frequent obscure C errors.

Always use python:X.Y-slim. It's Debian-based, uses glibc, and is small enough for production.

Why Non-Root Matters

Docker runs containers as root by default. A compromised FastAPI process with root access inside the container can write to any file, install tools, and pivot to other containers via shared volumes. The USER appuser line drops to a low-privilege user before the process starts. This is required for Kubernetes environments with PodSecurityAdmission enforcement.

Version 2: BuildKit Cache Mounts (Fast CI Rebuilds)

The classic multistage build re-downloads every pip package on every build if requirements.txt changes even slightly. BuildKit cache mounts fix this.

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

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

RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY requirements.txt .

# --mount=type=cache persists pip's HTTP cache across builds
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

FROM python:3.12-slim
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
    && rm -rf /var/lib/apt/lists/*
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY . .
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

BuildKit is enabled by default in Docker 23+. For older versions, set DOCKER_BUILDKIT=1 before building.

CI impact: On GitHub Actions with a warm cache, a full rebuild that previously took 4m30s drops to ~45s because pip reads compiled wheels directly from the cache mount instead of downloading and compiling them.

Version 3: uv | The Fastest Modern Approach

uv is a Rust-based pip replacement that is 10–100x faster. Astral ships a Docker image (ghcr.io/astral-sh/uv) that makes integrating it trivial:

# syntax=docker/dockerfile:1.4
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder

WORKDIR /app

# Install dependencies with uv - uses BuildKit cache automatically
RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    uv sync --frozen --no-install-project --no-dev

# Copy application code and install the project itself
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev

# Runtime stage - no uv needed here
FROM python:3.12-slim

RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app

COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app /app

RUN chown -R appuser:appuser /app
USER appuser

ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

This uses pyproject.toml + uv.lock instead of requirements.txt, which gives you reproducible, pinned builds across environments.

Migration: If you're still on requirements.txt, run uv init and uv add $(cat requirements.txt) to generate pyproject.toml and uv.lock.

Advertisement

Image Size Comparison

ApproachBase ImageImage SizeCold BuildWarm Rebuild
Single-stagepython:3.12~1.2 GB4m 30s4m 30s
Multistage (classic)python:3.12-slim~150 MB2m 15s2m 15s
Multistage + BuildKit cachepython:3.12-slim~150 MB2m 15s~45s
uv multistage + BuildKituv:python3.12~95 MB~60s~12s

The uv approach achieves both the smallest image and the fastest rebuild - the two metrics that matter most for CI/CD iteration speed and deploy time.

Uvicorn Workers in Production

The CMD in the examples above starts a single Uvicorn worker. For production with multiple CPU cores, use Gunicorn as a process manager:

CMD ["gunicorn", "main:app", \
     "--workers", "4", \
     "--worker-class", "uvicorn.workers.UvicornWorker", \
     "--bind", "0.0.0.0:8000", \
     "--timeout", "120", \
     "--access-logfile", "-"]

Or use Uvicorn's built-in --workers flag (simpler, but Gunicorn handles graceful restarts better):

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]

General rule: --workers = 2 × CPU cores + 1 for I/O-bound FastAPI apps. For CPU-bound workloads, match workers to CPU count exactly.

GitHub Actions CI Integration

# .github/workflows/build.yml
name: Build and Push Docker Image

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          # GitHub Actions cache backend - persists BuildKit layers between runs
          cache-from: type=gha
          cache-to: type=gha,mode=max

The cache-from: type=gha / cache-to: type=gha,mode=max pair is the GitHub Actions native BuildKit cache. Combined with uv's cache mounts, it makes CI rebuilds near-instant when only application code changes (no dependency changes).

What to Use When

ScenarioRecommendation
New project with pyproject.tomluv multistage + BuildKit
Legacy project with requirements.txtClassic multistage, add BuildKit cache
Kubernetes with strict security policyEither, ensure non-root USER
Multi-arch builds (ARM + x86)Add --platform linux/amd64,linux/arm64 to buildx

Stop shipping build-essential to production. Even the classic multistage approach cuts your image by 87% and removes your attack surface. Switching to uv gets you there faster.

You Might Also Like

Test Your Knowledge

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement