CoursePython · Environment, Tooling, and Version Control · part 6 of 79
Part 6 · Environment, Tooling, and Version Control

Lesson 6: Containers

3 min read·9 Sept 2026

Why "works on my machine" is a dependency problem

You have pinned Python and every package with hashes. Your colleague still cannot run the project. Why?

Because some things live outside Python entirely:

  • System libraries that packages link against, such as libpq for Postgres drivers or libjpeg for image handling
  • Compilers and build tools needed for packages that have no prebuilt wheel for your platform
  • CUDA drivers and versions, which matter enormously for GPU work
  • The operating system itself, and its version

"Works on my machine" is not stubbornness. It is an accurate report that the machine is part of the dependency set, and until now it was the only part not captured in the repository.

A container captures it. You describe the whole environment, meaning the base operating system, system packages, Python, your dependencies, and your code, as a file. That description builds into an image that runs identically anywhere the container runtime runs.

This completes the picture from Lesson 1. The lockfile closed variation sources one and two. The container closes the third.

Layered stack of operating system, system libraries, Python interpreter, Python packages, and application code, with a shorter bracket showing that a lockfile controls the top three layers and a longer bracket showing that a container controls all five.

A minimal Dockerfile

bash
FROM python:3.12-slim

# Install uv by copying its binary from the official image
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

WORKDIR /app

# Copy dependency files first, on their own, so this layer caches
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-dev

# Now copy the source, which changes far more often
COPY src/ ./src/

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

CMD ["python", "-m", "recipe_extractor"]

Line by line:

FROM python:3.12-slim selects the base image. The slim variant is a smaller Debian build and a good default. The full image is large. The alpine variant is smaller still but uses a different C library, which causes compiled Python packages to build from source, often slowly and sometimes not at all.

COPY --from=... pulls the uv binary from its official image rather than installing it with a script. This is fast and version controlled.

COPY pyproject.toml uv.lock ./ placed before COPY src/ is the single most important thing in the file. Docker caches each instruction as a layer and reuses cached layers when nothing that feeds them has changed. Dependencies change rarely and your source changes constantly. Installing dependencies before copying source means an ordinary code edit rebuilds only the last two layers instead of reinstalling everything. Reversing these two lines can turn a five-second rebuild into a three-minute one.

uv sync --locked --no-dev installs exactly the lockfile, fails if it is stale, and skips development tools. A production image should not contain pytest.

ENV PATH=... puts the virtual environment first on the path, so python means the project's interpreter.

Add a .dockerignore so the build context stays small and local artifacts do not leak in:

text
.venv/
.git/
.env
__pycache__/
*.pyc

Note .env in that list. Secrets are injected at run time and never baked into an image, because an image is a distributable artifact and anything inside it travels wherever it goes.

Build and run.

bash
docker build -t recipe-extractor .
docker run --env-file .env recipe-extractor
Comparison of two Docker layer stacks after editing one source file, showing that copying dependency files before source keeps three layers cached and rebuilds in seconds, while copying source first invalidates the dependency install layer and rebuilds in minutes.

When containers are worth it

Reach for a container when you are deploying a service, when you have GPU or CUDA dependencies, when you have non-Python system requirements, when onboarding people onto a complex project, or when CI must match production exactly.

Do not reach for one by reflex for a solo script, a pure-Python library with no system dependencies, or fast iterative work where the rebuild loop slows you down. uv plus a lockfile is genuinely enough for a large share of projects, and adding containers where they are not needed adds real friction. Add them at the point where the environment has escaped Python's control, because that is when they start earning their cost.