Build It Twice: The Reproducibility Test Your Static Site Will Fail

Here is a test that takes four minutes and that almost every static site fails.
Check out a commit. Build it. Save the output. Delete it, build the same commit again, and diff the two directories byte for byte.
If the output is not identical, you do not have a deterministic build. You have a function of your commit and the wall clock and the machine and whatever npm felt like resolving this morning. Most teams have never run this test, which is why most teams are surprised by what it finds.
Run it
Two builds, two manifests, one diff:
npm ci && npm run build && mv dist dist-a
rm -rf node_modules && npm ci && npm run build && mv dist dist-b
diff <(cd dist-a && find . -type f -exec sha256sum {} \; | sort) \
<(cd dist-b && find . -type f -exec sha256sum {} \; | sort)
If you are on Windows or want it in CI without shell gymnastics, the portable version is twenty lines:
// scripts/manifest.mjs — node scripts/manifest.mjs dist > a.txt
import { createHash } from 'node:crypto'
import { readdir, readFile } from 'node:fs/promises'
import { join, relative } from 'node:path'
const root = process.argv[2] ?? 'dist'
async function* walk(dir) {
const entries = await readdir(dir, { withFileTypes: true })
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const p = join(dir, e.name)
if (e.isDirectory()) yield* walk(p)
else yield p
}
}
for await (const file of walk(root)) {
const hash = createHash('sha256').update(await readFile(file)).digest('hex')
console.log(`${hash} ${relative(root, file).replace(/\\/g, '/')}`)
}
Then diff a.txt b.txt. Every line that comes back is a piece of your site that changes for reasons unrelated to your source code.
What you will find
In rough order of how often it shows up.
A timestamp baked into the output. A “last built” line in the footer, lastBuildDate in the RSS feed, <lastmod> in the sitemap set to new Date(). This is the number one offender and it is almost always a one-line fix.
Unsorted directory reads. fs.readdir returns entries in whatever order the filesystem hands back. That is stable on your laptop and stable on CI, but it is not guaranteed to be the same stable, and it is definitely not the same across ext4, APFS, and NTFS. If a glob feeds a “recent posts” list or a bundle’s module graph, unsorted input means non-deterministic output.
Floating dependency ranges. ^4.1.0 in package.json means “anything under 5.” If your CI runs npm install rather than npm ci, it is free to resolve a version your lockfile never saw. Every transitive dependency of every build tool is in scope here, and a patch bump inside a minifier is enough to change bytes.
Node version drift. Different Node versions ship different V8, different Intl data, and different behaviour in the small corners that bundlers live in. If CI says “Node 22.x” and your laptop says something else, you are comparing two different programs.
Native modules that re-encode assets. This is the sneaky one. If your build pipeline runs images through sharp, the bytes you get depend on the bundled libvips encoder, not just on your source image. A sharp patch bump can produce a visually identical WebP with a different hash — which produces a different fingerprinted filename — which busts the CDN cache for an image that did not change.
Anything random. Manually generated element IDs, cache-busting query strings from Math.random(), a UUID stamped into a build manifest. Rare, but instantly fatal to the diff.
Locale and timezone. toLocaleDateString() renders against the ambient TZ. CI is UTC. Your laptop is not. A post published at 6pm Pacific renders as the next day in CI, and you will find this out from a reader, not from a test.
Why this matters more on a static site than anywhere else
The usual argument for reproducible builds is supply-chain integrity, which is real but abstract. On a static site there are three consequences you feel directly.
It wrecks your cache strategy. Fingerprinted asset names are the entire basis of immutable caching — a file’s name changes only when its contents change. Non-determinism inverts that contract: names change when nothing changed. Every returning visitor re-downloads assets that are identical to the ones already on their disk, and you get no warning, because the site looks fine.
It destroys deploy diffs. A static deploy is a directory swap, and the most useful safety check available is “what actually changed in dist/ compared to production?” A one-word copy edit should produce a two-file diff. If your build is non-deterministic, every deploy touches hundreds of files, the signal drowns, and nobody reads the diff again.
It makes bisecting impossible. When a page renders wrong in production and right locally, the first question is whether the input or the environment changed. A reproducible build answers that in one command. A non-reproducible one leaves you guessing for an afternoon.
Fixing it
In the order I would actually do it.
1. npm ci, never npm install, in CI
npm ci wipes node_modules and installs exactly what the lockfile says, failing loudly if package.json and the lockfile disagree. npm install is allowed to update the lockfile mid-build. One of these is a build step; the other is a mutation.
Commit the lockfile. Pin the Node version in one place and read it everywhere:
# .nvmrc
22.14.0
# .github/workflows/build.yml
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
- run: npm ci
2. Derive build timestamps from the commit, not the clock
This is the SOURCE_DATE_EPOCH convention from the Reproducible Builds project, and it costs one line. The commit timestamp is a real, meaningful, stable time — the clock is not:
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
npm run build
// anywhere you were about to write new Date()
const buildTime = new Date(
(Number(process.env.SOURCE_DATE_EPOCH) || Math.floor(Date.now() / 1000)) * 1000,
)
Feed that into your RSS lastBuildDate and your sitemap <lastmod>. Better still for <lastmod>: use the post’s own publishedAt or last-modified date, which is what crawlers actually want to see and which is stable by construction.
3. Sort every glob
Anywhere you read a directory and the order affects output, sort it explicitly:
const files = (await readdir(dir)).sort()
Framework content APIs mostly do this for you — Astro’s content collections and Next.js’s file-based routing both produce stable ordering — but your own scripts do not, and neither does a bare fs.readdir.
4. Force UTC in the build environment
TZ=UTC npm run build
Then format dates with an explicit timeZone rather than trusting ambient state:
new Intl.DateTimeFormat('en-US', {
timeZone: 'UTC',
dateStyle: 'long',
}).format(date)
Same string on your laptop, on CI, and in a container. This one fixes a real user-visible bug, not just a hash.
5. Take asset generation out of the build
The best fix for non-deterministic image encoding is to not encode images during the build at all. Generate them once, commit the output, and let the build copy bytes. That is what this site does: every hero image and every responsive variant is a committed file in public/media/, produced by a script that runs on demand and is never part of npm run build.
The build gets faster, the output gets deterministic, and image changes show up in code review as an actual diff instead of appearing silently in the deploy.
If you must encode at build time, pin the encoder exactly — no caret — and treat a bump as a change that touches every image on the site, because it is.
Make it a check, not a ritual
Nobody runs a manual four-minute test twice a year. Put it in CI on a schedule, where it costs you nothing until it fails:
name: reproducible-build
on:
schedule:
- cron: '0 6 * * 1'
workflow_dispatch:
jobs:
double-build:
runs-on: ubuntu-latest
env:
TZ: UTC
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
- run: npm ci
- run: npm run build && node scripts/manifest.mjs dist > /tmp/a.txt
- run: rm -rf dist && npm run build && node scripts/manifest.mjs dist > /tmp/b.txt
- run: diff /tmp/a.txt /tmp/b.txt
Two builds in one job, one process, one machine. That is deliberately the easy version of the test — it will not catch Node drift or a dependency resolving differently next month, because both builds share an environment. It catches everything in the first three categories above, which is most of what is actually wrong. Splitting the two builds across separate jobs on separate runners raises the bar; do that once the easy version stays green.
The bar is lower than “bit-for-bit”
Full reproducibility — same bytes from a different machine, a year later, on a different OS — is a serious engineering commitment and probably not worth it for a blog.
The useful bar is much lower: two builds of the same commit, on the same machine, minutes apart, produce an identical diff — or produce a difference you can explain in one sentence.
If you cannot explain the difference, you do not know what your build does. And “I don’t know what my build does” is a fine position to hold right up until the afternoon you need to know.

