Skip to content

Image Version Management


You now know how to get an image onto a registry. What you haven’t dealt with yet is what happens six months from now, when that image has been rebuilt fifty times and someone asks “which version is actually running in production?” — and the honest answer shouldn’t be a shrug. Version management is what makes that question answerable.

Why Tags Alone Aren’t Version Management

A tag is just a label pointing at a manifest, and — as you saw in the registry section — that pointer can move. Nothing stops you from pushing a new image under an old tag name, and the moment you do, anyone who pulls that tag gets different bytes than they did yesterday, with no error, no warning, nothing in the tag itself to indicate anything changed.

That’s fine for casual use. It’s a real problem the moment other people, other systems, or a production environment depend on a specific tag meaning a specific thing. “Version management” is really just a set of conventions for deciding which tags are allowed to move, which aren’t, and how someone can tell the difference just by reading the tag name.

Semantic Versioning for Images

The industry-standard convention, borrowed straight from software versioning in general, is semantic versioning (semver): MAJOR.MINOR.PATCH.

  • MAJOR increments when you make a change that isn’t backward compatible — anyone using the old version needs to do something differently to use the new one.
  • MINOR increments when you add functionality that’s still backward compatible — old usage keeps working exactly as before.
  • PATCH increments for backward-compatible fixes — bugs corrected, nothing about how the image is used changes.

Applied to an image, that’s a tag like myapp:1.4.2. It’s language-agnostic and tool-agnostic by design — it says nothing about what’s inside the image, only how this version relates to the ones before it.

The latest Tag Trap

Every image gets a latest tag automatically if you don’t specify one — and it’s tempting to treat it as “the current version.” Resist that. latest doesn’t mean “newest” in any enforced sense; it’s just the tag Docker assigns by default when no tag is given. It’s exactly as mutable as any other tag, and its name actively encourages people to trust it more than they should.

Warning

myapp:latest today and myapp:latest tomorrow can be two completely different images, and nothing about the tag will tell you that happened. Pin real deployments — anything you’d need to reproduce or roll back — to an actual version tag, never to latest.

latest still has a legitimate use: as a convenience pointer for people casually trying out your image who don’t care exactly which version they get. Just don’t let anything that matters depend on it.

Multi-Tag Strategy

A single build is usually pushed under several tags at once, each answering a different level of “which version do you want”:

myapp:1.4.2   ← this exact version, never moves again
myapp:1.4     ← "give me the latest 1.4.x patch" — moves as patches ship
myapp:1       ← "give me the latest 1.x.x" — moves as minor/patch versions ship
myapp:latest  ← "give me whatever's newest" — moves on every release

All four tags point at the same image the moment it’s pushed. What differs is what happens on the next release: pushing 1.4.3 moves (or you should move) 1.4, 1, and latest to point at it, but myapp:1.4.2 keeps pointing at exactly what it always pointed at. Someone who pinned to 1.4.2 gets stability; someone who tracks 1 gets updates automatically, within the boundary they chose.

                 myapp:1.0.0   myapp:1.0.1   myapp:1.1.0
                      │             │              │
myapp:1.0.0  ───────► ●
myapp:1.0.1  ─────────────────────► ●
myapp:1.0    ─────────────────────► ●  (moved from 1.0.0)
myapp:1.1.0  ────────────────────────────────────► ●
myapp:1      ────────────────────────────────────► ●  (moved again)
myapp:latest ────────────────────────────────────► ●  (always the newest)

Immutable Tags via Digest Pinning

Even a specific-looking tag like myapp:1.4.2 is still just a label — technically, nothing stops someone from force-pushing over it. If you need an absolute guarantee, go back to the digest concept from the registry lesson: pin to myapp@sha256:<digest> instead of any tag. A digest can’t be reassigned to different content, ever — it is the content’s fingerprint. Production systems that need bulletproof reproducibility often record the digest alongside the human-readable version tag, using the tag for readability and the digest for the actual guarantee.

Other Common Tagging Schemes

Semver isn’t the only convention in use, and different situations call for different ones:

SchemeLooks likeBest for
Semantic version1.4.2Anything with a real release process and a public or semi-public consumer
Git commit SHAa3f9c21CI/CD pipelines — every build is traceable to an exact commit, no ambiguity ever
Date-based2026-08-12Things that release on a schedule rather than a feature/fix basis (nightly builds, data snapshots)
Environmentstaging, productionPointing at “whatever’s currently deployed where” — always paired with a real version tag underneath, never used alone

