Dependency Pinning
Pinning buys reproducibility and tamper-evidence, and hands you the update duty the range was performing on your behalf.
The question, the obvious approach, and why it breaks
Every lesson starts where the work starts: an operational problem, a first attempt that is entirely reasonable, and the way production disagrees with it.
Should dependencies be pinned to exact versions, and what do you take on when you do?
A version range means the set of code you ship changes without any change on your side. A pin means it does not — and neither does the security patch you needed.
Use ranges so security patches arrive automatically. Pinning everything is how projects end up years out of date.
A range delivers whatever is published, not what you reviewed. The build on Tuesday and the build on Wednesday can ship different code from the same commit (Reproducible Builds).
- A range delivers whatever is published, not what you reviewed. The build on Tuesday and the build on Wednesday can ship different code from the same commit (Reproducible Builds).
- "Patches arrive automatically" only holds if you rebuild. Many services build on merge, so a patch published today reaches production whenever someone next changes something — an arrival schedule nobody chose.
- Ranges make rollback ambiguous. Reverting your commit does not revert the resolved dependency set, so "the previous version" may not be reconstructible.
- They are also the injection path: a compromised package published inside your accepted range reaches your build with no human decision at any point (Typosquatting and Malicious Packages).
- But the criticism of pinning is fair. A pinned dependency receives nothing — no security patch, no bug fix — unless someone moves the pin, and pins with no update process are how a project ends up four years behind (Vulnerability Management by Exposure).
What is actually happening
Underneath the tooling, which is the part that survives a change of tool.
- A pin removes a decision from build time and moves it to a commit. That is the entire mechanism, and both the benefit and the cost follow from it.
- Pins have strengths. A version number says "this label"; a content hash says "these bytes". Only the second is tamper-evident, because a version number depends on the registry continuing to serve the same content under that label.
- Pinning is only meaningful over the transitive closure. Pinning direct dependencies while their dependencies float leaves most of the shipped code unpinned — which is why the lockfile, not the manifest, is the pinning mechanism.
- The same logic applies to every input, not just packages: base images by digest, CI actions by commit SHA, toolchains by version, runner images where the platform allows it.
- The update duty is not optional overhead — it is the work the range was doing invisibly, made visible and given a schedule. Pinning without automation trades one silent risk for another.
- Pinning plus automated update proposals is the combination that gets both properties: nothing changes without a commit, and commits proposing changes arrive continuously (Dependency Management).
What each kind of pin actually pins
These are all called pinning and they offer different guarantees. The two columns to read together are the last two: what the pin protects against, and who is now responsible for updating it.
| Form | Example | Deterministic? | Tamper-evident? | Who updates it |
|---|---|---|---|---|
| Range | ^4.17.0 | No — resolves at build time | No | The publisher, silently |
| Mutable tag | FROM node:20-alpine | No — the tag can be moved | No | The image publisher, silently |
| Exact version | 4.17.21 | Yes, if the registry serves the same bytes | No — the label is not the content | You, by editing the manifest |
| Lockfile with integrity hashes | package-lock.json | Yes, over the transitive closure | Yes — mismatched content fails the install | You, by committing a lockfile change |
| Content digest | node@sha256:9c4b... | Yes, by construction | Yes — the name is the content | You, by editing the digest |
| Vendored source | committed vendor/ | Yes, and visible in review | Yes, via version control history | You, and it is the most work |
Pinning the direct set is not pinning
The most common half-measure: exact versions in the manifest, nothing about what those versions bring with them. It looks pinned in review and is not pinned in the artefact.
package.json "express": "4.19.2" exact "pg": "8.11.5" exact "zod": "3.23.8" exact no lockfile committed pinned: 3 packages floating: ~340 transitive packages same commit, next month: different artefact
package.json (ranges are fine here) package-lock.json (committed, reviewed) every resolved package, exact version + integrity sha512-... per package CI: npm ci (installs from lock, fails on mismatch) not: npm install (may re-resolve and rewrite the lock) pinned: all ~343 same commit, next month: same artefact
The transitive closure is what ships. Pinning the three names you typed while three hundred others float means the artefact still changes without a commit — you have only pinned the part that was already visible in review. Note the install command: using the resolving command in CI silently discards the lockfile guarantee you thought you had.
The duty you just accepted
Every row here is a real failure of pinning-without-process. None of them is an argument against pinning; each is an argument for the automation that has to come with it.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Advisory published for a pinned dependency | Scanner flags the artefact; nothing arrives automatically | Pins do not update themselves — that is the point of them | An update bot plus a defined response window; prioritise by exposure, not by CVSS alone (Scanning, and Why a Finding Is Not a Risk) |
| Nobody merged bot pull requests for six months | Update pull request now touches hundreds of packages | The backlog was never bounded | Bound it: a weekly slot, or auto-merge patches with a trusted suite. Then upgrade in small batches (Change Size: Why Small Changes Are Safer, and When They Are Not) |
| Base image pinned by tag | Same Dockerfile, different image contents on different days | A tag is a mutable pointer, not a pin | Pin by digest, with the human-readable version in a comment (Layers and the Build Cache) |
| Base image pinned by digest, never updated | OS packages inside the image are years old | The pin froze the OS layer too, which was the intent and is also the problem | Automate base image bumps on the same cadence as packages (What Image Size Actually Costs) |
| Pinned version yanked from the registry | Historical commits no longer build | Your build depends on someone else's retention policy | Mirror what you depend on; it is the only durable answer (Securing the Pipeline Itself) |
| Auto-merge enabled on a flaky suite | A regression merges; nobody read the change | The merge decision was delegated to a signal that is only sometimes right | Fix the flake rate before trusting auto-merge with a production decision (Flaky Tests) |
How to do it properly
Most important first.
- Pin the transitive closure with a committed lockfile containing integrity hashes, and install from the lockfile in CI.
- Pin by digest wherever digests exist: container base images with
FROM image@sha256:..., CI actions by commit SHA, published artefacts by digest (Tags Versus Digests). - Pin the toolchain and the runner image alongside the packages; an unpinned compiler makes a pinned dependency set only half a guarantee (Build Environments).
- Pair every pin with automated update proposals from day one. Adopting pinning without the bot is choosing the worse half of the trade.
- Review lockfile changes. A diff that moves the transitive closure is a change to what you ship.
- Keep a mirror so a pinned version stays fetchable after it is yanked upstream (Securing the Pipeline Itself).
- Watch what the pin does not cover — a pinned base image still contains OS packages that were current when the image was built, and those age (Scanning, and Why a Finding Is Not a Risk).
How much can this affect
Every production change has a blast radius. Stated as a scale so it is comparable between changes rather than adjectival — and paired with what actually contains it, because a wide scope with a real containment mechanism is a different situation from a wide scope with none.
An unpinned input can change what every future build contains, with no commit anywhere. A lockfile contains it by making every change explicit and reviewable.
What can go wrong
- Pinning adopted, automation never added: the dependency set freezes and quietly accumulates known vulnerabilities.
- Direct dependencies pinned, transitive ones floating, producing the illusion of a pinned build.
- A tag treated as a pin.
FROM node:20-alpineis a moving pointer; the same Dockerfile builds different images on different days (Mutable Servers and Immutable Images). - Bot pull requests auto-merged without a trustworthy suite, which is a range with extra ceremony.
- A pinned version yanked upstream, so historical commits become unbuildable without a mirror.
- Pinned packages inside an unpinned base image, so the OS layer drifts while the application layer does not.
- Update pull requests batched into one enormous change because the backlog was ignored for six months, at which point every regression has hundreds of suspects.
- "Pinning means never updating." Pinning means updating deliberately. Without an update process it does become never, which is a process failure rather than a property of pinning.
- "An exact version is a pin." It is a label pin. Only a content hash pins the bytes — the distinction matters exactly when a registry serves something different under the same name (Artifact and Build Integrity).
- "Ranges give us security patches." They give you whatever is published in the range, patch or regression or compromise, unreviewed.
- "We pinned the base image to
node:20.11-alpine." That is still a tag and it can move.node@sha256:...is the pin.
Operating it
Evidence is the signal, not the intention. Rollback is sometimes 'you cannot, and that is the point'.
- Building the same commit at two different times produces the same resolved dependency set — checked, not assumed (Reproducible Builds).
- Every action, base image and toolchain reference in the repository resolves to a digest or an exact version; a grep confirms it.
- Update pull requests exist, are recent, and are being merged — the pin is maintained rather than frozen.
- The lag between an advisory being published and the fix being merged is measured.
- A build with the public registry unreachable still succeeds, because pinned versions are mirrored.
- Pinning is reversible: loosen the constraint and the resolver takes over again. The reason not to is that you lose the ability to reconstruct what was shipped.
- A bad update reverts by reverting the lockfile commit, and the previous version is guaranteed fetchable if it is mirrored — which is the argument for the mirror.
- Rolling back a digest-pinned base image is exact by construction; rolling back a tag-pinned one is not, because you cannot name what the tag used to point at.
- Automate proposals — this is the load-bearing automation. Pinning without it is a decision to fall behind.
- Automate the check that nothing is unpinned: a CI job that fails on a floating action reference, a tag-based
FROM, or a range in a manifest that should be locked (Policy as Code). - Automate advisory matching against the pinned closure so the update queue is prioritised by exposure rather than by publication date.
- Keep major-version adoption human. A major bump is an API migration and needs someone to read the changelog (Deprecation as a Process, Not a Label).
- Reproducibility and tamper-evidence in exchange for owning the update schedule. That is the trade, stated plainly, and it is not free in either direction.
- Digest pins are the strongest and the least readable: nobody can tell from
sha256:9c4b...which version it is, so the version belongs in a comment beside it. - A mirror gives you availability and history and is a service someone has to run.
- Strict pinning of everything, including development-only tools, adds review load for changes that cannot affect production; scope the strictness to what ships.
Where this applies
This domain is unusually tool- and organisation-dependent. These labels say what each claim is specific to, and what a different platform, provider or organisation does instead.
- TOOL-SPECIFICGo records module hashes in
go.sumby default and verifies them, so Go projects are pinned whether or not anyone decided to be; npm and Cargo lockfiles carry integrity hashes but only apply when the install command reads the lockfile; Maven has no lockfile and requires dependency management sections or a plugin. The amount of work "pin your dependencies" describes varies by an order of magnitude. - GENERALThe trade — determinism and tamper-evidence in exchange for owning updates — is identical for packages, base images, CI actions and toolchains, and identical across every ecosystem.
Where the depth lives
This domain teaches delivery and operation, and hands the mechanism off to the domain that owns it.