📖 Lecture — Containers, Multi-Stage Builds, and Reproducible Python Environments

By now your capstone project has a model, pipeline, or agent that runs on your machine. This week's job is to make it run identically everywhere else — a teammate's laptop, a CI runner, a cloud VM. The tool for that is the container. A container packages your application with everything it needs to run: the language runtime, system libraries, your code, and a locked dependency list. Because it carries its own environment instead of depending on whatever's installed on the host, it runs the same way regardless of where it's launched. That's the reproducibility and dev/prod parity this whole certificate has built toward — the image you test locally is the exact image that runs in production. No "works on my machine." Multi-stage builds are the single highest-leverage optimization you can make to a Dockerfile. A naive Dockerfile installs compilers, build tools, dev headers, and source code into one image and ships all of it to production — even though none of that is needed once the app is built. A multi-stage build fixes this with more than one FROM statement in the same Dockerfile:

Stage Base image Purpose What leaves the image
builder Full build image (e.g. python:3.12) Installs build tools, resolves/compiles dependencies Nothing — this stage is discarded
runtime Minimal base (e.g. python:3.12-slim) Copies over only the installed packages and app code This is the image you ship

The final FROM becomes your shipped image; earlier stages are copied in selectively with COPY --from=builder. The effect is dramatic: images that would otherwise be hundreds of megabytes routinely shrink to tens of megabytes. Smaller images pull and start faster, and — just as important — have a far smaller attack surface, since no compiler or dev-tool shell sits in production waiting to be abused if the container is ever compromised. Use uv inside the Dockerfile to make the Python side reproducible and fast. Instead of a loose pip install -r requirements.txt, COPY your pyproject.toml and uv.lock into the builder stage and run uv sync --frozen. The --frozen flag refuses to update the lockfile — it installs exactly the versions your team tested, every time, on every machine. Because Docker layer caching means the dependency-install layer only reinvalidates when uv.lock changes, builds stay fast even as the project grows. This week's lab ties it together: a Dockerfile for your capstone's core service (likely FastAPI wrapping your model or pipeline), plus a docker-compose.yml that brings up your full local stack — API and datastore — with one command. Correcting three common misconceptions:

  1. "Bake config or secrets into the image at build time." This breaks "deploy what you tested" — the image promoted from dev to production must be the same image, byte-for-byte. Environment-specific values (database URLs, API keys) belong outside the image: inject them at runtime via environment variables, an .env file read by Compose, or a secrets manager. Never bake secrets into a build stage.
  2. "Containers run as root by default, so that's fine." A compromised root process has root-equivalent reach. Add an explicit non-root USER (create one with useradd, then USER appuser before CMD), avoid --privileged, and grant only the capabilities the service actually needs — for a FastAPI prediction endpoint, essentially none.
  3. "Pulling :latest is convenient, and shipping build tools in the final image doesn't hurt." :latest is a moving target that can silently swap in a breaking version. Pin explicit tags (e.g. python:3.12.5-slim). And shipping compilers or source in the runtime image only bloats size and attack surface — multi-stage builds are exactly what prevents this.

Get comfortable with this pattern now — every remaining capstone week assumes your core service already runs in a container.