Deterministic and Reproducible Builds: Why Your Build Should Be a Pure Function
Deterministic and Reproducible Builds: Why Your Build Should Be a Pure Function
A build is a function. It takes source code as input and produces artifacts as output. Like any function, the question is: given the same inputs, does it always produce the same output?
For most software projects, the answer is no. Run a build twice on different machines, or on the same machine a week apart, and you’ll likely get different bytes in your output — different embedded timestamps, different tool versions, different ordering of symbols resolved by the linker. Most of the time this doesn’t matter. Sometimes it matters enormously.
This post covers why reproducible builds matter, the specific sources of non-determinism you need to eliminate, and the tools that make hermeticity achievable at scale.
Why Reproducibility Matters
Supply Chain Security
The most compelling reason is security. The SolarWinds attack compromised the build pipeline itself — malicious code was injected during the build process, not into the source repository. The resulting binaries couldn’t be traced back to the legitimate source.
If your build is reproducible, an independent party can take the same source code, run the same build, and verify that the resulting binary is identical to what you shipped. A compromised build pipeline would produce different bytes. This is exactly what the Reproducible Builds project enables for Linux distributions.
Debian, Fedora, NixOS, and Arch Linux all have ongoing efforts to make every package in their repositories reproducible. NixOS leads — over 95% of packages are currently reproducible.
Debugging Across Time
Non-deterministic builds make debugging harder. When a bug appears in production but not in your local build, is it a real difference in behavior, or just a different ordering of symbols in the binary? Reproducible builds remove this variable.
When you can reproduce the exact binary that’s running in production, your debug symbols actually match. Stack traces resolve correctly. Profiling data aligns with source.
Caching
Deterministic builds are a prerequisite for effective build caching. If the same source produces different outputs each time, no cache can help you. Remote build caches (Bazel’s remote cache, Gradle’s build cache) only work if builds are hermetic — every input is declared, and identical inputs always produce identical outputs.
The economic argument: Google’s internal Bazel deployment reportedly reduces engineer-facing build times by over 90% through remote caching. The entire model depends on reproducibility.
Sources of Non-Determinism
Before you can fix non-determinism, you need to know where it comes from.
Timestamps
The most common culprit. Many build tools embed the current time into output artifacts:
__DATE__and__TIME__macros in C/C++- Java
.classfiles contain timestamps - Go binaries embed build time
- Archive formats (tar, zip, jar) include file modification times
- Debian packages embed the build date
Fix: SOURCE_DATE_EPOCH is a specification for how build tools should handle this. Set it to a fixed value, and compliant tools will use that timestamp instead of the current time:
|
|
Major languages and tools that respect SOURCE_DATE_EPOCH:
- GCC, Clang (for
__DATE__/__TIME__) - Go toolchain
- Python (
.pycfiles) - Rust (via
--remap-path-prefix) - GNU tar, zip, ar
- Debian packaging tools
File System Ordering
Many build tools iterate over directory entries. File system ordering is not guaranteed to be consistent across different systems or even across runs on the same system.
|
|
The fix is simple: always sort file lists before processing. This is a common source of non-determinism that’s easy to fix and hard to notice.
Parallel Builds
Parallel builds introduce race conditions in how outputs are assembled. The order in which compilation units finish can affect the final binary if your build system doesn’t handle this carefully.
Bazel handles this correctly by design — its action graph ensures dependencies are respected and outputs are merged deterministically. Make’s parallel mode (make -j) can be non-deterministic if Makefiles have incorrect dependency declarations.
Tool Versions
If your build uses whatever version of gcc, python, or node happens to be on the machine, builds on different machines or at different times will use different tool versions, potentially producing different outputs.
This is the hardest problem to solve without a hermetic build system. A new version of a compiler may generate slightly different code for the same source.
Fix: Pin all tool versions explicitly. This means:
- Docker images with pinned base images (
FROM debian:12.5notFROM debian:latest) - Lockfiles for language package managers
- Version-pinned toolchains in your build system
Randomness in Build Tools
Some tools generate UUIDs, random padding, or non-deterministic identifiers during builds. Common offenders:
- Debug info (some compilers use build IDs derived from random data)
- Protocol buffer generated code (historically had non-deterministic field ordering)
- Some linkers include random sections
For debug build IDs, use a content-based ID instead of a random one:
|
|
Path Embedding
Build tools often embed absolute paths into binaries — for debug info (DWARF), coverage profiles, or error messages. The same source built in /home/alice/project and /home/bob/project will produce different binaries.
Fix: Use path remapping:
|
|
Bazel: Hermetic Builds at Scale
Bazel is Google’s open-source build system, descended from the internal Blaze system. Its central design principle is hermeticity: every build action runs in a sandbox with only explicitly declared inputs available.
The Core Model
In Bazel, you define build targets in BUILD files:
|
|
Every target declares its inputs explicitly. Bazel’s sandbox ensures that if a file isn’t declared as an input, the build action can’t see it. This makes “it works on my machine” impossible by construction — if the build succeeds, it’s because all inputs are correctly declared.
Remote Caching
Bazel can cache build outputs in a remote store keyed by a cryptographic hash of all inputs:
# .bazelrc
build --remote_cache=grpcs://cache.example.com
build --remote_executor=grpcs://remote.example.com # optional: remote execution
# With Google Cloud Storage
build --remote_cache=https://storage.googleapis.com/my-build-cache
build --google_default_credentials
When a build action runs, Bazel computes a cache key from:
- The action’s command line
- The content hash of all input files
- Environment variables explicitly listed as inputs
- The Bazel version and toolchain configuration
If a cache hit exists for that key, the outputs are fetched rather than computed. This works across machines — CI builds populate the cache, developer builds hit it.
Toolchain Configuration
Bazel manages toolchains explicitly. Instead of relying on the system GCC, you declare which compiler to use:
|
|
With this, all developers and CI runs use exactly the same LLVM 18.1.8, regardless of what’s installed on their system. Bazel downloads it automatically.
Bzlmod: Dependency Management
Modern Bazel (6.0+) uses Bzlmod for external dependency management, with a lockfile (MODULE.bazel.lock) that pins exact versions and content hashes:
|
|
Run bazel mod deps to generate the lockfile. The lockfile pins content hashes, so builds are reproducible even if upstream repositories change.
Build without Bytes
One of Bazel’s most powerful features: --build_without_the_bytes. Bazel can execute the full build remotely without downloading intermediate artifacts to the local machine — only the final outputs you request are transferred. This makes building large projects on a laptop with a fast remote executor dramatically more efficient.
Nix: Reproducibility Through Purity
Nix takes a different approach. Rather than sandboxing individual build actions, Nix defines each package as a pure function of its inputs. Every package in the Nix store is identified by a cryptographic hash of everything that influenced its build: source code, dependencies, build scripts, and compiler flags.
|
|
The output is stored at a path like /nix/store/4k8abl9z...-myapp-1.2.3/, where the hash encodes all inputs. Two builds with identical inputs always produce the same hash. If the outputs at that path already exist, Nix doesn’t rebuild — it’s essentially a global build cache.
Nix for Developer Environments
This extends to developer environments. A flake.nix specifies exactly which tools are available in a dev shell:
|
|
Running nix develop gives every developer the same Go 1.23, same linter, same debugger — regardless of what’s installed on their system. The flake.lock file pins exact revisions of nixpkgs, so the environment is identical across time.
Comparing Nix and Bazel
| Dimension | Bazel | Nix |
|---|---|---|
| Primary focus | Build system (incremental, remote cache) | Package manager (environment management) |
| Language support | Rules for most languages | Universal |
| Learning curve | Steep (BUILD files for everything) | Steep (Nix expression language) |
| Remote execution | First-class | Limited |
| Dev environments | Workspace toolchains | Excellent (nix develop) |
| Incremental builds | Excellent (action graph) | Limited (package-level) |
| Best for | Large monorepos, polyglot codebases | System packages, dev environments, NixOS |
For most teams: use Nix for developer environments and Bazel (or a language-native tool like Cargo, Gradle) for build incremental compilation. They complement each other.
SLSA: Supply Chain Levels for Software Artifacts
SLSA (Supply-chain Levels for Software Artifacts, pronounced “salsa”) is a framework from Google for assessing and improving build pipeline security. It defines four levels:
| Level | Requirements |
|---|---|
| SLSA 1 | Build process is documented; provenance is generated |
| SLSA 2 | Build is hosted on a CI platform; provenance is signed |
| SLSA 3 | Build is hardened against tampering; builds are isolated |
| SLSA 4 | Reproducible builds; two-party review for all changes |
Build Provenance
The core artifact is a provenance attestation: a signed document that states “this artifact was built from these sources, by this build system, at this time, producing this hash.”
GitHub Actions can generate SLSA provenance automatically for releases:
|
|
This generates a multiple.intoto.jsonl attestation file alongside your release artifacts. Users can verify it:
|
|
Cosign for Container Images
For container images, cosign (part of the Sigstore project) provides signing and attestation:
|
|
Keyless signing uses short-lived certificates from Sigstore’s Fulcio CA, with entries logged in Rekor’s transparency log. No private key to manage, no key rotation — just OIDC tokens from your CI provider.
diffoscope: Comparing Binaries
When a build is supposed to be reproducible but isn’t, you need to find out why. diffoscope is the tool for this. It recursively compares files and archives, unpacking them layer by layer and producing a human-readable diff.
|
|
diffoscope handles: ELF binaries, ar/tar/zip archives, Java JARs, Debian packages, RPMs, container images, PDF files, and more. It uses objdump, readelf, and similar tools to extract structured information from binary formats before diffing.
Example output when timestamps are embedded:
├── readelf --wide --decompress --hex-dump=.rodata {}
│ @@ -1,4 +1,4 @@
│ - [00000000] 42 75 69 6c 74 20 61 74 20 4a 61 6e 20 31 35 20 Built at Jan 15
│ + [00000000] 42 75 69 6c 74 20 61 74 20 46 65 62 20 32 38 20 Built at Feb 28
Practical Starting Points
For a Go Project
|
|
For a Docker Image
|
|
|
|
For a Python Package
|
|
For a Rust Project
|
|
The Reproducible Builds Checklist
Work through this list when making a build reproducible:
- Set
SOURCE_DATE_EPOCHfrom the most recent git commit - Remove
__DATE__/__TIME__usage from source code - Use
-trimpath(Go) or--remap-path-prefix(Rust) or-fdebug-prefix-map(GCC/Clang) - Sort all file lists before processing
- Pin all dependency versions with lockfiles
- Pin base Docker images by digest, not just tag
- Pin tool versions (compiler, linker, build system)
- Use content-based build IDs (
-Wl,--build-id=sha1) - Test reproducibility: build twice, compare with
diffoscope - Add a CI job that builds twice and asserts byte-for-byte equality
- For releases: generate SLSA provenance and sign with cosign
Conclusion
Reproducible builds are not an academic concern. They’re the technical foundation for verifiable software supply chain security, effective build caching, and trustworthy debugging. The tools to achieve them — SOURCE_DATE_EPOCH, Bazel, Nix, SLSA generators, cosign, diffoscope — are mature and well-documented.
The path forward doesn’t require adopting everything at once. Start with SOURCE_DATE_EPOCH and path normalization for quick wins. Add build caching (Bazel remote cache, Gradle build cache) for the performance win. Add SLSA provenance to your release workflow for the security win. Each step independently valuable; together, they make your software supply chain auditable and trustworthy.
The question isn’t whether your build should be reproducible. The question is how much of your supply chain attack surface you’re comfortable leaving open.
Comments