Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Git carries the scorer and fixture between machines, but it can't pin Python, OS packages, runtime users, filesystem layout, or how secrets arrive. Docker records those assumptions in an image and a runtime command.[1]
The repo from the previous chapter already contains eval/access_requests.jsonl and a scorer that must print 0.667. This lesson packages that exact command so another laptop or CI worker uses the same version-constrained environment instead of discovering a different Python version, missing dependency, leaked .env, or volume permission error.
Check your reasoning: if two people run the same files with different Python versions and different installed packages, Git did its job but the project still isn't reproducible.

What a container must guarantee
A useful Docker setup answers six questions before a teammate has to ask them.
| Boundary | Question | Repo contract |
|---|---|---|
| Base image | Which Python and OS run the code? | A version-constrained base image such as python:3.12-slim-trixie; use a digest when exact bytes must be reproducible. On Apple Silicon, also pick the platform deliberately (--platform linux/amd64 vs linux/arm64) so teammate Macs don't silently use a different architecture |
| Dependencies | Which packages are installed? | requirements.txt copied before application code |
| Build context | Which files enter the image build? | .dockerignore that excludes secrets, caches, model weights, and virtualenvs |
| Runtime user | Who owns files inside the container? | A non-root user and explicit writable directories |
| Data | Where does eval data live? | Tiny fixtures can be copied; changing data and model caches should be mounted |
| Secrets | How does the API key arrive? | Runtime --env-file or Compose env_file, not ARG or COPY .env |
Git made the files reproducible. Docker makes the environment portable.
These boundaries become a small lifecycle: build inputs become image layers, then run-time data and credentials join only when the scorer starts.

The solid path is the artifact lifecycle. The dotted inputs stay outside image layers and arrive only for a specific run.

