Docker Multi-Stage Builds for Python: Smaller Images, Faster Deploys
Your Python Docker image is 1.2 GB. It takes 8 minutes to build, 4 minutes to push, and every deploy feels like waiting for a download from 2005. The application itself is 50 MB of code and dependencies. The rest is build tools, compilers, and OS packages you do not need at runtime.
Multi-stage builds solve this by separating the build environment from the runtime environment. The final image contains only what the application needs to run.
The Problem
A typical Python Dockerfile looks like this:
FROM python:3.12
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0"]
This image includes:
- The full Python distribution (200+ MB)
- pip, setuptools, wheel (50+ MB)
- GCC and build headers for compiled dependencies (300+ MB)
- Your actual application (50 MB)
Result: a 1.2 GB image when you only need 200 MB at runtime.
Multi-Stage Solution
# Stage 1: Build
FROM python:3.12-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Stage 2: Runtime
FROM python:3.12-slim
WORKDIR /app
# Copy only installed packages from builder
COPY --from=builder /install /usr/local
# Copy application code
COPY . .
# Non-root user
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
The builder stage installs all dependencies including compiled ones that need GCC. The runtime stage copies only the installed packages — no compiler, no build headers, no pip cache.
Result: 180 MB instead of 1.2 GB.
Layer Caching Strategy
Docker caches each layer. If a layer has not changed, Docker reuses the cached version. Order your COPY statements from least-changed to most-changed:
# This changes rarely — cached most of the time
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# This changes on every commit — rebuilt every time
COPY . .
If you copy the entire codebase before installing requirements, every code change invalidates the pip install cache. Separating the requirements file means dependencies are only reinstalled when requirements.txt changes.
Poetry and Multi-Stage
If you use Poetry for dependency management:
FROM python:3.12-slim AS builder
RUN pip install poetry==1.8.2
WORKDIR /app
COPY pyproject.toml poetry.lock ./
# Export to requirements.txt and install
RUN poetry export -f requirements.txt --output requirements.txt --without-hashes
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
RUN useradd --create-home appuser
USER appuser
CMD ["uvicorn", "main:app", "--host", "0.0.0.0"]
Poetry itself is not needed at runtime. Export to requirements.txt in the build stage, install with pip, and the runtime image never knows Poetry exists.
.dockerignore
Without a .dockerignore, Docker copies everything — node_modules, .git, __pycache__, test files, documentation:
.git
.gitignore
__pycache__
*.pyc
.env
.venv
tests/
docs/
docker-compose*.yml
*.md
.mypy_cache
.pytest_cache
This reduces the build context size and prevents secrets from leaking into the image.
Health Checks
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
Docker and orchestrators use health checks to know when a container is ready to receive traffic. Without one, traffic is routed to the container as soon as the process starts, even if the application is still initializing database connections.
Security: Non-Root User
RUN useradd --create-home appuser
USER appuser
Running as root inside a container means a container escape gives the attacker root on the host. Always run as a non-root user in production images.
Size Comparison
| Approach | Image Size |
|---|---|
python:3.12 (no multi-stage) |
1.2 GB |
python:3.12-slim (no multi-stage) |
450 MB |
Multi-stage with slim |
180 MB |
Multi-stage with alpine |
95 MB |
Alpine images are smaller but can cause issues with compiled Python dependencies that expect glibc. Use slim unless you have a specific reason for Alpine.
Takeaways
Multi-stage builds separate what you need to build from what you need to run. The build stage can be as large and complex as necessary. The runtime stage should be minimal — your application code, its dependencies, and nothing else.
Smaller images mean faster pulls, faster deploys, smaller attack surface, and lower storage costs. For a Python FastAPI application, multi-stage builds typically reduce image size by 80%.