๐Ÿณ Docker ยท Intermediate

Optimise Docker images for production

โฑ 25 minutes ๐Ÿ“ฆ Docker 24+ ๐ŸŽฏ Production-ready

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.

The problem: oversized images

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.

๐Ÿ“– Term: Multi-stage build

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.

Naive Dockerfile (to avoid)
# โŒ 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 ๐Ÿšซ
This Dockerfile combines all antipatterns: heavy image, no cache optimisation, no file management. Result: massive image and slow builds. Following sections fix each problem.

1. Choose the right base image

Base image size comparison
# 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
slim saves 1 GB while keeping essential tools (gcc, curl). Alpine saves 90 MB more but lacks C headers needed for some packages. For 90% of APIs, slim is the sweet spot: lightweight, compatible, and one simple choice.
Why not the full image? python:3.11 includes C compilers, apt (package manager), and many non-essential utilities. These tools double the size without providing production value. slim removes them but keeps what's truly necessary.

2. Multi-stage builds โ€” the key technique

The idea: use a heavy "builder" image to compile, then copy only the result into a lightweight final image.

Multi-stage Dockerfile (production)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# 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"]
Multi-stage builds work in two phases: 1. Stage builder: installs pip and compiles packages. Heavy but temporary. 2. Stage runner: starts fresh, copies only compiled packages. Builder is completely forgotten after build. The key is COPY --from=builder: it establishes a "bridge" copying only /install, automatically excluding pip cache and builder tools.
Without multi-stage, the final image would contain C compilers, pip cache, and all builder tools (~1 GB). With multi-stage, only compiled packages (~95 MB) are included. That's 92% savings! Paradoxically, both images (builder and runner) build faster in parallel than a single monolithic one.
Final image ~95 MB vs. 1.2 GB traditionally. Savings: -92% size, -3 sec deployment, -30% memory usage.

3. Optimise layer cache

Docker builds images layer by layer. If a layer changes, all following layers are rebuilt. Instruction order is crucial:

๐Ÿ“– Term: Build cache

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.

Optimal instruction order
# โœ… 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
Docker uses a SHA256 hash of content + instructions to decide if a layer can be reused. If the hash changes, cache is invalidated. By placing frequent changes (source code) last, we maximise layer reuse for stable parts (system, dependencies).
Every code modification recompiles all dependencies if code is copied first. That's wasteful: pip only runs if requirements.txt changes. By copying requirements alone first, we can reuse the "pip install" layer between code commits.

4. Security: non-root user + secret management

โŒ Anti-pattern: hard-coded secrets
# โŒ 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
ENV coded in Dockerfile remains in image metadata. "docker history" or "docker inspect" reveal them. Worse, Docker layers are immutable: even if deleted later, they remain accessible. Never hard-code secrets.
โœ… Good practice 1: Runtime secrets
# 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
Secrets are passed at runtime via --env-file or -e, never hard-coded. This completely separates configuration (secret) from image (public). The image is "blank"; each deployment injects its own secrets.
โœ… Good practice 2: Non-root user
# 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"]
By default, containers run as root (UID 0), which is dangerous. If code has a vulnerability, attacker gains root access to the container. By creating a non-root user and using USER app, we limit damage: attacker can't read /etc/shadow or modify system files.

5. Built-in healthcheck

๐Ÿ“– Term: HEALTHCHECK

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.

Dockerfile with HEALTHCHECK
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"]
The HEALTHCHECK CMD runs periodically inside the container. If it returns 0, status is "healthy". If it fails 3 times in a row, Docker marks it "unhealthy". Orchestrators see this status and can auto-restart. start-period=40s gives warmup time (prevents marking unhealthy during startup).
Terminal โ€” check status
# 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
docker ps shows health check status. Orchestrators regularly check this and restart unhealthy containers. Without HEALTHCHECK, Docker only sees "process running" not "API responds to requests".
A "running" but non-responsive container is a dangerous zombie state. Users get timeouts instead of responses. HEALTHCHECK lets orchestrators detect and fix this automatically without manual intervention.

6. Final production Dockerfile

Complete production Dockerfile (all best practices)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# 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"]
This Dockerfile combines all best practices: multi-stage (reduces size), optimised cache (instruction order), security (non-root, no secrets), health (HEALTHCHECK), documentation (LABEL). Final image ~95 MB includes everything needed for production and nothing more.
Each decision here has a reason: slim for weight, multi-stage for builder exclusion, USER app for security, HEALTHCHECK for observability, --workers for scalability. Together, they create a production-ready image: small, secure, and observable.

Summary of gains

Before vs. After: concrete benefits
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
The numbers aren't just cosmetic: a 12x smaller image means 12x less deployment time, less registry load, less bandwidth, and less server memory. Orchestrating 100+ containers, savings multiply exponentially.