LABEL adds OCI metadata, EXPOSE documents a port. Prefer COPY over ADD: ADD also fetches URLs and auto-extracts archives, which surprises people.
Start here
A first Dockerfile — and everything wrong with it
FROM golang:1.25-alpine
WORKDIR /src
COPY . .
ENV CGO_ENABLED=0
RUN go build -o /pinboard-api .
EXPOSE 8080
CMD ["/pinboard-api"]
It works. It also produces a 412 MB image containing the Go compiler, your source code and a package manager — running as root.
COPY . . before the build means any file change re-downloads every dependency
No version metadata, no USER, no .dockerignore, nothing pinned
02
Layers and the cache
Instruction order is not style. It is the difference between a two-second rebuild and a two-minute one, fifty times a day.
Mechanics
Every instruction is a cached step
Instructions that change the filesystem produce a layer; the rest only change config
Layers are content-addressed, so identical layers are stored and pulled once
BuildKit reuses a step when its cache key matches — and prints CACHED
docker history tells you which line cost you which megabytes
$ docker history pinboard-api:1.0
IMAGE CREATED BY SIZE
<missing> ENTRYPOINT ["/pinboard-api"] 0B
<missing> USER 65532:65532 0B
<missing> COPY /out/pinboard-api / # bui 9.6MB
<missing> /bin/sh -c #(nop) LABEL org.op 0B
<missing> distroless base 1.9MB
The rule that matters
What invalidates the cache
Steps are checked top to bottom; the first miss rebuilds everything after it
COPY/ADD: the key is a checksum of the copied files — content, mode, path
RUN: the key is the command string, not its result — so apt-get update alone can serve week-old package lists
Changing an ARG or ENV a stage uses invalidates from that point
A new base image digest invalidates the whole stage
Always combine apt-get update and apt-get install in oneRUN, or you will install yesterday's packages forever.
Put the rarely changing things first and the constantly changing things — your source code — last.
In practice
Ordering Pinboard's build stage
# 1. dependencies change rarely — cache them on their own layer
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
# 2. the source changes constantly — copy it last
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -o /out/pinboard-api .
Editing a .go file now rebuilds only the last step — dependencies stay CACHED
--mount=type=cache keeps the module and compiler caches outside the image, across builds
Live Demo · 10 min
Watch the cache work — and break
Build the naive Dockerfile, read docker history, change one line of Go, rebuild, and watch every dependency download again. Then reorder the file and rebuild in seconds.
What you'll see · 412 MB · CACHED lines · a busted cacheFiles · src/pinboard-api/Duration · 10 min
03
Small and safe images
412 MB to 13 MB with one idea: build in one image, ship from another.
The big idea
Multi-stage builds
Several FROMs in one file; each starts a fresh stage
COPY --from=build takes only the artefact across
The toolchain, the source and the module cache never reach the final image
# syntax=docker/dockerfile:1
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG APP_VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath -o /out/pinboard-api \
-ldflags="-X main.defaultVersion=${APP_VERSION}" .
FROM gcr.io/distroless/static-debian12:nonroot AS runtime
COPY --from=build /out/pinboard-api /pinboard-api
EXPOSE 8080
USER 65532:65532
ENTRYPOINT ["/pinboard-api"]
Stage control
Building part of the file: --target
# stop at the build stage — a shell, the compiler, the source
docker build --target build -t pinboard-api:builder ./pinboard-api
docker run --rm -it pinboard-api:builder sh
# the real thing
docker build -t pinboard-api:1.0 --build-arg APP_VERSION=1.0 ./pinboard-api
Handy for a test or lint stage that CI builds and production never ships
BuildKit builds independent stages in parallel and skips stages the target does not need
A debug image built from the same stage is far better than adding a shell to production
Choosing a base
The base-image ladder
Base
Size
Contains
Choose when
golang:1.25 / debian:12
~350–800 MB
Full distro + toolchain
Build stages only
debian:12-slim
~75 MB
apt, shell, glibc
You need apt packages at runtime
alpine:3.22
~8 MB
busybox, apk, musl
Small and you want a shell
distroless/static
~2 MB
CA certs, tzdata, nonroot user
Static binaries — Pinboard's choice
scratch
0 B
Nothing at all
You will add certs and a user yourself
Alpine uses musl, not glibc — a real source of surprises with DNS resolution and pre-compiled binaries. Test, don't assume.
Least privilege
Don't run as root
Root in a container is root on the host kernel unless user namespaces are on
Distroless :nonroot ships UID 65532; alpine images get an explicit USER
Kubernetes can refuse root images outright with runAsNonRoot: true (session 09)
Non-root cannot bind ports below 1024 — that is why Pinboard listens on 8080, not 80
$ docker run --rm pinboard-api:1.0 id
uid=65532(nonroot) gid=65532(nonroot)
$ docker run --rm nginx:1.28-alpine id
uid=0(root) gid=0(root)
Set USER as the last instruction, after anything that needs to write during the build.
The payoff
Same binary, one twentieth the size
412 MB
Naive
golang:1.25-alpine with the source, the compiler and the module cache still inside. Runs as root.
23 MB
Alpine runtime
Multi-stage onto alpine:3.22. Has a shell — convenient, and an attack surface.
13 MB
Distroless
Binary + certs + nonroot user. Nothing to exec into, nothing to exploit.
Multiply by every node that pulls it, every rollout, every CI run. Small images make session 06's rolling updates feel instant.
Live Demo · 8 min
Naive to multi-stage, side by side
Build the real multi-stage Dockerfile, compare the two images in docker image ls, prove there is no shell in the distroless one, then run it and call /api/info.
What you'll see · 412 MB vs 13 MB · --target · no shellFiles · src/pinboard-api/DockerfileDuration · 8 min
04
Publish and prove
Getting the image to a registry — and being able to say, later, exactly what is in it and where it came from.
Scaffolding
docker init — don't start from a blank file
$ cd src/pinboard-api && docker init
? What application platform does your project use? Go
? What version of Go do you want to use? 1.24
? What port does your server listen on? 8080
Created: .dockerignore, Dockerfile, compose.yaml, README.Docker.md
Generates a sane multi-stage Dockerfile, a .dockerignore and a Compose file for Go, Node, Python, Rust, PHP and ASP.NET
Treat it as a draft: check the base image, the USER, the pinning and the health check before committing
Ship it
Tag and push
docker login ghcr.io # credentials go to ~/.docker/config.json
docker tag pinboard-api:1.0 ghcr.io/zonoth/pinboard-api:1.0
docker push ghcr.io/zonoth/pinboard-api:1.0
# or build straight to the registry
docker buildx build --push -t ghcr.io/zonoth/pinboard-api:1.0 ./pinboard-api
A tag is just a pointer — the same image can carry 1.0, 1 and a commit SHA at once
push uploads only the layers the registry is missing
OCI labels (org.opencontainers.image.version, .source, .revision) travel inside the image — Pinboard sets them in its LABEL block
SBOM — the bill of materials: every package inside the image, in SPDX form
Provenance — how it was built: source, builder, arguments (SLSA-style attestation)
Both attach to the image index as attestations and travel with the image
docker scout compares those contents against advisory data and suggests a better base
Before you commit
Dockerfile best-practice checklist
Size & base
Multi-stage build · smallest base that works · pin the tag (ideally the digest) · no compilers or package managers in the runtime stage.
Cache & context
Dependencies before source · one RUN for update+install · a real .dockerignore · cache mounts instead of copied caches.
Security
USER non-root · no secrets in layers (use --mount=type=secret) · only the ports you need · a scan in CI.
Metadata & runtime
OCI LABELs · version via ARG · ENTRYPOINT in exec form · HEALTHCHECK · config from env, never baked in.
Live Demo · 7 min
Attest and scan the image we just built
Rebuild pinboard-api:1.0 for two architectures with an SBOM and provenance, inspect the resulting index, then run docker scout quickview on it and on the naive image for contrast.
What you'll see · image index · attestations · a CVE tableFiles · src/pinboard-api/Duration · 7 min
Lab 02 · 30–40 min
Build the Pinboard images
Fill in the blanks of the multi-stage Dockerfile for pinboard-api, build 1.0 and 1.1 from the same source with a build argument, build pinboard-web, run the API and call /api/info to see the version you injected.
You'll practise: writing a Dockerfile · .dockerignore · --build-arg · image ls & history · run --rm -p · optional tag + push
Session 02
Key takeaways
Order for the cache
Rarely-changing steps first, source last. The first cache miss rebuilds everything below it.
Build here, ship from there
Multi-stage plus a minimal base turned 412 MB into 13 MB with the same binary.
Non-root, pinned, described
USER, a pinned base, OCI labels, an SBOM and a scan — the 2026 minimum.
Next up
Session 03 — running containers well: configuration, signals and PID 1, volumes and networking.
Questions?
Up next — Session 03: Running containers well. Configuration, storage and networking, with Pinboard wired together by hand.