Docker Multi-Stage Builds for Python: Smaller Images, Faster Deploys

· 10 min read · DevOps & Cloud

Reduce Python Docker images from 1.2 GB to 180 MB using multi-stage builds, layer caching, and security best practices.

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