Docker for Python: Images, Containers & the Dockerfile¶
What this lesson gives you
A container image packages your Python application with its exact runtime and dependencies, so it runs identically on a developer laptop, a CI runner, and a production server. Multi-stage builds and layer caching make images fast to build and small to ship.
Estimated time: 20 min read · Part: Docker & Kubernetes
Learning Objectives
After this lesson you will be able to: (1) explain the image/container distinction and the layered filesystem; (2) write a production-quality Dockerfile for a FastAPI service using multi-stage builds; (3) explain layer caching and arrange steps to maximize cache reuse; (4) describe the security benefits of running as a non-root user; (5) use docker compose for local multi-container development; (6) read any Dockerfile and identify security and efficiency issues.
Images, Containers, and Layers¶
A container image is an immutable, layered filesystem snapshot that packages an application and everything it needs to run: the OS libraries, the Python interpreter, the application code, and the dependencies. An image is built by executing a Dockerfile; each instruction adds a layer. A container is a running instance of an image — a lightweight, isolated process with its own filesystem view, network namespace, and process tree, all sharing the host kernel (no separate OS per container, as with virtual machines).
Image vs. Container: The Class vs. Instance Analogy
A Docker image is like a Python class definition — an immutable blueprint. A container is like an instance created from that class — a live, running process with mutable state. You can create many containers from the same image, just as you can instantiate many objects from the same class. The image never changes; containers come and go. When a container writes to the filesystem, the changes go into a writable layer on top of the read-only image layers — this is the Copy-on-Write mechanism that keeps images shareable across containers.
flowchart TB
subgraph Image ["Docker Image (immutable layers)"]
L1[Layer 1: Base OS - python:3.12-slim]
L2[Layer 2: System packages - curl, etc.]
L3[Layer 3: Python dependencies]
L4[Layer 4: Application code]
end
L1 --> L2 --> L3 --> L4
L4 --> C1[Container 1<br/>writable layer]
L4 --> C2[Container 2<br/>writable layer]
L4 --> C3[Container 3<br/>writable layer] Layer Caching: Arrange for Maximum Reuse¶
Docker caches each layer by hashing its instruction and the state of the filesystem when it ran. If nothing has changed, the cached layer is reused — dramatically speeding up subsequent builds. The key rule: order instructions from least-to-most frequently changed. Dependencies (rarely changed) should come before application code (frequently changed). If you copy your application code before installing dependencies, every code change invalidates the dependency layer cache and forces a full reinstall.
| Instruction order | Cache behavior on code change |
|---|---|
| COPY . . → RUN pip install (wrong) | Every code change invalidates the pip cache — full reinstall every build |
| COPY pyproject.toml . → RUN pip install → COPY . . (correct) | pip cache reused when only code changes; reinstall only when dependencies change |
Multi-Stage Builds¶
A multi-stage build uses multiple FROM instructions in a single Dockerfile. Each stage is an independent image; you copy only what you need into the final stage. This separates build tooling (compiler, test frameworks, dev dependencies) from the runtime image. The result is a dramatically smaller production image — a Python service that might be 800 MB with a full build environment becomes 150 MB with only the runtime. Smaller images are faster to push, faster to pull, and have a smaller attack surface.
# syntax=docker/dockerfile:1
# ── Stage 1: build (install dependencies) ─────────────────────────
FROM python:3.12-slim AS builder
WORKDIR /app
# Install uv for fast dependency installation
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# Copy ONLY the dependency files first — cache this layer
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-editable # install production deps only
# ── Stage 2: runtime (only what runs in production) ───────────────
FROM python:3.12-slim AS runtime
# Security: create a non-root user to run the application
RUN addgroup --system app && adduser --system --ingroup app app
WORKDIR /app
# Copy the installed virtual environment from the builder stage
COPY --from=builder /app/.venv /app/.venv
# Copy application source code (last, changes most often)
COPY src/ src/
# Switch to non-root user before running the application
USER app
# Configure the PATH to use the venv
ENV PATH="/app/.venv/bin:$PATH"
# Document the port the application listens on
EXPOSE 8000
# Healthcheck: container orchestrators use this to monitor readiness
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"
# The main process — PID 1 in the container
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
Never Run Containers as Root
By default, Docker containers run as root. If an attacker exploits a vulnerability in your application, they gain root access inside the container — and if the container shares mounts or has weak isolation, they may be able to escalate to the host. Always create a dedicated non-root user with adduser and switch to it with USER before the CMD. This is a one-line change with significant security impact. Many compliance frameworks (SOC 2, PCI-DSS) require it. Production Kubernetes environments often enforce it via Pod Security Standards.
version: "3.9"
services:
# The FastAPI application
api:
build:
context: .
target: runtime # use the runtime stage, not builder
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://postgres:password@db:5432/myapp
depends_on:
db:
condition: service_healthy # wait for DB healthcheck before starting
volumes:
- ./src:/app/src # mount source for live reload in dev
# PostgreSQL database for local development
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: password
POSTGRES_DB: myapp
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
# Build the image with a tag
docker build -t payments-api:dev .
# Run the container, mapping port 8000
docker run -p 8000:8000 payments-api:dev
# Run with an environment variable overridden
docker run -p 8000:8000 -e DATABASE_URL=... payments-api:dev
# Inspect image layers and sizes
docker image history payments-api:dev
# Start all compose services
docker compose up --build
# Output (abbreviated):
# [+] Building 12.3s (14/14) FINISHED
# [+] Running 2/2
# ✔ Container myapp-db-1 Healthy
# ✔ Container myapp-api-1 Started
# Check running containers
docker ps
# CONTAINER ID IMAGE STATUS PORTS
# a1b2c3d4e5f6 payments-api:dev Up 2 minutes 0.0.0.0:8000->8000/tcp
| Dockerfile instruction | Purpose | Security / efficiency note |
|---|---|---|
FROM python:3.12-slim | Minimal Debian base with Python | Use -slim or -alpine variants to minimize attack surface |
WORKDIR /app | Set working directory | Prefer explicit paths; avoid relying on default / |
COPY pyproject.toml . | Copy dep spec before source code | Enables layer caching — dep install is skipped if unchanged |
RUN uv sync --frozen | Install exact locked deps | --frozen ensures reproducibility; --no-dev excludes test tools |
COPY src/ src/ | Copy app code (most-changed) | Last position — cache invalidation affects only this layer |
USER app | Switch to non-root | Required for security; must come after all system setup |
HEALTHCHECK | Tell orchestrator if container is healthy | Docker and K8s use this for readiness detection |
CMD ["uvicorn", ...] | Process to run at startup | JSON array form avoids shell expansion; process becomes PID 1 |
Consulting Lens: Reading a Client's Dockerfile
When reviewing a client's containerization, look for these five issues: (1) Is it multi-stage? A single-stage build that includes compilers and test tools in the production image is needlessly large and vulnerable. (2) Are dependencies copied before source code? Reversed order destroys layer caching. (3) Is the container running as root? Check for a USER instruction. (4) Are secrets baked into the image via ENV? They will be visible in docker history and in any registry that stores the image. Secrets must be injected at runtime. (5) What base image? python:latest is large and tracks the cutting edge — surprising changes on every pull. Use a pinned version (python:3.12.3-slim). These five observations turn a Dockerfile review into a structured, actionable report.
Knowledge check
InterviewWhy should a production Dockerfile copy pyproject.toml and uv.lock and run the dependency install before copying the application source code?
- Because Docker requires dependency files to be present before any other files.
- Layer caching: if only source code changes (the common case), the dependency install layer is still valid and is reused — saving minutes per build. Copying source first would invalidate the cache on every code change.
- It doesn't matter — Docker evaluates all layers simultaneously.
- pyproject.toml must be in the root before the WORKDIR is set.
Answer
Layer caching: if only source code changes (the common case), the dependency install layer is still valid and is reused — saving minutes per build. Copying source first would invalidate the cache on every code change.
Docker builds images layer by layer. Each instruction produces a layer identified by a hash of the instruction and the filesystem state at that point. When a layer's hash matches the cache, Docker skips re-executing it. By placing the rarely-changed dependency files first, followed by the install command, and then the frequently-changed application code, you ensure that the expensive dependency installation is only re-run when the dependency spec actually changes — not on every commit. This can reduce build times from 3-5 minutes to 20-30 seconds for typical Python services.
Knowledge check
InterviewWhat are two security benefits of using a multi-stage Docker build?
- Multi-stage builds encrypt the container at rest and prevent container breakout.
- The production image contains only runtime components, not build tools or dev dependencies — reducing attack surface. And build artifacts (intermediate files, cached data) from the build stage are not included in the final image, preventing accidental secret exposure.
- Multi-stage builds require root access to the host, improving security.
- They prevent the use of environment variables in the container.
Answer
The production image contains only runtime components, not build tools or dev dependencies — reducing attack surface. And build artifacts (intermediate files, cached data) from the build stage are not included in the final image, preventing accidental secret exposure.
Multi-stage builds improve security in two ways: (1) Attack surface reduction — the production image doesn't contain compilers, test frameworks, or development packages that an attacker could exploit. Only the runtime (Python interpreter, application code, production dependencies) is present. (2) Build artifact isolation — secrets or sensitive files used during the build (e.g., a GitHub token to download a private package) exist only in the builder stage and are never copied to the runtime stage. They don't appear in the final image layers, so they can't be extracted from a stolen image.
Exercise
You are reviewing a client's Dockerfile. Identify all the problems in the snippet below and write an improved version:
FROM python:latest
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
EXPOSE 8000
CMD uvicorn main:app --host 0.0.0.0 --port 8000
Show solution
Problems identified:
1. FROM python:latest — unpinned; image may change unexpectedly on every pull;
no -slim variant means the image includes many unused packages.
2. COPY . . before pip install — every code change invalidates the pip cache.
3. No multi-stage build — all of pip, wheel build tools, and any build artifacts
end up in the production image.
4. No USER instruction — container runs as root.
5. CMD uvicorn ... (shell form) — shell form starts a shell process as PID 1;
the app becomes a subprocess that doesn't receive signals cleanly.
Improved Dockerfile:
FROM python:3.12-slim AS builder
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
FROM python:3.12-slim AS runtime
RUN addgroup --system app && adduser --system --ingroup app app
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY src/ src/
USER app
ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
Key Takeaways
- An image is an immutable layered filesystem; a container is a running instance with an added writable layer.
- Copy dependency specs before source code — this maximizes layer cache reuse.
- Multi-stage builds keep production images small by excluding build tools and dev deps.
- Always run as a non-root user: one
adduser+USERinstruction for significant security gain. - Use the JSON array CMD form so the application is PID 1 and receives signals cleanly.
- Review any Dockerfile against: multi-stage?, cache order correct?, root user?, secrets baked in?, base image pinned?