Skip to main content
RunBook Academy

Docker & ContainersV · Dockerfiles & BuildKitReproducibility

Reproducibility — pinned bases, deterministic layers

Intermediate⏱ ~26 mindocker

What you'll learn

  • Distinguish reproducibility from rebuildability, and say which one you actually need
  • Pin every input that affects the output
  • Use SOURCE_DATE_EPOCH correctly, including the exporter option it needs to touch file timestamps
  • Name the sources of non-determinism that timestamp normalisation cannot fix
  • Verify reproducibility by comparing image digests rather than by inspection

Prerequisites

Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-12

Not yet marked complete on this device.

A reproducible build produces the same output bytes given the same inputs. Reproducibility is the foundation of supply chain security: if you cannot reproduce a build, you cannot audit it, you cannot sign it, and you cannot compare it to what is running.

It is also frequently oversold. Most of what is written about container reproducibility describes SOURCE_DATE_EPOCH and stops, which leaves the impression that setting one environment variable gets you byte-identical images. It does not, and the gap between what it fixes and what it does not is the useful content of this lesson.

Two different goals, often confused

What it claimsWhat it costsWhen you need it
RebuildableThe same source and pinned inputs produce a functionally identical imagePinning disciplineAlways
ReproducibleThe same source and pinned inputs produce the same digestPinning, plus timestamp normalisation, plus eliminating every non-deterministic tool in the chainAttested supply chains, regulated builds, independent verification

Almost every estate needs the first and treats the second as aspirational. That is a defensible position, provided it is a decision rather than an accident. Say which one you are claiming, in writing, because “our builds are reproducible” is an assertion an auditor will test.

What non-determinism looks like

A non-reproducible build produces different bytes each time, even with identical inputs. Common causes:

  • Timestamps (date in commands).
  • Random values (UUIDs, temp files in /tmp).
  • Filesystem order (ls outputs differently on different filesystems).
  • Package manager state (apt’s package cache, npm’s installed-by).
  • Compression artefacts (gzip header includes mtime).
  • Order of files in tar archives.

Pinning inputs

Pin everything that affects the output:

FROM ubuntu:24.04@sha256:REPLACE_WITH_REAL_DIGEST

Pin the base image by digest. Pin package versions:

