Skip to content

08 · Managing Images & Tags

An image name like myapp:1.4.2 has two parts — the repository (myapp) and the tag (1.4.2) — and how you assign and clean up tags directly affects whether you can reliably reproduce what's running in production, and how much disk space your build hosts accumulate.

Tagging strategies

docker build -t myapp:1.4.2 .
docker tag myapp:1.4.2 myapp:latest
docker tag myapp:1.4.2 registry.example.com/team/myapp:1.4.2

docker tag doesn't copy anything — it adds another name pointing at the same image ID, the way a hard link adds another filename for the same inode.

Strategy Example Trade-off
Semantic version myapp:1.4.2 Precise, reproducible, but you must remember to bump it
Git SHA myapp:a1b2c3d Always unique, traceable to exact source, unreadable to humans
latest myapp:latest Convenient default, but mutable — it silently points at whatever was last pushed, which is exactly why it should never be the tag a production deployment pins to
Environment myapp:staging, myapp:prod Readable, but also mutable — same caveat as latest

A robust pattern combines them: build and push both myapp:a1b2c3d (immutable, for deploys to pin to) and myapp:1.4.2 (human-readable) pointing at the same image, and only move myapp:latest to point at the newest release for convenience during local development.

Listing and cleaning up images

docker images                        # every locally stored image
docker images myapp                  # filter by repository
docker images --filter "dangling=true"   # untagged, unreferenced layers

A dangling image is one with no tag at all (<none>:<none>) — typically left behind when you rebuild myapp:latest and the old image ID that used to hold that tag is now nameless but still on disk.

docker image prune                   # remove all dangling images
docker image prune -a                # remove ALL images not used by a running container
docker rmi myapp:1.3.0               # remove one specific tag

docker image prune -a is far more aggressive than plain docker image prune — it also removes tagged images that simply aren't currently backing a running container, which is appropriate for a CI build agent that should stay clean between jobs but dangerous on a workstation where you keep images around between sessions.

Pushing and pulling by tag

docker tag myapp:1.4.2 registry.example.com/team/myapp:1.4.2
docker push registry.example.com/team/myapp:1.4.2
docker pull registry.example.com/team/myapp:1.4.2

Worked example: promoting an image across environments

# Build once
docker build -t registry.example.com/team/myapp:a1b2c3d .
docker push registry.example.com/team/myapp:a1b2c3d

# "Promote" to staging by tagging the SAME image, not rebuilding it
docker pull registry.example.com/team/myapp:a1b2c3d
docker tag registry.example.com/team/myapp:a1b2c3d registry.example.com/team/myapp:staging
docker push registry.example.com/team/myapp:staging

# Later, promote the identical, already-tested artifact to prod
docker tag registry.example.com/team/myapp:a1b2c3d registry.example.com/team/myapp:prod
docker push registry.example.com/team/myapp:prod

Retagging the exact same image ID for each environment (rather than rebuilding per environment) is what guarantees "what we tested in staging is byte-for-byte what's running in production."

How It Actually Works

A tag is a pointer, and pointers can be moved without touching any content. Internally, the daemon (and the registry) key everything by digest — a SHA-256 hash of the image manifest, which itself lists the content-addressed hashes of every layer. A tag like myapp:1.4.2 is stored as a mutable mapping from that human-readable string to a specific digest, kept in the daemon's local image store (and, for a registry, in its own tag-to-manifest index). docker tag writes a new entry in that mapping pointing at an existing digest — no bytes are duplicated on disk, and no network transfer happens. This is exactly why docker pull myapp:latest twice, an hour apart, can silently hand you two different sets of bytes despite an identical command: latest is just whichever digest the mapping currently resolves to, and someone could have pushed a new image under that tag in between — the immutable, verifiable alternative is to pull by digest directly (docker pull myapp@sha256:...), which can never resolve to different content.

Why docker image prune -a can free gigabytes instantly. Layers are shared, reference-counted content on disk (via the storage driver's content store, keyed by layer digest) — removing a tag decrements the reference count for the layers it pointed to, and only layers with a reference count of zero (not referenced by any remaining tag or by any container's read-only layer set) are actually deleted from /var/lib/docker. This is why building ten variants of an image that all FROM python:3.12-slim costs almost no extra disk beyond the first — they all share that base's layers by reference — and why pruning can look dramatic: a single removed tag can be the last reference holding dozens of otherwise-orphaned layers, all reclaimed together.

Exercise

Build the same Dockerfile twice under two different tags without changing anything (docker build -t myapp:v1 . then docker build -t myapp:v2 .), and confirm with docker images --digests that both tags resolve to the identical digest. Then change one line, rebuild as myapp:v3, and use docker image prune to observe that the dangling intermediate layers from the previous untagged builds (if any) get cleaned up, while v1/v2/v3 — all currently tagged — are left alone.