Skip to content
Skip to lesson
Back to Topics
LearnComputing FoundationsDocker for Reproducible AI
⚙️EasyMLOps & Deployment

Docker for Reproducible AI

Package the access-request scorer so a fresh container can reprint 0.667 without your laptop's Python.

21 min read
Learning path
Step 2 of 199 in the full curriculum

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

A teammate clones access-rag, finds the three and scripts/score_access_requests.py, and still can't reprint 0.667. Their Python version, OS packages, or filesystem permissions differ from yours. Git carried the files. It didn't pin how they run.

The scorer compares a predicted label with an expected label on each row. Two matches out of three give 2/3, displayed as 0.667. Nothing about that calculation should depend on a teammate's Python setup.

packages Python, its dependencies, and application files in a , the stored artifact produced by docker build. Its filesystem is assembled from reusable layers. A is an isolated instance of that image, created by docker run. Ours runs one scorer process; other applications may start child processes too.

When the scorer exits, --rm removes the stopped container and its writable layer. The image remains available for another run.

The kernel is the operating system component that manages processes, memory, and devices. A Linux image packages application code and user-space tools, including Python and Debian libraries, but no kernel. Containers share the Linux kernel of the machine running Docker Engine. With Docker Desktop's Linux containers, that machine is a Linux virtual machine (VM), so a Mac container uses the VM's Linux kernel.[1]

Use the access-rag/ directory from Git, Shell, and Linux for Reproducible AI. Run the commands in a Bash-compatible terminal with Docker Engine running, or Docker Desktop set to Linux containers. On Windows, use a WSL terminal. docker version should show both a client and a server; a client alone can't build or run an image.

Read upward through four image filesystem layers: Debian 13 and Python 3.12, /opt/venv, the scorer script, and the three-row eval fixture. The retained access-rag:local image supplies a read-only filesystem to a temporary container. Its appuser process, UID 10001, reads the fixture and prints 0.667 (2/3). System calls reach the Linux kernel outside the container, on the Engine host or Docker Desktop VM. Exit status 0 ends the process, and --rm removes the container while preserving the image.
Read upward through the image layers, then across to the running container. The shared Linux kernel is supplied by the engine host or Desktop VM. --rm removes the container on exit while preserving the image.

Where each input lives

The scorer needs Python and its own files every time. The eval data may change between runs, and future API keys shouldn't travel with the image at all. A Dockerfile is the build recipe; it chooses which inputs become part of the image. The rest arrive when a container starts:

BoundaryQuestionRepo answer
Base imageWhich Python, OS, and CPU architecture run the code?A version-constrained such as python:3.12-slim-trixie; add a digest and an explicit target platform when a release needs a fixed artifact
DependenciesWhich packages are installed?A checked-in lock or fully pinned, hashed requirements file copied before application code
Build contextWhich files may the builder read?.dockerignore that excludes secrets, caches, model weights, and virtualenvs
Runtime userWhich identity runs the process?A non-root user; add a named volume or temporary filesystem only when it must write
DataWhere does eval data live?Tiny can be copied; changing host data uses a bind mount, and container-managed caches use named volumes
SecretsHow does an API key arrive?A runtime secret file or platform secret manager, never ARG, ENV, or COPY .env

Don't solve every placement with COPY. A bind mount exposes a specific host path inside a container. A named volume is storage Docker manages, which suits caches or state that should outlive one container without depending on a teammate's directory layout.

Build-time and run-time inputs also have different lifetimes. Stable inputs become image layers. Run-specific data and configuration join only when a container starts, and Docker can reuse an earlier layer when its inputs didn't change.[2]

Suppose you fix a line in the scorer but leave its packages alone. Docker should reuse the installed packages and rebuild only the application-dependent steps. That reuse is the build cache:

Two build traces compare a scorer edit with a dependency edit. Requirements and the installed environment remain reusable above the changed scorer. The scorer and subsequent fixture copy rebuild. A changed dependency path rebuilds the requirements copy, installed environment, scorer copy and fixture copy. Purple outlines mark the changed input; green layers are reusable and amber layers rebuild. The traces assume an unchanged base and an available build cache.
With the same base image and an available cache, a scorer edit preserves the dependency copy and installation. A dependency edit reruns package installation in the builder, then rebuilds the runtime copy of that environment and later instructions. These traces show selected instructions from the Dockerfile below.

