Lesson 6: Containers
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
libpqfor Postgres drivers orlibjpegfor 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.
[IMAGE PROMPT M1-8
Purpose: Show which layers of an environment a lockfile controls and which only a container controls.
Visual type: Layered stack diagram with two coverage brackets.
Prompt: A clean educational layered stack diagram. A vertical stack of five labelled horizontal bands, from bottom to top: "Operating system", "System libraries and drivers", "Python interpreter", "Python packages", "Your code". To the right of the stack, two vertical brackets run alongside it. The inner, shorter bracket spans the top three bands and is labelled "uv.lock controls this". The outer, taller bracket spans all five bands and is labelled "container controls this". A short caption beneath the stack reads "works on my machine lives in the bottom two bands".
Required elements: Five clearly labelled stacked bands in the stated order, two nested brackets of different lengths with their labels, the caption beneath.
Style: Clean educational illustration, professional, uncluttered, high contrast, flat vector, evenly sized bands.
Layout: Vertical stack on the left occupying most of the width, brackets aligned vertically on the right, caption centred below.
Text labels: "Operating system", "System libraries and drivers", "Python interpreter", "Python packages", "Your code", "uv.lock controls this", "container controls this", "works on my machine lives in the bottom two bands".
Aspect ratio: 4:3
Accessibility: Distinguish the two coverage spans by bracket length and text label rather than colour alone.
Avoid: Product logos, decorative icons, screenshots, tiny text, watermarks, clutter.
Alt text: 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.
END IMAGE PROMPT]
A minimal Dockerfile
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"][VOLATILE: the base image tag, the uv image reference, and the recommended way to install uv inside a container all change over time. Verify against current uv documentation before publishing.]
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:
.venv/
.git/
.env
__pycache__/
*.pycNote .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.
docker build -t recipe-extractor .
docker run --env-file .env recipe-extractor
[IMAGE PROMPT M1-9
Purpose: Show why copying dependency files before source code makes rebuilds fast, using Docker layer caching.
Visual type: Side-by-side comparison of two build layer stacks after a small code change.
Prompt: A clean educational comparison diagram with two vertical layer stacks side by side, each built from four stacked labelled blocks reading bottom to top. The left stack is headed "Dependencies copied first" and its blocks from bottom to top read "base image", "install uv", "install dependencies", "copy source". The bottom three blocks carry a small "cached" tag and the top block carries a "rebuilt" tag, with a timing caption beneath reading "rebuild: seconds". The right stack is headed "Source copied first" and its blocks from bottom to top read "base image", "install uv", "copy source", "install dependencies". Here the bottom two blocks carry "cached" tags while the top two carry "rebuilt" tags, with a timing caption beneath reading "rebuild: minutes". A shared note above both stacks reads "after editing one source file".
Required elements: Two four-block vertical stacks with the stated block order, cached and rebuilt tags on the correct blocks in each, timing captions beneath each stack, the shared note above.
Style: Clean educational illustration, professional, uncluttered, high contrast, flat vector, evenly sized blocks.
Layout: Two stacks side by side, each reading bottom to top, with a shared caption above and individual captions below.
Text labels: "Dependencies copied first", "Source copied first", "base image", "install uv", "install dependencies", "copy source", "cached", "rebuilt", "rebuild: seconds", "rebuild: minutes", "after editing one source file".
Aspect ratio: 4:3
Accessibility: Mark cached and rebuilt layers with explicit text tags rather than colour alone.
Avoid: Docker branding or logos, terminal screenshots, decorative elements, tiny text, watermarks.
Alt text: 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.
END IMAGE PROMPT]
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.