Reproducible Dependency Installs with Yarn Berry: node-modules, Lockfiles, and Immutable CI
Configure Yarn Berry with the node-modules linker, a committed yarn.lock, and immutable installs in CI to guarantee that production dependency trees match what developers tested.
19 Oct 2025, 15:43 UTC

The problem: installing dependencies is easy; proving they match is not
Most JavaScript projects can install dependencies. The harder problem is proving that the dependency tree evaluated in CI is exactly the same tree a developer ran locally, and that any drift fails loudly instead of silently shipping a different build. When resolution changes between environments, bugs appear only in production, and supply-chain audits become guesswork.
The smallest design that solves this for an application repository uses Yarn 2+ (Berry) with the node-modules linker, a committed yarn.lock file, and immutable installs enforced in pipelines. It avoids the larger operational shift of Plug'n'Play while still giving you deterministic resolution and an auditable trail.
Requirements driving the design
Three requirements narrow the solution space:
- Identical dependency trees across developer machines and CI runners.
- Fast failure when resolution drifts, so a stale lockfile blocks the build rather than masking it.
- Auditable supply-chain trail: a record of exactly which packages and versions were resolved, with integrity data.
A committed lockfile plus an immutable install command satisfies all three without introducing a new module resolution strategy.
The smallest suitable design
Use Yarn 2+ (Berry) rather than Yarn 1 (Classic). Classic is end-of-life and lacks the --immutable flag, the nodeLinker option, and the .yarnrc.yml configuration format. Mixing Classic and Berry commands in one repository is unsupported.
In .yarnrc.yml, set the linker explicitly:
nodeLinker: node-modules
enableTelemetry: false
Setting nodeLinker: node-modules keeps the familiar node_modules directory layout. This matters because many tools—test runners, native module postinstall scripts, and IDE language servers—resolve modules by filesystem path. Plug'n'Play, Berry's default resolution strategy, removes node_modules and breaks those tools unless they are PnP-aware. Choosing node-modules is the smallest change that preserves compatibility.
Commit yarn.lock to version control. It records the resolved version and integrity checksum for every package in the tree. The registry remains the only network trust anchor; the lockfile is the local record of what was fetched.
Trust and data boundaries
Treat .yarnrc.yml as configuration, not secrets. It may contain registry URLs and scope mappings, but it must never hold authentication tokens. Supply the npm token through a masked CI environment variable—for example, YARN_NPM_AUTH_TOKEN—and let Yarn read it at install time. If your policy requires it, disable telemetry with enableTelemetry: false.
The lockfile is a data boundary between the registry and your build. If the registry is compromised or a package is poisoned, the integrity checksums in yarn.lock will cause the install to fail rather than silently accept the malicious artifact, provided the lockfile itself has not been tampered with. Review lockfile diffs in pull requests the same way you review source code.
Operational checks
In CI, run installs immutably:
yarn install --immutable
Berry can auto-enable immutability when it detects a CI environment, but detection can misfire in containers or unusual runners. Pass the flag explicitly so the behavior does not depend on environment heuristics. If package.json has changed without a corresponding lockfile update, this command exits with a non-zero status and the pipeline fails.
Cache the Yarn global cache or the .yarn/cache directory, but key the cache on the hash of yarn.lock. A cache keyed only on the branch or commit SHA can serve stale artifacts after a lockfile change.
Pin the toolchain so every machine runs the same Yarn release. Add a packageManager field to package.json:
{
"packageManager": "yarn@4.5.0"
}
Then use Corepack to shim the correct version. Confirm Corepack is available in your Node.js distribution before relying on it; availability and default behavior vary across Node versions and vendors.
Concrete verification workflow
You can confirm the design works with a scratch repository. Pin the toolchain, run yarn install, then edit package.json—for example, change a dependency range—without running yarn install to update the lockfile. Run yarn install --immutable again. It should exit non-zero, proving that drift fails fast.
For a reproducibility check, delete node_modules and the Yarn cache on a clean runner, reinstall, and diff the resulting tree and lockfile against the committed lockfile. They should match. Add yarn constraints and yarn dedupe --check to CI to catch unintended range drift and duplicate resolved versions.
Finally, print yarn --version in CI logs and compare it against the pinned packageManager value to detect toolchain drift.
Failure modes to guard against
| Failure mode | Consequence | Mitigation |
|---|---|---|
Developer runs yarn add and commits a stale lockfile | CI installs a different tree than the developer tested | Immutable install in CI fails the build |
| CI uses mutable installs | Resolution drift is silently masked | Always pass --immutable |
| Corrupted or poisoned global cache | Clean machine and cached machine produce different trees | Key cache on lockfile hash; periodic clean rebuilds |
Secrets committed in .yarnrc.yml | Token leakage in version control | Grep config for token-like strings; inject via CI env only |
| Registry outage | Installs block entirely | Internal registry mirror or zero-installs for air-gapped CI |
When this design stops being enough
Several conditions justify a larger change:
- Monorepo hoisting or install time becomes the bottleneck. Workspace hoisting—lifting shared dependencies to a common parent directory—and install duration can dominate CI time in large workspaces.
- Air-gapped or hermetic CI. If runners cannot reach a registry, move to zero-installs by committing
.yarn/cacheso installs require no network, and pair it with an internal registry mirror. - Mandated proxy with different auth semantics. A corporate proxy that rewrites URLs or requires non-standard headers changes the trust boundary and may require custom
.yarnrc.ymlsettings. - Team standardization on pnpm or npm. Lockfile discipline transfers, but the commands and configuration do not.
Plug'n'Play is the tempting alternative because it removes node_modules and can reduce install size. Treat it as a separate architectural decision, not a default. Defer it unless install size or speed is a measured problem and your entire toolchain is PnP-compatible.
Limitations and assumptions
This design assumes a recent Yarn 4.x Berry release. Berry's defaults and CLI differ substantially from Classic, and some behavior has changed across Berry minor versions; verify against the exact pinned release rather than tutorials written for a different major.
yarn npm audit depends on registry advisory data and network access. A clean audit is a point-in-time signal, not proof of safety. Corepack availability and default behavior vary by Node.js distribution and version, so confirm it is present before relying on it for toolchain pinning.
The practical way to check the result is the clean-runner diff described above: if a fresh install on a machine with no cache produces the same tree and leaves yarn.lock unchanged, the design is holding.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.