Start with .dockerignore
Create .dockerignore at the root of access-rag/, next to .gitignore:
1# .dockerignore - keep the build context tiny and secret-free
2.env
3.env.*
4*.pem
5secrets/
6.git/
7.github/
8.venv/
9__pycache__/
10*.py[cod]
11*.egg-info/
12node_modules/
13models/
14*.gguf
15*.safetensors
16*.bin
17chroma/
18faiss_index/
19*.db
20runs/
21eval_cache/
22wandb/
23mlruns/
24.DS_Store
25.idea/
26.vscode/
27*.swpDocker reads this file before sending the build context to the daemon. Without it, a broad COPY . /app can accidentally send .env, cached model files, local indexes, notebooks, and virtualenvs into the build. That makes images slower to build and easier to leak.
The filter is simple in principle: if a path matches .dockerignore, it never enters the build context.
Reuse the scorer you already tested
The previous chapter committed scripts/score_access_requests.py and eval/access_requests.jsonl. Keep those as the single scorer and fixture instead of creating a second implementation for Docker. The image also expects requirements.txt; it can stay empty until the scorer gains external dependencies.
1test -f scripts/score_access_requests.py
2test -f eval/access_requests.jsonl
3touch requirements.txt
4python3 scripts/score_access_requests.py1Eval rows: 3
2Exact-match accuracy on tiny fixture: 0.667 (2/3)
3Gate passed. You may commit.Build the smallest runnable scorer image
Start with a CPU image. That choice is deliberate. The three-row scorer doesn't need a GPU, and a beginner should be able to prove the container contract on a normal laptop before adding NVIDIA runtime setup.
Create this Dockerfile:
1# syntax=docker/dockerfile:1
2# Tag is fine for learning. For exact rebuilds, pin both stages by digest, e.g.:
3# FROM python:3.12-slim-trixie@sha256:<verified-digest> AS builder
4FROM python:3.12-slim-trixie AS builder
5
6ENV PYTHONDONTWRITEBYTECODE=1 \
7 PYTHONUNBUFFERED=1 \
8 PIP_NO_CACHE_DIR=1 \
9 PIP_DISABLE_PIP_VERSION_CHECK=1
10
11RUN python -m venv /opt/venv
12ENV PATH="/opt/venv/bin:$PATH"
13
14COPY requirements.txt /tmp/requirements.txt
15# Empty requirements are fine here. When packages appear, prefer a lockfile
16# (uv.lock / hashed requirements) and install with hashes for repeatable builds.
17RUN python -m pip install --no-cache-dir -r /tmp/requirements.txt
18
19FROM python:3.12-slim-trixie AS runtime
20
21ENV PYTHONDONTWRITEBYTECODE=1 \
22 PYTHONUNBUFFERED=1 \
23 PATH="/opt/venv/bin:$PATH"
24
25RUN useradd --create-home --no-log-init --user-group --uid 10001 --shell /usr/sbin/nologin appuser
26
27WORKDIR /app
28
29COPY /opt/venv /opt/venv
30COPY scripts/score_access_requests.py scripts/score_access_requests.py
31COPY eval/ eval/
32
33USER appuser
34
35ENTRYPOINT ["python", "scripts/score_access_requests.py"]Dependencies install into /opt/venv in the first stage. A clean second stage copies the virtualenv, scorer, and fixture, then runs as appuser instead of root. Its entrypoint calls the same scorer the Git gate used.
| Dockerfile section | Purpose |
|---|---|
builder stage | installs packages once into a reusable virtualenv |
runtime stage | runs only the existing scorer, fixture, and installed environment |
The official Python image manifest maps python:3.12-slim-trixie to the Python 3.12 line on Debian 13 (trixie).[2][3] This constrains the Python minor line and Debian release, but it doesn't pin exact bytes. Image tags are mutable, so a later rebuild can pick up a new Python patch release or rebuilt OS packages.[4] Use a digest (python:3.12-slim-trixie@sha256:<verified-digest>) when the same base-image bytes must resolve on every machine and in CI. Lock application dependencies, including any build tools you add, and use hashes when the project requires repeatable package artifacts.
Later, when you add dependencies, package changes invalidate the dependency layer instead of the code layer. Editing scripts/score_access_requests.py won't force a full package reinstall.
Docker layer caching follows the same order rule: copy requirements.txt and install dependencies before application code so routine code edits don't bust the dependency layer.
Why does the Dockerfile copy requirements.txt before the scorer source?
Answer
Dependency installation stays in an earlier cacheable layer. Editing scorer code can then reuse the dependency layer instead of reinstalling every package.
Build and run it
From the project root:
1docker build -t access-rag:local .Run the image without any host mount first:
1docker run --rm access-rag:localExpected output:
1Eval rows: 3
2Exact-match accuracy on tiny fixture: 0.667 (2/3)
3Gate passed. You may commit.The printed score is rounded for humans. Keep the gate comparison on the exact fraction, as this tiny check does:
1matches, total = 2, 3
2score = matches / total
3
4assert 0 <= matches <= total
5print(f"score={score:.3f} ({matches}/{total})")
6print("gate_passed:", score >= 2 / 3)1score=0.667 (2/3)
2gate_passed: TrueVerify copied and mounted data
The image ID is not the proof. The proof is that the same three rows produce the same 0.667 after moving into the container. Exact rebuilds also require a base-image digest and locked dependency artifacts.
| Contract check | What proves it |
|---|---|
| Same scorer and fixture | the Dockerfile copies only scripts/score_access_requests.py and eval/ |
| Same command | the entrypoint runs that scorer directly |
| Same result | docker run --rm access-rag:local prints 0.667 (2/3) |
| Runtime data can replace baked data | a read-only bind mount supplies /app/eval |
What proves more than the image ID when checking this containerized scorer?
Answer
Running the image against the three-row fixture and observing 0.667 (2/3). That receipt checks the command, code, data, and runtime together.
Run the image against host data:
1docker run --rm \
2 -v "$(pwd)/eval:/app/eval:ro" \
3 access-rag:localA bind mount replaces the image path for that run; Docker doesn't merge the host and image directories. If the score changes, compare the mounted fixture with the committed one before changing the image.
Practice: break one thing on purpose
After the first successful run, make one small mistake and predict the symptom before you rerun the command.
| Change | Prediction | Why |
|---|---|---|
Rename eval/access_requests.jsonl on the host | mounted run fails with a missing file | the :ro mount replaces the image's /app/eval directory |
Remove requirements.txt from the build context | build fails at the COPY requirements.txt step | Docker can copy files that exist in the context |
Add env_file: .env to Compose before creating .env | Compose reports the missing env file | declared runtime files must exist locally |
| Run on a machine without Docker Compose v2 | docker compose version is unknown | Docker Engine and the Compose plugin are separate on some Linux installs |
These failures are useful. Each one proves which part of the contract you were relying on.
Compose keeps the local command stable
For one service, docker run is fine. As soon as the project adds a vector database, API, worker, or model cache, you want one checked-in Compose file so each engineer starts the same stack.
Start with a runnable docker-compose.yml for the scorer:
1services:
2 scorer:
3 build:
4 context: .
5 dockerfile: Dockerfile
6 image: access-rag:local
7 volumes:
8 - ./eval:/app/eval:roValidate the file before running it:
1if docker compose version >/dev/null 2>&1; then
2 docker compose config --quiet
3 docker compose run --rm scorer
4else
5 echo "Docker Compose v2 plugin missing. Install docker-compose-plugin before using compose."
6fiDocker Desktop includes Compose v2. Some Linux installs need the docker-compose-plugin package first.[1] If docker compose version is unknown, the Dockerfile is still usable through docker run, but the Compose workflow isn't installed yet. Note two modern conventions: the old top-level version: key is obsolete and Compose v2 ignores it (omit it, as above), and the preferred filename is now compose.yaml, though docker-compose.yml still works. Always use the hyphen-free docker compose command; the legacy docker-compose v1 binary is end-of-life.
When the RAG stack grows, add vector-db, api, and worker services to this same file. Don't add fake services before they exist. A Compose file that starts today is better than an impressive YAML file that fails on the first command.
Secrets belong at runtime
Don't pass real secrets with ARG:
1# BAD: the value can leak through image history and layers
2ARG OPENAI_API_KEY
3RUN echo "$OPENAI_API_KEY" > /tmp/key.txtUse runtime environment instead:
1docker run --rm \
2 --env-file .env \
3 -v "$(pwd)/eval:/app/eval:ro" \
4 access-rag:localCompose uses the same idea:
1services:
2 scorer:
3 # Add this after you have a local .env file.
4 env_file:
5 - .envThe secret is available to the process when the container runs. It isn't copied into the image, pushed to the registry, or shown by docker history.
Build context and runtime env are separate channels. A secret should appear only in the runtime channel.
Why should an API key enter through runtime environment configuration instead of COPY or a Docker build argument?
Answer
Runtime injection keeps the key out of image layers, registry artifacts, and docker history. .dockerignore also prevents the local secret file from entering the build context.
When the GPU enters the story
The CPU-first image is the right base contract here. It works on Linux, macOS, CI runners, and most developer laptops. A CUDA image is different: it targets Linux hosts with NVIDIA drivers and the NVIDIA Container Toolkit, which installs a runtime hook so the container can see the host GPU. Configure the runtime, restart Docker so the daemon loads the change, then pass --gpus at run time.[5]
1sudo nvidia-ctk runtime configure --runtime=docker
2sudo systemctl restart dockerWhen a later PyTorch or inference chapter needs GPU acceleration, keep the same contracts and change the platform-specific pieces:
| Contract | CPU scorer now | GPU workload later |
|---|---|---|
| Base image | python:3.12-slim-trixie | official PyTorch CUDA image or NVIDIA CUDA runtime image matched to the torch wheel |
| Runtime check | scorer returns 0.667 | python -c "import torch; assert torch.cuda.is_available()" |
| Run command | docker run access-rag:local | docker run --gpus all --shm-size=2g ... |
| Portability claim | same Python runtime on normal machines | same image on compatible Linux NVIDIA machines |
Don't promise that one CUDA image gives Apple Silicon and NVIDIA parity. On Apple Silicon, use the CPU image for this foundation scorer or a separate Metal/MPS path for PyTorch. For production GPU serving, test the Linux NVIDIA image on a host that has the NVIDIA runtime.
The first GPU smoke test is:
1docker run --rm --gpus all nvidia/cuda:12.9.2-base-ubuntu24.04 nvidia-smiIf that fails, the Dockerfile isn't the problem yet. The host can't expose the GPU to containers.
That CUDA tag is illustrative; NVIDIA publishes new CUDA versions often, so pin to whatever release your PyTorch wheel targets rather than copying the number here. One subtle trap: the container's CUDA runtime must not be newer than the host's NVIDIA driver supports, or the container fails with CUDA driver version is insufficient for CUDA runtime version. That mismatch lives on the host, not in your Dockerfile.
The gate that protects the contract
Add a local or CI gate that proves the image still builds and the score still matches:
1#!/usr/bin/env bash
2set -euo pipefail
3
4docker build -t access-rag:check .
5docker run --rm \
6 -v "$PWD/eval:/app/eval:ro" \
7 access-rag:check
8if docker compose version >/dev/null 2>&1; then
9 docker compose config --quiet
10else
11 echo "Docker Compose v2 plugin missing. Install docker-compose-plugin before enabling the compose gate."
12fiThis catches broken COPY paths, missing files, invalid Compose syntax, and scorer drift before a teammate pulls the repo. If your machine doesn't have the Compose plugin yet, keep the docker build and docker run lines as the mandatory gate, then install Compose before using the Compose part.
Failure modes to keep ready
| Symptom | Most common cause | Fix that belongs in the repo |
|---|---|---|
ModuleNotFoundError inside the container | dependency missing from requirements.txt | add it to requirements.txt, rebuild, and keep dependency install before COPY scripts/ |
.env appears in the image context | .dockerignore forgot .env* | add .env*, rebuild, and check docker history before pushing |
Permission denied on a mounted directory | container user can't read or write the host path | mount eval fixtures with :ro, use named volumes for writable data, or document the host permissions |
| each code edit reinstalls dependencies | COPY . /app happens before dependency install | copy requirements.txt first, install dependencies, then copy application code |
could not select device driver with capabilities: [[gpu]] | NVIDIA Container Toolkit missing or host isn't NVIDIA Linux | document host GPU setup and keep the CPU scorer path working |
| Cloud Run starts but can't read config | local .env was assumed to exist in production | use Secret Manager or the deployment platform's secret mechanism, not a copied file |
The important habit isn't memorizing each Docker flag. It's making the repo contain the diagnosis, the command, and the prevention.
The runtime contract
The runtime contract is explicit:
git clonebrings the code, the three-row eval, the scorer, the Dockerfile,.dockerignore, anddocker-compose.yml.docker build -t access-rag:local .produces a version-constrained Python runtime. Add a verified base-image digest and locked dependency artifacts when exact rebuilds are required.docker run --rm access-rag:localreturns the expected0.667from the starter scorer.docker compose run --rm scorergives teammates one stable local command.- Later GPU images, vector databases, API services, workers, and Cloud Run deployments must preserve the same habit: explicit runtime constraints, mounted data, runtime secrets, and a gate that proves the expected output.
The next chapter opens the Python scorer instead of leaving it hidden behind a command. Docker matters there because code bugs are hard enough without also wondering whether two machines are running different Python environments.
Reproducibility release checklist
Before publishing the image, build from a clean context, run the baked fixture and read-only mounted fixture, confirm both print 0.667 (2/3), inspect that no .env entered image history, and validate Compose configuration. For exact rebuilds, also pin the base image by digest and lock dependency artifacts.