EVERYTHING AIAI engineering, made visual
0/12 complete
LESSON 07 · SETUP & TOOLING × AI · BUILD

One image.
Every machine.

Your laptop has Python 3.12, CUDA 12.4 and PyTorch 2.6. Your colleague’s does not. This lesson builds a Docker image with GPU access — a graphics processing unit (GPU) passed through from the host — so the same environment lands on both. Then it mounts the 14 GB of weights outside the image and starts an inference server plus a vector database with one command.

60 MIN · 7 CHAPTERS + CHECKPREREQ · PHASE 0 · LESSONS 01 + 03
FIG. 07 / FROM DOCKERFILE TO RUNNING CONTAINER (SIMULATED)
layers container volume
LESSON 07TYPE · BUILD~60 MINPREREQ · PHASE 0 · LESSONS 01 AND 03ORIGINAL LESSON ↗
THE 60-SECOND VERSIONSee the problem ↓
01 / ONE IMAGE, EVERY MACHINE

A container is a process, not a virtual machine.

Your laptop runs Python 3.12, CUDA 12.4 and PyTorch 2.3; your colleague has 3.10, 11.8 and 2.1; the server has 3.11, 12.1 and 2.2. The model crashes on two of the three. Docker wraps Python, CUDA, cuDNN and the system libraries into one image that runs identically everywhere — sharing the host kernel, so it starts in seconds instead of booting a whole guest OS.

VM (virtual machine) boots an OS · container starts a process
02 / RECIPE, KITCHEN, PANTRY

Dockerfile → image → container → volume.

The Dockerfile is the recipe, written layer by layer; the image is the read-only result; a container is one running kitchen built from it. Each instruction is a cached layer, so rebuilding after an edit re-runs only the layers below the change. And because a container is disposable, the 14 GB of fp16 weights (7B × 2 bytes) lives in a volume on the host — never in the image.

≈ 4 GB devel · ≈ 1.5 GB runtime · ≈ 6 GB pytorch · ≈ 150 MB slim
03 / TWO SERVICES, ONE COMMAND

Compose wires the stack; service names route it.

A real RAG application needs an inference container and a vector database. One YAML file describes both: the ai-dev service with its GPU reservation and three mounts, plus qdrant/qdrant:v1.12.5 with ports 6333 and 6334 and a named volume. Compose builds a shared network, so http://qdrant:6333 resolves inside it, while your browser reaches the same database at localhost:6333.

docker compose up -d · down · down -v (deletes the volume)
MENTAL MODEL IN ONE SENTENCE

An image is a read-only recipe, a container is a kitchen built from it, and a volume is the pantry outside — so the environment travels between machines while the 14 GB of weights stay exactly where they are; that is why docker run needs no re-download and docker compose up -d can build a whole RAG stack.

By the end you will be able to verify Docker and GPU access (docker run --rm --gpus all … nvidia-smi), read the source’s Dockerfile instruction by instruction and say what each layer buys, choose between the ≈ 4 GB devel, ≈ 1.5 GB runtime, ≈ 6 GB PyTorch and ≈ 150 MB slim base images, mount code, models and datasets so nothing is re-downloaded, run the two-service Compose stack and reach Qdrant by service name, and fall back cleanly to the CPU (central processing unit) when there is no GPU.

THREE MACHINES, ONE IMAGE

It worked on your machine.
It worked on nobody else’s.

You trained a model on your laptop with PyTorch 2.3, CUDA 12.4 and Python 3.12. Your colleague has PyTorch 2.1, CUDA 11.8 and Python 3.10. The server has PyTorch 2.2, CUDA 12.1 and Python 3.11. The model crashes on both. One Dockerfile changes the ending.

An AI stack is not one program. It is Python, PyTorch, CUDA drivers, cuDNN, system-level C libraries, and packages like flash-attn that were compiled against an exact combination of the others. Each layer has its own version number, and the combinations that work are a small island in a large sea. When the three machines disagree, there is nothing to debug: the environment itself is the bug.

A container wraps the code, the runtime, the libraries and the system tools into one isolated unit that runs identically everywhere. It is not a virtual machine. A virtual machine boots a whole guest operating system with its own kernel; a container shares the host kernel and isolates at the process level, so it starts in seconds instead of minutes. That one design choice is why a 9 GB image can feel instant — and why containers, not virtual machines, became the unit of AI software.

The source’s three machines. With Docker, the right-hand column is the same image tag on every row.
machinePythonCUDAPyTorchwithout Dockerwith Docker
your laptop3.1212.42.3runsai-dev
colleague’s laptop3.1011.82.1crashesai-dev
the server3.1112.12.2crashesai-dev
WITHOUT DOCKERpython 3.12 · cuda 12.4pytorch 2.3python 3.10 · cuda 11.8pytorch 2.1python 3.11 · cuda 12.1pytorch 2.2WITH DOCKER — SAME IMAGE EVERYWHEREmachine 1 · dockerai-dev · python 3.12 · cuda 12.4 · torch 2.6.0machine 2 · dockerai-dev · python 3.12 · cuda 12.4 · torch 2.6.0machine 3 · dockerai-dev · python 3.12 · cuda 12.4 · torch 2.6.0the image carries the versions; the host only provides the kernel and the GPU driver
Docker does not make the three machines identical. It makes the environment identical and lets each machine stay what it is.

