Docker & ContainersV · Dockerfiles & BuildKitReproducibility
Reproducibility — pinned bases, deterministic layers
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
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 claims | What it costs | When you need it | |
|---|---|---|---|
| Rebuildable | The same source and pinned inputs produce a functionally identical image | Pinning discipline | Always |
| Reproducible | The same source and pinned inputs produce the same digest | Pinning, plus timestamp normalisation, plus eliminating every non-deterministic tool in the chain | Attested 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 (
datein commands). - Random values (UUIDs, temp files in
/tmp). - Filesystem order (
lsoutputs 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 nginxpicks whatever the archive serves today. Pinning the exact version string helps until the version is superseded and the.debis deleted from the pool — at which point the build stops working rather than producing different bytes, which is arguably the better failure.npm ciandpip install -rwith hashes are genuinely deterministic;npm installand unpinnedpipare 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=cachestep 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.
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'$ bash ./check-reproducible.shA=sha256:9f2b1c4a7d3e5081bb6c2f9a4e7d1c8305a6f4b2e9d7c1a3
B=sha256:41c8e0a95b7d2f36ca9e4b1d80f27a6c3e5b9d84f1a02c67
FAIL: build is not reproducibleIllustrative 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:
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.jsonIf 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
Q1. A reproducible build produces:
Q2. You export SOURCE_DATE_EPOCH and rebuild. Which timestamps does BuildKit rewrite by default?
Q3. A team wires `SOURCE_DATE_EPOCH=$(date +%s)` into every CI build. What is the practical result?
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.
Q5. Building twice in a row and getting the same digest proves the build is reproducible.
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.