A poorly optimised Docker image can weigh 1 GB, expose secrets and start in 30 seconds. This tutorial shows how to get below 100 MB, secure secrets and speed up builds with multi-stage builds.
By default, a typical Python image can weigh between 800 MB and 1.2 GB. In production, this costs money in storage, network transfer and deployment time.
Definition: A Docker technique using multiple stages in a single Dockerfile, where only the final result is included in the production image.
Purpose: Drastically reduce image size by excluding build tools and non-essential development dependencies from production.
Why here: Compiling some Python packages (with C extensions) requires heavy tools. Multi-stage allows compilation in a heavy "builder" image, then copying only compiled results into a lightweight final image.
# โ PROBLEM 1: Base image too heavy (~1.2 GB)
FROM python:3.11
WORKDIR /app
# โ PROBLEM 2: Code copied before requirements
# โ every code change invalidates dependency cache
COPY . .
RUN pip install -r requirements.txt
# โ PROBLEM 3: No .dockerignore
# โ copies everything (.git, venv/, __pycache__) = +200 MB
# โ PROBLEM 4: pip cache in final image
# โ adds ~50 MB unused, never used in prod
CMD ["python", "main.py"]
# RESULT: Final image ~1.2 GB ๐ซ
# Before (slim) vs. After (alpine): 2.7x lighter
# python:3.11 โ ~1.2 GB (Full Debian, everything included)
# python:3.11-slim โ ~145 MB (Minimal Debian, essential tools)
# python:3.11-alpine โ ~55 MB (Alpine Linux, ultra-lightweight)
# RECOMMENDATION: For FastAPI/Flask APIs:
FROM python:3.11-slim # โ
Good compromise: lightweight (~145 MB) + compatible
# Alpine for special cases (if no C dependencies):
FROM python:3.11-alpine # โ ๏ธ Risky: scipy, numpy, psycopg2 can be complex to compile
The idea: use a heavy "builder" image to compile, then copy only the result into a lightweight final image.
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Stage 1: BUILDER โ installs dependencies
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Use complete image for compilation (build tools included)
FROM python:3.11-slim AS builder
WORKDIR /build
# Optimise pip: no cache, no version checking
ENV PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Copy requirements and install in /install
# This tree will be copied entirely to the final stage
COPY requirements.txt .
RUN pip install --prefix=/install -r requirements.txt
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Stage 2: RUNNER โ minimal final image
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Fresh new image, nothing from stage 1 (except what we copy)
FROM python:3.11-slim AS runner
# Create non-root user for security (see section 4)
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
# Copy ONLY compiled packages from builder
# Builder's pip cache is excluded, saving 50 MB
COPY --from=builder /install /usr/local
# Copy source code with correct ownership
COPY --chown=appuser:appuser . .
# Switch to non-root user for security
USER appuser
# Python environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
EXPOSE 8000
# Use JSON array form (exec form) for clean SIGTERM handling
# --workers 2: use 2 uvicorn workers for request parallelisation
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
Docker builds images layer by layer. If a layer changes, all following layers are rebuilt. Instruction order is crucial:
Definition: A Docker mechanism reusing unchanged layers during successive builds.
Purpose: Accelerate builds: instead of redoing each step, Docker skips steps where nothing changed.
Why here: Misunderstanding cache can multiply build time by 10. Instruction order determines cache hit rate.
# โ
GOOD ORDER: what changes rarely comes first
FROM python:3.11-slim
# 1. System packages (changes ~never) โ cache reused 99% of time
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/* # --no-install-recommends = -30 MB
# 2. Python dependencies (changes occasionally) โ reused 80% of time
COPY requirements.txt . # Copy ONLY requirements, not all code
RUN pip install --no-cache-dir -r requirements.txt
# 3. Source code (changes often) โ reused 20% of time
# Put last to avoid invalidating previous steps
COPY . .
# โ BAD ORDER: every code change invalidates cache
# COPY . . โ copy before requirements
# RUN pip install -r requirements.txt โ recalculated every commit!
# Result: 2 min per build instead of 30 sec
# โ NEVER DO THIS!!!
# ENV in Dockerfile remains in historical layers
# and are inspectable by anyone with image access
ENV SECRET_KEY=my-super-secret-hardcoded
ENV AWS_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE
RUN pip install awscli
# Secrets remain in history even after deletion:
# docker history my-image # shows all historical ENV
# In Dockerfile: code NOTHING hard-coded
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Run container with --env-file (external secrets)
# The .env file contains SECRET_KEY=xxxx, never committed to Git
docker run --env-file .env my-api:v1
# Create non-root user to isolate the application
FROM python:3.11-slim
# Create group and user app (no access to /etc/passwd)
RUN groupadd -r app && useradd -r -g app -d /app -s /sbin/nologin app
WORKDIR /app
COPY --chown=app:app . .
# Switch to non-root user BEFORE CMD
USER app
# If the app is compromised, attacker won't have root access
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Definition: A Docker instruction periodically verifying a container is operational by running a command (e.g: curl http://localhost:8000/health).
Purpose: Give orchestrators (Kubernetes, Docker Swarm, Cloud Run) info to automatically restart failed containers.
Why here: A container can be "started" (process active) but non-responsive (database not connected). HEALTHCHECK detects this distinction.
FROM python:3.11-slim AS runner
# ... (rest of Dockerfile) ...
# Define health check: Docker tests every 30 seconds
# --interval=30s: test frequency
# --timeout=10s: max time for curl to respond
# --start-period=40s: wait 40s before first test (warmup)
# --retries=3: mark unhealthy after 3 consecutive failures
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# -f: fail if server returns error HTTP code (!=200)
# || exit 1: return 1 on curl failure
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# See container status
docker ps
# STATUS: Up 2 minutes (healthy) โ ๐ข Container responsive
# STATUS: Up 2 minutes (unhealthy) โ ๐ด Problem detected, restart pending
# Inspect health check details
docker inspect my-api-container | grep -A 10 Health
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Stage 1: BUILDER
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
FROM python:3.11-slim AS builder
WORKDIR /build
# Optimise pip: no cache (saves 50 MB), no version check (faster)
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
# Copy ONLY requirements.txt to leverage cache on rebuilds
COPY requirements.txt .
# Install to /install for easy copy to runner stage
RUN pip install --prefix=/install -r requirements.txt
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Stage 2: RUNNER โ final image
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
FROM python:3.11-slim AS runner
# Security 1: create non-root user
# -r: system user (UID < 1000, no login shell)
# -s /sbin/nologin: impossible to login as app
RUN groupadd -r app && useradd -r -g app -d /app -s /sbin/nologin app
WORKDIR /app
# Metadata for traceability and documentation
LABEL maintainer="alderi@kamtchoua.com" \
version="1.0" \
description="Production-ready Python API with multi-stage"
# Copy ONLY compiled packages from builder
# Pip cache and build tools stay in builder stage
COPY --from=builder /install /usr/local
# Copy source code with correct ownership (non-root)
COPY --chown=app:app . .
# Environment variables for production
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app
# Health check: Docker tests if API responds
# Use urllib to avoid curl dependency
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
# Switch to non-root user BEFORE CMD
USER app
# Declare listened port (documentation, not actual binding)
EXPOSE 8000
# Start the application
# --workers 2: use 2 processes to parallelise requests
# Use exec form (JSON array) for clean SIGTERM signals
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
| Metric | Before (naive) | After (optimised) | Gain |
|---|---|---|---|
| Image size | 1.2 GB | ~95 MB | -92% ๐ |
| Push to registry | 4-5 min | 10-15 sec | 25x faster |
| Deployment (pull) | 2-3 min | 5-10 sec | 20x faster |
| Rebuild (code change) | 2 min (no cache) | 30 sec (cached layers) | 4x faster |
| Memory used | ~500 MB | ~100 MB | -80% |
| Security | โ Root user | โ Non-root | Security + |
| Observability | โ No health | โ HEALTHCHECK | Auto-healing |