The source’s framing is worth keeping: Docker is a lightweight virtual machine that shares the host OS kernel instead of running its own. The kernel is the part every container on a machine has in common, so it cannot be part of the image — and that is precisely why the GPU driver also stays outside. A container carries the CUDA toolkit; the host supplies the driver that talks to the physical card. Chapter 03 and chapter 04 come back to that split, because it is the single most confusing thing about GPUs in Docker.

the whole idea in two commandsbash
# before: install the right Python, CUDA and PyTorch on every machine
# after:  build once, run anywhere the Docker daemon + GPU driver exist

docker build -t ai-dev .          # one artifact, built from text
docker run --rm -it --gpus all ai-dev python -c "import torch; print(torch.__version__)"
Adapted from the source's Build It steps. The Dockerfile that defines ai-dev is chapter 05; the flags in that run command are decoded in chapter 04.
RECIPE, KITCHEN, PANTRY

The image is the recipe.
The container is the kitchen.

Five words carry all of Docker, and four of them are routinely confused with each other. The source’s kitchen metaphor is the fastest way through: a read-only recipe, a running kitchen, and a pantry that outlives both.

An image is a read-only template — the recipe card. A container is a running instance of that image — the kitchen built from the card today. The Dockerfile is the recipe itself, written as instructions that Docker executes layer by layer. A volume is storage that survives the container: the pantry, outside the kitchen. And Docker Compose (compose is short for composition, the act of putting parts together) is the manager who opens several kitchens at once from a single YAML (YAML Ain’t Markup Language) file.

The word layer is the one that pays rent. Each Dockerfile instruction produces one layer — a filesystem snapshot that Docker stores by content and reuses whenever the instruction and its parent are unchanged. The source’s Dockerfile has seven: the FROM base (≈4 GB of CUDA toolkit and compilers), an apt layer that installs Python 3.12 and erases the package lists, a hash-verified get-pip layer, a pip upgrade, the pinned PyTorch wheels (≈2.5 GB), the AI library set (≈1.3 GB), and a metadata layer that records the working directory, the mount points, the port and the default command. Change an early layer and every layer after it is rebuilt; leave it alone and the whole stack comes back from cache in seconds. That is why AI images are practical at all: the 4 GB base is downloaded once, and a rebuild after a code edit usually costs nothing.

The source’s vocabulary, with the kitchen translation.
termwhat it meansin the kitchen
Imagea read-only template, built from a Dockerfilethe recipe card
Containera running instance of an imagethe kitchen, built from the card
Dockerfileinstructions to build an image, layer by layerthe written recipe
Volumepersistent storage that survives container restartsthe pantry, outside the kitchen
Docker Composemulti-container applications defined in YAMLthe manager with one opening checklist

The layer & cache builder

Click any instruction to “edit” it, then rebuild. Docker reuses every layer above your edit and re-runs the edited layer plus everything after it — that is the whole reason AI images are fast to rebuild.

edit an instruction
nothing edited yet · the image is whole image ≈ 8.9 GB over 7 layers cold build ≈ 12 min 32 s pick an instruction above, then rebuild: the layers before your edit stay cached the edited layer and every layer after it re-run

The source’s first build downloads the CUDA base and PyTorch and takes a while; later builds reuse cached layers. Times here are teaching estimates — the invalidation rule is real.

Quick check

You change one word in the Dockerfile's pip line — torch==2.6.0+cu124 becomes torch==2.6.0+cu126. Which layers rebuild when you run docker build again?

WHY AI NEEDS CONTAINERS MORE

Two fragile giants and a
three-part application.

Every software project has dependency problems. AI projects have three that ordinary web apps do not — and each one is the reason a specific Docker feature exists.

One: GPU (graphics processing unit) drivers are fragile. CUDA 12.4 code does not run on CUDA 11.8 — the versions are not interchangeable, and a PyTorch wheel is compiled against exactly one of them. The source’s resolution splits the problem in two: the CUDA toolkit (≈4 GB of libraries and compilers) lives inside the container, while the GPU driver stays on the host and is shared through the NVIDIA Container Toolkit. One driver, many toolkits — which is why the same ai-dev image can run on a workstation, a training node and a laptop with a different Python entirely.

Two: model weights are large. A 7-billion-parameter model is 14 GB in fp16 (16-bit floating point) — 2 bytes per parameter. A rebuild should never re-download it, because 14 GB is 14 × 1024 = 14,336 MB, which is ≈ 2 min 23 s on a 100 MB/s link and ≈ 12 min on a 20 MB/s one, times every rebuild and every teammate. The fix is a volume: -v ~/models:/models keeps the weights on the host, and the container reads them from a path that never changes.

Three: real AI applications are multi-service. A retrieval-augmented generation (RAG) application is not a script: it is an inference server, a vector database for retrieval, and often a web frontend. Running each one by hand means three terminals, three networks and three sets of flags. Docker Compose describes all of them in one YAML file and starts them with one command, on a shared network where they address each other by name.

HOSTthe GPU driver + the physical card — installed onceGPU · drivercontainer · your laptopcuda toolkit 12.4 · torch 2.6.0python 3.12 · librariesshared drivercontainer · training nodecuda toolkit 12.4 · torch 2.6.0python 3.12 · librariescontainer · inference podcuda toolkit 12.4 · torch 2.6.0python 3.12 · librariesthe toolkit is in the image; the driver is on the host
The split that makes GPU containers possible: one toolkit per image, one driver per machine, a runtime hook between them.

