Multi-Stage Builds & Optimization
Reduce Docker image size by 70-85% using multi-stage builds, UV package manager, and Alpine base images
- Status
- stable
- Trust
- unverified
- Approved
- ag-archive/0.1.0 ·
Container images that work are good. Container images that work AND are small are great. This lesson teaches you why small images matter and how to achieve 70-85% size reduction through iterative optimization.
When you build a Docker image for a Python service, you typically need compilers and development libraries during the build process. But in production, you only need the installed packages themselves. A naive Dockerfile includes everything---build tools, development headers, cache files---adding hundreds of megabytes of unnecessary weight.
Multi-stage builds solve this elegantly. You perform dependency installation in a large build image, then copy only the artifacts you need into a minimal production image. Combined with UV (a Rust-based package manager that's 10-100x faster than pip) and Alpine base images, you can reduce a 1.2GB image to under 120MB.
In this lesson, you'll containerize a Task API service---the same pattern you'll use for your Part 6 FastAPI agent. You'll start with a bloated Dockerfile and progressively optimize it through four iterations, measuring the size reduction at each step.
Setup: Create the Task API Project
Create a fresh project with UV (30 seconds):
uv init task-api-optimize && cd task-api-optimize
uv add "fastapi[standard]"Now add your application code. Open main.py and replace its contents with:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from datetime import datetime
app = FastAPI(title="Task API", version="1.0.0")
# In-memory task storage (production would use database)
tasks: dict[str, dict] = {}
class TaskCreate(BaseModel):
title: str
description: str | None = None
priority: int = 1
class Task(BaseModel):
id: str
title: str
description: str | None
priority: int
created_at: datetime
completed: bool = False
@app.get("/health")
def health_check():
return {"status": "healthy", "service": "task-api"}
@app.post("/tasks", response_model=Task)
def create_task(task: TaskCreate):
task_id = f"task_{len(tasks) + 1}"
new_task = {
"id": task_id,
"title": task.title,
"description": task.description,
"priority": task.priority,
"created_at": datetime.now(),
"completed": False
}
tasks[task_id] = new_task
return new_task
@app.get("/tasks")
def list_tasks():
return list(tasks.values())
@app.get("/tasks/{task_id}", response_model=Task)
def get_task(task_id: str):
if task_id not in tasks:
raise HTTPException(status_code=404, detail="Task not found")
return tasks[task_id]
@app.patch("/tasks/{task_id}/complete")
def complete_task(task_id: str):
if task_id not in tasks:
raise HTTPException(status_code=404, detail="Task not found")
tasks[task_id]["completed"] = True
return tasks[task_id]UV automatically created pyproject.toml with your dependencies. For Docker, we need a requirements.txt. Export it:
uv pip compile pyproject.toml -o requirements.txtVerify your setup works:
uv run fastapi dev main.pyVisit http://localhost:8000/health to confirm the API responds. Press Ctrl+C to stop.
Iteration 0: The Naive Dockerfile (~1.2GB)
Let's start with a Dockerfile that works but doesn't consider image size at all.
Create Dockerfile.naive:
FROM python:3.12
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY main.py .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]Build this image and check its size:
docker build -t task-api:naive -f Dockerfile.naive .Output:
[+] Building 45.2s (9/9) FINISHED
=> [internal] load build definition from Dockerfile.naive 0.0s
=> [internal] load .dockerignore 0.0s
=> [internal] load metadata for docker.io/library/python:3.12 1.2s
=> [1/4] FROM docker.io/library/python:3.12 28.3s
=> [2/4] WORKDIR /app 0.1s
=> [3/4] COPY requirements.txt . 0.0s
=> [4/4] RUN pip install -r requirements.txt 12.8s
=> [5/5] COPY main.py . 0.0s
=> exporting to image 2.7sNow check the image size:
docker images task-api:naiveOutput:
REPOSITORY TAG IMAGE ID CREATED SIZE
task-api naive a1b2c3d4e5f6 15 seconds ago 1.21GB1.21GB for a simple Task API. That bloat comes from:
| Component | Approximate Size |
|---|---|
| Full Python image (compilers, headers, build tools) | ~900MB |
Pip cache (stored in /root/.cache/pip) | ~150MB |
| Development dependencies | ~150MB |
None of that is needed to RUN the application. You only need the installed Python packages---maybe 100MB total.
Iteration 1: Slim Base Image (~450MB)
The python:3.12 image is the full-featured version. Docker provides leaner alternatives:
| Base Image | Size | Contents |
|---|---|---|
python:3.12 (full) | ~900MB | Build tools, compilers, development headers |
python:3.12-slim | ~150MB | Essential runtime, no build tools |
python:3.12-alpine | ~50MB | Minimal Linux, tiny footprint |
distroless/python3 | ~50MB | Only runtime, no shell or package manager |
Let's try slim first---it's the safest improvement with the least risk.
Create Dockerfile.v1-slim:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]Note: We added --no-cache-dir to pip to avoid storing the download cache.
Build and measure:
docker build -t task-api:slim -f Dockerfile.v1-slim .Output:
[+] Building 18.4s (9/9) FINISHED
=> [internal] load metadata for docker.io/library/python:3.12-slim 0.8s
=> [1/4] FROM docker.io/library/python:3.12-slim 8.2s
=> [4/4] RUN pip install --no-cache-dir -r requirements.txt 8.1sdocker images task-api:slimOutput:
REPOSITORY TAG IMAGE ID CREATED SIZE
task-api slim f6e5d4c3b2a1 8 seconds ago 458MB| Version | Size | Reduction |
|---|---|---|
| Naive | 1.21GB | --- |
| Slim | 458MB | 62% smaller |
Progress: 62% reduction from a single change (base image). But we're still carrying pip overhead and could do better.
Iteration 2: Multi-Stage Build with UV (~180MB)
Multi-stage builds use multiple FROM instructions in a single Dockerfile. Each stage can use a different base image. You build dependencies in a large stage, then copy only what you need into a small stage.
We'll also introduce UV, a Rust-based Python package manager that's 10-100x faster than pip.
Create Dockerfile.v2-multistage:
# Stage 1: Build stage (install dependencies)
FROM python:3.12-slim AS builder
WORKDIR /app
# Install UV package manager (10-100x faster than pip)
RUN pip install uv
COPY requirements.txt .
# UV installs packages to system Python
# --system: install to system Python instead of virtual environment
# --no-cache: don't store package cache
RUN uv pip install --system --no-cache -r requirements.txt
# Stage 2: Runtime stage (only what's needed to run)
FROM python:3.12-slim
WORKDIR /app
# Copy installed packages from builder stage
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# Set environment variables
ENV PYTHONUNBUFFERED=1
# Copy application code
COPY main.py .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]Let's understand what's happening:
Stage 1 (builder):
- Starts with
python:3.12-slim(has pip available) - Installs UV package manager
- Installs application dependencies with UV
- This stage is used only for building; it's discarded when the build finishes
Stage 2 (runtime):
- Starts with a fresh
python:3.12-slim(clean slate) - Copies only the installed packages from builder stage
- Copies application code
- Does NOT include UV, pip cache, or build artifacts
- This is the final image Docker keeps
Build and measure:
docker build -t task-api:multistage -f Dockerfile.v2-multistage .Output:
[+] Building 12.8s (14/14) FINISHED
=> [builder 1/4] FROM docker.io/library/python:3.12-slim 0.0s
=> [builder 2/4] RUN pip install uv 3.2s
=> [builder 3/4] COPY requirements.txt . 0.0s
=> [builder 4/4] RUN uv pip install --system --no-cache ... 1.8s <-- Much faster!
=> [stage-1 1/4] FROM docker.io/library/python:3.12-slim 0.0s
=> [stage-1 2/4] COPY --from=builder /usr/local/lib/python... 0.4s
=> [stage-1 3/4] COPY --from=builder /usr/local/bin ... 0.1s
=> [stage-1 4/4] COPY main.py . 0.0sNotice how UV installed dependencies in 1.8 seconds vs pip's 8+ seconds.
docker images task-api:multistageOutput:
REPOSITORY TAG IMAGE ID CREATED SIZE
task-api multistage d3c2b1a0f9e8 5 seconds ago 182MB| Version | Size | Reduction from Naive |
|---|---|---|
| Naive | 1.21GB | --- |
| Slim | 458MB | 62% |
| Multi-stage | 182MB | 85% |
Progress: 85% reduction. The runtime image has no UV, no pip, no build tools---only the installed packages.
Iteration 3: Alpine Base Image + UV (~120MB)
Alpine Linux is a minimal distribution (~5MB base) designed for containers. Combined with multi-stage builds and UV, we can achieve maximum size reduction.
Create Dockerfile.v3-alpine:
# Stage 1: Build stage with Alpine
FROM python:3.12-alpine AS builder
WORKDIR /app
# Install UV package manager
RUN pip install uv
COPY requirements.txt .
# Install dependencies with UV
RUN uv pip install --system --no-cache -r requirements.txt
# Stage 2: Runtime stage with Alpine
FROM python:3.12-alpine
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
ENV PYTHONUNBUFFERED=1
COPY main.py .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]Build and measure:
docker build -t task-api:alpine -f Dockerfile.v3-alpine .Output:
[+] Building 15.2s (14/14) FINISHED
=> [builder 1/4] FROM docker.io/library/python:3.12-alpine 2.1s
=> [builder 2/4] RUN pip install uv 4.8s
=> [builder 3/4] COPY requirements.txt . 0.0s
=> [builder 4/4] RUN uv pip install --system --no-cache ... 2.4s
=> [stage-1 1/4] FROM docker.io/library/python:3.12-alpine 0.0s
=> [stage-1 2/4] COPY --from=builder /usr/local/lib/python... 0.3s
=> [stage-1 3/4] COPY --from=builder /usr/local/bin ... 0.1s
=> [stage-1 4/4] COPY main.py . 0.0sdocker images task-api:alpineOutput:
REPOSITORY TAG IMAGE ID CREATED SIZE
task-api alpine e4f5a6b7c8d9 4 seconds ago 118MB| Version | Size | Reduction from Naive |
|---|---|---|
| Naive | 1.21GB | --- |
| Slim | 458MB | 62% |
| Multi-stage | 182MB | 85% |
| Alpine + UV | 118MB | 90% |
Progress: 90% reduction. From 1.21GB to 118MB.
Iteration 4: Layer Optimization (~115MB)
Docker builds images in layers. Each RUN instruction creates a new layer. By combining commands and cleaning up in the same layer, we can squeeze out a few more megabytes.
Create Dockerfile.v4-optimized:
# Stage 1: Build stage
FROM python:3.12-alpine AS builder
WORKDIR /app
# Single RUN: install UV + dependencies + cleanup
RUN pip install uv && \
pip cache purge
COPY requirements.txt .
RUN uv pip install --system --no-cache -r requirements.txt && \
find /usr/local -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true && \
find /usr/local -type f -name '*.pyc' -delete 2>/dev/null || true
# Stage 2: Runtime stage
FROM python:3.12-alpine
WORKDIR /app
# Copy only necessary artifacts
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
ENV PYTHONUNBUFFERED=1
COPY main.py .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]Optimizations applied:
- Combined RUN commands to reduce layer count
- Removed
__pycache__directories (bytecode cache) - Removed
.pycfiles - Purged pip cache after installing UV
Build and measure:
docker build -t task-api:optimized -f Dockerfile.v4-optimized .Output:
[+] Building 14.8s (14/14) FINISHED
=> [builder 4/4] RUN uv pip install --system --no-cache ... 2.6s
=> exporting to image 0.2sdocker images task-api:optimizedOutput:
REPOSITORY TAG IMAGE ID CREATED SIZE
task-api optimized f7g8h9i0j1k2 3 seconds ago 115MBFinal Size Comparison
Let's see all versions side by side:
docker images task-api --format "table {{.Tag}}\t{{.Size}}"Output:
TAG SIZE
optimized 115MB
alpine 118MB
multistage 182MB
slim 458MB
naive 1.21GB| Version | Size | Technique | Reduction |
|---|---|---|---|
| Naive | 1.21GB | Full Python image + pip | Baseline |
| Slim | 458MB | python:3.12-slim | 62% |
| Multi-stage | 182MB | Separate build/runtime + UV | 85% |
| Alpine | 118MB | Alpine base + UV | 90% |
| Optimized | 115MB | Layer cleanup + cache purge | 90.5% |
Result: 1.21GB reduced to 115MB (90.5% reduction)
Analyzing Layers with docker history
The docker history command shows what each layer contains:
docker history task-api:optimizedOutput:
IMAGE CREATED CREATED BY SIZE
f7g8h9i0j1k2 2 minutes ago CMD ["uvicorn" "main:app" "--host" "0.0.0... 0B
<missing> 2 minutes ago EXPOSE 8000 0B
<missing> 2 minutes ago COPY main.py . # buildkit 1.52kB
<missing> 2 minutes ago ENV PYTHONUNBUFFERED=1 0B
<missing> 2 minutes ago COPY /usr/local/bin /usr/local/bin # bui... 1.2MB
<missing> 2 minutes ago COPY /usr/local/lib/python3.12/site-pack... 58.4MB
<missing> 2 minutes ago WORKDIR /app 0B
<missing> 3 weeks ago CMD ["python3"] 0B
<missing> 3 weeks ago RUN /bin/sh -c set -eux; apk add --no-... 1.85MB
<missing> 3 weeks ago ENV PYTHON_VERSION=3.12.8 0B
...The SIZE column shows the contribution of each layer:
- Application code: ~1.5KB
- Installed binaries (uvicorn, etc.): ~1.2MB
- Installed packages (site-packages): ~58MB
- Python Alpine base: ~55MB (shown in earlier layers)
If you see an unexpectedly large layer, that's where to focus optimization efforts.
Base Image Tradeoffs
| Base Image | Size | Pros | Cons | Use When |
|---|---|---|---|---|
python:3.12-slim | ~150MB | Most compatible, safer | Larger than Alpine | Default choice; C extensions work out of box |
python:3.12-alpine | ~50MB | Smallest, fast builds | Some packages need compilation | Size-critical deployments, pure Python |
distroless/python3 | ~50MB | Maximum security, no shell | Can't debug interactively | Production security-critical services |
For this chapter: Alpine is excellent for AI services that don't require complex C dependencies. Your Task API (and most FastAPI services) work perfectly with Alpine.
When Alpine fails: If you need numpy, pandas, or other packages with C extensions that aren't available as Alpine wheels, fall back to slim.
Handling Large Model Files
A critical consideration for AI services: never embed large model files in Docker images.
Wrong approach (image becomes 4GB+):
# DON'T DO THIS
COPY models/model.bin /app/models/Correct approach (use volume mounts):
# Image stays small, models loaded at runtime
# No COPY for model filesRun with volume mount:
docker run -v $(pwd)/models:/app/models task-api:optimizedOutput:
INFO: Started server process [1]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000Your application code loads models from the mounted directory:
from pathlib import Path
models_dir = Path("/app/models")
model_path = models_dir / "model.bin"
@app.on_event("startup")
async def load_model():
if model_path.exists():
print(f"Loading model from {model_path}")
# Load your model hereBenefits:
- Image stays small (~115MB)
- Models can be updated without rebuilding
- Same model can be shared across container instances
- Model storage handled by Kubernetes PersistentVolumes in production
The Production Pattern
Here's the pattern to apply to any Python AI service:
# Stage 1: Build
FROM python:3.12-alpine AS builder
WORKDIR /app
# Install UV for fast dependency installation
RUN pip install uv
COPY requirements.txt .
RUN uv pip install --system --no-cache -r requirements.txt
# Stage 2: Runtime
FROM python:3.12-alpine
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
ENV PYTHONUNBUFFERED=1
# Copy application code (NOT model files)
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]When to deviate:
| Situation | Adjustment |
|---|---|
| C extensions fail on Alpine | Use python:3.12-slim instead |
| Need system libraries | Add RUN apk add --no-cache [packages] in builder |
| Security-critical production | Consider distroless/python3 |
| Debugging required | Keep Alpine (has shell) or slim |
Try With AI
Now apply what you've learned to your own service.
Prompt 1: Analyze Your Current Dockerfile
I have a Dockerfile for a FastAPI service. Analyze it for size optimization opportunities:
[paste your Dockerfile here]
Questions:
1. What's the estimated image size with current base image?
2. Would multi-stage builds help? Why or why not?
3. What specific changes would achieve 70%+ size reduction?What you're learning: How to evaluate an existing Dockerfile against optimization criteria. AI can identify which techniques apply to your specific dependencies.
Prompt 2: Generate Optimized Dockerfile
Create a multi-stage Dockerfile for my Python service with these requirements:
Dependencies: [list your requirements.txt]
Entry point: uvicorn main:app --host 0.0.0.0 --port 8000
Target: Under 150MB final image
Constraints:
- Use UV package manager (not pip)
- Use Alpine base image
- No model files in image (volume mount)
Include comments explaining each optimization.What you're learning: How to specify constraints clearly so AI generates a Dockerfile matching your exact requirements. The constraints prevent AI from defaulting to less optimized patterns.
Prompt 3: Debug Size Issues
My Docker image is larger than expected. Here's my Dockerfile and the output of docker history:
[paste Dockerfile]
docker history output:
[paste docker history output]
Questions:
1. Which layer is contributing the most unexpected size?
2. What's likely included that shouldn't be?
3. How would you modify the Dockerfile to fix this?What you're learning: How to use docker history output to diagnose size problems. AI can interpret layer contributions and suggest targeted fixes.
Verify your learning by building an optimized image for your Part 6 FastAPI agent and measuring the size reduction. Target: 70%+ reduction from a naive Dockerfile.
Reflect on Your Skill
You built a docker-deployment skill in Lesson 0. Test and improve it based on what you learned.
Test Your Skill
Using my docker-deployment skill, generate a multi-stage Dockerfile for image size optimization.
Does my skill generate separate build and runtime stages with proper COPY --from directives?Identify Gaps
Ask yourself:
- Did my skill include multi-stage build patterns?
- Did it handle UV package manager and Alpine base images?
Improve Your Skill
If you found gaps:
My docker-deployment skill is missing image optimization techniques.
Update it to include multi-stage builds, UV package manager usage, Alpine base images, and layer cache optimization strategies.Core Concept
Multi-stage builds separate dependency installation (build stage with tools) from runtime (minimal stage), combined with UV package manager and Alpine base images to achieve 70-90% image size reduction.
Key Mental Models
- Build vs Runtime Separation: Build stage has compilers and dev tools; runtime stage has only what's needed to run
- COPY --from: Transfer only necessary artifacts between stages; build stage is discarded
- Base Image Tradeoffs: Full (900MB) > slim (150MB) > alpine (50MB) with compatibility vs size tradeoff
- Layer Analysis: Use
docker historyto identify which layers contribute most to size
Critical Patterns
- Use
FROM python:3.12-slim AS builderthen freshFROM python:3.12-alpinefor runtime - Copy site-packages and bin from builder:
COPY --from=builder /usr/local/lib/python3.12/site-packages ... - Install UV for 10-100x faster package installation:
RUN pip install uv && uv pip install --system --no-cache - Combine RUN commands and clean caches in same layer
- Volume mount large files (>100MB) instead of embedding in image
AI Collaboration Keys
- Analyze existing Dockerfiles for size optimization opportunities
- Generate multi-stage Dockerfiles with specific constraints
- Debug unexpected image sizes using
docker historyoutput
Common Mistakes
- Embedding model files in image (use volume mounts instead)
- Using Alpine when packages need C compilation (fall back to slim)
- Leaving build tools and caches in runtime stage
Connections
- Builds on: Lesson 4 (debugging) with size measurement and validation
- Leads to: Lesson 6 (Production Hardening) for security and observability
Container Lifecycle and Debugging
Master container lifecycle management and debugging techniques using docker logs, docker exec, and docker inspect to troubleshoot containerized FastAPI services
Production Hardening
Secure and harden Docker containers for production deployment with environment variables, health checks, and non-root users