Two engineers deploy myapp:v2.4.1 two hours apart and get different images. Nothing failed, nothing was misconfigured, and no alert fired.
That is possible because a tag is a pointer, and pointers move. Understanding which parts of your workflow can tolerate that and which cannot is most of what a tagging strategy is.
Tags and digests
Section titled “Tags and digests”The distinction everything rests on.
A tag is a mutable reference. ghcr.io/org/api:v2.4.1 is a name in a registry pointing at some content. Push a different image with the same tag and the name now points at the new one. Nothing in the OCI specification prevents this.
A digest is content-addressed and immutable. ghcr.io/org/api@sha256:abc… is the cryptographic hash of the image manifest. It cannot point at anything other than that exact content, because the name is the content’s fingerprint.
| Tag | Digest | |
|---|---|---|
| Mutable | Yes | No |
| Human readable | Yes | No |
| Meaningful | v2.4.1 says something | Says nothing |
| Guarantees content | No | Yes |
| Good for | Discovery, humans | Deployment, promotion |
They are not competing options. A tag is how you find an image; a digest is how you pin one. A workflow needs both, at different stages.
What to tag with
Section titled “What to tag with”A single build should usually produce several tags, each answering a different question.
| Tag | Example | Answers | Mutable |
|---|---|---|---|
| Full commit SHA | sha-a1b2c3d4e5f6… | Which commit built this? | No, in practice |
| Semantic version | v2.4.1 | Which release is this? | By convention only |
| Major/minor | 2.4, 2 | Latest patch of this line | Yes, deliberately |
| Branch | main | Head of this branch | Yes |
| Pull request | pr-1234 | This proposal’s build | Yes |
latest | latest | Whatever we last pushed | Yes |
Full commit SHA is the workhorse. It is unique, it traces directly to source history, and it is effectively immutable because a commit’s content determines its SHA — the only way to reassign it is to build different content from the same commit, which means the build itself is non-deterministic and you have a different problem. Every build should carry one, including builds that also carry a version tag.
Semantic versions are for consumers. They communicate compatibility. Immutable by convention — nothing enforces it, which is why a tag protection policy on the registry matters if you publish for others.
Rolling major and minor tags are deliberately mutable. 2.4 pointing at the newest 2.4.x is a feature for consumers who want patches automatically. It is not something to deploy from.
Branch tags are for continuous deployment to non-production environments. A development cluster that always runs main wants exactly this: the tag moves, the deployment follows. The mutability is the feature, and it is also why the same mechanism is wrong for production — a re-pull after a node restart would silently pick up whatever main points at now rather than what was deployed.
latest is a convention with no semantics. The registry does not treat it specially. It means “whatever was pushed last with that tag”, which on a repository with several active branches can be anything. Publish it if consumers expect it; never deploy from it.
Short SHAs collide
Section titled “Short SHAs collide”A specific, avoidable failure.
Git’s abbreviated SHAs — 7 or 8 characters — are convenient and not unique. Git itself lengthens abbreviations as a repository grows precisely because collisions occur. In a repository with tens of thousands of commits, a 7-character prefix collision is not exotic.
For a tag, a collision means two different commits produce the same image tag, and the second overwrites the first. Now sha-a1b2c3d is an image built from a commit that is not the one you think.
- uses: docker/metadata-action@v6 with: images: ghcr.io/${{ github.repository }} tags: | type=sha,prefix=sha-,format=longformat=long uses the full 40-character SHA. The tag is ugly and nobody types it by hand — automation reads it, and automation does not mind.
A scheme that works
Section titled “A scheme that works” - uses: docker/metadata-action@v6 id: meta with: images: ghcr.io/${{ github.repository }} tags: | type=sha,prefix=sha-,format=long type=ref,event=branch type=ref,event=pr type=semver,pattern=v{{version}} type=semver,pattern={{major}}.{{minor}} type=raw,value=latest,enable={{is_default_branch}}What each line produces:
Every build gets sha-<full>. The traceability anchor.
Branch pushes get the branch name. main follows the default branch.
Pull requests get pr-<number>, useful for preview environments and easy to clean up.
Tag pushes matching a semver pattern get v2.4.1 and the rolling 2.4.
latest only on the default branch, and only because consumers expect it.
The property that matters: the SHA tag is on every image, so any image can be traced to a commit regardless of which other tags it carries.
Deploy digests
Section titled “Deploy digests”The rule that prevents the opening scenario.
# Wrong — mutableimage: ghcr.io/example-org/api:v2.4.1
# Right — immutableimage: ghcr.io/example-org/api@sha256:0000000000000000000000000000000000000000000000000000000000000000Capture the digest from the build and carry it forward:
- name: Build and push id: build uses: docker/build-push-action@v7 with: push: true tags: ${{ steps.meta.outputs.tags }}
- name: Digest run: echo "${{ steps.build.outputs.digest }}"Why it matters for promotion. Staging tests a digest. Production deploys that same digest. The artifact is byte-identical, which is the only way “we tested it” means anything. Promote a tag and you are promoting a name that may have been reassigned in between.
The readability objection is real and solvable. A manifest full of digests is unreadable. The answer is a comment recording the human-readable version, and automation that maintains both:
# api v2.4.1 image: ghcr.io/example-org/api@sha256:0000000000000000000000000000000000000000000000000000000000000000Where the tag scheme meets the branching model
Section titled “Where the tag scheme meets the branching model”Tags come from Git refs, so the tagging scheme is downstream of how you branch. Three shapes.
Trunk-based with tagged releases. Merges to main produce sha-… and main. A release is a Git tag, which produces v2.4.1, 2.4 and 2. Clean, and it is what most of this cluster assumes.
Trunk-based with continuous deployment. No version tags at all; the SHA tag is the version. Production runs a digest promoted from a SHA-tagged build. This is coherent and it removes the semantic-version conversation entirely — appropriate when nothing external consumes your images.
Release branches. A release/2.x branch producing 2.4.1 while main produces 3.0.0-rc. Necessary when you support multiple major versions, and it needs an explicit rule about what latest points at. Getting that wrong means consumers on the old major silently receive the new one.
The question to settle early: does anything outside your team consume these images? If yes, semantic version tags are an interface and their stability is a contract. If no, SHA tags and digests are sufficient and the versioning discussion is overhead.
Immutable tags at the registry
Section titled “Immutable tags at the registry”Some registries can enforce what convention only asks for.
Tag immutability, where a registry supports it, rejects a push that would reassign an existing tag. That converts “we agreed not to move release tags” into something that cannot happen. Where it is available it is worth enabling for release tags specifically — you still want main and latest to move.
The trade-off is a broken build on a re-run. If a release build fails after pushing and somebody re-runs it, the second push is rejected. That is the correct behaviour and it will annoy somebody the first time. The fix is a build that either succeeds completely or pushes nothing, not disabling the protection.
Where the registry cannot enforce it, the fallback is procedural: only CI pushes release tags, CI only builds them from Git tags, and Git tags are protected. That chain is weaker than a registry-level rule and much better than nothing.
Check what your registry actually offers. Capabilities vary and change; GHCR covers what GitHub’s provides. Do not assume a policy exists because it would be sensible.
Choosing, by situation
Section titled “Choosing, by situation”| Situation | Reference |
|---|---|
| Local development | Tag — readability wins |
| CI build output | SHA tag, plus whatever else |
| Development environment auto-deploy | Branch tag, deliberately mutable |
| Preview environment for a pull request | pr-N tag |
| Staging | Digest |
| Production | Digest |
| Published for external consumers | Semantic version tags |
| A base image in a Dockerfile | Digest, with the tag in a comment |
| Anything in an incident runbook | Digest — reproducibility matters most when things are broken |
The pattern: mutable where re-resolving is the point, immutable where you need to know exactly what ran.
Multi-architecture and the manifest list
Section titled “Multi-architecture and the manifest list”A wrinkle worth understanding before it confuses you.
A multi-platform image is not one image. It is a manifest list — an index whose digest points at a set of per-platform manifests, each with its own digest.
The consequences for tagging:
A tag points at the index. api:v2.4.1 resolves to the manifest list, and a client pulling it selects the manifest matching its own platform.
There are several digests in play. The index digest is the one to use in a deployment, because it works on every supported platform. A per-platform manifest digest pins to one architecture, which is occasionally what you want and usually not.
docker/build-push-action outputs the index digest, which is the correct one to promote.
Inspecting a digest can be surprising. Pulling by index digest on an arm64 machine gives you the arm64 image; the same digest on amd64 gives a different image. Both are correct, and somebody comparing “the same digest” across two machines and finding different content has usually hit this.
Attestations attach per platform. A provenance attestation is generated for each platform’s build, referenced from the index. Verification tooling handles this; it is worth knowing when the output looks like more entries than you expected.
None of this changes the tagging strategy. It changes what a digest means, from “this image” to “this set of images, one of which you will get”.
Retention
Section titled “Retention”Tagging generously produces a lot of images, and registries charge for storage.
Keep every semantic version. They are your release history, they are referenced by consumers, and they are what you roll back to. The storage cost of keeping every release forever is almost always lower than the cost of the one occasion you needed a two-year-old version and it had been pruned.
Keep SHA-tagged images for the default branch for a defined window — long enough to cover your rollback horizon and any incident investigation. Some teams keep every one; storage is usually cheaper than the day you need one that was deleted.
Delete pull request images shortly after the pull request closes. High volume, near-zero value afterwards, and they are the bulk of what accumulates in a busy repository.
Delete untagged images with care. An image that has lost its tags may still be referenced by digest from a running deployment, and deleting it means a node that needs to pull it cannot. Check what is actually running before enabling aggressive untagged cleanup.
Never delete anything a running deployment references. This is the failure that turns a cost-saving exercise into an outage: a cleanup policy removes an image, a node restarts, the pull fails, and the service does not come back.
Answering “what is running?”
Section titled “Answering “what is running?””The question a tagging scheme exists to make answerable, and the sequence that answers it.
-
Ask the runtime what it is running. In Kubernetes the pod status reports the resolved digest, which is the ground truth regardless of what the manifest said.
-
Look up that digest in the registry and read its tags. If your scheme is working, one of them is
sha-<full commit>. -
That SHA is a commit.
git showit, and you have the source. -
The commit is in a pull request, which links to an issue and a review.
Four steps, each mechanical, and the chain holds only if every link was recorded at the time. There is no way to reconstruct it afterwards.
Where the chain typically breaks:
The digest has no SHA tag, because the build only applied a version tag. You know the version and not the commit — usually recoverable from a release, and not always.
The image was built locally. No tags beyond whatever somebody typed, no provenance, and possibly from a working tree with uncommitted changes. This is the strongest practical argument for CI-only pushes.
The tag was reassigned. The tag on the digest is accurate; the tag somebody remembers deploying points somewhere else now.
The image was deleted by a retention policy. The running container is fine; the registry no longer has the record.
A stronger version of the same chain uses attestations rather than tags. A provenance attestation binds the digest to the source repository, commit and workflow cryptographically, so the answer does not depend on a mutable label having survived. Tags make the chain convenient; attestations make it evidence.
Documenting the scheme
Section titled “Documenting the scheme”A tagging scheme that only exists in a workflow file is one nobody outside the team can use, and one the team itself will misremember.
Write it in the repository README. Which tags exist, what each means, which are mutable, and which one a consumer should depend on. Five lines.
State the deployment rule explicitly. “Deployments reference digests; tags are for discovery” is the sentence that stops somebody helpfully simplifying a manifest to use a version tag because it reads better.
Say what latest points at, or say that you do not publish it. Ambiguity here causes consumers to depend on something you did not intend to be an interface.
Publish the retention policy alongside it. A consumer pinning to v1.2.0 should know whether that will still exist in a year.
For internal images, say who may push. The convention that only CI pushes is worth writing down, because it is the one most likely to be broken by somebody in a hurry with valid credentials.
The test for whether the documentation is sufficient: could somebody on another team deploy your image correctly without asking you a question? If the answer involves them guessing whether to use a tag or a digest, the gap is worth five minutes.
Common mistakes
Section titled “Common mistakes”Short SHA tags. They collide, and the second build silently overwrites the first.
Deploying tags. The tested image and the running image can differ with nothing to show it.
latest in production. No semantics, changes without notice.
Only one tag per build. Either traceable or readable, not both.
Reassigning a released version tag. Consumers get different content under the same name.
Rebuilding per environment. Different digests, so the testing proved nothing about what shipped.
Aggressive untagged cleanup. Deletes images that running deployments reference by digest.
No retention policy at all. Registry costs grow until somebody deletes something important in a hurry.
Rolling back
Section titled “Rolling back”The moment the whole scheme is tested, and the reason digests are worth the inconvenience.
Rollback is re-pointing at a previous digest. Not rebuilding, not reverting the application commit and waiting for CI. The previous image already exists in the registry; the change is one line in a manifest.
That is why it is fast. A rollback that requires a build is a rollback measured in minutes at best, during an incident, from a pipeline that may itself be affected. A rollback that changes a digest reference is measured in the time it takes to merge and reconcile.
Know the previous digest before you need it. The deployment history in your GitOps repository has it — that is one of the reasons the digest goes in a committed manifest rather than being resolved at deploy time. If the previous digest is only in a CI log, you are searching logs during an incident.
A Git revert of the promotion commit is the clean mechanism, where the manifest is in a repository: it restores the previous digest reference exactly, and the history records both the promotion and the rollback.
What rollback does not fix. If the change ran a database migration, re-pointing at the old image gives you old code against a migrated schema. This is the general limitation of treating Git revert as rollback, and it is why “we can roll back” should be verified per change rather than assumed as a property of the platform.
Retention policy and rollback horizon are the same decision. If you keep images for seven days, your rollback horizon is seven days. Most teams have never connected the two and would be unpleasantly surprised.
Mental model
Section titled “Mental model”A tag is a label somebody can move. A digest is the content itself. Use tags to find an image and digests to run one.
Every rule in this lesson is an application of that. Human-facing surfaces get tags because humans need names. Machine-facing surfaces get digests because machines need certainty. The workflow’s job is to record both and to be deliberate about which appears where.
What you learned
Section titled “What you learned”- Tags are mutable pointers; digests are immutable content addresses
- One build should carry several tags, each answering a different question
- Full commit SHAs, not abbreviated ones — short prefixes collide
latestis a convention with no registry semantics- Deploy and promote by digest; the tag is how a human found it
- Keep the human-readable version in a comment beside the digest
- Retention: keep versions and default-branch SHAs, delete pull request images, be careful with untagged cleanup
Exercise
Section titled “Exercise”Use a disposable repository and a registry namespace you can delete. No production credentials.
-
Build an image and push it as
test:v1. Note the digest. -
Change the Dockerfile, rebuild, and push again as
test:v1. Note the new digest. Predict: did the registry object? -
Pull
test:v1and inspect it. Predict: which build did you get? -
Pull the first digest explicitly. Predict: is the original still there?
-
Configure
docker/metadata-actionwithtype=shaand noformat=long. Look at the tag length. Compute how many commits your repository would need before a collision is plausible. -
Switch to
format=longand compare. -
Write a deployment manifest referencing a digest with the version in a comment. Ask somebody else which version it is. Predict: can they tell?
-
Delete the images and the repository.
Related lessons
Section titled “Related lessons”The GitOps and infrastructure repository templates are in the Professional Toolkit.