Skip to content

Reproducible builds

  • Floating dependency ranges and transitive updates.
  • Different Node, npm or OS versions.
  • Timestamps embedded in archives or metadata.
  • Absolute build paths in sourcemaps.
  • CRLF versus LF line endings.
  • Build caches that reuse stale output.

There is no single fix. Choose the combination that matches the risk of the deliverable.

ApproachMechanismStrengthLimit
Committed lockfilenpm ci installs the exact graphStrongest JavaScript determinismLockfile churn and platform-specific optional dependencies
Lockfile-lessExact versions in package.json, npm installNo lockfile conflicts, simple reviewTransitive dependencies can drift
Frozen pnpm or Yarn lockfilepnpm install --frozen-lockfileStrict graph and content-addressed storeRequires team adoption
Container digest pinningFROM node@sha256:...Reproduces the OS and toolchainImage updates are manual
Content hash manifestSHA-256 of every output fileVerifies a build after the factDetects drift, does not prevent it
SOURCE_DATE_EPOCHNormalises timestampsStabilises archive metadataTool support varies

This is the default for client deliverables. Commit package-lock.json, remove it from .gitignore, and install with npm ci everywhere.

Terminal window
npm ci
npm run build
FROM node:24-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build

npm ci refuses to run when the lockfile and package.json disagree. That failure is useful: it catches an unreviewed dependency change before deployment.

Some repositories deliberately exclude lockfiles. This documentation site is one of them: npm install is used locally and in the container. It is simpler to review, but it is not a strong reproducibility guarantee.

{
"dependencies": {
"astro": "7.4.1",
"@astrojs/starlight": "0.42.6"
},
"overrides": {
"example-transitive-package": "1.2.3"
}
}
Terminal window
npm install --no-audit --no-fund
npm run build

Pin direct dependencies exactly, use overrides for known transitive risks, and treat the build hash as evidence rather than a guarantee. If a client requires reproducibility, switch to Approach A.

The base image digest fixes the OS packages and Node binary. Pin it by digest, not only by tag:

FROM node:24-alpine@sha256:REPLACE_WITH_DIGEST

Set SOURCE_DATE_EPOCH when a tool embeds timestamps, and normalise line endings in .gitattributes:

* text=auto eol=lf

Generate a manifest from the build output and compare it across two clean builds:

Terminal window
npm run build
find dist -type f -print0 | sort -z | xargs -0 sha256sum > dist.sha256
# rebuild from a clean checkout, then compare
sha256sum -c dist.sha256

A clean pass proves the two builds match. A failure identifies the exact files that drifted, which is usually enough to find the source.

DeliverableRecommended combination
Client production siteLockfile, container digest, hash manifest, signed artifact
Internal docs or marketing siteLockfile-less with exact versions and a hash manifest
Regulated or high-assurance systemLockfile, digest-pinned container, hash manifest, provenance attestation
Library or packageFrozen lockfile plus published integrity metadata

For the pipeline around the build, see GitOps CI/CD. For the static artifact itself, see Static site generation.