Edit the scorer and the dependency layer can stay cached. Change requirements.txt and its installation must run again, so the stable file goes in first. The Dockerfile below separates installation and execution into stages while preserving that dependency order.

Start with .dockerignore

The build context is the first boundary. If access-rag/ contains a model cache, a local index, and .env, docker build . can make all of them available to the builder before the Dockerfile chooses what to copy. Which paths should never cross that boundary?

Create .dockerignore at the root of access-rag/, next to .gitignore:

.dockerignore
# .dockerignore - keep the build context tiny and secret-free
.env*
*.pem
secrets/
.git/
.github/
.venv/
.docker-lab/
__pycache__/
*.py[cod]
*.egg-info/
node_modules/
models/
*.gguf
*.safetensors
*.bin
chroma/
faiss_index/
*.db
runs/
eval_cache/
wandb/
mlruns/
.DS_Store
.idea/
.vscode/
*.swp

Docker removes matching paths before it sends the build context to the builder. Without that filter, .env, cached model files, local indexes, notebooks, and virtualenvs can reach a local or remote builder even when the Dockerfile never meant to copy them. A later broad COPY . /app can also bake them into a layer.[2]

Build context and image contents aren't the same thing. Context is everything the builder is allowed to read; COPY and ADD decide which of those files become layers. If a path matches .dockerignore, it never enters the context. Now choose which of the remaining files the scorer actually needs.

Reuse the scorer you already tested

The previous chapter committed scripts/score_access_requests.py and eval/access_requests.jsonl. Package those same files so the container result can be compared with the Git check. A second scorer would add another possible cause of a changed result.

The Git scorer falls back to assets/access_requests.jsonl when eval/ is missing. Copying eval/ into the image is enough for the primary path, so you don't need assets/ in the image. requirements.txt can stay empty until the scorer gains third-party packages.

Check that you're in the project root, create the empty dependency file if needed, and run the scorer once outside Docker:

terminal
test -f scripts/score_access_requests.py
test -f eval/access_requests.jsonl
touch requirements.txt
python3 scripts/score_access_requests.py
Output
Eval rows: 3
Exact-match accuracy on tiny fixture: 0.667 (2/3)
Gate passed. You may commit.

That output is the baseline the image must preserve. The scorer and the fixture stay as they are. The image has to pin which Python actually runs them, so a teammate's laptop stops being part of the result.

Give the scorer its own Python environment

Start with a CPU image. The three-row scorer doesn't need a , so prove the container contract on an ordinary laptop before adding NVIDIA runtime setup.

Each FROM starts a build stage, a separate filesystem based on the named image. The first stage installs packages into a virtual environment, a directory containing a Python environment isolated from other packages. The second copies that environment and runs the scorer. This two-stage pattern becomes useful when installation needs compilers or other tools that the running application doesn't need. Our empty dependency file doesn't need those tools yet, so two stages aren't inherently smaller here.

Create this Dockerfile:

Dockerfile
# syntax=docker/dockerfile:1
# Tag is fine for learning. To fix base-image bytes, pin both stages by digest:
# FROM python:3.12-slim-trixie@sha256:<verified-digest> AS builder
FROM python:3.12-slim-trixie AS builder

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY requirements.txt /tmp/requirements.txt
# Empty requirements are fine here. Once packages appear, export every
# transitive dependency with hashes or use a lock-aware frozen install.
RUN python -m pip install --no-cache-dir --require-hashes -r /tmp/requirements.txt

FROM python:3.12-slim-trixie AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PATH="/opt/venv/bin:$PATH"

RUN useradd --create-home --no-log-init --user-group --uid 10001 --shell /usr/sbin/nologin appuser

WORKDIR /app

COPY --from=builder /opt/venv /opt/venv
COPY --chown=appuser:appuser scripts/score_access_requests.py scripts/score_access_requests.py
COPY --chown=appuser:appuser eval/ eval/

USER appuser

ENTRYPOINT ["python", "scripts/score_access_requests.py"]