These aren’t mutually exclusive — a single CI pipeline commonly pushes both a semver tag and a git-SHA tag for the same build, since the SHA is unambiguous and the semver tag is what a human actually wants to read.

Hands-On: Tagging a Release, Then Patching It

This continues where the previous section’s push exercise left off, using the same account. Replace <your-dockerhub-username> throughout.

Step 1 — Build your “1.0.0” release.

docker container run -d --name version-demo alpine sleep 3600
docker container exec version-demo sh -c "echo 'v1.0.0' > /version.txt"
docker container commit version-demo <your-dockerhub-username>/version-demo:1.0.0

Step 2 — Apply the full multi-tag strategy to this one image.

docker image tag <your-dockerhub-username>/version-demo:1.0.0 <your-dockerhub-username>/version-demo:1.0
docker image tag <your-dockerhub-username>/version-demo:1.0.0 <your-dockerhub-username>/version-demo:1
docker image tag <your-dockerhub-username>/version-demo:1.0.0 <your-dockerhub-username>/version-demo:latest

Step 3 — Push all four tags.

docker login
docker image push <your-dockerhub-username>/version-demo:1.0.0
docker image push <your-dockerhub-username>/version-demo:1.0
docker image push <your-dockerhub-username>/version-demo:1
docker image push <your-dockerhub-username>/version-demo:latest

Step 4 — Ship a patch, without touching 1.0.0.

docker container exec version-demo sh -c "echo 'v1.0.1' > /version.txt"
docker container commit version-demo <your-dockerhub-username>/version-demo:1.0.1

Step 5 — Move only the tags that are supposed to move.

docker image tag <your-dockerhub-username>/version-demo:1.0.1 <your-dockerhub-username>/version-demo:1.0
docker image tag <your-dockerhub-username>/version-demo:1.0.1 <your-dockerhub-username>/version-demo:1
docker image tag <your-dockerhub-username>/version-demo:1.0.1 <your-dockerhub-username>/version-demo:latest
docker image push <your-dockerhub-username>/version-demo:1.0.1
docker image push <your-dockerhub-username>/version-demo:1.0
docker image push <your-dockerhub-username>/version-demo:1
docker image push <your-dockerhub-username>/version-demo:latest

Step 6 — Prove the immutable tag actually stayed put.

docker container run --rm <your-dockerhub-username>/version-demo:1.0.0 cat /version.txt
docker container run --rm <your-dockerhub-username>/version-demo:latest cat /version.txt

The first should print v1.0.0 — untouched, exactly as pushed in Step 3. The second should print v1.0.1 — proof that latest moved to the patch while 1.0.0 didn’t budge.

Step 7 — Clean up.

docker container stop version-demo
docker container rm version-demo
docker image rm <your-dockerhub-username>/version-demo:1.0.0 <your-dockerhub-username>/version-demo:1.0.1 <your-dockerhub-username>/version-demo:1.0 <your-dockerhub-username>/version-demo:1 <your-dockerhub-username>/version-demo:latest

Note

As before, this only cleans up local images — the tags you pushed still exist on Docker Hub. Delete them there through Hub’s web interface if you don’t want them lingering.

Common Pitfalls

  • Treating latest as a version. It’s a moving pointer, not a release. If a deployment config, a script, or documentation says latest, it’s implicitly saying “I don’t actually care which version this is” — fine for a demo, risky for anything else.
  • Skipping the rolling tags (1.0, 1) entirely. Pushing only the full 1.0.1 tag and nothing else means consumers have to manually bump their pinned version on every release, even for a trivial patch — most of the convenience of semver comes from the rolling tags, not the exact one.
  • Reusing an immutable-looking tag anyway. Nothing technically stops you from force-pushing over 1.0.0 later. The convention only works if everyone treats fully-qualified version tags as a promise, not a rule the tool enforces for you.

What’s Next

Everything up to this point — export/import, save/load, registries, commit, push, versioning — has been about moving images around. What’s been missing entirely is how a well-built image gets created in the first place, and commit is not how that’s normally done in practice. Next, we’ll build a small calculator app from scratch using a Dockerfile — the actual standard way to define an image — and bring every concept from this section together: proper versioning, a private registry, and a multi-stage build that keeps the final image lean.

Last updated on