Home Python Docker for Python: Multi-Stage Builds and Best Practices
Intermediate 4 min · July 14, 2026

Docker for Python: Multi-Stage Builds and Best Practices

Learn how to optimize Python Docker images using multi-stage builds.

N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Drawn from code that ran under real load.

Follow
Production
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
Before you start⏱ 15-20 min read
  • Basic knowledge of Docker (images, containers, Dockerfiles)
  • Python programming experience
  • Familiarity with pip and virtual environments
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Multi-stage builds use multiple FROM statements to separate build and runtime environments, resulting in smaller images.
  • Best practices include using slim base images, .dockerignore, and non-root users.
  • Production incidents often involve bloated images due to including build tools.
  • Debugging in production requires minimal images with proper logging and health checks.
✦ Definition~90s read
What is Docker for Python?

Multi-stage builds are a Docker feature that lets you use multiple FROM statements in a single Dockerfile to separate build and runtime environments, resulting in smaller and more secure Python images.

Imagine building a house.
Plain-English First

Imagine building a house. You need tools like hammers and saws during construction, but once the house is finished, you don't want to live with those tools cluttering your living room. Multi-stage builds let you build your Python app in a 'workshop' container with all the tools, then copy only the final product into a clean 'living room' container.

You've written a Python application that works perfectly on your local machine. But when you try to containerize it with Docker, the image is 1.2GB! That's not just a storage problem—it slows down deployments, increases bandwidth costs, and expands the attack surface. In production, every megabyte matters. This is where multi-stage builds come to the rescue.

Multi-stage builds are a Docker feature that allows you to use multiple FROM statements in a single Dockerfile. Each FROM begins a new stage, and you can selectively copy artifacts from one stage to another. This means you can use a heavy base image with all build tools (compilers, headers, etc.) to compile dependencies, then copy only the necessary runtime files into a minimal final image.

In this tutorial, you'll learn how to apply multi-stage builds to Python applications, following best practices that production teams use. We'll cover real-world debugging techniques, a cautionary production incident, and common mistakes. By the end, you'll be able to reduce your Python Docker images by 80-90% while maintaining security and performance.

What Are Multi-Stage Builds?

Multi-stage builds are a Docker feature introduced in Docker 17.05. They allow you to use multiple FROM statements in a single Dockerfile, each starting a new stage. You can copy artifacts from one stage to another, discarding everything you don't need in the final image. This is particularly useful for compiled languages or when you need build tools that are not required at runtime.

For Python, the typical pattern is
  • Build stage: Use a full Python image with build tools (gcc, python3-dev, etc.) to compile dependencies that require C extensions (e.g., numpy, pandas, cryptography).
  • Runtime stage: Use a minimal Python image (slim or alpine) and copy only the compiled wheels or installed packages from the build stage.

This approach can reduce image size by 80% or more. For example, a Flask app with numpy might go from 1.2GB to 150MB.

DockerfileDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# Build stage
FROM python:3.9-slim AS builder

WORKDIR /app

# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    python3-dev \
    && rm -rf /var/lib/apt/lists/*

# Copy requirements and install dependencies
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

# Runtime stage
FROM python:3.9-slim

WORKDIR /app

# Copy only the installed packages from builder
COPY --from=builder /root/.local /root/.local

# Copy application code
COPY . .

# Make sure scripts in .local are usable
ENV PATH=/root/.local/bin:$PATH

CMD ["python", "app.py"]
💡Use --user flag
📊 Production Insight
In production, always use a specific Python version (e.g., python:3.9-slim) rather than 'latest' to ensure reproducible builds.
🎯 Key Takeaway
Multi-stage builds let you separate build and runtime environments, drastically reducing image size.

Setting Up a Multi-Stage Dockerfile for Python

Let's walk through a complete example. Suppose you have a Python Flask app that uses numpy for some computation. Your requirements.txt includes flask, numpy, and gunicorn.

`` __pycache__ .pyc .git .env .md ``

```dockerfile # Stage 1: Build dependencies FROM python:3.9-slim AS builder

WORKDIR /app

# Install system build dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ python3-dev \ libffi-dev \ && rm -rf /var/lib/apt/lists/*

# Create a virtual environment and install dependencies RUN python -m venv /venv ENV PATH="/venv/bin:$PATH"

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

# Stage 2: Runtime FROM python:3.9-slim

WORKDIR /app

# Copy virtual environment from builder COPY --from=builder /venv /venv ENV PATH="/venv/bin:$PATH"

# Copy application code COPY . .

# Expose port and run EXPOSE 8000 CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "app:app"] ```

Notice we use a virtual environment. This is cleaner than --user because it isolates dependencies and avoids PATH issues. The virtual environment is created in /venv and copied entirely to the final stage.

DockerfileDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Build stage
FROM python:3.9-slim AS builder

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    python3-dev \
    libffi-dev \
    && rm -rf /var/lib/apt/lists/*

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

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

# Runtime stage
FROM python:3.9-slim

WORKDIR /app

COPY --from=builder /venv /venv
ENV PATH="/venv/bin:$PATH"

COPY . .

EXPOSE 8000
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "app:app"]
🔥Virtual Environment Benefits
🎯 Key Takeaway
Use a virtual environment in the build stage and copy it to the runtime stage for clean dependency management.

Best Practices for Python Docker Images

Beyond multi-stage builds, follow these best practices to create production-ready Python Docker images:

  1. Use slim or alpine base images: 'python:3.9-slim' is based on Debian slim, which is much smaller than the full image. Alpine is even smaller but may have compatibility issues with some C extensions.
  2. Minimize layers: Combine RUN commands to reduce layers. Use '&&' to chain commands and clean up in the same layer (e.g., remove apt cache).
  3. Use .dockerignore: Exclude unnecessary files like .git, __pycache__, .env, and test files to reduce build context and image size.
  4. Run as non-root user: Create a dedicated user in the runtime stage to improve security.
  5. Use specific tags: Avoid 'latest'; pin to a specific Python version and patch (e.g., python:3.9.18-slim).
  6. Leverage Docker layer caching: Order your Dockerfile from least to most frequently changing. Copy requirements.txt and install dependencies before copying the rest of the code.
  7. Scan for vulnerabilities: Use 'docker scan' or third-party tools to check for known vulnerabilities in your base image and dependencies.
DockerfileDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# Best practices example
FROM python:3.9-slim AS builder

WORKDIR /app

# Install build deps and create venv
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    python3-dev \
    && rm -rf /var/lib/apt/lists/* \
    && python -m venv /venv

ENV PATH="/venv/bin:$PATH"

# Copy only requirements first to leverage caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Runtime stage
FROM python:3.9-slim

# Create non-root user
RUN groupadd -r appuser && useradd -r -g appuser appuser

WORKDIR /app

COPY --from=builder /venv /venv
ENV PATH="/venv/bin:$PATH"

# Copy application code
COPY --chown=appuser:appuser . .

USER appuser

EXPOSE 8000
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "app:app"]
⚠ Alpine Caution
📊 Production Insight
In production, always run containers as a non-root user to limit potential damage from security breaches.
🎯 Key Takeaway
Follow best practices like using slim images, non-root users, and layer caching to create secure and efficient images.

Handling System Dependencies in Multi-Stage Builds

Some Python packages require system libraries at runtime (e.g., libpq for psycopg2, libssl for cryptography). In a multi-stage build, you need to install these runtime dependencies in the final stage, but you can keep them minimal.

For example, if you use psycopg2, you need libpq5 at runtime. Instead of installing the full postgresql-dev in the final stage, install only the runtime library:

```dockerfile # Build stage FROM python:3.9-slim AS builder

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

# ... install dependencies

# Runtime stage FROM python:3.9-slim

RUN apt-get update && apt-get install -y --no-install-recommends \ libpq5 \ && rm -rf /var/lib/apt/lists/*

# ... rest ```

This way, you only include the necessary shared libraries, not the development headers.

Another approach is to use the '--copy-links' option when copying from the build stage, but that's less common.

DockerfileDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Build stage
FROM python:3.9-slim AS builder

WORKDIR /app

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 -r requirements.txt

# Runtime stage
FROM python:3.9-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY --from=builder /usr/local/lib/python3.9/site-packages /usr/local/lib/python3.9/site-packages
COPY . .

CMD ["python", "app.py"]
💡Check Runtime Libraries
📊 Production Insight
Use tools like 'docker-slim' to automatically analyze and shrink images further.
🎯 Key Takeaway
Install only runtime libraries in the final stage, not development headers.

Debugging Multi-Stage Builds

When something goes wrong, debugging multi-stage builds can be tricky because intermediate stages are not saved by default. Here are techniques:

  1. Build with --target: You can build a specific stage to inspect it: docker build --target builder -t myapp:builder . Then run a shell in that stage: docker run -it myapp:builder /bin/bash.
  2. Use docker history: After building the final image, run docker history <image> to see layer sizes and commands. This helps identify which layers are bloated.
  3. Check installed packages: In the final stage, run pip list to verify only runtime dependencies are present.
  4. Use buildkit: Enable BuildKit by setting DOCKER_BUILDKIT=1 for better output and debugging. You can also use --progress=plain to see detailed logs.
  5. Inspect intermediate containers: If a build fails, Docker leaves the intermediate container. You can commit it and inspect: docker commit <container-id> debug-image.
debug.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
# Build a specific stage for debugging
export DOCKER_BUILDKIT=1
docker build --target builder -t myapp:builder .
docker run -it myapp:builder /bin/bash

# Check layer sizes
docker history myapp:latest

# Inspect failed build container
docker ps -a | grep myapp
docker commit <container-id> debug-image
docker run -it debug-image /bin/bash
🔥BuildKit Features
📊 Production Insight
In CI/CD, use 'docker build --cache-from' to reuse cached layers from previous builds, speeding up pipelines.
🎯 Key Takeaway
Use --target to build intermediate stages for debugging, and leverage docker history to analyze layer sizes.

Advanced: Using Docker Compose with Multi-Stage Builds

In a microservices architecture, you often have multiple services. Docker Compose can build each service using multi-stage Dockerfiles. You can also define build arguments to control stages.

``yaml version: '3.8' services: web: build: context: . target: runtime # Build only the runtime stage ports: - "8000:8000" worker: build: context: . target: runtime command: celery -A tasks worker ``

This ensures both services use the same base image and dependencies, but you can also have separate Dockerfiles for different services.

You can also use build arguments to conditionally include development tools:

```dockerfile ARG BUILD_ENV=production

FROM python:3.9-slim AS base ...

FROM base AS development RUN apt-get install -y vim

FROM base AS production ... ```

``yaml services: web: build: args: BUILD_ENV: development ``

docker-compose.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
version: '3.8'
services:
  web:
    build:
      context: .
      target: runtime
    ports:
      - "8000:8000"
  worker:
    build:
      context: .
      target: runtime
    command: celery -A tasks worker
💡Use .dockerignore with Compose
📊 Production Insight
Use build arguments to switch between development and production stages without duplicating Dockerfiles.
🎯 Key Takeaway
Docker Compose can target specific stages, enabling efficient multi-service builds.

Security Considerations

Security is paramount in production. Multi-stage builds help by reducing the attack surface—fewer packages mean fewer vulnerabilities. But there's more:

  1. Use official images: Always pull from official repositories (e.g., python, debian) to ensure they are maintained and scanned.
  2. Regularly update base images: Set up automated builds to rebuild images when base images are updated.
  3. Scan images: Integrate tools like Trivy, Clair, or Docker Scan into your CI/CD pipeline.
  4. Avoid running as root: As shown earlier, create a non-root user.
  5. Use secrets properly: Never hardcode secrets in Dockerfiles. Use Docker secrets or build args with care (build args are visible in image history).
  6. Remove unnecessary files: In the build stage, clean up apt cache and temporary files in the same RUN command to avoid leaving them in layers.
  7. Use read-only root filesystem: When running containers, mount volumes for writable directories and set --read-only flag.
DockerfileDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# Security-focused Dockerfile
FROM python:3.9-slim AS builder

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    python3-dev \
    && rm -rf /var/lib/apt/lists/* \
    && python -m venv /venv

ENV PATH="/venv/bin:$PATH"

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

FROM python:3.9-slim

RUN groupadd -r appuser && useradd -r -g appuser appuser \
    && apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY --from=builder /venv /venv
ENV PATH="/venv/bin:$PATH"

COPY --chown=appuser:appuser . .

USER appuser

EXPOSE 8000

# Use exec form for proper signal handling
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "app:app"]
⚠ Build Args Are Visible
📊 Production Insight
Implement a policy to rebuild images at least monthly to incorporate security patches.
🎯 Key Takeaway
Security is a layered approach: minimal base, non-root user, regular scans, and proper secret management.
● Production incidentPOST-MORTEMseverity: high

The 1.2GB Image That Broke the CI Pipeline

Symptom
CI/CD pipeline timed out when pushing Docker images to registry; developers couldn't pull images due to disk space.
Assumption
The team assumed the image size was normal for a Python app with machine learning dependencies.
Root cause
The Dockerfile used a single stage with 'python:3.9' as base, installed build tools (gcc, g++) to compile numpy and scipy, and never removed them. The final image included all build artifacts and cached package downloads.
Fix
Refactored to a multi-stage build: first stage used 'python:3.9-slim' with build dependencies, compiled wheels, then copied only the wheels and app code into a final stage based on 'python:3.9-slim' without build tools.
Key lesson
  • Always use multi-stage builds to separate build and runtime environments.
  • Use slim or alpine base images for the final stage.
  • Leverage pip's wheel cache and install only runtime dependencies.
  • Include a .dockerignore to exclude unnecessary files.
  • Regularly audit image sizes with 'docker history'.
Production debug guideSymptom to Action4 entries
Symptom · 01
Image too large (>500MB)
Fix
Check layers with 'docker history <image>'. Identify stages with large files. Refactor to multi-stage build.
Symptom · 02
Build fails due to missing dependencies
Fix
Ensure build stage has all required system packages. Use 'apt-get update && apt-get install' in build stage only.
Symptom · 03
Runtime error: module not found
Fix
Verify that the final stage copies the correct site-packages or use 'pip install --target' to a specific directory.
Symptom · 04
Security vulnerability in base image
Fix
Use official Python images with security updates. Scan with 'docker scan' or Trivy.
★ Quick Debug Cheat SheetCommon Docker issues and immediate fixes.
Image too large
Immediate action
Check layer sizes
Commands
docker history <image>
docker image inspect <image> | jq '.[].RootFS.Layers'
Fix now
Use multi-stage build with slim base
Build fails with 'command not found'+
Immediate action
Check if build tools are installed
Commands
docker run --rm <build-stage> which gcc
docker run --rm <build-stage> apt-get list --installed
Fix now
Add 'apt-get install build-essential' in build stage
Module not found at runtime+
Immediate action
Check installed packages in final stage
Commands
docker run --rm <final-image> pip list
docker run --rm <final-image> python -c "import <module>"
Fix now
Copy site-packages from build stage or use 'pip install --no-cache-dir' in final stage
FeatureSingle-Stage BuildMulti-Stage Build
Image sizeLarge (includes build tools)Small (only runtime files)
Build timeFaster (no copying between stages)Slightly slower (extra copy step)
SecurityHigher attack surfaceLower attack surface
ComplexitySimpleModerate
CachingGoodGood with BuildKit
⚙ Quick Reference
3 commands from this guide
FileCommand / CodePurpose
DockerfileFROM python:3.9-slim AS builderWhat Are Multi-Stage Builds?
debug.shexport DOCKER_BUILDKIT=1Debugging Multi-Stage Builds
docker-compose.ymlversion: '3.8'Advanced

Key takeaways

1
Multi-stage builds drastically reduce Python Docker image size by separating build and runtime environments.
2
Use slim base images, .dockerignore, and non-root users for security and efficiency.
3
Debug builds using --target and docker history; leverage BuildKit for better performance.
4
Install only runtime system libraries in the final stage, not development headers.
5
Regularly scan images for vulnerabilities and rebuild to incorporate security patches.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What are multi-stage builds in Docker and why are they useful for Python...
Q02SENIOR
How do you handle system dependencies like libpq in a multi-stage build?
Q03SENIOR
What are the security benefits of using multi-stage builds?
Q04SENIOR
How can you debug a multi-stage build when it fails?
Q01 of 04SENIOR

What are multi-stage builds in Docker and why are they useful for Python applications?

ANSWER
Multi-stage builds allow you to use multiple FROM statements in a Dockerfile, each representing a stage. You can copy artifacts from one stage to another, discarding the rest. For Python, this means you can compile dependencies in a stage with build tools, then copy only the compiled packages into a minimal runtime image, reducing image size significantly.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between multi-stage builds and using a single stage with apt-get clean?
02
Can I use multi-stage builds with Alpine-based Python images?
03
How do I copy only specific files from the build stage?
04
What is the best base image for Python production?
05
How can I reduce image size further after multi-stage builds?
N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Drawn from code that ran under real load.

Follow
Verified
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
🔥

That's Advanced Python. Mark it forged?

4 min read · try the examples if you haven't

Previous
asyncio Patterns: Queues, Semaphores, Timeouts, and Cancellation
33 / 35 · Advanced Python
Next
Python Protocols: Structural Subtyping and Duck Typing