Dockerfiles¶
What You'll Learn¶
- What each instruction does, and which ones create layers
- How build context and caching work, and how to order a Dockerfile for fast rebuilds
- Multi-stage builds that ship only what runs
- BuildKit cache mounts and build secrets
CMDvs.ENTRYPOINT, signals and PID 1, non-root users, and health checks- Multi-platform builds and linting
Instructions at a Glance¶
| Instruction | Purpose | Creates a filesystem layer |
|---|---|---|
FROM |
Base image, starts a build stage | — |
RUN |
Run a command at build time | Yes |
COPY |
Copy files from the build context or another stage | Yes |
ADD |
Like COPY, plus URL download and archive extraction |
Yes |
WORKDIR |
Set (and create) the working directory | Yes, if created |
ENV |
Environment variable for build and runtime | No |
ARG |
Build-time variable, not present at runtime | No |
USER |
User for later instructions and the container process | No |
EXPOSE |
Document a listening port (doesn't publish it) | No |
CMD |
Default command or arguments | No |
ENTRYPOINT |
Fixed executable | No |
HEALTHCHECK |
How Docker checks the container is healthy | No |
LABEL |
Metadata such as source and version | No |
Prefer COPY over ADD unless you need ADD's extra behavior — implicit extraction and downloads make builds harder to reason about.
Build Context and .dockerignore¶
docker build . sends the context (the . directory) to the builder. COPY can only read from it.
.git
.venv
node_modules
__pycache__
*.log
.env
dist/
coverage/
Dockerfile*
compose*.yaml
A missing .dockerignore makes builds slow, busts the cache whenever any file changes, and can copy .env or .git into images.
Caching: Order Matters¶
Docker reuses a layer when the instruction and its inputs haven't changed. Once one layer changes, every layer after it is rebuilt.
# Fast: dependencies are reinstalled only when requirements.txt changes
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
Rule of thumb: copy what changes least first.
A Production-Ready Python Image¶
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
FROM base AS deps
RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=bind,source=requirements.txt,target=requirements.txt \
pip install --prefix=/install -r requirements.txt
FROM base AS runtime
RUN groupadd --system --gid 10001 app \
&& useradd --system --uid 10001 --gid app --no-create-home app
COPY --from=deps /install /usr/local
COPY --chown=app:app app/ ./app/
USER 10001:10001
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
docker build -t orders-api:1.4.0 .
docker run -d --name orders -p 8000:8000 orders-api:1.4.0
docker inspect -f '{{.State.Health.Status}}' orders # starting → healthy
What each choice buys you:
# syntax=docker/dockerfile:1enables current BuildKit Dockerfile features.--mount=type=cachekeeps pip's download cache between builds without putting it in the image.--mount=type=bindreadsrequirements.txtwithout creating aCOPYlayer.- A numeric
USERworks even with orchestrators that verify the user isn't root. HEALTHCHECKlets Docker and Compose know the app is actually serving, not just running.
Multi-Stage Builds¶
Build tools belong in a build stage; only the result ships. A Go service:
# syntax=docker/dockerfile:1
FROM golang:1.25 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
The final image contains one static binary and minimal OS files: no shell, no package manager, no compiler — a few megabytes instead of hundreds, and far less to patch. Build just one stage with docker build --target build . when debugging.
Build Secrets¶
Never pass credentials with ARG or ENV — they're recorded in the image history.
The secret is available to that one RUN step and never written to a layer.
CMD, ENTRYPOINT, and the Exec Form¶
| You run | Executed |
|---|---|
docker run orders |
python -m orders.cli --help |
docker run orders migrate --to 42 |
python -m orders.cli migrate --to 42 |
CMD supplies replaceable defaults; ENTRYPOINT fixes the executable.
Always use the exec form (JSON array). The shell form (CMD uvicorn app:app) runs /bin/sh -c, so the shell is PID 1, doesn't forward SIGTERM, and docker stop waits 10 seconds and then kills your app ungracefully.
PID 1 and Signals¶
The main process is PID 1 in its namespace. PID 1 doesn't get default signal handling and must reap orphaned child processes. If your app spawns children or doesn't handle SIGTERM, add a tiny init:
Or install tini in the image and use ENTRYPOINT ["tini", "--", "myapp"].
ARG vs. ENV¶
ARG APP_VERSION=dev # build time only
ENV APP_VERSION=${APP_VERSION} # copy into runtime if the app needs it
LABEL org.opencontainers.image.version=${APP_VERSION}
Pinning the Base Image¶
A digest makes builds reproducible. Pair it with automated update pull requests (Renovate or Dependabot), so pins don't silently go stale and miss security fixes.
Multi-Platform Builds¶
docker buildx create --name multi --use
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t registry.example.com/team/orders-api:1.4.0 \
--push .
Multi-platform images are pushed as an index, so each server pulls its own architecture. See Container Images.
Linting the Dockerfile¶
docker build --check . # BuildKit's built-in build checks
docker run --rm -i hadolint/hadolint < Dockerfile
Common Mistakes¶
COPY . .before installing dependencies, rebuilding everything on every code change.- No
.dockerignore, shipping.git,.env, ornode_modulesinto the image. - Shell-form
CMD, so the app never receivesSIGTERM. - Running as root because nothing set
USER. - Passing tokens via
ARG, wheredocker historyreveals them. - Installing and cleaning package caches in separate
RUNsteps, so the cache stays in an earlier layer. apt-get updatein oneRUNandapt-get installin another, reusing a stale cached package index.
Check Your Understanding¶
- Why does copying
requirements.txtbefore the source code speed up rebuilds? - What does a multi-stage build remove from the final image?
- Why is
CMD ["uvicorn", ...]better thanCMD uvicorn ...? - How do you give a build step a credential without leaving it in the image?
Next¶
Continue to Docker Compose.