What reproducible builds actually prove
A checksum proves you got the file the publisher sent. A reproducible build proves that file matches the source you can read. Two different claims.
What reproducible builds prove
Reproducible builds prove one narrow fact: the binary you were handed is the binary that this exact source code produces. Anyone can take the same source, build it again, and compare the bytes. Verification stops being something only the publisher is able to do.
The Reproducible Builds project defines it this way: "A build is reproducible if given the same source code, build environment and build instructions, any party can recreate bit-by-bit identical copies of all specified artifacts." The comparison itself is a hash. All of the difficulty is in pinning the environment and the instructions down tightly enough that two different machines agree.
Why a checksum does not answer this question
A published checksum proves the file arrived without corruption. A signature over that checksum proves it came from the person holding the key. Neither one says anything about what happened before the artifact existed. If the publisher's build machine is compromised, the malicious binary is checksummed and signed exactly like a clean one, so every check downstream passes. If a maintainer builds from a working tree that was never pushed, the same is true.
That is the gap. You can read the source, verify the signature, verify the checksum, and still be running code that never appeared in the repository. Reproducibility is therefore a different subject from verifying a download against its published checksum. The checksum protects the transfer. The rebuild protects everything that happened before the transfer.
The attack is not theoretical. The 2020 SolarWinds Orion compromise sat in exactly that position: the build system emitted signed artifacts that did not correspond to the source anyone had reviewed. Every signature check passed, because signatures start at the artifact.
What a reproducible build does not prove
This is the part that gets oversold, so be exact about the limits.
- It does not say the source is safe. A backdoor committed in the open builds reproducibly, and every rebuilder confirms it, because they all built the same hostile source. Reproducibility moves the target to the source tree. Somebody still has to read that tree, which is why review policy, including policies for AI-assisted code in open source projects, stays a separate control.
- It does not say your inputs are safe. Dependencies are part of what you build. A malicious package resolved at build time is compiled into the artifact, and every rebuilder that resolves the same dependency agrees with you. That is how an npm supply chain attack reaches a server, and a reproducible build reproduces it faithfully.
- It does not say the toolchain is honest. If the compiler is compromised, every rebuilder using that compiler emits the same compromised output, and the verdicts all agree. Reproducibility raises the cost of that attack. It does not detect it.
- It says nothing about vulnerabilities. An old library reproduced bit for bit is still an old library with its published flaws, so keep checking your server for known CVEs on its own schedule.
What reproducibility removes is one specific attacker position: the build machine, and the whole path from source to binary. Until a package is reproducible, nobody outside the publisher can inspect that path at all.
Why the same source produces different bytes
Most software is not reproducible by default, and the causes are dull. Compilers and archive formats record facts about the machine that ran them.
- A timestamp. The
tar,arandzipformats store file modification times, so building at a different second changes the output file. - A path. Debug information records the absolute build directory, so a build in
/home/alice/srcand a build in/build/pkgdiffer even though the code is identical. - An ordering. Reading a directory returns entries in filesystem order, so the link line or the archive member order shifts between machines.
- An identity. Build scripts embed the username, the hostname, or the locale of whoever built it.
- A decision made at build time. Detecting CPU features, or seeding something randomly, makes the output depend on the machine instead of the source.
You can watch the first cause happen in about ten seconds:
mkdir -p /tmp/rb && cd /tmp/rb
echo hello > a.txt && tar cf one.tar a.txt
sleep 2
echo hello > a.txt && tar cf two.tar a.txt
sha256sum one.tar two.tarThe two hashes differ because the tar header stores the modification time of a.txt, and rewriting the file moved that time forward two seconds. The content is byte for byte the same. Pinning the metadata fixes it:
export SOURCE_DATE_EPOCH=1700000000
tar --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 --numeric-owner -cf three.tar a.txt
touch a.txt
tar --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 --numeric-owner -cf four.tar a.txt
sha256sum three.tar four.tarNow the hashes match, because no field in the archive header comes from the current state of the machine. --sort=name fixes the ordering, --mtime fixes the clock, and the ownership flags stop your user id from being recorded.
Reading the difference with diffoscope
When two builds differ, sha256sum tells you that they differ and nothing else. diffoscope exists to tell you why, in terms a person can read. It unpacks both sides recursively, converts binary formats into text, and diffs the text. It handles Debian packages, ELF binaries, tar and ZIP archives, PDFs, SQLite databases, and more than a hundred other formats.
sudo apt install -y diffoscope
diffoscope one.tar two.tarFor the tar pair above the report is short. Trimmed, it looks like this:
--- one.tar
+++ two.tar
├── file list
│ @@ -1 +1 @@
│ -rw-r--r-- 0/0 6 2026-08-18 10:14:02.000000 a.txt
│ +rw-r--r-- 0/0 6 2026-08-18 10:14:04.000000 a.txtThat is the entire diagnosis: same size, same path, same permissions, different modification time. A real package produces a much longer report, so write it out and open it in a browser:
diffoscope --html report.html build1.changes build2.changesdiffoscope exits 0 when the inputs are identical, 1 when they differ, and 2 when it hits trouble, so it drops straight into a CI job with no wrapper script. On a small VPS, install diffoscope-minimal instead of diffoscope: the full package pulls in a large set of format helpers you will probably never call.
What SOURCE_DATE_EPOCH fixes, and where it stops
SOURCE_DATE_EPOCH is one environment variable holding one number: the last modification time of the source, counted in seconds since 1 January 1970 UTC. A build tool that honours it uses that number wherever it would otherwise ask the operating system for the current time. Set it from version control, so the value follows the source rather than the build:
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)In a Debian package, debhelper exports it from the changelog for you. Setting it by hand in debian/rules looks like this:
export SOURCE_DATE_EPOCH ?= $(shell dpkg-parsechangelog -STimestamp)Support is per tool, never global. cmake 3.8 and newer, gcc 7 and newer, rpm above 4.13 and Docker buildx 0.10 and newer all read it. Your own scripts do not, unless you write them to. If a script calls date, feed it the variable:
BUILD_DATE="$(date --utc --date="@${SOURCE_DATE_EPOCH:-$(date +%s)}" +%Y-%m-%d)"One rule matters when you implement it. If the variable is already set, that value is the current time as far as your build is concerned, so never overwrite what the caller gave you.
Container images have the same problem in a different wrapper. Docker buildx 0.10 and newer passes SOURCE_DATE_EPOCH from your shell into the build as a build argument. Timestamps on the files inside the layers need the exporter to rewrite them, which BuildKit added in 0.13, and the documented form pushes the result to a registry:
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
docker buildx build --output type=image,name=registry.example.com/app:1.0,push=true,rewrite-timestamp=true .Testing your own build with reprotest
reprotest builds the same source twice and changes the environment between the two builds on purpose, then compares the results. The variations are the whole point. By default it varies the build path, the time, the timezone, the locale, the umask, the hostname, the user and group, the number of CPUs, the home directory and file ordering.
sudo apt install -y reprotest
reprotest . -- nullEverything after -- selects the build environment backend, and null means the system you are sitting on. Add -vv -d to keep the temporary directories for inspection, as in reprotest . -vv -- null -d. Use reprotest auto -- null to let it work out what kind of source tree it is looking at.
Some variations need privileges or extra packages, and they fail loudly when they cannot run. Turn those off rather than running the whole thing as root:
reprotest --vary=-user_group,-domain_host,-fileordering auto -- nullAnything reprotest reports is something a rebuilder would have reported later, in public, with your project's name on it.
What a rebuilder verdict means
A rebuilder is a machine that is not the publisher's. It takes the published source and the recorded build environment, builds the package again, and compares its own output against the artifact in the archive. The verdict is worth something only because the machine is independent.
Debian records the environment in a .buildinfo file that dpkg-buildpackage writes next to the .deb. The fields are the interesting part. Installed-Build-Depends lists every installed package that could affect the build, with exact versions. Build-Path records where the build ran. Environment records the environment variables known to matter. Checksums-Sha256 covers the outputs. That file is the recipe for a second attempt:
sudo apt install -y devscripts mmdebstrap
debrebuild --buildresult=./artifacts --builder=mmdebstrap hello_2.10-2_amd64.buildinfodebrebuild reads the buildinfo and pulls the exact dependency versions it names from snapshot.debian.org, so a rebuild today can use the package versions that existed on the day of the original build. The mmdebstrap builder needs no chroot setup and no superuser rights. Compare the artifacts it produces against the archive copy with diffoscope.
Arch Linux runs rebuilderd, which does this continuously and publishes the verdicts:
rebuildctl -H https://reproducible.archlinux.org pkgs ls --name rebuilderdStatuses are GOOD, BAD and UNKWN, and the plain reading of each is wrong in a different direction. GOOD means an independent party got the same bytes, which is a strong statement about the build and no statement at all about the source. BAD is almost never an attack: the usual cause is a timestamp or a path the packaging failed to pin, which is why rebuilderd can attach a diffoscope report to the failure. UNKWN means nobody has tested it, and an untested package is not a passing package.
So the operational rule is short. A BAD verdict is a reason to read the report. If the report shows timestamps, build paths or member ordering, file a packaging bug. If it shows different executable code with no such explanation, stop deploying that build and escalate it.
How reproducible is Debian right now?
The data behind this chart
[
{
"label": "unstable",
"percent_reproducible": 94.2,
"tested_count": "41,163"
},
{
"label": "forky",
"percent_reproducible": 93.4,
"tested_count": "39,059"
},
{
"label": "experimental",
"percent_reproducible": 67.0,
"tested_count": "588"
}
]On the day this post was written, unstable on amd64 stood at 94.2% reproducible across 41,163 packages tested. Experimental sat at 67.0%, over a much smaller and much newer sample of 588 packages, which is what you expect from packages nobody has finished fixing yet.
Those figures come from the Debian page on tests.reproducible-builds.org, read on 2026-08-18, when the page carried the stamp "Last update: 2026-08-18 16:02 UTC". They move. Read the tracker instead of quoting this paragraph in six months.
One caveat matters more than the percentage. This framework builds each package twice on its own hardware, varying the environment between the two builds, and compares its own two results. It measures whether a package can build reproducibly. It is not a check that the .deb sitting in the archive matches, which is the separate job of a rebuilder comparing against the published artifact. Both numbers are useful. They answer different questions, and people quote the first one as though it were the second.
What to do on your own server
You are not going to rebuild a distribution. The parts that transfer to an ordinary server are smaller and cheap.
- Pin the toolchain. A base image referenced by tag moves under you without warning. Reference it by digest, and record the digest with the release.
- Record the inputs. Keep the lockfile, the image digest and the compiler version next to the artifact. A build whose environment you cannot reconstruct cannot be rebuilt, so it can never be checked.
- Build twice in CI and fail the job when the outputs differ. This costs one extra build and catches nondeterminism on the day somebody introduces it, rather than a year later during an incident.
- Strip the paths the compiler embeds. For Go,
go build -trimpath -buildvcs=falseremoves the build directory and the version control stamp, andgo version -m ./appprints what actually ended up in the binary. - Keep the hash of what you deployed. When you need to know whether the running binary corresponds to a source revision, that record is the only thing that can answer.
The CI check is four lines:
set -eu
./build.sh && mv dist/app app.1
./build.sh && mv dist/app app.2
diffoscope --text - app.1 app.2diffoscope exits non-zero when the two artifacts differ, so the job fails on its own and leaves a readable explanation in the log. That is the whole idea, scaled down to one repository: the claim that a binary comes from a source tree should be something a second machine can check.
FAQ
Does a reproducible build mean the software is safe?
No. It proves the binary corresponds to the source, and nothing more. A backdoor committed into the public source tree builds reproducibly, and every rebuilder confirms it, because each of them built the same hostile source. A package with a known CVE reproduces perfectly and stays vulnerable. Reproducibility removes one attacker position, the build machine and the path from source to binary. Reading the source and tracking vulnerabilities are separate jobs that reproducibility does not do for you.
Why do my two builds differ when nothing in the source changed?
Almost always a timestamp, a path, or an ordering. Archive formats such as tar and zip store file modification times, so a checkout made at a different second produces different bytes. Debug information records the absolute build directory, so /home/alice/src and /build/pkg produce different binaries from identical code. Directory reads return entries in filesystem order, so the object files on a link line can be ordered differently on another machine. Run diffoscope build1 build2 and the report names which of these it is instead of leaving you guessing.
What is SOURCE_DATE_EPOCH and do I have to set it?
It is a standard environment variable holding one number: the last modification time of the source, in seconds since 1 January 1970 UTC. Tools that honour it use that value wherever they would otherwise read the system clock. Set it from version control with export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct). It is not automatic and it is not a general fix. Only tools that implement it read it, and your own build scripts must read it themselves, so a script that calls date keeps stamping the current time until you change it.
What should I do when a rebuilder reports BAD?
Read the report before doing anything else. A BAD verdict means one independent rebuild did not produce the same bytes, and the ordinary cause is nondeterminism in the packaging rather than an attack. rebuilderd can generate a diffoscope report for exactly this reason. If the differences are timestamps, build paths or file ordering, that is a packaging bug worth filing. If the difference is executable code with no such explanation, stop deploying that build, keep the artifacts, and escalate it to the publisher.