RUN apt-get update \
 && apt-get install -y --no-install-recommends nginx=1.24.0-2ubuntu7.4 \
 && rm -rf /var/lib/apt/lists/*

Pin toolchain versions:

FROM golang:1.24.5@sha256:REPLACE_WITH_REAL_DIGEST

Pin the Dockerfile frontend too, if you use the # syntax directive — # syntax=docker/dockerfile:1 is a moving tag, and a frontend change can alter how instructions are lowered into the build graph.

SOURCE_DATE_EPOCH: what it does and does not touch

SOURCE_DATE_EPOCH is a Reproducible Builds convention: a Unix timestamp, normally the commit time of the source, that build tools use in place of “now”.

BuildKit understands it, and understands it in a more limited way than most people assume.

Determinism in build steps

# Bad — uses `date` which changes every build
RUN echo "Built at $(date)" > /build-info.txt

# Better — use the SOURCE_DATE_EPOCH build arg
ARG SOURCE_DATE_EPOCH
RUN echo "Built at $(date -u -d "@$SOURCE_DATE_EPOCH" --iso-8601=seconds)" > /build-info.txt

Declaring ARG SOURCE_DATE_EPOCH with no default is what makes the propagated build arg visible inside the stage. With a default of 0, a missing value silently becomes 1970 rather than failing.

# Bad — depends on filesystem enumeration order
RUN find /usr/share -type f | xargs strip

# Better — deterministic ordering, and safe against odd filenames
RUN find /usr/share -type f -print0 | sort -z | xargs -0 -r -n1 strip

For Go specifically:

RUN CGO_ENABLED=0 GOOS=linux \
    go build -trimpath -ldflags="-s -w -buildid=" -o /out/app

-trimpath removes local paths from the binary — without it the build directory is embedded and a build under /home/alice differs from one under /build. -buildid= clears the action ID Go otherwise records. Go does not read SOURCE_DATE_EPOCH; do not add it to the go build line expecting an effect.

For tar archives:

ARG SOURCE_DATE_EPOCH
RUN tar --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 \
        --numeric-owner -cf /tmp/app.tar -C /src .

Sort by name, fixed mtime, fixed ownership.

The honest ceiling

Timestamp normalisation is the easy half. These are the things it cannot reach, and they are why full bit-for-bit reproducibility is a project rather than a flag:

  • Package managers resolve. apt-get install nginx picks whatever the archive serves today. Pinning the exact version string helps until the version is superseded and the .deb is deleted from the pool — at which point the build stops working rather than producing different bytes, which is arguably the better failure. npm ci and pip install -r with hashes are genuinely deterministic; npm install and unpinned pip are not.
  • Compilers embed things. Build paths, build IDs, debug sections, parallelism-dependent symbol ordering. Every toolchain has its own set of flags for this and its own remaining gaps.
  • Compression is not part of the content. Layer blobs are compressed, and identical uncompressed content can compress to different bytes under a different zlib build or compression level. That changes the layer digest and therefore the image digest, with no difference in what the layer contains.
  • Cache mounts hide inputs. A RUN --mount=type=cache step can consume an artefact left by an earlier, unrelated build. Its contents are deliberately excluded from the cache key, so nothing records what was used.
  • The base image is somebody else’s build. You can pin its digest; you cannot make it reproducible.

Verifying reproducibility

Comparing exported tarballs does not answer the question — a filesystem tar includes mtimes and ordering that have nothing to do with the image. Compare the thing that is actually addressed: the image digest.

Read-only / Safedigest comparison
export SOURCE_DATE_EPOCH=1767225600

docker buildx build --no-cache --provenance=false \
--metadata-file /tmp/build-a.json --output type=image,rewrite-timestamp=true .
docker buildx build --no-cache --provenance=false \
--metadata-file /tmp/build-b.json --output type=image,rewrite-timestamp=true .

A=$(jq -r '."containerimage.digest"' /tmp/build-a.json)
B=$(jq -r '."containerimage.digest"' /tmp/build-b.json)
printf 'A=%s\nB=%s\n' "$A" "$B"
test "$A" = "$B" || { echo 'FAIL: build is not reproducible'; exit 1; }
echo 'reproducible'
Read-only / Saferesult
$ bash ./check-reproducible.sh
A=sha256:9f2b1c4a7d3e5081bb6c2f9a4e7d1c8305a6f4b2e9d7c1a3
B=sha256:41c8e0a95b7d2f36ca9e4b1d80f27a6c3e5b9d84f1a02c67
FAIL: build is not reproducible

Illustrative output

--no-cache matters: a second build that reuses every layer will of course produce the same digest, and proves nothing at all. The check is only meaningful from a cold cache, which is also why it belongs in a nightly job rather than on every commit.

When it fails, narrow it down:

Read-only / Safenarrow the difference
docker buildx imagetools inspect --raw registry.example.com/myorg/app:a > /tmp/a-manifest.json
docker buildx imagetools inspect --raw registry.example.com/myorg/app:b > /tmp/b-manifest.json
diff -u /tmp/a-manifest.json /tmp/b-manifest.json

If only the config digest differs, it is metadata — timestamps, history, labels. If layer digests differ, it is content or compression, and dive or an unpacked docker save will show you which paths.

Why this matters for security

A reproducible build can be reproduced by an auditor. If the auditor’s build differs from yours, your build is suspicious.

Signatures apply to reproducible builds. A signature on a non-reproducible build is fragile: a bit-flipped timestamp invalidates the signature.

Supply chain attacks (like SolarWinds) succeed in part because the affected products were not reproducible; the auditors could not independently verify what was shipped.

Knowledge check

Knowledge check · 6 questions

  1. Q1. A reproducible build produces:

  2. Q2. You export SOURCE_DATE_EPOCH and rebuild. Which timestamps does BuildKit rewrite by default?

  3. Q3. A team wires `SOURCE_DATE_EPOCH=$(date +%s)` into every CI build. What is the practical result?

  4. Q4. Which of these can make two builds of the same commit produce different image digests even with SOURCE_DATE_EPOCH set? Select all that apply.

  5. Q5. Building twice in a row and getting the same digest proves the build is reproducible.

  6. Q6. A digest-pinned base image never receives security refreshes, so pinning has to be paired with a mechanism that proposes moving the pin.

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