Nearly every software outage has a story that begins the same way: But nobody changed anything.
Very often that is technically true. Nobody on the team edited the code. But the code depended on something described by a label — latest, v3, ^4.17.0, main — and somebody, somewhere, moved that label. The line of configuration stayed the same, but the software it pointed to changed.
To pin a dependency means to name it so precisely that it cannot quietly turn into something else. This post covers why floating labels are so dangerous, what a real pin looks like, why a pin on its own still isn’t enough, and the eight rules that tie it all together.
What “Latest” Actually Means
In the container world, latest sounds like a promise: give me the newest version
. It is not. In Docker, latest is simply the default tag name used when you don’t specify one. It points at whatever the publisher last tagged that way, which might be the newest release, an old one, or a development build.
Kubernetes, the most widely used system for running containers, makes this worse. If a container image uses the latest tag (or no tag at all) and no pull policy is set, Kubernetes defaults to downloading the image again every time a container starts. That means two copies of the “same” service, started an hour apart on different machines, can be running different software.
Package managers have the same problem under a different name. A version range such as ^4.17.0 in a JavaScript project means “any compatible version from 4.17.0 up to (but not including) 5.0.0.” The day a new version is published, every fresh install picks it up automatically, whether or not anyone reviewed it.
Every version reference falls into one of three categories:
| Kind of Reference | Examples | Who Can Change What It Points To | What It Protects Against |
|---|---|---|---|
| Floating label | latest, main, v3, ^4.17.0 |
The publisher, every time they release | Nothing; it is designed to move |
| Version tag | 15.4.0, v35.1.2 |
Anyone who controls the repository, at any time | Accidental upgrades, if nobody moves or deletes the tag |
| Content fingerprint | sha256:9f8e… image digest, 40-character commit SHA (Secure Hash Algorithm) |
Nobody; it is a mathematical hash of the exact bytes | Silent changes of any kind, malicious or accidental |
The middle row is the one that catches most people out. A version number looks permanent, but in Git, container registries, and most package registries, a tag is just a movable pointer. The Open Container Initiative (OCI) specification that every container registry follows treats tags as mutable and makes no promise that they stay put. Immutability is an optional policy that some registries let you switch on.
The Day a Version Tag Pointed 23,000 Repositories at Malicious Code
On 14 March 2025, attackers compromised tj-actions/changed-files, a popular add-on (“Action”) for GitHub’s automated build system, GitHub Actions. Security firm StepSecurity, which detected the attack, reported that more than 23,000 repositories used it.
The attackers did not need anyone to install anything new. They moved the Action’s existing version tags to point at a malicious commit that printed the build system’s secrets (cloud access keys, GitHub tokens, package-publishing tokens, private keys) into the build logs. For public repositories, those logs could be read by anyone.
Every project that referenced the Action by a version tag ran the attacker’s code on its next build. Projects that referenced it by the full commit SHA of an earlier release kept running the code they had already trusted. The U.S. Cybersecurity and Infrastructure Security Agency (CISA) issued an alert (CVE-2025-30066) that also covered a second compromised Action, reviewdog/action-setup@v1, in the same campaign. Its guidance included pinning third-party Actions to commit SHAs.
GitHub’s own documentation says the same thing plainly: Using the commit SHA is the safest option for stability and security.
The same failure shows up in package registries, where version ranges do the attacker’s distribution for them:
| Incident | What Happened | Why Floating Versions Made It Worse |
|---|---|---|
| event-stream (npm, 2018) | A new maintainer was given publishing rights and added a malicious dependency aimed at a cryptocurrency wallet | Projects using a version range pulled the poisoned release automatically |
| tj-actions/changed-files (GitHub Actions, 2025) | Version tags were moved to a commit that dumped build secrets into logs | Any workflow referencing a tag ran the new code on its next build |
| Self-replicating npm worm (npm, September 2025) | Stolen developer credentials were used to publish new, infected versions of packages in bulk | Each new version was picked up by every install that allowed a range |
A Pin Is Not a Backup
So pin everything to a fingerprint and you’re safe? Not quite. A pin guarantees that if you get the software, it is exactly the software you expected. It does not guarantee that you can still get it.
The clearest recent example is Bitnami, a widely used source of free, ready-made container images and Helm charts (installation packages for Kubernetes). In July 2025, Bitnami’s owner Broadcom announced that from 28 August 2025 the free public catalog would shrink to a limited set of images available only on the latest tag. All versioned tags moved to a separate bitnamilegacy archive that receives no further updates, and production users were directed to a paid subscription.
Read that again: the free tier now offers only the floating label, and teams that did the responsible thing and pinned a version were the ones whose configuration stopped resolving.
bitnamilegacy archive.What makes this kind of break so nasty is when it fires. Running services keep working, because they already have the image. The failure only appears when something needs to download it fresh:
- A new deployment or upgrade. The least harmful case: you find out during a planned change, with a rollback available.
- A build or security-scan job. The pipeline turns red. Annoying, but not dangerous.
- Automatic scaling. A busy service adds a new machine, the machine cannot download the image, and the extra capacity silently never arrives, right when you need it.
- Disaster recovery. You rebuild in a new region with an empty cache, and discover the recovery plan cannot work on the very day you are carrying it out.
The lesson is that pinning controls drift, while keeping your own copies controls availability. You need both. A pull-through cache or mirror (a registry you run that saves a copy of everything you download) protects you, but only if it was already in place before the upstream copy disappeared.
A Pin Without Updates Is Just Slower Decay
The other common objection to pinning is fair: If nothing ever changes, I’ll never get security fixes.
The United States National Institute of Standards and Technology (NIST) puts it well in its container security guide (SP 800-190): an image built from current components is free of known vulnerabilities for days or weeks, and then it starts aging as new vulnerabilities are discovered. A large study of 356,218 Docker Hub images (the DIVA study) found many images that had gone unupdated for hundreds of days, with vulnerabilities passed down from parent images to the images built on top of them.
The answer is not to float. It is to make updates a deliberate, reviewed change. Tools such as Renovate and Dependabot watch your pinned versions and open a pull request when something new is published, so an upgrade arrives as a proposal you can test, not a surprise you discover in production.
Pin Everything: Eight Rules
A good house rule fits in one sentence: Production always references a specific version, never
The eight rules below put that sentence into practice.latest, never “always download,” and the last two deployed versions stay available for rollback.
- Never deploy a floating label to production. No
latest, no branch names likemain, no open version ranges. If a vendor only offerslatest, resolve it to a digest yourself and pin that. - Pin container images by digest, and keep the tag for humans. Write
postgres:16.4@sha256:…so people can read the version and machines verify the bytes. - Pin third-party build steps to a full commit SHA. Add a comment with the version number (
# v4.2.0) so the line stays readable. - Commit your lockfile, and make the build fail if it is out of date. A lockfile records the exact version of every dependency, including the dependencies of your dependencies. Build systems should install from it in strict mode and refuse to silently rewrite it.
- Pin the tools, not just the libraries. The package manager, compiler, infrastructure tool, and Helm chart versions all change your output. Upgrading one of them is a change and deserves review.
- Build once, promote the same bytes. Test an artifact, then move that exact artifact (by digest) to staging and production. Rebuilding for each environment reintroduces drift.
- Keep your own copies. Put a cache or mirror in front of public registries before you need it, and set retention so the versions you might roll back to are never cleaned up.
- Automate the updates. Let a bot propose new versions on a schedule, and let your tests decide. Pins that nobody updates turn into a backlog of known vulnerabilities.
Here is what that looks like across the most common kinds of dependency:
| What You Depend On | Floating (Avoid) | Pinned (Prefer) | What Keeps It Current |
|---|---|---|---|
| Container image | nginx:latest |
nginx:1.27.2@sha256:… |
Renovate or Dependabot digest updates |
| GitHub Actions step | actions/checkout@v4 |
actions/checkout@<40-character SHA> # v4.x.y |
Renovate or Dependabot Actions updates |
| JavaScript packages | "^4.17.0" with no lockfile |
Committed package-lock.json, installed with npm ci |
Scheduled dependency pull requests |
| Python packages | requests>=2 |
A lockfile with hashes (for example uv.lock or the new standard pylock.toml) |
Scheduled lockfile refresh |
| Helm chart | Newest chart from the repository | Exact version, recorded in Chart.lock |
Chart update pull requests |
| Infrastructure module | ref=main |
An exact release version | Module version pull requests |
Common Objections
Digests are unreadable.
That’s why you keep the tag next to them. The tag is for people; the digest is for the machine. Both fit on one line.
This is a lot of overhead for a small project.
Most of it is a one-time setup: commit the lockfile, switch on an update bot, and replace latest once. After that, the bot does the remembering.
Our registry makes tags immutable, so we’re fine.
For your registry, partly. It does nothing for the public images, packages, and Actions you pull from someone else’s registry, which is exactly where the incidents above happened.
The Bottom Line
A floating label means whatever someone else decided most recently.
A pin means you decide. That one difference is what separates an upgrade you planned from an outage you have to explain.
Pin everything to a fingerprint, keep copies of what you pin, and let automation propose the updates. Your configuration will finally mean the same thing tomorrow that it means today.
Sources and References
- Docker:
docker image tagreference, thelatestdefault tag. - Kubernetes: Images: image pull policy, the default
Alwayspull policy for:latestor untagged images. - Open Container Initiative (OCI): Distribution Specification, tags as mutable pointers; Image Specification, Descriptors, digests as content identity.
- Harbor: Tag Immutability Rules, immutability as an optional registry policy.
- StepSecurity (14 March 2025): tj-actions/changed-files action is compromised, source of the 23,000+ repositories figure.
- CISA (18 March 2025, revised 26 March 2025): Supply Chain Compromise of Third-Party tj-actions/changed-files (CVE-2025-30066) and reviewdog/action-setup@v1 (CVE-2025-30154).
- GitHub: Reusing workflows, the commit-SHA guidance quoted above.
- Snyk (December 2018): A post-mortem of the malicious event-stream backdoor.
- CISA (23 September 2025): Widespread Supply Chain Compromise Impacting npm Ecosystem.
- Bitnami / Broadcom (16 July 2025): Upcoming changes to the Bitnami catalog. The 404 result shown in the second figure comes from a public Docker Hub registry probe run on 13 July 2026; registry contents may have changed since.
- NIST: SP 800-190, Application Container Security Guide, section 3.1.1 on image aging.
- ACM CODASPY (2017): A Study of Security Vulnerabilities on Docker Hub, the DIVA study of 356,218 images.
- Renovate: Docker digest pinning and updates.
- Gamage, Tiwari, Monperrus and Baudry (2025): The Design Space of Lockfiles Across Package Managers, how lockfile enforcement differs across ecosystems.