You deployed myapp:1.4.2 in March and myapp:1.4.2 in September. Those are not necessarily the same image.
A tag is a mutable pointer. Almost every tag you rely on can be moved, by whoever controls the registry or repository, without any signal to you. That property breaks reproducibility, it breaks incident scoping, and it is the mechanism behind a specific class of supply chain attack. This post is where tags are mutable, what it costs, and the fix in each ecosystem. For whoever needs to say what was actually running last Tuesday.
Everywhere a tag can move
Container tags. docker push myapp:1.4.2 over an existing tag replaces what that tag points at. Docker Hub and most registries permit this by default. latest is the obvious case and version tags are equally mutable, including on upstream base images. The node:20-alpine you built against last month is a different image today, correctly, because it gets security patches. That is a feature and it means your build is not reproducible.
Git tags. git tag -f v1.4.2 && git push --force --tags re-points a release tag. Anyone consuming your repository by tag gets different code with no version change.
npm dist-tags. latest is a pointer the publisher moves. Specific versions are immutable once published, which is npm's real protection, but npm install package@latest resolves through a pointer.
GitHub Action refs. uses: some/action@v3 is a git ref in someone else's repository. The maintainer moves v3 with every release in that series, which is the intended workflow, and it means a third party can change code running in your pipeline with access to your secrets, at any time, without you doing anything.
Helm charts, Terraform modules, Go module branches. Same pattern wherever a human-readable name resolves to content that can change.
The common thread: the name is a convenience for humans and a mutable reference for machines, and we have built supply chains on the assumption that it identifies content.
What it costs
Reproducibility. A build from the same source, today and in six months, produces different artifacts. When you cannot reproduce a build, you cannot bisect a regression across it, and you cannot verify that a given source produced a given binary.
Incident scoping. A malicious version is published, you need to know whether you ever ran it. If your records say 1.4.2, and 1.4.2 has pointed at three different images, you cannot answer. This is the failure that turns a one-hour investigation into a week.
Silent change of third-party code. The GitHub Action case is the sharpest, because those run with access to your repository and your secrets. Tag-pinning an action means trusting the maintainer, and every account that maintains one, indefinitely and continuously.
Audit evidence. "We deployed version 1.4.2" is not a statement about content unless tags are immutable in your registry. An auditor asking you to demonstrate what was running is asking about content.
The fix, by ecosystem
Pin to content, not to names.
Containers: pin the digest.
# mutable: can change under you
FROM node:20-alpine
# immutable: this exact image, forever
FROM node:20-alpine@sha256:4f8e2b...
Keep the readable tag alongside the digest, as above, so a human can still see what it is.
GitHub Actions: pin the commit SHA.
# mutable: the maintainer moves v4 whenever they like
- uses: actions/checkout@v4
# immutable
- uses: actions/checkout@8f4b7f8 # v4.1.1
Leave the version in a comment. Several tools will update these for you and preserve the comment.
Git dependencies: reference a commit, never a tag or branch.
Packages: a lockfile with integrity hashes, committed, and actually respected at install time. npm ci, not npm install. pip install -r requirements.txt --require-hashes. The lockfile does nothing if your build command ignores it, which is a surprisingly common configuration.
Your own registry: turn on tag immutability. Most registries support it, including ECR, GCR, Artifactory and Harbor. Once enabled, a tag cannot be overwritten, which removes this entire class from your own artifacts and costs nothing except a slightly stricter release process.
The record you need regardless
Even fully pinned, keep a deployment record mapping each release to the exact digests it contained, with a timestamp. That is what answers "what was running on 14 March" without archaeology, and it is the artifact you will want during an incident.
An SBOM per release attached to the artifact does this and more. Without it, you are reconstructing history from tags, which is precisely the thing this post is about not trusting.
The concession
Digest pinning has a real cost and it is not small: you stop receiving patches. A base image pinned by digest never gets the security updates that made the floating tag valuable, so you have traded a supply chain risk for a patching risk, and the second one is more likely to hurt you.
So pinning only works paired with automation that proposes digest updates regularly, as reviewable changes. Renovate and Dependabot both do this for images and actions. Without that, pinning quietly becomes neglect, and a year-old pinned base image is worse than a floating tag.
The honest position: pin, automate the updates, and review them like any other dependency change. Pinning alone is half a practice.
The implication
Your deployment records probably describe tags, and tags describe intent rather than content. That gap is invisible until the day you need to prove what you ran, which is always the worst day.
Digests and commit SHAs are ugly and they are facts. Keep the tag in a comment for the humans, and let the machines resolve something that cannot move.