The honest arithmetic behind reason two. One 7B model in fp16 is 14 GB, and a single rebuild without a volume would pull all 14 GB again. Instead of trusting that intuition, price it: on the lesson’s 100 MB/s teaching link the download is ≈ 2 min 23 s; three teammates × two rebuilds is six downloads, ≈ 14 minutes of pure network and 86 GB of transfer. Mount ~/models once and the number is zero — for every rebuild, every container and every teammate who pulls the image. The image never contains the weights, which is also why the image stays around 9 GB instead of 23 GB.

INSTALL & VERIFY

Install the engine.
Then prove it can see the GPU.

Two commands install Docker, two more prove it works, and — only on Linux with an NVIDIA card — one extra toolkit lets containers use the GPU. Every step has a check that either passes or tells you exactly what is missing.

Docker has two pieces: a daemon (dockerd) that does the real work, and a client (the docker command) that talks to it. Docker Desktop packages both for macOS and Windows; on Linux they come from Docker’s own repository. The commands below are the source’s, verbatim.

step 1 — install Dockerbash
# macOS
brew install --cask docker
open /Applications/Docker.app

# Ubuntu
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# Log out and back in for the group change to take effect
The usermod line puts your account in the docker group so docker runs without sudo. Group membership is read at login, which is why a new shell is not always enough — a full log out and back in is.
step 1b — verifybash
docker --version        # Docker version 27.x.x, build ...
docker run hello-world  # prints a greeting, then exits

# what the greeting proves, in order:
#   1 the client reached the daemon
#   2 the daemon pulled an image from a registry
#   3 a container started, ran a process, printed I/O and exited
hello-world is deliberately tiny. If it fails, nothing above it needs debugging: start the daemon (Docker Desktop, or sudo systemctl start docker on Linux) and try again.

Step 2 is only for Linux machines with an NVIDIA GPU. The NVIDIA Container Toolkit installs a runtime hook that teaches Docker how to hand the host GPU to a container. macOS and Windows (WSL2) users skip it: Docker Desktop handles GPU passthrough differently on those platforms. The source gives the exact keyring and apt dance, because the toolkit is not in the default Ubuntu repositories.

step 2 — NVIDIA Container Toolkit (Linux + NVIDIA only)bash
distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/$distribution/libnvidia-container.list | \
    sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
    sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list

sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
nvidia-ctk writes the nvidia runtime into Docker's daemon configuration; the restart is what makes it take effect. Read the two commands as: trust NVIDIA's key, then subscribe to NVIDIA's repository.

Then the test that decides everything. It runs a small CUDA image, asks the container to run nvidia-smi — the NVIDIA System Management Interface — and the answer comes from the physical card on the other side of the runtime hook:

step 2b — prove the GPU is visible inside a containerbash
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi

# expected: the usual nvidia-smi table — driver version, CUDA version,
# the card's name, memory used / total, running processes.
# if you see the table, the toolkit is working.
This uses the base image — 12.4.1-base-ubuntu22.04 — not the devel image the Dockerfile builds from. The test needs nvidia-smi and nothing else.
Quick check

You are on Ubuntu with an RTX card. You installed Docker and pulled the CUDA image, but you skipped the NVIDIA Container Toolkit. What does the GPU test do?

The run-command builder

Toggle the flags and watch the command assemble. Each flag has one job; the warnings list what happens when a job goes unassigned.

THE COMMAND, ASSEMBLED · THE SOURCE'S STEP 4 RUN COMMAND
docker run \
    --rm \
    -it \
    --gpus all \
    -v $(pwd):/workspace \
    -v ~/models:/models \
    ai-dev \
    python -c "import torch; print(f'PyTorch {torch.__version__}, CUDA: {torch.cuda.is_available()}')"
What each flag in the box above actually does.
flagjob
--rmdelete the container when the command exits — no graveyard of stopped containers
-itinteractive terminal: stdin + a TTY, so a REPL, a shell or Jupyter's log stream behave
--gpus allhand every host GPU to the container (needs the NVIDIA Container Toolkit on Linux) — drop it for the CPU fallback
-v $(pwd):/workspaceyour code, live: edits appear inside without a rebuild
-v ~/models:/modelsthe ≈14 GB of weights stay on the host, outside the image
flags
what to run inside
flags 5 of 7 set gpu requested (--gpus all) model mount ~/models → /models (14 GB stays on the host) port none published ⚠ --gpus all requires the NVIDIA Container Toolkit (Linux). On macOS or Windows-WSL2, or with no NVIDIA GPU, drop it — PyTorch falls back to the CPU. the source's two commands, for reference python check docker run --rm -it --gpus all -v $(pwd):/workspace -v ~/models:/models \ ai-dev python -c "import torch; print(f'PyTorch {torch.__version__}, CUDA: {torch.cuda.is_available()}')" Jupyter docker run --rm -it --gpus all -v $(pwd):/workspace -v ~/models:/models \ -p 8888:8888 ai-dev jupyter notebook --ip=0.0.0.0 --port=8888 --no-browser --allow-root

In the source’s compose file the same settings become YAML: volumes:, ports: and the NVIDIA deploy block. Same facts, different syntax.

No GPU? The fallback is a first-class path. Remove --gpus all from the run command and the NVIDIA deploy block from the compose file (chapter 07), and everything still works for CPU lessons: PyTorch detects the absence of CUDA and runs on the CPU automatically. The images are identical, the flags are not — which is exactly the kind of difference a Dockerfile is supposed to make forgettable.