Read the runtime stage from top to bottom. WORKDIR /app sets the directory for later relative paths. COPY --from=builder transfers the installed environment, while the next two COPY instructions bring in the scorer and fixture. USER appuser selects the identity for the running process. ENTRYPOINT supplies its default command, so docker run doesn't need to repeat the Python filename.

Both stages use the same base image and /opt/venv path to keep the copied environment compatible. If later packages need shared OS libraries, those libraries must also exist in the runtime stage. Copying a virtual environment alone doesn't copy every system dependency.

Here the runtime stage starts from python:3.12-slim-trixie, so it declares its own PATH, bytecode, and buffering settings. It doesn't inherit the builder's ENV settings. A stage based on FROM builder would inherit them. Likewise, installing a compiler in this builder doesn't install it in this runtime. COPY --from=builder transfers only the selected files.

COPY defaults to root ownership. Root-owned files with world-readable permissions can still be read by appuser, but COPY --chown=appuser:appuser makes ownership explicit and avoids permission failures when files have restrictive modes or the application later needs to write its own files.

Dockerfile sectionPurpose
builder stageinstalls packages once into a reusable virtualenv
runtime stageruns 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).[3][4] That 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 or rebuilt OS packages.[2]

Dependency pins need the same care. package==1.2.3 fixes a version, but an installer may still choose among wheels (prebuilt packages) and source archives. A hash identifies an allowed downloaded artifact. For a release, export all dependencies, including dependencies of dependencies, with hashes and keep --require-hashes. Alternatively, use a package-manager with its frozen install command. An empty requirements.txt passes because there are no packages to verify. Adding an unhashed package makes the build fail instead of silently weakening the policy.[5]

A lockfile affects this image only if its install step consumes it. Merely copying a future uv.lock into the context won't change this pip command. Keep one reviewed dependency source for the image, or explicitly export the lock to the hashed requirements this Dockerfile reads. Hashing a source archive also doesn't lock the tools that compile it. For a wheel-only release, --only-binary=:all: rejects source builds, but it will fail when no compatible wheel exists for the target platform.[5]

python:3.12-slim-trixie is a multi-platform tag. Docker normally selects the variant that matches the host. An Apple Silicon laptop can build linux/arm64 while an x86 CI worker builds linux/amd64. Those are compatible recipes, not identical image bytes. Predict which architecture your release needs, inspect the available variants, then set the deployment target:

terminal-platform
docker buildx imagetools inspect python:3.12-slim-trixie
docker build --platform linux/amd64 -t access-rag:local .

For a fixed release, copy a verified sha256:... digest from the inspection into both FROM lines. A digest identifies content, unlike a tag that can be reassigned. A multi-platform index digest fixes a set of platform variants; a platform manifest digest fixes one variant. Keep --platform in the build command to select the intended architecture. Emulating linux/amd64 on an Arm laptop can be much slower than a native build, especially when packages compile code.[6]

Once that target is explicit, cache behavior becomes easier to reason about. A package change invalidates the dependency layer and later layers; editing scripts/score_access_requests.py won't force a full package reinstall, because the Dockerfile installs requirements first.

Build, run, and inspect the artifact

From the project root, check the Dockerfile and then build the artifact. The first command reports Dockerfile findings without producing an image; the second creates the tagged image. The final . selects the current directory as the build context, and -t gives the image its local name.

terminal-3
docker build --check .
docker build -t access-rag:local .

docker build --check exits nonzero when a check reports a finding. A regular build can report those findings as warnings, so running the check separately makes the policy explicit.[7]

Run the image without any host mount first. Will --read-only break the scorer? It only reads the fixture, and the Dockerfile disables Python bytecode writes, so making the container's root filesystem read-only should preserve the result:

terminal-4
docker run --rm --read-only access-rag:local

Expected output:

Output
Eval rows: 3
Exact-match accuracy on tiny fixture: 0.667 (2/3)
Gate passed. You may commit.

Inspect the artifact as well as its stdout:

terminal-inspect-image
docker image inspect access-rag:local \
  --format 'platform={{.Os}}/{{.Architecture}} user={{json .Config.User}} entrypoint={{json .Config.Entrypoint}}'

The platform should match the build target, the configured user should be appuser, and the entrypoint should name the scorer. A successful --read-only run shows that this execution can finish without writing to its root filesystem. Different inputs or a later workload may require writable storage. PYTHONDONTWRITEBYTECODE=1 keeps Python from trying to write .pyc files when that filesystem is read-only.

