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

Table of Contents(12 sections)
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:
- Classic multistage: the baseline fix
- BuildKit cache mounts: fast rebuilds in CI
- uv-based: the fastest modern approach

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.
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.

Version 1: The Classic Multistage Build
A multistage build splits your Dockerfile into discrete stages:
- Builder Stage: Heavy base image. Installs compilers, builds the virtual environment.
- Runtime Stage: Lightweight slim image. Copies only the compiled venv and application code.
The compilers never reach production.

# 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.
Image Size Comparison
| Approach | Base Image | Image Size | Cold Build | Warm Rebuild |
|---|---|---|---|---|
| Single-stage | python:3.12 | ~1.2 GB | 4m 30s | 4m 30s |
| Multistage (classic) | python:3.12-slim | ~150 MB | 2m 15s | 2m 15s |
| Multistage + BuildKit cache | python:3.12-slim | ~150 MB | 2m 15s | ~45s |
| uv multistage + BuildKit | uv: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
| Scenario | Recommendation |
|---|---|
| New project with pyproject.toml | uv multistage + BuildKit |
| Legacy project with requirements.txt | Classic multistage, add BuildKit cache |
| Kubernetes with strict security policy | Either, 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
- Docker BuildKit Cache Mount and Multi-Stage Optimization
- Docker Compose for Local Development: A Complete Setup Guide
- Astral uv in Docker: Multi-Stage Builds, BuildKit Caching & Fast CI
- FastAPI vs Litestar 2026: Performance Benchmark and Feature Comparison
Test Your Knowledge
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

Astral uv in Docker: Multi-Stage Builds, BuildKit Caching & Fast CI
Speed up Python Docker builds from minutes to seconds using Astral uv, multi-stage targets, BuildKit persistent cache mounts, and lean runtime images.
Read more
Turborepo Remote Caching at Scale: Pruned Docker Builds, GCS Cache & CI Pipeline Speedup
Comprehensive guide covering turborepo remote caching at scale: pruned docker builds, gcs cache & ci pipeline speedup with production-grade architecture and code examples.
Read more
Why My Docker Images Were 1GB (And How I Shrank Them to 50MB)
How to reduce Docker container images from 1GB to 50MB using multi-stage builds, Alpine bases, distroless runtimes, and layer caching best practices.
Read more