Docker from First PrinciplesPost 6 of 18 · 18 of 18 written
- How to use this guide
- 01The problem Docker solves
- 02What a container really is
- 03Images: layers, hashes and registries
- 04Docker's architecture
- 05Container lifecycle
- 06Dockerfiles in depth
- 07Storage: where data lives
- 08Networking
- 09Docker Compose for multi-service AI apps
- 10GPUs in containers
- 11Building good images for AI workloads
- 12Serving and production patterns
- 13Security
- 14Debugging and introspection
- 15Beyond Docker
- 16Hands-on learning path
- 17Question bank
- 18Glossary and cheat sheet
6. Dockerfiles in depth
A Dockerfile is a build program whose output is an image. Writing good ones comes down to three things: controlling the build context, ordering instructions for the layer cache, and keeping build-time tools out of the runtime image.
The build context
In docker build -t myimg ., the final . is the build context: the directory the builder can read from. COPY can only reach files inside it, and paths are relative to it. COPY . . references everything in it, so a 40 GB data/ folder or a .git history gets scanned and can end up in the image.
A .dockerignore file in the context root excludes paths, using the same pattern syntax as .gitignore. For an ML repository, a sensible start:
.git
.venv
__pycache__/
*.pyc
.ipynb_checkpoints/
data/
checkpoints/
runs/
wandb/
mlruns/
.env
*.safetensors
*.ptThe instructions
| Instruction | What it does | Gotcha |
|---|---|---|
FROM |
Picks the base image; starts a build stage | Pin a tag or digest, never rely on latest |
RUN |
Runs a command at build time and saves the result as a layer | Clean caches in the same RUN, or they stay in the layer |
COPY |
Copies files from the context or another stage | Copy dependency files before source code (cache) |
ADD |
Like COPY, but also fetches URLs and unpacks archives |
Prefer COPY unless you need those extras |
WORKDIR |
Sets and creates the working directory | Use it instead of RUN cd …, which doesn’t persist |
ENV |
Sets environment variables for build and runtime | Values are visible in the image; never put secrets here |
ARG |
Build-time variable, passed with --build-arg |
Not available at runtime; also visible in history |
USER |
Switches the user for later steps and at runtime | Create the user first with useradd |
EXPOSE |
Documents which port the app listens on | Publishes nothing; -p does that |
ENTRYPOINT |
The executable that always runs | Use exec form (JSON array) |
CMD |
Default arguments, or the default command | Replaced by anything after the image name in docker run |
HEALTHCHECK |
Command Docker runs periodically to judge health | The tool it calls must exist in the image |
LABEL |
Metadata such as source repository or version | Use OCI label keys like org.opencontainers.image.source |
VOLUME |
Declares a mount point | Creates anonymous volumes silently; usually better left out |
How the layer cache works
For each instruction, BuildKit computes a cache key from the instruction text, the parent layer, and for COPY and ADD the checksums of the files copied. If the key matches a previous build, the layer is reused instantly. The first miss invalidates every instruction after it, so order instructions from least to most frequently changed: base image, system packages, Python dependencies, then your code.
# Slow: any edit to any .py file reinstalls every package
COPY . /app
RUN pip install -r /app/requirements.txt# Fast: packages reinstall only when requirements.txt changes
COPY requirements.txt /app/
RUN pip install -r /app/requirements.txt
COPY . /appThe cache doesn’t know about the outside world. RUN pip install torch stays cached even after a new torch release, until something above it changes. --pull refreshes the base image, --no-cache rebuilds everything. For system packages, keep update and install in one RUN so the package index is never stale or left behind:
RUN apt-get update \
&& apt-get install -y --no-install-recommends git libgomp1 \
&& rm -rf /var/lib/apt/lists/*Shell form versus exec form
CMD python app.py is shell form: Docker runs it as /bin/sh -c "python app.py". Variable expansion and pipes work, but the shell becomes PID 1 and may swallow signals (Part 5). CMD ["python", "app.py"] is exec form: a JSON array, no shell, your process is PID 1. Use exec form for ENTRYPOINT and CMD.
ENTRYPOINT versus CMD
ENTRYPOINT is the program; CMD is its default arguments. Arguments you pass after the image name replace CMD and are appended to ENTRYPOINT. That makes an image behave like a command-line tool:
ENTRYPOINT ["python", "-m", "src.train"]
CMD ["--config", "configs/base.yaml"]docker run trainer runs with the base config; docker run trainer --config configs/large.yaml --epochs 3 swaps the arguments. If you need setup logic (waiting for a database, templating a config), use a shell script as the entrypoint and end it with exec "$@" so the real process takes over PID 1.
Multi-stage builds
A Dockerfile can contain several FROM stages; a later stage copies only what it needs from earlier ones with COPY --from=<stage>. Compilers, headers and caches stay in the builder stage, and the final image contains just the runtime. For ML this is how you compile CUDA extensions in a large devel image and ship them in a small runtime one. You can also build a specific stage with --target, for example a dev stage with Jupyter and a prod stage without.
A production-quality Python service with uv and a lockfile:
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS builder
# Pin a specific uv version in real projects
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
# 1. Dependencies only: cached until the lockfile changes
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --no-install-project
# 2. Then the project itself
COPY src/ ./src/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
FROM python:3.12-slim AS runtime
RUN useradd --create-home --uid 1000 app
WORKDIR /app
COPY --from=builder --chown=app:app /app /app
ENV PATH="/app/.venv/bin:$PATH" PYTHONUNBUFFERED=1
USER app
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]Two details carry the design. Both stages use the same base image, because the virtual environment’s python points at the base image’s interpreter path. And the server binds 0.0.0.0, not 127.0.0.1, so that published ports can reach it through the container’s network namespace.
BuildKit features worth knowing
- Cache mounts (
RUN --mount=type=cache,target=…) keep pip, uv or apt download caches between builds without putting them in the image. Reinstalling torch then takes seconds instead of minutes. - Secret mounts (
RUN --mount=type=secret,id=hf_token …, built withdocker build --secret id=hf_token,env=HF_TOKEN .) expose a token to oneRUNstep only; it never lands in a layer or the history. - SSH mounts (
--mount=type=ssh) let a build clone private Git repositories using your SSH agent. - Parallel stages. Stages that don’t depend on each other build concurrently.
- Remote cache (
--cache-toand--cache-from type=registry,…) shares the layer cache between CI runs and machines. - Multi-platform builds with
docker buildx build --platform linux/amd64,linux/arm64(Part 11).
Users and file ownership
Containers run as root unless the image sets USER. Run services as an unprivileged user, as in the example above. On a Linux host, root-in-container writing to a bind mount creates root-owned files on your disk; run with -u $(id -u):$(id -g) or give the image’s user your UID. Docker Desktop on macOS translates ownership across its file sharing, so you mostly meet this problem on Linux servers.