Inspect the process identity separately from the host user who launches Docker. Overriding the entrypoint lets us run one diagnostic command without changing the image:

terminal-inspect-user
docker run --rm --read-only --entrypoint id access-rag:local

Expect uid=10001(appuser). The group number is chosen when useradd creates the group, so don't assume it also equals 10001. On a normal Linux Engine without user-namespace remapping, numeric ownership controls access to bind-mounted files. Rootless engines and Docker Desktop can map identities differently. The mounted-path check belongs to that host setup.

USER appuser limits the application process. It doesn't reduce the authority of whoever controls Docker's daemon. Access to a normal rootful daemon can grant host-level privileges, and a container with a writable bind mount can alter the permitted host files. Don't mount the Docker socket or unrelated host directories into this scorer. The application user, daemon access, and mount permissions are separate boundaries.[8]

The printed score is rounded for humans. Keep the gate on integer counts, the same rule the Git scorer uses (correct * 3 >= total * 2), not equality to the decimal 0.667. This tiny check makes that distinction visible:

check_gate_fraction.py
matches, total = 2, 3
score = matches / total

assert 0 <= matches <= total
print(f"score={score:.3f} ({matches}/{total})")
print("gate_passed:", matches * 3 >= total * 2)
Output
score=0.667 (2/3)
gate_passed: True

A bind mount obscures the copied eval

A passing run confirms that the image can read its baked fixture. Editing the host's eval/ won't change those copied files: they're a snapshot from build time. A bind mount lets us supply new data without rebuilding the scorer.

Run the image against host data:

terminal-5
docker run --rm \
  --read-only \
  --mount type=bind,source="$PWD/eval",target=/app/eval,readonly \
  access-rag:local

A bind mount at /app/eval obscures the image's copied files for that run. Docker doesn't merge the two directories. --mount is the better default here because it fails when the host source path is missing. -v can silently create a missing source as an empty directory. If the score changes, compare the mounted fixture with the committed one before changing the image.[1]

The source path must exist where the Docker daemon runs. Docker Desktop handles sharing your local project into its Linux virtual machine. With a remote daemon or another VM setup, a path that exists in your terminal may still be unavailable to Docker; check file sharing before changing the scorer.

The mount is read-only from inside the container, but file permissions still apply. appuser must be able to traverse the mounted directories and read the fixture. For writable data, a named volume removes the dependency on a particular host path, not the need for correct ownership. Initialize its directory for the application's user, or document the numeric user and group IDs (UID/GID) required on a bind-mounted path. Switching to root hides that setup problem.[9]

Turn one boundary into a failure

Test the mount behavior without renaming or deleting the committed fixture. Before running the command, predict which directory Python will see at /app/eval. Will a read-only mount preserve access to the copied fixture?

Create an empty scratch directory and mount it over /app/eval:

terminal-empty-fixture
mkdir -p .docker-lab/empty-eval
docker run --rm --read-only \
  --mount type=bind,source="$PWD/.docker-lab/empty-eval",target=/app/eval,readonly \
  access-rag:local

The scorer should fail with a missing-file error and a nonzero exit status. Because the source directory exists, Docker can create the mount. Python then looks for access_requests.jsonl and can't see the baked copy underneath it. The image doesn't contain the scorer's assets/ fallback either. Rerun without the mount and 0.667 (2/3) should return without rebuilding anything.

The error names the fallback path because the scorer tries it after failing to find eval/access_requests.jsonl:

Expected failure
ERROR: assets/access_requests.jsonl missing; commit the eval fixture
Two aligned filesystem views follow the scorer read at /app/eval. Without a mount, the path reaches the baked access_requests.jsonl file with three rows, producing 0.667 (2/3) and exit 0. In the second view, the empty host directory .docker-lab/empty-eval is mounted read-only over /app/eval. Its opaque foreground covers the still-retained image fixture behind it, and the read reaches an empty directory. The scorer finds neither its fixture nor the assets fallback and exits 1. Recreating the run without the mount reveals the image fixture again.
Follow the arrows to the directory visible at /app/eval. An empty bind mount hides the image fixture for that run without deleting it. Removing the mount restores the original read path.

