Lesson 02 of 13 · Level 1 — Containers & images
Images & Dockerfiles
Write Dockerfiles that build fast (good cache use), produce small images (multi-stage) and run safely (non-root). Understand layers, CMD vs ENTRYPOINT, and the .dockerignore file that keeps secrets out of your image.
Images are layers
An image is a stack of read-only layers plus some metadata (which command to run, which user, which ports). Every RUN, COPY and ADD in a Dockerfile creates a new layer holding only what changed.
Two things follow from that:
- Layers are shared and cached. Ten images built on
python:3.12-slimstore that base only once, and a rebuild reuses every layer whose inputs didn't change. - Deleting in a later layer doesn't remove anything. If one layer adds a 500 MB file and the next deletes it, the image is still 500 MB bigger, and the file can still be extracted from the earlier layer. That matters for secrets.
A stack of transparent sheets on an overhead projector. The bottom sheet has the map, the next adds roads, the next adds your house. To change your house you only redraw the top sheet. Drawing a white box over something on a higher sheet hides it, but it's still there on the sheet underneath.
A first Dockerfile
A small Python API, built with the cache in mind:
FROM python:3.12-slim
WORKDIR /app
# 1. Dependencies first: this layer is reused until requirements.txt changes
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 2. Then the code, which changes on every commit
COPY . .
# 3. Don't run as root
RUN useradd --uid 10001 --no-create-home appuser
USER 10001
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
$ docker build -t shop/api:1.0 .
$ docker run -d --name api -p 8000:8000 shop/api:1.0
$ docker history shop/api:1.0 # one line per layer, with its size
Change one line of app.py and rebuild: the pip install step says CACHED and the build takes seconds.
.dockerignore: what never goes in
docker build . sends the whole folder (the build context) to the builder. A .dockerignore file next to the Dockerfile keeps things out:
.git
.env
*.pem
node_modules
__pycache__
dist/
Dockerfile*
docker-compose*.yml
This makes builds faster and, more importantly, stops .env files, keys and Git history from being baked into an image that you then push to a registry.
CMD vs ENTRYPOINT
| Purpose | Replaced by | |
|---|---|---|
CMD |
The default command, or default arguments | Anything after the image name in docker run |
ENTRYPOINT |
The fixed program | Only --entrypoint |
Used together, ENTRYPOINT is the program and CMD its default arguments:
ENTRYPOINT ["/usr/local/bin/backup"]
CMD ["--target", "/data"]
docker run backup-img --target /mnt runs /usr/local/bin/backup --target /mnt.
Always use the exec form (JSON array). The shell form CMD gunicorn app:app runs under /bin/sh -c, so the shell becomes PID 1, SIGTERM from docker stop doesn't reach your app, and every stop waits 10 seconds and ends in SIGKILL.
Multi-stage builds: small final images
Compile in one stage, ship from another. A Go service:
# Stage 1: build
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
# Stage 2: run
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/server /server
USER nonroot
ENTRYPOINT ["/server"]
The Go toolchain, source code and module cache stay in the builder stage. The final image holds a single static binary on a minimal base with no shell and no package manager: typically a few tens of MB instead of close to a gigabyte, and far fewer packages for a scanner to flag.
| Base image | Good for | Trade-off |
|---|---|---|
debian:12-slim, ubuntu:24.04 |
Most apps, easy debugging | Larger, more packages to patch |
alpine:3.20 |
Small images | musl libc; some Python/Node native modules behave differently |
distroless, scratch |
Static binaries, Java/Python runtimes | No shell: debug with nsenter or kubectl debug |
Pin what you build on
FROM python:3.12-slim moves when upstream publishes a new patch. For repeatable builds, pin a specific tag, or the digest (FROM python:3.12-slim@sha256:...) and let a bot (Renovate, Dependabot) raise updates as pull requests.
Try it: shrink an image
- Build the Go example above with a single stage (
FROM golang:1.23, build,CMD ["/out/server"]) and note the size withdocker images. - Build the two-stage version and compare.
- Run
docker historyon both and find the biggest layers. - Add
.envwith a fake secret to the folder, build without a.dockerignore, then rundocker run --rm --entrypoint cat <single-stage-image> /src/.env. Now add the.dockerignoreand try again.
Going deeper: BuildKit, cache mounts and build secrets
Modern Docker builds with BuildKit (the docker build default since Engine 23.0; docker buildx exposes all its features). Two features are worth knowing:
# syntax=docker/dockerfile:1
# Keep pip's download cache between builds without putting it in a layer
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
# Use a secret during the build without it ever being written to a layer
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
$ docker build --secret id=npmrc,src=$HOME/.npmrc -t web:1 .
Never pass secrets as ARG or ENV: both are recorded in the image metadata and visible to anyone who runs docker history or docker image inspect.
Recap
- Images are read-only layers. Order instructions from least to most frequently changed so the cache does its job.
.dockerignorekeeps.git,.envand keys out of the build context and the image.ENTRYPOINT= the program,CMD= default arguments. Use the exec form so signals reach your app.- Multi-stage builds ship only the runtime and the built artefact. Run as a non-root
USER.
This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.