Hermeticity & Reproducible Builds - Sandboxes, Pinned Toolchains, the Nix Store & Bit-for-Bit Output
Pinned vs hermetic vs deterministic vs reproducible; sources of non-reproducibility; SOURCE_DATE_EPOCH archive demo (runnable); the Nix model (derivations, store paths, closures, substituters, input- vs content-addressed) with a store-path demo (runnable); lockfiles vs Docker vs Bazel sandboxes vs Nix/Guix; SBOMs and provenance.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
Hermetic vs reproducible?
Answer
Hermetic limits what the build can see. Reproducible means identical bytes from identical inputs.
L2
Name three sources of non-reproducibility.
Answer
Timestamps, file ordering and absolute paths, plus locale, randomness, network fetches and host toolchains.
L3
What is SOURCE_DATE_EPOCH?
Answer
An environment variable with a Unix timestamp that tools use instead of now.
L4
Why does Nix put a hash in every store path?
Answer
The hash identifies the full recipe, so builds never collide and binary caches can serve exact results.
L5
What is a fixed-output derivation?
Answer
A derivation allowed to fetch from the network because its output hash is declared and checked.
L6
Is a Docker image reproducible?
Answer
The digest pins one built image, but rebuilding the Dockerfile usually is not reproducible.
L7
How do reproducible builds help supply-chain security?
Answer
Independent rebuilds can compare hashes, so a compromised builder cannot quietly ship a different binary.
Failure modes
Embedded timestamps make identical builds differ
Archive entries and build dates record now, so two builds of the same source hash differently.
Rebuilding the same Dockerfile produces a new image
apt-get and other package installs resolve whatever the mirror serves today.
Disabling the sandbox for one build drops the guarantee
Turning off isolation to let a test reach the network silently removes hermeticity for that build.
Misconceptions
We use Docker, so builds are reproducible.
A pinned artifact is not a reproducible process.
A sandbox makes the build reproducible.
Sandboxes catch undeclared files, not undeclared variation such as timestamps or an unpinned env var.
Lockfiles pin the whole environment.
They pin language dependencies only. Compilers and system libraries still drift.
Interviewer traps
Using hermetic and reproducible as synonyms.
Hermetic is about the process. Reproducible is about the bytes.
Claiming Nix rebuilds one changed file inside an app.
Nix works at derivation granularity. Pair it with a language build tool.
Design scenario
Same prompt for every reader.
Requirements
Anyone can rebuild from a tagged commit and get the same hash, and an SBOM and provenance accompany each release.
Failure assumptions
- Archives embed build times.
- CI runners have different compilers.
- One test needs the network.
Constraints
- No network inside the build sandbox except fetches with declared hashes.
- Release artifacts are published by digest.
Prompt
Your team ships a CLI binary that customers want to verify independently. Design the build.
API
How does a verifier request and compare a rebuild?
Data
What do you record with each artifact: hash, SBOM, provenance?
Architecture
Where do pinning, sandboxing and normalisation each happen?
Overview
Two words get used interchangeably and should not be. Hermetic is about the build process: the build can only see inputs it declared, so the host machine cannot leak in. Reproducible is about the result: the same declared inputs produce bit-for-bit identical output, on any machine, at any time. Hermeticity removes hidden inputs; reproducibility also requires removing hidden variation such as timestamps, file ordering, absolute paths and randomness. Nix, Guix, Bazel sandboxes, Docker images and lockfiles each attack part of this problem from a different layer, and none of them gives reproducibility for free.
Hermetic vs reproducible vs pinned
| Property | Question it answers | Typical mechanism | Without it |
|---|---|---|---|
| Pinned | Do we always ask for the same versions? | Lockfiles, image digests, flake.lock, toolchain version files | "Same commit" builds with different dependencies over time |
| Hermetic | Can the build see anything it did not declare? | Sandboxes (Bazel, Nix), no network during build, hermetic toolchains | Host tools, env vars and network fetches leak in |
| Deterministic | Does the same process give the same bytes twice? | Fixed timestamps, sorted inputs, stable IDs | Two builds of the same inputs differ |
| Reproducible | Can an independent party rebuild and get identical bytes? | Pinned + hermetic + deterministic, plus a recorded environment | You must trust the builder; no independent verification |
Bazel's own documentation defines hermeticity in two parts: isolation (the build treats tools as source code and does not depend on what is installed on the host) and source identity (inputs are identified, for example by content hash or commit). The Reproducible Builds project defines a reproducible build as one where, given the same source, build environment and instructions, any party can recreate bit-by-bit identical artifacts.
Decisions
- 1
Step 1: pin every input (sources, deps, toolchain, base image)
- nextStep 2: build inside an isolated sandbox with no network
- 2
Step 2: build inside an isolated sandbox with no network
- nextStep 3: did the build touch an undeclared path, env var or the network?
- ?
Step 3: did the build touch an undeclared path, env var or the network?
- nextFailure path: sandbox denies it, build fails loudly, declare the input
- nextStep 4: normalise variation (SOURCE_DATE_EPOCH, sorted files, relative paths)
- 4
Failure path: sandbox denies it, build fails loudly, declare the input
- 5
Step 4: normalise variation (SOURCE_DATE_EPOCH, sorted files, relative paths)
- nextStep 5: produce artifact and record its hash plus provenance
- 6
Step 5: produce artifact and record its hash plus provenance
- nextStep 6: independent rebuild on another machine
- 7
Step 6: independent rebuild on another machine
- nextStep 7: hashes match?
- ?
Step 7: hashes match?
- nextReproducible: anyone can verify the binary came from this source
- nextFailure path: diff the outputs (diffoscope) to find timestamps, ordering or paths
- 9
Reproducible: anyone can verify the binary came from this source
- 10
Failure path: diff the outputs (diffoscope) to find timestamps, ordering or paths
Lesson map
Hermeticity & Reproducible Builds - Sandboxes, Pinned Toolchains, the Nix Store & Bit-for-Bit Output
Pinned vs hermetic vs deterministic vs reproducible; sources of non-reproducibility; SOURCE_DATE_EPOCH archive demo (runnable); the Nix model (derivations, store paths, closures, substituters, input- vs content-addressed) with a store-path demo (runnable); lockfiles vs Docker vs Bazel sandboxes vs Nix/Guix; SBOMs and provenance.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB a["Step 1: pin every input (sources, deps, toolchain, base image)"] b["Step 2: build inside an isolated sandbox with no network"] c["Step 3: did the build touch an undeclared path, env var or the network?"] x["Failure path: sandbox denies it, build fails loudly, declare the input"] d["Step 4: normalise variation (SOURCE_DATE_EPOCH, sorted files, relative paths)"] e["Step 5: produce artifact and record its hash plus provenance"] f["Step 6: independent rebuild on another machine"] g["Step 7: hashes match?"] h["Reproducible: anyone can verify the binary came from this source"] y["Failure path: diff the outputs (diffoscope) to find timestamps, ordering or paths"] a -->|continues| b b -->|continues| c c -->|continues| x c -->|continues| d d -->|continues| e e -->|continues| f f -->|continues| g g -->|continues| h g -->|continues| y
What does reproducible actually require?
Prefer
Pinned, hermetic and deterministic, then verified
Pin every input, isolate the build, normalise variation, and let an independent rebuild compare hashes.
- SOURCE_DATE_EPOCH replaces now.
- Sorted files and relative paths remove ordering and path noise.
- A sandbox without network blocks hidden inputs.
Alternative
A lockfile or a Docker image alone
Each pins one layer and leaves the rest to drift.
- Compilers and system libraries still vary.
- Rebuilding a Dockerfile resolves today's packages.
- Embedded timestamps make every build unique.
From pinned inputs to a verified binary
Diagram 1 condensed. The Nix section shows the strictest version of each step.
- 1
Pin every input
Sources, dependencies, toolchain and base image. - 2
Build in a sandbox
Undeclared paths, env vars and the network fail loudly. - 3
Normalise variation
SOURCE_DATE_EPOCH, sorted files and relative paths. - 4
Rebuild independently
Matching hashes mean anyone can verify the binary came from this source.
Sources of non-reproducibility
| Source | Example | Common fix |
|---|---|---|
| Timestamps | Archive entries, embedded build dates, __DATE__ | Use SOURCE_DATE_EPOCH (often the last commit time) |
| File ordering | readdir order differs between filesystems | Sort file lists before archiving or hashing |
| Absolute paths | Debug info and source maps contain /home/user/src | Path prefix remapping, relative paths |
| Locale and timezone | Sorting or formatting differs by LANG or TZ | Fix LC_ALL and TZ in the build environment |
| Randomness and parallelism | Hash-map iteration order, parallel link order, random temp names | Deterministic seeds, stable sorting, deterministic tools |
| Network fetches | curl latest.tar.gz during build | Pre-fetch by hash; sandbox without network |
| Host toolchain | Different gcc or node on each machine | Pinned or hermetic toolchains declared as inputs |
The Nix model in one picture
Nix treats a build as a pure function in the strictest practical way. A derivation lists its inputs (other derivations and sources), its builder and arguments, its environment and its system type. Nix hashes that description and turns the result into a store path such as /nix/store/<hash>-zlib-1.3.1. Builds run in a sandbox (enabled by default on Linux) with no network and only declared store paths visible; the exception is fixed-output derivations, which may fetch from the network because their output hash is declared in advance and checked.
Consequences that the other tools only approximate:
- Many versions side by side. Two
opensslbuilds with different inputs get different paths, so they never overwrite each other. - Closures. A package's runtime closure is the set of store paths it references, which makes copying a complete, self-contained environment to another machine exact.
- Binary substitution. If a substituter (by default
cache.nixos.org) already has the store path for this hash, Nix downloads it instead of building. This is a constructive-trace cache keyed by the input description. - Input-addressed vs content-addressed. Classic store paths hash the inputs, so any input change (even one that yields identical bytes) moves every downstream path. Content-addressed derivations, still an experimental feature, hash the output and can cut off early.
// A simplified model of the Nix store (NOT Nix's exact hashing scheme).
// Input-addressed path = hash(builder, args, env, input paths) -> known before building
// Content-addressed path = hash(output bytes) -> known only after building
// We rebuild "zlib" with a patched compiler that emits identical bytes and watch
// which downstream paths move. Then we compute a closure: everything a path references.
function hash(s: string): string {
// FNV-1a, 32-bit, printed as 8 hex chars; real Nix uses SHA-256 rendered in Nix32 (base-32).
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
return h.toString(16).padStart(8, "0");
}
type Drv = { name: string; builder: string; args: string; inputs: string[]; produce: (ins: string[]) => string };
function realise(drvs: Drv[], mode: "input" | "content") {
const path: Record<string, string> = {};
const bytes: Record<string, string> = {};
for (const d of drvs) { // drvs are listed in dependency order
const ins = d.inputs.map((i) => path[i]);
const out = d.produce(d.inputs.map((i) => bytes[i]));
const digest = mode === "input"
? hash([d.builder, d.args, ...ins].join("|")) // describes HOW it is built
: hash(out); // describes WHAT came out
path[d.name] = `/nix/store/${digest}-${d.name}`;
bytes[d.name] = out;
}
return path;
}
const graph = (zlibCompiler: string): Drv[] => [
// A compiler bug-fix release that does not change zlib's machine code: same bytes either way.
{ name: "zlib-1.3", builder: zlibCompiler, args: "-O2", inputs: [], produce: () => "libz.so v1.3" },
{ name: "openssl-3.3", builder: "gcc-13", args: "-O2", inputs: ["zlib-1.3"], produce: (i) => "libssl linked " + i[0].length },
{ name: "curl-8.9", builder: "gcc-13", args: "-O2", inputs: ["openssl-3.3", "zlib-1.3"], produce: (i) => "curl " + i.join("+").length },
];
for (const mode of ["input", "content"] as const) {
const before = realise(graph("gcc-13.2"), mode);
const after = realise(graph("gcc-13.3"), mode);
const moved = Object.keys(before).filter((k) => before[k] !== after[k]);
console.log(`${mode}-addressed: curl -> ${after["curl-8.9"]}`);
console.log(` paths that changed after rebuilding zlib with gcc-13.3: ${moved.length ? moved.join(", ") : "none"}`);
}
// Closure: the full set of store paths needed at run time (copy these and the program works).
const refs: Record<string, string[]> = {
"curl-8.9": ["openssl-3.3", "zlib-1.3", "glibc-2.39"],
"openssl-3.3": ["zlib-1.3", "glibc-2.39"],
"zlib-1.3": ["glibc-2.39"],
"glibc-2.39": [],
};
const closure = new Set<string>();
const walk = (p: string) => { if (!closure.has(p)) { closure.add(p); refs[p].forEach(walk); } };
walk("curl-8.9");
console.log("closure of curl:", [...closure].sort().join(", "));Output:
input-addressed: curl -> /nix/store/5f7343d2-curl-8.9
paths that changed after rebuilding zlib with gcc-13.3: zlib-1.3, openssl-3.3, curl-8.9
content-addressed: curl -> /nix/store/fc582430-curl-8.9
paths that changed after rebuilding zlib with gcc-13.3: none
closure of curl: curl-8.9, glibc-2.39, openssl-3.3, zlib-1.3Expectedinput-addressed: curl -> /nix/store/5f7343d2-curl-8.9 paths that changed after rebuilding zlib with gcc-13.3: zlib-1.3, openssl-3.3, curl-8.9 content-addressed: curl -> /nix/store/fc582430-curl-8.9 paths that changed after rebuilding zlib with gcc-13.3: none closure of curl: curl-8.9, glibc-2.39, openssl-3.3, zlib-1.3
Press Run. Snippets must be self-contained — no network, files, or native modules.
Guix follows the same functional store model, with package definitions written in Guile Scheme rather than the Nix language.
Nix vs Docker vs Bazel sandboxes vs lockfiles
| Lockfiles | Docker image | Bazel sandbox and hermetic toolchains | Nix / Guix | |
|---|---|---|---|---|
| Unit of identity | Package name, version and integrity hash | Image digest (content hash of manifest and layers) | Action digest over declared inputs and command | Store path derived from the derivation (or content) |
| Pins | Language dependencies only | Everything inside the image once built | Declared deps and toolchains | Every input, down to the C library and compiler |
| Isolation while building | None | Container filesystem; network on by default | Per-action sandbox; only declared inputs visible | Per-build sandbox; no network except fixed-output derivations |
| Reproducible output? | Not by itself | Not by default (apt-get update, timestamps); possible with effort | Often, if actions are deterministic | Often; still needs deterministic builders; checkable with rebuild comparisons |
| Rebuild unit | Whole install | Layer (a linear chain, so one change rebuilds everything after it) | Single action | Single derivation |
| Learning curve | Low | Low to medium | High | High |
| Best at | App dependency stability | Shipping a runtime environment | Large polyglot repos needing fine-grained caching | Whole-system and toolchain reproducibility |
Note the subtle point about Docker: an image digest pins the result once built, but the Dockerfile is not a reproducible recipe, because RUN apt-get install resolves whatever the mirror serves today. Build Systems a la Carte also notes Docker's structure: each layer depends on the previous one, so the build is a linear chain rather than a graph.
Decision chart: how much reproducibility do you need?
Decisions
- 1
A
- nextPinned, sandboxed, deterministic builds (Nix, Guix or hermetic Bazel) plus independent rebuilds
- nextStep 2: do native libraries or compilers drift between machines?
- 2
Pinned, sandboxed, deterministic builds (Nix, Guix or hermetic Bazel) plus independent rebuilds
- ?
Step 2: do native libraries or compilers drift between machines?
- nextStep 3: is a container acceptable for dev and CI?
- nextStep 4: is the build large enough to need fine-grained caching?
- ?
Step 3: is a container acceptable for dev and CI?
- nextDocker or devcontainer images pinned by digest, plus a lockfile
- nextNix shells or hermetic toolchains
- 5
Docker or devcontainer images pinned by digest, plus a lockfile
- 6
Nix shells or hermetic toolchains
- ?
Step 4: is the build large enough to need fine-grained caching?
- nextBazel-style sandboxed actions with pinned toolchains
- nextLockfile, pinned runtime versions and SOURCE_DATE_EPOCH
- 8
Bazel-style sandboxed actions with pinned toolchains
- 9
Lockfile, pinned runtime versions and SOURCE_DATE_EPOCH
SBOMs and provenance
Reproducibility tells you the binary matches the source. Two related artifacts tell you what is in it and how it was made:
| Artifact | Answers | Formats | Relationship to this page |
|---|---|---|---|
| SBOM (software bill of materials) | Which components and versions are inside? | SPDX, CycloneDX | Easy to generate exactly from a lockfile or Nix closure; guesswork from a mutable image |
| Provenance attestation | Which source, builder and steps produced this artifact? | SLSA provenance, in-toto attestations | Hermetic, pinned builds make the provenance claims meaningful |
| Independent rebuild | Does a third party get the same bytes? | Hash comparison, diffoscope reports | Only possible if the build is reproducible |
Signing, SBOM distribution and provenance policies in CI are covered in depth in the CI/CD series; this page only explains why hermetic, reproducible builds make them trustworthy.
Runnable: making an archive reproducible
The script packs the same two files twice. The naive version records the current time and reads files in filesystem order; the normalised version sorts names, uses SOURCE_DATE_EPOCH, and zeroes owner fields.
"""Bit-for-bit reproducibility: the same sources packed twice, naive vs normalised.
Naive packing leaks the environment into the artifact: wall-clock mtimes, the order the
filesystem lists files in, and the build user's uid/gid. Normalising those (sorted entries,
mtime clamped to SOURCE_DATE_EPOCH, fixed owner, gzip header mtime 0) gives identical bytes.
The two "machines" are simulated, so the output is deterministic.
"""
import gzip, hashlib, io, tarfile
sources = {"src/main.py": b"print('hello')\n", "src/util.py": b"X = 1\n", "README": b"demo\n"}
def pack(listing_order, mtime, uid, user, normalise, epoch=None):
raw = io.BytesIO()
names = sorted(listing_order) if normalise else listing_order
with tarfile.open(fileobj=raw, mode="w", format=tarfile.PAX_FORMAT) as tar:
for name in names:
data = sources[name]
info = tarfile.TarInfo(name)
info.size = len(data)
info.mtime = min(mtime, epoch) if normalise else mtime # clamp to SOURCE_DATE_EPOCH
info.uid, info.gid = (0, 0) if normalise else (uid, uid)
info.uname, info.gname = ("", "") if normalise else (user, user)
info.mode = 0o644
tar.addfile(info, io.BytesIO(data))
out = io.BytesIO()
# gzip stores a timestamp in its header too; fix it at 0 when normalising
with gzip.GzipFile(fileobj=out, mode="wb", mtime=0 if normalise else mtime) as gz:
gz.write(raw.getvalue())
return hashlib.sha256(out.getvalue()).hexdigest()[:16]
SOURCE_DATE_EPOCH = 1759795200 # e.g. the last commit time, from `git log -1 --pretty=%ct`
laptop = dict(listing_order=["src/util.py", "README", "src/main.py"], mtime=1759881600, uid=1000, user="ajay")
ci = dict(listing_order=["README", "src/main.py", "src/util.py"], mtime=1759885200, uid=1001, user="runner")
a, b = pack(**laptop, normalise=False), pack(**ci, normalise=False)
print("naive laptop", a, " ci", b, " identical:", a == b)
a = pack(**laptop, normalise=True, epoch=SOURCE_DATE_EPOCH)
b = pack(**ci, normalise=True, epoch=SOURCE_DATE_EPOCH)
print("normalised laptop", a, " ci", b, " identical:", a == b)
print("an independent rebuilder can now compare hashes instead of trusting the CI machine")Output:
naive laptop 42f26564fec115eb ci 09e1a24ccb81f2d9 identical: False
normalised laptop 2790007285e768d7 ci 2790007285e768d7 identical: True
an independent rebuilder can now compare hashes instead of trusting the CI machineThe SOURCE_DATE_EPOCH specification is a single environment variable holding a Unix timestamp that tools use instead of "now". Many toolchains honour it, including Docker BuildKit (buildx 0.10 and later), which can use it for image timestamps.
What happens if you choose otherwise
- Lockfile only: application dependencies are stable, but the compiler, OS libraries and env vars still drift. Usually good enough for a web app; not enough for verifiable releases.
- Docker only: great for shipping, weak for rebuilding. Rebuilding the same Dockerfile next month can produce a different image.
- Bazel sandboxes with host toolchains: fine-grained and fast, but the "same" action on two runners may use different compilers unless toolchains are hermetic.
- Nix everywhere: the strongest guarantees, at the cost of a steep learning curve and friction with ecosystems that expect to download things at build time.
Pitfalls
- "We use Docker, so builds are reproducible" confuses a pinned artifact with a reproducible process.
- Sandboxes catch undeclared files, not undeclared meaning: an env var you declared but forgot to pin still varies.
- Turning off the sandbox to fix one failing build (for example a test needing network) silently drops the guarantee for that build.
- Embedded build timestamps and git describe strings are the most common reason two "identical" builds differ.
Interview Q&A
Hermetic vs reproducible?
Answer
Hermetic means the build can only access declared inputs. Reproducible means the same inputs produce bit-identical output. Hermeticity helps but you also need determinism (timestamps, ordering, paths).
Why does Nix put a hash in every store path?
Answer
The hash identifies the full build recipe including dependencies, so different builds never collide, many versions coexist, and a binary cache can serve a prebuilt result for exactly that recipe.
Is a Docker image reproducible?
Answer
The digest identifies one specific image exactly, but rebuilding the Dockerfile usually is not reproducible, because package installs and timestamps vary. You need pinned bases by digest, pinned packages and timestamp normalisation.
What is SOURCE_DATE_EPOCH?
Answer
A standard environment variable carrying a Unix timestamp that build tools use instead of the current time, so embedded dates are stable across rebuilds.
How do reproducible builds help supply-chain security?
Answer
Independent parties can rebuild from source and compare hashes, so a compromised build server cannot quietly ship a binary that differs from the published source.
What is a fixed-output derivation in Nix?
Answer
A derivation that may fetch from the network because its output hash is declared in advance and checked.
Input-addressed vs content-addressed store paths?
Answer
Input-addressed paths hash the recipe, so any input change moves every downstream path. Content-addressed paths hash the output and can cut off early when bytes are identical.
Why does a sandbox not guarantee reproducibility?
Answer
It blocks undeclared files and network access, but timestamps, ordering, randomness and declared-but-unpinned values can still vary.
Check yourself
Build one artifact you own twice on two machines and compare hashes. If they differ, find the first difference (diffoscope helps) and say which row of the non-reproducibility table caused it.
Elsewhere in the library
These pages stay as they are. This lesson only points at them: Supply Chain Security — Signing, SBOMs & OIDC Federation, Artifacts & Registries — Digests, Provenance & Immutability, CI/CD Pipelines — Stages, Artifacts, Caching & Supply Chain, Go — Packages, Testing, Fuzzing & Benchmarks.