Compare that failure with other small changes:

ChangePredictionWhy
Rename eval/access_requests.jsonl on the hostmounted run fails with a missing filethe read-only mount replaces the image's /app/eval directory
Rename the host eval/ directory--mount rejects the missing source pathexplicit bind mounts don't create missing host directories by default
Remove requirements.txt from the build contextbuild fails at the COPY requirements.txt stepDocker can copy only files that exist in the context
Add env_file: .env to Compose before creating .envCompose reports the missing env fileenv_file entries are required unless marked optional
Run on a machine without Docker Composedocker compose version is unknownDocker Engine and the Compose CLI plugin are separate on some Linux installs

The distinction saves a rebuild: a missing host path is a mount problem, while a missing file inside an existing mount is an input-data problem.

Check in one Compose file

For one service, docker run is enough. As soon as the project adds a database, API, worker, or model cache, one checked-in Compose file gives each engineer the same starting command and mount policy. Add only the scorer that exists today.

Start with a runnable compose.yaml for the scorer:

compose.yaml
services:
  scorer:
    build:
      context: .
      dockerfile: Dockerfile
    image: access-rag:local
    read_only: true
    volumes:
      - type: bind
        source: ./eval
        target: /app/eval
        read_only: true
        bind:
          create_host_path: false

create_host_path: false preserves the missing-directory failure we chose with docker run --mount. Compose otherwise creates a missing bind source directory by default. read_only: true on the service protects its root filesystem; the second read_only protects the separate data mount.[10]

Validate the file before running it:

terminal-6
if docker compose version >/dev/null 2>&1; then
  docker compose config --quiet
  docker compose run --build --rm scorer
else
  echo "Docker Compose plugin missing. Install docker-compose-plugin before using compose."
fi

Docker Desktop includes Compose. Some Linux Engine installs need the docker-compose-plugin package first; follow Docker's plugin installation instructions. If docker compose version is unknown, the Dockerfile still works through docker run, but the Compose workflow isn't installed yet.

Omit the obsolete top-level version: field. Docker's Compose file reference names compose.yaml as the preferred filename and retains docker-compose.yml for compatibility. Use the current docker compose command.

docker compose config --quiet validates and resolves configuration without starting a container; it doesn't prove the scorer works or that its mount is readable. docker compose run --build --rm scorer first builds the service image, then creates a one-off container. --build matters after a source edit because an existing tag may still point at an older image. --rm removes the container after the scorer exits, while the image remains available.

As the stack gains real services, add their images, commands, and mounts to this same file. Until then, keep the scorer as the only service so another engineer can run the file as written.

Keep credentials out of image layers

The scorer doesn't need an API key yet, so don't give it one. starts by attaching no secret to a process that doesn't use it. When a future build needs private-package access, separate that short build lifetime from the runtime secret a process reads later.

When a build must authenticate to a private package index, don't pass the credential with ARG or ENV. ARG isn't copied into the running container the way ENV is, but it still isn't safe. The Dockerfile reference warns that build-arg values appear in docker history and max-mode attestations.

This bad pattern puts the in a build argument. Don't run it with a real credential:

Dockerfile-2
# BAD: ARG values can appear in image history and provenance attestations
ARG PRIVATE_INDEX_TOKEN
RUN python -m pip install --index-url "https://token:${PRIVATE_INDEX_TOKEN}@packages.example.invalid/simple" private-package

BuildKit, Docker's build engine, has a separate secret-mount channel. The mounted file is available only for that RUN instruction and isn't committed to its layer. Commands can still leak it by copying its contents elsewhere or printing them, so the channel doesn't replace careful handling.[11]

If requirements.txt later needs a private index, replace its install instruction in the builder stage with this fragment. required=true fails clearly when the build command forgets to supply the secret:

Dockerfile (builder-stage fragment)
RUN --mount=type=secret,id=pip_config,target=/etc/pip.conf,required=true \
    python -m pip install --require-hashes -r /tmp/requirements.txt

Supply that temporary file from the build command:

terminal-7
docker build \
  --secret id=pip_config,src="$HOME/.config/pip/pip.conf" \
  -t access-rag:private .