the CPU-only variant of the source's run commandbash
# GPU machine (Linux + toolkit):
docker run --rm -it --gpus all \
    -v $(pwd):/workspace \
    -v ~/models:/models \
    ai-dev python -c "import torch; print(f'PyTorch {torch.__version__}, CUDA: {torch.cuda.is_available()}')"

# any other machine — the same command, one flag lighter:
docker run --rm -it \
    -v $(pwd):/workspace \
    -v ~/models:/models \
    ai-dev python -c "import torch; print(f'PyTorch {torch.__version__}, CUDA: {torch.cuda.is_available()}')"
Expected output on the GPU machine: PyTorch 2.6.0+cu124, CUDA: True. On the CPU machine the version string is the same and CUDA prints False.
THE DOCKERFILE, LINE BY LINE

Thirteen instructions.
Every one has a reason.

The source’s Dockerfile is a complete GPU-enabled Python environment: CUDA, Python 3.12, PyTorch, Jupyter and the Hugging Face stack. Reading it top to bottom is the fastest way to learn what each Dockerfile verb actually buys.

First, choose the starting point. The FROM image decides what you do not have to install — and what you can never remove. The source lays out four options and when each is right:

Base images from the source. Sizes are the source’s approximations (≈); every build adds its own packages on top.
base imagesizeuse for
nvidia/cuda:12.4.1-devel-ubuntu22.04≈ 4 GBbuilding packages that need nvcc (flash-attn, bitsandbytes)
nvidia/cuda:12.4.1-runtime-ubuntu22.04≈ 1.5 GBrunning pre-built code
pytorch/pytorch:2.6.0-cuda12.4-cudnn9-runtime≈ 6 GBskipping the PyTorch install step
python:3.12-slim≈ 150 MBinference on CPU, lightweight tools

The image-size comparator

Every gigabyte in the base image is a gigabyte on every machine that runs it. Pick a base and a package set; the bar shows the pull cost your cold start pays.

pattern
base image
package layers
base nvidia/cuda:12.4.1-devel-ubuntu22.04 building packages that need nvcc (flash-attn, bitsandbytes) cuda yes — a GPU can be used nvcc yes — can compile CUDA extensions torch not installed nvidia/cuda:12.4.1-devel-ubuntu22.04 ≈ 4 GB apt tools + Python 3.12 ≈ 900 MB pip + setuptools + wheel ≈ 100 MB PyTorch cu124 wheels ≈ 2.5 GB AI library set ≈ 1.3 GB Jupyter ≈ 400 MB TOTAL ≈ 9.2 GB pull at 100 MB/s ≈ 1 min 34 s (every machine, every cold start) pull at 200 MB/s ≈ 47 s pull at 20 MB/s ≈ 7 min 51 s

The source’s rule of thumb: devel to build, runtime to run, pytorch/pytorch to skip the install, python:3.12-slim when there is no GPU at all.

Now the file itself. This is code/Dockerfile from the source, unedited:

code/Dockerfile — the complete GPU development imagedockerfile
FROM nvidia/cuda:12.4.1-devel-ubuntu22.04

ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1

