Skip to main content
RunBook Academy

Git, CI/CD & GitOpsCXII · Container Delivery PipelineBuildAndOCI

Build and OCI image — BuildKit, multi-stage

Advanced⏱ ~28 mingitdocker

What you'll learn

  • Run a BuildKit-enabled docker build that produces a content-addressed image tagged by commit SHA
  • Author a multi-stage Dockerfile that separates the build environment from the runtime environment
  • Use BuildKit cache mounts and secret mounts to keep the build deterministic without leaking credentials into layers
  • Recognise why the digest, not the tag, is the durable artefact the pipeline keys off

Prerequisites

Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x

Not yet marked complete on this device.

The build stage takes a green source tree and produces a content-addressed OCI image. The image is tagged by commit SHA, pushed to a registry, and from that moment forward referenced by digest. The tag is a human-readable alias; the digest is what the pipeline and the cluster key off. BuildKit is the engine that produces the image with features that legacy builders do not have: cache mounts, secret mounts, parallel stages, and a content-addressable layer cache.

Running a BuildKit build

The simplest production invocation:

DOCKER_BUILDKIT=1 docker build --tag app:$COMMIT_SHA .

The environment variable opts the legacy CLI into BuildKit. In a modern setup (Docker 23+, buildx), BuildKit is the default. The --tag flag sets the human-readable alias; the digest is assigned by the build engine after the layers are assembled and is available as docker inspect app:$COMMIT_SHA --format '{.Id}' immediately after the build.

The build produces an image that is content-addressed: the digest is a SHA-256 of the image configuration plus the layer digests. Two builds with byte-identical inputs produce byte-identical digests. Two builds with different timestamps, different base image tags, or different layer orderings produce different digests.

Multi-stage Dockerfiles

A multi-stage Dockerfile separates the build environment from the runtime environment. The build stage contains compilers, headers, package managers, and intermediate artefacts. The runtime stage contains only the binary and its runtime dependencies. The COPY from build stage to runtime stage is the narrow waist:

FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./

FROM gcr.io/distroless/static-debian12
COPY --from=build /out/app /app
USER nonroot
ENTRYPOINT ["/app"]

The distroless runtime stage carries no shell, no package manager, no debugging utilities. The attack surface of the runtime image is the binary plus its glibc-equivalent; nothing else.

Cache mounts and secret mounts

BuildKit introduces two mount types that change the build determinism equation:

  • Cache mounts (--mount=type=cache) persist build cache between runs without baking it into layers. A Go module cache or a pip cache can be reused across builds without leaving traces in the image:

    RUN --mount=type=cache,target=/root/.cache/go-build \
        go build -o /out/app ./
  • Secret mounts (--mount=type=secret) pass credentials at build time without storing them in image layers. A secret read with --mount=type=secret is not present in the final image; it is mounted at build time and unmounted at the end of the RUN step:

    RUN --mount=type=secret,id=github_token \
        git config --global url."https://${GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"

Legacy builders leaked secrets into layer history because ENV and ARG values persist. BuildKit’s secret mount is the fix: the secret is available during the step, absent after it.

Why the digest is the durable artefact

A tag is mutable; a digest is not. The registry stores the mapping tag -> digest, and the mapping can change between pushes. The mapping digest -> blob is immutable: the digest is a SHA-256 of the blob, and the blob cannot change without changing the digest. The pipeline and the cluster reference digests; the tag is for humans:

flowchart LR
    A["Build at commit 8a3f9d2"] --> B["Digest sha256:8a3f..."]
    B --> C["Tag app:8a3f9d2 (alias)"]
    B --> D["Pipeline references digest"]
    B --> E["Cluster pulls by digest"]
    C -.moves.-> F["Tag app:latest"]
    F -.mutable.-> G["Cannot pin to this"]

A production render that lands app:8a3f9d2 in a values file works; a render that lands app:latest does not. The pipeline that produces the image tags it by commit SHA for humans and keys every downstream step by digest.

What this stage does not do

The build stage produces a tagged image and a digest. It does not:

  • Push the image to a registry. The next stage does that.
  • Sign the image. Signing keys off the digest; the build emits the digest.
  • Scan the image for vulnerabilities. The scan is a separate stage that consumes the digest.
  • Render or apply any Kubernetes manifest. The image is an artefact; the deployment is a downstream concern.

Production discipline

  1. Build with BuildKit. Legacy builders lack cache mounts, secret mounts, and parallel stages. The features are not optional in 2026.
  2. Pin the base image by digest. A FROM line that references golang:1.22 is a FROM line that resolves to whatever the registry points the tag at. Pin by digest for reproducibility.
  3. Multi-stage by default. Single-stage images carry compilers, headers, and intermediate artefacts into production. The distroless or minimal runtime stage is the production target.
  4. Tag by ${COMMIT_SHA}, reference by digest. The tag is a human alias. Every downstream consumer - the registry, the GitOps controller, the admission controller - keys off the digest.
  5. No :latest in production. A latest tag is a contract that resolves to whatever someone pushed most recently.

Cross-course references

  • Containers for Production Sysadmins - Parts III-VII cover Dockerfile authoring and base-image selection; this lesson is the CI side of the same practice.
  • This course, Part LIII (ContainerSupplyChain) - LIII-02 and LIII-03 cover BuildKit and multi-stage in detail.
  • This course, Part CXI (KubernetesDelivery) - CXI-04 covers the push step that consumes the digest this lesson produces.

Quiz

Knowledge check · 4 questions

  1. Q1. Why is a Dockerfile that pins its base image by tag (for example, `FROM golang:1.22`) a supply-chain risk, even though the tag is specific?

  2. Q2. BuildKit's `--mount=type=secret` keeps a secret out of the final image's layers because the secret is mounted only for the duration of the RUN step.

  3. Q3. Name the two identifiers a build produces, and identify which one is durable across time and which one is a human-readable alias.

  4. Q4. Diagnose a pipeline where the build claims to be reproducible but is not.

    A team runs BuildKit and tags every build by commit SHA. The Dockerfile begins `FROM python:3.12-slim`. The build is green; the image is pushed; production runs it. Two months later, an engineer pulls the same source commit at `${COMMIT_SHA}`, runs the same `DOCKER_BUILDKIT=1 docker build`, and produces a different digest. The team's reproducibility claim is broken; the audit a year later cannot reconstruct what was running in production.

Passing score: 75%. Answers are checked in this browser.