Runtime secrets use a separate channel. Docker's Compose guidance warns against putting passwords or API keys in , because container configuration and diagnostic tooling can expose them. When the scorer later calls an API, mount a Compose secret as /run/secrets/access_api_key or use the deployment platform's secret manager, then have the process read that file. Keep env_file for non-sensitive settings such as log level or feature mode.[12]

Once the scorer reads that file, extend the existing Compose model with this fragment:

compose-secret-fragment.yaml
services:
  scorer:
    secrets:
      - access_api_key

secrets:
  access_api_key:
    file: ./secrets/access_api_key.txt

Don't add this fragment before the process consumes the file. Local Compose mounts a host file; it doesn't encrypt that file or remove host-access risks. Keep it out of Git, restrict its host permissions, and verify that appuser can read the mounted secret without granting unrelated users access.

Keep secrets/ and .env* in both .gitignore and .dockerignore. Those exclusions protect two different routes out of your machine. Explicit build-secret and runtime-secret mounts deliver the selected file without copying it into ordinary image layers.

When the GPU enters the story

The CPU-first image is the right base here. It works through Docker Engine or Docker Desktop on common Linux, macOS, and Windows development machines. For the Linux Docker Engine path below, a workload needs a compatible NVIDIA GPU, an installed host driver, and NVIDIA Container Toolkit configuration that exposes selected devices to the container. CUDA user-space libraries live in the application image; the kernel driver stays on the host.[13]

Windows also has a supported GPU path: Docker Desktop with its WSL2 backend and a compatible NVIDIA Windows driver can expose the GPU to Linux containers. Use Docker's Windows-specific setup and validation instructions for that path; the systemctl and Linux driver commands below configure a Linux Engine host.[14]

When the same scorer later becomes a GPU workload, don't start by swapping Python packages. First ask which boundary can see the device: the host, the container runtime, or the framework inside the image.

After installing the toolkit on a systemd-managed Docker Engine host, configure its runtime and restart the daemon:

terminal
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker

Rootless Docker uses a user-scoped daemon configuration and restart command, so follow NVIDIA's rootless instructions instead of copying the system-wide commands.

When a later or inference chapter needs GPU acceleration, keep the same habits and change only the platform-specific pieces:

ContractCPU scorer nowGPU workload later
Base imagepython:3.12-slim-trixieofficial PyTorch CUDA image or NVIDIA CUDA runtime image matched to the torch wheel
Runtime checkscorer returns 0.667python -c "import torch; assert torch.cuda.is_available()"
Run commanddocker run access-rag:localdocker run --gpus all ...; add shared memory only when the workload needs it
Portability claimsame Python runtime on normal machinescompatible NVIDIA platform, driver, and container runtime

Don't promise that one CUDA image gives Apple Silicon and NVIDIA parity. Docker Desktop on Apple Silicon can run this Linux CPU image, but it doesn't turn the Mac GPU into an NVIDIA CUDA device. Use the later Metal/MPS path for native Mac PyTorch, and test production CUDA images on compatible Linux NVIDIA hosts.

Before running the , predict what it can prove: device exposure through Docker, not that a particular PyTorch build is compatible. NVIDIA uses a plain ubuntu image so the toolkit can expose nvidia-smi without coupling the check to a CUDA application image.[15]

terminal-8
sudo docker run --rm --runtime=nvidia --gpus all ubuntu nvidia-smi

Read GPU failures in layers:

ObservationBoundary to inspect next
nvidia-smi fails on hostinstall or repair host NVIDIA driver first
host command works, container smoke test failsinspect Container Toolkit installation, Docker runtime configuration, and requested devices
smoke test works, but torch.cuda.is_available() is falseinspect application image, installed PyTorch build, visible-device settings, and driver/runtime compatibility

The smoke test checks device exposure. It doesn't establish compatibility with a chosen PyTorch image. Read the table from top to bottom: fix the host first, then the container runtime, then the application image. For a real workload, select an official framework or CUDA image whose architecture and CUDA runtime match the locked Python packages. Verify its documented minimum driver requirement, then pin that application image for the release as you pinned the CPU base.

A build-and-run gate

The separate checks can become a small Bash script for local use or continuous integration (CI), the automated checks run after a code change. Run both fixture paths: a passing mounted run can hide a missing or broken baked fixture. Save this as scripts/check_container.sh and run it from the project root with bash scripts/check_container.sh:

terminal-9
#!/usr/bin/env bash
set -euo pipefail

docker build --check .
docker build -t access-rag:check .
image_id=$(docker image inspect --format '{{.Id}}' access-rag:check)
expected_report=$(cat <<'EOF'
Eval rows: 3
Exact-match accuracy on tiny fixture: 0.667 (2/3)
Gate passed. You may commit.
EOF
)
baked_report=$(docker run --rm --read-only "$image_id")
mounted_report=$(docker run --rm \
  --read-only \
  --mount type=bind,source="$PWD/eval",target=/app/eval,readonly \
  "$image_id")
if [[ "$baked_report" != "$expected_report" ]]; then
  printf '%s\n' "ERROR: baked fixture report changed" "$baked_report" >&2
  exit 1
fi
if [[ "$mounted_report" != "$expected_report" ]]; then
  printf '%s\n' "ERROR: mounted fixture report changed" "$mounted_report" >&2
  exit 1
fi
printf '%s\n' "$baked_report" "Verified local image: $image_id"
if docker compose version >/dev/null 2>&1; then
  docker compose config --quiet
else
  echo "Docker Compose plugin missing. Install docker-compose-plugin before enabling the compose gate."
fi

set -euo pipefail stops this script when a mandatory command fails. A missing Compose plugin is deliberately reported and skipped; an installed plugin with invalid configuration fails the script. On an older Docker release without docker build --check, upgrade or temporarily omit only that static check. Neither static check substitutes for running the image.

The scorer's exit status enforces its existing threshold, at least two-thirds correct. It doesn't require exactly 0.667: three matches would pass too. The two report comparisons separately enforce this unchanged starter fixture's exact result. Deliberately updating the fixture requires reviewing that expected report too. A threshold check and an exact regression check answer different questions.

For a small regression exercise, change request 102's host prediction to blocked, then rerun the script. Predict the two reports first. The script rebuilds the image, so both runs report 3/3, which passes the scorer's threshold. The exact comparison nevertheless exits with ERROR: baked fixture report changed. Restore the committed row before continuing. This failure distinguishes changed input from a broken container runtime.

Both runs use the captured local image ID, so reassigning access-rag:check between commands won't switch the artifact under test. A local identity doesn't prove the image exists in a deployment registry. Before deployment, record and promote the registry's verified repository@sha256:... reference for the tested image. Don't construct that reference by attaching a local ID to a registry URL. Docker distinguishes image IDs from repository digests, and its push or pull output reports the registry digest.[16]

Failures you'll see first

SymptomPossible causeFix that belongs in the repo
ModuleNotFoundError inside the containerdependency missing from requirements.txtadd it to requirements.txt, rebuild, and keep dependency install before COPY scripts/
.env appears in the image context.dockerignore forgot .env*add .env*, rotate any exposed credential, rebuild, and inspect image history before pushing
Permission denied on a mounted directoryappuser lacks the required accesscheck file modes and UID/GID; initialize writable volume ownership rather than assuming Docker grants access
each code edit reinstalls dependenciesCOPY . /app happens before dependency installcopy requirements.txt first, install dependencies, then copy application code
could not select device driver with capabilities: [[gpu]]Docker's configured runtime can't provide the requested GPUcheck the platform's GPU setup and keep the CPU scorer path working
Cloud Run starts but can't read configlocal file or environment setup was assumed to exist in productiondeclare non-secret configuration and use Secret Manager for credentials

Document the diagnosis, command, and prevention in the repo so the next clone can reproduce the fix without rediscovering it.

What survives a fresh clone

Commit the Dockerfile, .dockerignore, requirements.txt, compose.yaml, and scripts/check_container.sh alongside the scorer and fixture. Repeat the previous lesson's temporary-clone check after committing these files, then run bash scripts/check_container.sh inside that clone. The builder should see only committed project inputs. Another engineer should be able to run both fixture paths and reproduce the empty-mount failure without borrowing your virtual environment or credentials.

Before publishing a release image, pin the base digest and deployment platform, lock any dependencies, and record the image identity with the successful run output. These controls constrain the inputs; they don't guarantee bit-for-bit identical rebuilds or identical GPU numerical results. For deployment, promoting one tested image by digest is a stronger guarantee than rebuilding the same recipe on every machine.