RUN apt-get update && apt-get install -y --no-install-recommends \
    software-properties-common \
    git \
    curl \
    build-essential \
    && add-apt-repository -y ppa:deadsnakes/ppa \
    && apt-get update && apt-get install -y --no-install-recommends \
    python3.12 \
    python3.12-venv \
    python3.12-dev \
    && rm -rf /var/lib/apt/lists/*

RUN update-alternatives --install /usr/bin/python python /usr/bin/python3.12 1

RUN curl -sSL https://raw.githubusercontent.com/pypa/get-pip/3b73145063be545b649ad9ca83ea8da5fc915a4f/public/get-pip.py -o /tmp/get-pip.py \
    && echo "a341e1a43e38001c551a1508a73ff23636a11970b61d901d9a1cad2a18f57055  /tmp/get-pip.py" | sha256sum -c - \
    && python /tmp/get-pip.py \
    && rm /tmp/get-pip.py \
    && update-alternatives --install /usr/bin/pip pip /usr/local/bin/pip3.12 1

RUN python -m pip install --no-cache-dir --upgrade pip setuptools wheel

RUN python -m pip install --no-cache-dir \
    torch==2.6.0+cu124 \
    torchvision==0.21.0+cu124 \
    torchaudio==2.6.0+cu124 \
    --index-url https://download.pytorch.org/whl/cu124

RUN python -m pip install --no-cache-dir \
    numpy \
    pandas \
    scikit-learn \
    matplotlib \
    jupyter \
    transformers \
    datasets \
    accelerate \
    safetensors

WORKDIR /workspace

VOLUME ["/workspace", "/models"]

EXPOSE 8888

CMD ["python"]
From the lesson's code/Dockerfile. The seven-row layer board in chapter 02 folds some instructions together — the two ENV lines, the update-alternatives step and the four metadata lines — so it stays readable; the file is the source of truth.

The walkthrough, instruction by instruction. Read it as a shopping list with a budget; the numbers in the sidebar are teaching estimates, but the reasons are not:

  1. FROM nvidia/cuda:12.4.1-devel-ubuntu22.04
    The base image, and the most consequential line in the file. Devel carries the CUDA toolkit plus compilers (≈ 4 GB), which matters because the pip installs below may need to build something. Swap it for runtime (≈ 1.5 GB) if nothing is ever compiled.
  2. ENV DEBIAN_FRONTEND=noninteractive
    apt asks questions when it installs. A build has no keyboard, so noninteractive mode makes every prompt take its default instead of hanging forever.
  3. ENV PYTHONUNBUFFERED=1
    Python buffers stdout when it is not attached to a terminal — which is exactly how a container runs. This makes logs appear as they are written, so docker logs -f shows training progress instead of a silent minute followed by a wall of text.
  4. RUN apt-get update && apt-get install … python3.12 … && rm -rf /var/lib/apt/lists/*
    One RUN is one layer, and the line ends by deleting apt's package lists in the same layer. Deleting them in a later RUN would not shrink the image — the earlier layer would still contain them. Ubuntu 22.04 ships Python 3.10, so the source adds the deadsnakes PPA (Personal Package Archive) to get 3.12.
  5. RUN update-alternatives --install /usr/bin/python python /usr/bin/python3.12 1
    Ubuntu 22.04's default python3 is 3.10; the source wants the word python to mean 3.12. update-alternatives registers the new interpreter with a priority so every later command — and every shell inside the running container — resolves python to it.
  6. RUN curl … get-pip.py && sha256sum -c - …
    get-pip.py is downloaded from a pinned commit and verified against a known SHA-256 hash (Secure Hash Algorithm, 256-bit) before it runs. Same idea as the flake, 88-character version: prove the bytes are the ones that were reviewed. This is the supply-chain check the source builds into the image.
  7. RUN python -m pip install --no-cache-dir --upgrade pip setuptools wheel
    The packaging tools themselves, upgraded before anything else is installed. --no-cache-dir keeps pip's download cache out of the layer — worth remembering when the cache would be gigabytes of wheels sitting in your image forever.
  8. RUN python -m pip install torch==2.6.0+cu124 … --index-url https://download.pytorch.org/whl/cu124
    Every version is pinned: torch 2.6.0, torchvision 0.21.0, torchaudio 2.6.0, all +cu124, from the CUDA 12.4 wheel index. Pinning is what turns “works today” into “works”, and the index URL is what gets the CUDA build instead of the default one. The wheels are large — ≈ 2.5 GB installed by the lesson's estimate.
  9. RUN python -m pip install numpy pandas scikit-learn matplotlib jupyter transformers datasets accelerate safetensors
    The library set: the numerical core, plotting, the notebook server, and the Hugging Face stack for loading models. Unpinned on purpose — these are the packages you will edit most often, and the ones to pin first once a project gets real. ≈ 1.3 GB plus Jupyter's share.
  10. WORKDIR /workspace
    Where the container stands by default. Combined with -v $(pwd):/workspace, it means the container's working directory is your project directory — the code is live, and nothing is copied into the image.
  11. VOLUME ["/workspace", "/models"]
    Declares the two mount points. The declaration alone does not persist anything: it marks these paths as places where a volume is expected and, if you run the image without -v, Docker creates an anonymous volume so data written there is at least not lost with the container.
  12. EXPOSE 8888
    Documents that the image wants port 8888 — Jupyter's default. It opens nothing: publishing happens at run time with -p 8888:8888. Treat EXPOSE as the comment that tools and readers can parse.
  13. CMD ["python"]
    The default command when you run the image without one. The exec form — a JSON array — skips the shell, and because it is the default, docker run … ai-dev jupyter notebook overrides it from the command line.

Build it. The trailing dot is not decoration: it is the build context, the directory Docker is allowed to read from. The source’s command runs from the repository root because the file lives deeper in the tree:

build and runbash
docker build -t ai-dev -f phases/00-setup-and-tooling/07-docker-for-ai/code/Dockerfile .

# first build: downloads the ≈4 GB CUDA base + PyTorch, several minutes
# later builds: cached layers, seconds (until an instruction changes)
The -t ai-dev names and tags the image. Without -f, Docker looks for a file literally named Dockerfile in the context directory.
run the PyTorch check, then Jupyterbash
docker run --rm -it --gpus all \
    -v $(pwd):/workspace \
    -v ~/models:/models \
    ai-dev python -c "import torch; print(f'PyTorch {torch.__version__}, CUDA: {torch.cuda.is_available()}')"

docker run --rm -it --gpus all \
    -v $(pwd):/workspace \
    -v ~/models:/models \
    -p 8888:8888 \
    ai-dev jupyter notebook --ip=0.0.0.0 --port=8888 --no-browser --allow-root
--ip=0.0.0.0 binds Jupyter to every interface inside the container, which is what makes the published port reachable; --allow-root exists because containers often run as root; --no-browser skips trying to open a browser that is not there.

The ordering lesson, with numbers. The source’s development image never copies code — it mounts it — so a code edit is not a rebuild at all. Production images do copy code, and then the order of the file becomes a cost decision with a 34× range. Put COPY . /workspace above the pip layer and editing one line of app.py invalidates the 260-second pip install plus everything after it: ≈ 269 s. Put the COPY requirements.txt + pip install -r pair first and the code copy last, and the same edit rebuilds ≈ 2 s of copying plus 5 s of compilation — ≈ 8 s. Same application, same dependencies, same cache rule; only the order changed.

The Dockerfile caching optimizer

Docker re-runs your changed layer and everything below it. Reorder the instructions — slow, rarely-changing layers first, your source code last — and watch the rebuild estimate collapse.

A PRODUCTION IMAGE THAT COPIES ITS CODE — TEACHING ORDER, REAL RULE
  1. 01FROM nvidia/cuda:12.4.1-devel-ubuntu22.044 min · no build context
  2. 02RUN apt-get update && apt-get install -y … python3.12 …3 min · no build context
  3. 03COPY . /workspace2 s · reads your code
  4. 04RUN python -m pip install -r /workspace/requirements.txt4 min 20 s · needs reqs-copy
  5. 05RUN python -m compileall -q /workspace5 s · needs code-copy
  6. 06CMD ["python"]1 s · no build context
  7. 07COPY requirements.txt /workspace/requirements.txt1 s · reads requirements.txt
11 min 29 s

✗ this order would not build: reqs-pip needs reqs-copy above it — Docker would fail with “no such file or directory”

what changed?
change you edited app.py (the source code) cold build ≈ 11 min 29 s (every layer runs once) fix the order above to see the estimate editing app.py should cost seconds. If it costs minutes, the code copy sits too high in the file: move COPY . /workspace below the pip layers.

The source’s dev Dockerfile never copies code at all — it mounts $(pwd):/workspace, so a code edit is not even a rebuild. For images that do copy code, the rule is: dependencies first, code last.

THE PANTRY OUTSIDE THE KITCHEN

The container is disposable.
The 14 GB is not.

Everything a container writes lives in one thin writable layer on top of the image — and that layer is deleted with the container. Volume mounts are how AI work survives: code, models and datasets stay on the host while the container comes and goes.

The source is blunt about why this matters: without volume mounts, your 14 GB model downloads vanish when the container stops. The mechanics are simple. Each container gets a writable layer; when the container is removed — and --rm removes it the moment the command exits — that layer goes with it. The image underneath is untouched, the volumes attached to it are untouched, and everything written to a plain container path is gone.

A bind mount maps a directory you can see in your own file manager into the container: -v ~/models:/models is host path first, container path second. A named volume is storage Docker manages for you, referenced by name — the compose file in chapter 07 uses one for the vector database. Both outlive containers; the difference is who owns the directory.

the source's three mounts — and how the code uses thembash
# Mount your code            (host path : container path)
-v $(pwd):/workspace

# Mount a shared models directory
-v ~/models:/models

# Mount datasets
-v ~/datasets:/data
$(pwd) expands to the directory you are standing in when you run the command, so the container's /workspace is your project — live edits, no rebuild.
loading from a mounted pathpython
from transformers import AutoModel

model = AutoModel.from_pretrained("/models/llama-7b")

# The model lives on your host filesystem.
# Rebuild the container as often as you want without re-downloading.
The mount path is a stable interface: the code always says /models, and the host decides what actually sits behind it.

The arithmetic that justifies the habit. A 7-billion-parameter model is 14 GB in fp16. On the lesson’s 100 MB/s example link, re-downloading it is 14 × 1024 ÷ 100 ≈ 143 s — call it 2 min 23 s, every time, per machine. Drop to a 20 MB/s connection and it is ≈ 12 min. Multiply by a week of rebuilds and the volume mount is not a nice-to-have; it is the difference between an afternoon of work and an afternoon of waiting. And it is not only models: checkpoints larger than the base model, embedding indexes, and multi-gigabyte datasets all have the same property — large, stable, and expensive to fetch.

The volume persistence simulator

Two containers hold the same 14 GB model. Restart both: the mounted one reads its weights from your disk, the unmounted one starts from nothing.

mounts attached to container A
no restart yet. container A has 2 mounts attached: ✓ $(pwd) → /workspace ✓ ~/models → /models · ~/datasets → /data container B has none: everything it downloads lives in its writable layer, which is deleted with the container. press “restart both” to see the difference. 14 GB · at 100 MB/s ≈ 2 min 23 s to re-download

The model is 14 GB because 7 billion parameters × 2 bytes is 14 GB. Rebuild the image as often as you like once ~/models is mounted.

Quick check

You forget -v ~/models:/models and run the source's Jupyter command. You download a model to /models inside the container, work for an hour, then the container exits. What is left?

One more nicety from the source’s VOLUME line: it makes the contract explicit. A reader of the Dockerfile can see which paths are meant to be mounted, and Docker can warn when a run has no volume where one was expected. It is documentation with teeth — the same instinct as EXPOSE, one chapter earlier.

ONE COMMAND, TWO SERVICES

A RAG stack in
thirty-two lines of YAML.

An inference container and a vector database, on a shared network, started together and stopped together. Compose is where the pieces from this whole lesson become one application.

A real AI application is not a Python script: it is a service that answers questions, a vector database that supplies context, and usually a frontend. Docker Compose describes that arrangement once, in YAML, and runs it with one command. The source’s file has exactly two services: ai-dev, which builds the Dockerfile from chapter 05 and keeps the GPU reservation, the three volume mounts and port 8888; and qdrant, the vector database image, with ports 6333 (REST, the HTTP/JSON API) and 6334 (gRPC, a binary remote-procedure-call protocol) and a named volume for its storage.

code/docker-compose.yml — the source's RAG stackyaml
services:
  ai-dev:
    build:
      context: .
      dockerfile: Dockerfile
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]
    volumes:
      - ../../../:/workspace
      - ~/models:/models
      - ~/datasets:/data
    ports:
      - "8888:8888"
    stdin_open: true
    tty: true
    command: jupyter notebook --ip=0.0.0.0 --port=8888 --no-browser --allow-root

  qdrant:
    image: qdrant/qdrant:v1.12.5
    ports:
      - "6333:6333"
      - "6334:6334"
    volumes:
      - qdrant_data:/qdrant/storage

volumes:
  qdrant_data:
Every line maps to a docker run flag from chapter 04. volumes: is -v, ports: is -p, the deploy block is --gpus all, stdin_open + tty are -it, and command: overrides CMD — which is why the Jupyter command is not in the Dockerfile.

The part Compose adds that no flag does: a network. Compose creates a network for the project and runs a DNS (Domain Name System) resolver at 127.0.0.11 inside it. Every service gets its name as a hostname, so from inside the AI container http://qdrant:6333 just resolves. The source’s test is the proof:

the test from inside the AI containerpython
from qdrant_client import QdrantClient

client = QdrantClient(host="qdrant", port=6333)
print(client.get_collections())
Service name, not IP address, not localhost. IPs change when containers restart; the name is the stable interface.
start, stop, and stop-with-prejudicebash
cd phases/00-setup-and-tooling/07-docker-for-ai/code
docker compose up -d          # build if needed, start both services, detached

# ... work ...

docker compose down           # stop and remove containers + network, keep qdrant_data
docker compose down -v        # also delete the named volume — the vectors are gone
up -d returns your terminal; logs -f gives it back with live output. The -v in down -v means volumes, and it is not undoable.

The Compose network board

One YAML file, two services, one shared network. Switch the client and the hostname to see which requests actually resolve — and what down -v takes with it.

stack
who is asking
hostname in the request
stack up · ai-dev + qdrant on the shared network volume qdrant_data intact request QdrantClient(host="qdrant", port=6333) # from inside ai-dev result ✓ 200 OK from http://qdrant:6333 compose created a shared network and runs a DNS resolver at 127.0.0.11: the service name qdrant resolves to the container's IP. from inside ai-dev http://qdrant:6333 ✓ service-name DNS from your browser http://localhost:6333 ✓ published port localhost inside http://localhost:6333 ✗ that is ai-dev itself down stops and removes the containers down -v also deletes the qdrant_data volume — the vectors are gone

The source’s test: QdrantClient(host="qdrant", port=6333) from inside the AI container — service name, not IP address.

Quick check

Your notebook runs inside the ai-dev container. QdrantClient(host='localhost', port=6333) refuses the connection, but the same code with host='qdrant' works. Why?

The commands worth knowing by heart. The source ends its build steps with a short list; each one answers a question you will ask this month:

commandwhat it answers
docker pslist running containers — add -a to include the stopped ones
docker imageslist images and their sizes; the honest measure of your base-image choice
docker system prune -areclaim disk space by deleting unused images, layers and build cache — read the prompt before typing y
docker exec -it <container_id> nvidia-smicheck GPU usage from inside a running container
docker cp <container_id>:/workspace/results.csv ./results.csvcopy a file out of a container (or in, by swapping the sides)
docker logs -f <container_id>follow a container's output live — much better with PYTHONUNBUFFERED=1, as the Dockerfile sets
CHECK YOURSELF

Five questions.
Then the terms worth keeping.

Answer before you look. The volume question and the service-name question are the two that separate “I followed the commands” from “I can build the stack myself”.

0 / 5 answered · 0 correct

01What is the primary difference between a Docker container and a virtual machine?

02What is a Dockerfile?

03Why are volume mounts critical for AI development with Docker?

04What does the NVIDIA Container Toolkit enable?

05In a Docker Compose file for AI, how does the 'ai-dev' service reach the 'qdrant' vector database?

Key terms, demystified

Click a card to swap the lazy description for what it actually means.

Exercises from the lesson

Four problems with exact commands — build the image and read its cache, reach Qdrant by name, add a Flask server, and price a base-image switch. Try first; a worked answer is one click away.

  1. Build the source's Dockerfile, then run the PyTorch check inside the container. Report the build time, what the second build does, and what the printed CUDA value tells you.
    Show one worked answer

    From the lesson's code directory: docker build -t ai-dev -f Dockerfile . — the trailing dot is the build context. Expect a long first build (the source warns it takes a while: the ≈4 GB CUDA base downloads, apt installs Python 3.12, then pip pulls the cu124 torch wheels) and a fast second build where Docker prints CACHED for every step, because nothing in the Dockerfile changed between builds. Then run the check: docker run --rm -it --gpus all -v $(pwd):/workspace -v ~/models:/models ai-dev python -c "import torch; print(f'PyTorch {torch.__version__}, CUDA: {torch.cuda.is_available()}')". On an NVIDIA machine with the Container Toolkit you should see PyTorch 2.6.0+cu124, CUDA: True. Without --gpus all (or with no NVIDIA GPU) the same command prints CUDA: False and works on the CPU — that is the designed fallback, not a broken build. Two honest notes: the version string is pinned by the Dockerfile, so it will not drift; and if the build fails at the apt step, the deadsnakes PPA or your network is the usual suspect.

  2. Start the Compose stack and verify Qdrant is reachable from the AI container at http://qdrant:6333/collections. Then explain what happens if you use localhost instead — from inside the container, and from your own machine.
    Show one worked answer

    cd into the code directory and run docker compose up -d; Compose builds ai-dev, pulls qdrant/qdrant:v1.12.5, creates the network and starts both. Three checks, three different answers. (1) From inside the AI container: docker compose exec ai-dev curl -s http://qdrant:6333/collections — the service name resolves through Compose's DNS to qdrant's IP, and you get JSON (JavaScript Object Notation) listing the collections. In Python the source uses QdrantClient(host="qdrant", port=6333). (2) From inside the same container, localhost:6333 fails: localhost is ai-dev itself, and nothing in ai-dev listens on 6333. (3) From your own machine, localhost:6333 works, because the port mapping 6333:6333 publishes qdrant's REST port on the host — but the name qdrant does not resolve outside the Compose network. Stop with docker compose down (containers and network removed, the qdrant_data volume kept); docker compose down -v also deletes the volume, so the vectors are gone for good.

  3. Add flask to the Dockerfile, rebuild, and run a minimal API server on port 5000. Map the port and test it from the host. Then say which layers rebuilt.
    Show one worked answer

    Add flask where the other Python packages live (the source's `RUN python -m pip install --no-cache-dir numpy pandas … safetensors` block), then docker build -t ai-dev . and watch the output: Docker reuses the FROM, apt and get-pip layers from cache and re-runs the pip layer that changed plus every layer after it — which is exactly the layer-cache rule the lesson teaches, and the reason adding the package to the existing pip layer is cheaper than adding a new pip layer after it. Run the server with docker run --rm -it --gpus all -v $(pwd):/workspace -p 5000:5000 ai-dev python app.py, where app.py is a two-line Flask application. The detail that trips people: app.run(host="0.0.0.0", port=5000) — binding to 127.0.0.1 inside the container makes the port unreachable from the host even though -p 5000:5000 is correct, because the published port forwards to the container's network interface, not to its loopback. Test with curl http://localhost:5000/ from the host; the container's own localhost:5000 is a different address space.

  4. Measure the image size with docker images. Then switch the base image from devel to runtime, rebuild, and compare — and say what the switch would break.
    Show one worked answer

    docker images shows every image with its repository, tag, image ID and size; the source also recommends docker system prune -a to reclaim disk space from unused images and layers. Switching FROM nvidia/cuda:12.4.1-devel-ubuntu22.04 to nvidia/cuda:12.4.1-runtime-ubuntu22.04 changes the first instruction, so every layer rebuilds — a layer's cache key includes its parent, so nothing below FROM can be reused. The source's approximate base sizes are the honest comparison: ≈4 GB devel versus ≈1.5 GB runtime, so the finished image drops by roughly the compiler toolchain's share (the rest — Python, torch, the AI libraries — is identical in both). The switch breaks one thing only: compiling packages that need nvcc. flash-attn and bitsandbytes style builds run in the Dockerfile and need the CUDA compiler present; on runtime they fail at build time, not at run time — pre-built code keeps working. The right pattern for a lean production image is a multi-stage build: compile on devel, copy the finished wheels onto runtime, ship the small one.

Terms this lesson borrows from later lessons (or outside)

You do not need to master these here. Each one gets a proper treatment in its own lesson; the one-line meaning is enough to keep reading. Orange dotted underlines in the prose point back to this list.

  • model weights & fp16The 2-bytes-per-parameter rule that makes a 7B model 14 GB and makes volume mounts non-optional. Worked out in Phase 0, Lesson 03 (GPU Setup & Cloud); the parameter itself gets its full treatment in Phase 3, Lesson 01 (The Perceptron).
  • CUDA toolkit vs GPU driverWhy the container can carry CUDA 12.4 while the host keeps the driver, and why a wheel built for one CUDA version refuses a driver that is too old. Phase 0, Lesson 03 (GPU Setup & Cloud) reads the three version numbers in order.
  • vector database & RAGWhat the qdrant service is for: storing embeddings so a model can retrieve relevant text before answering (retrieval-augmented generation). Phase 11, Lesson 06 (RAG — Retrieval-Augmented Generation) builds the full pipeline.
  • cold startThe time between “run this container” and “the service answers” — including pulling the image, which is why the lesson weighs ≈9 GB against ≈150 MB. Phase 17, Lesson 10 (Cold Start Mitigation for Serverless LLMs) attacks the same problem from the serving side.
  • supply-chain verificationThe hash check in the source's Dockerfile (echo "a341e1a4…" | sha256sum -c -) proves the downloaded get-pip.py is the exact file that was reviewed, not whatever a compromised mirror serves. The broader discipline shows up in Phase 17's production lessons.
KEEP GOING

A picture is a start.
Practice is the rest.

This lesson is a port of an open course. Everything here traces back to it — and the next step is running the code yourself.

Lesson text, quiz and the Dockerfile and docker-compose.yml (from code/) are adapted from AI Engineering from Scratch (Phase 00, Lesson 07) and the Math Foundations Notebook reference build. The six labs — the layer-and-cache builder, the image-size comparator, the volume-persistence simulator, the Compose network board, the caching optimizer and the run-command builder — are original to this page, as are the build arithmetic (≈ 240 s CUDA base, ≈ 180 s apt, ≈ 210 s torch, ≈ 752 s cold build; a 269 s versus 8 s code-edit rebuild), the 14 GB download arithmetic (14 × 1024 MB ÷ 100 MB/s ≈ 2 min 23 s), the package-size estimates layered on the source's base-image sizes, the service-name DNS walkthrough, the down -v warning and the deployment-unit connection. Every per-layer time, package size and download rate is labelled in the labs as a teaching estimate; the base-image sizes (≈ 4 GB / ≈ 1.5 GB / ≈ 6 GB / ≈ 150 MB) are the source's approximations and the port numbers (8888, 6333, 6334) are the source's.