Separate the files that belong in the image from the files that arrive on a mount, and expect a known failure when the eval directory is missing. The scorer is still a command that prints 0.667. Follow that command through one row: how it parses a field, compares a label, and decides the gate.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.A verified release index contains amd64 member A and arm64 member B. Both an Arm laptop and x86 CI explicitly build and test with --platform linux/arm64. The required deployment target is amd64, and these are the only recorded runs. Which tested member and deployment-evidence gap follow?
2.A builder adds ENV TOOL_MODE=review and a custom file /opt/helper.bin. Runtime A uses FROM builder. Runtime B starts FROM the original base and copies only /opt/venv from builder. Neither runtime adds other instructions. Which variable/file availability follows?
3.A local secret is excluded by .gitignore but not .dockerignore. The Dockerfile copies only the scorer and requirements, and no instruction reads the secret. Classify eligibility for the Docker build context and whether those selected COPY instructions bake the secret. This asks about the eligible file set, not which unused bytes BuildKit actually transfers.
4.An unchanged install command consumes requirements.txt with --require-hashes. A developer edits only an unused lockfile, and separately offers an unapproved wheel for a pinned version. Which paired interpretation is correct?
5.Two read paths use one image ID and the exact two-thirds threshold. The first fixture has 6 matches among 8 rows; the second has 5 among 8. Which threshold results follow from the integer gate?
6.A fresh absolute host directory does not exist. First, a -v bind run creates it as an empty directory and exits with --rm. Nothing changes the host directory afterward. A second run uses the default explicit --mount form at that same source. Where do the two missing-fixture failures occur?
7.Compose config --quiet succeeds for a service whose root is read-only and whose separate input bind is writable. The source code was edited after the old image build. Which next step and permission conclusion are justified?
8.A build uses a temporary secret mount, but its command copies the secret into /app/debug.txt before the step finishes. A later layer deletes debug.txt. Which credential and output conclusions follow?
9.On a normal rootful Linux Engine without user-namespace remapping, appuser cannot write a mounted host directory owned by another UID. The host GPU probe passes but the container device probe fails. Which two boundaries need the next repairs?
10.A gate captures local image ID L, then both baked and mounted runs print the expected report. The mutable tag later points elsewhere, and no verified repository digest has been recorded. What remains established and what is still needed for deployment?

10 questions remaining.

References

Bind mounts

Docker Inc. · 2026

https://docs.docker.com/engine/storage/bind-mounts/

Building Best Practices

Docker Inc. · 2026 · Official documentation

https://docs.docker.com/build/building/best-practices/#pin-base-image-versions

Official Python Image Manifest

Docker Official Images Maintainers · 2026 · Official image manifest

https://github.com/docker-library/official-images/blob/master/library/python

Debian Trixie Release Information

Debian Project · 2025 · Official release information

https://www.debian.org/releases/trixie/

Secure Installs

Python Packaging Authority · 2026

https://pip.pypa.io/en/stable/topics/secure-installs/

Multi-platform builds

Docker Inc. · 2026

https://docs.docker.com/build/building/multi-platform/

Checking your build configuration

Docker Inc. · 2026

https://docs.docker.com/build/checks/

Docker security

Docker · 2026

https://docs.docker.com/engine/security/

Volumes

Docker Inc. · 2026

https://docs.docker.com/engine/storage/volumes/

Compose service reference

Docker Inc. · 2026

https://docs.docker.com/reference/compose-file/services/

Build secrets

Docker Inc. · 2026

https://docs.docker.com/build/building/secrets/

Manage secrets securely in Docker Compose

Docker Inc. · 2026

https://docs.docker.com/compose/how-tos/use-secrets/

NVIDIA Container Toolkit Installation Guide

NVIDIA · 2026 · Official documentation

https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html

GPU Support in Docker Desktop for Windows

Docker · 2026

https://docs.docker.com/desktop/features/gpu/

Running a Sample Workload with Docker

NVIDIA · 2026

https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/sample-workload.html

docker image ls: image IDs and digests

Docker · 2026

https://docs.docker.com/reference/cli/docker/image/ls/

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.