How to Version an Image with Tags: Docker Image Tagging Strategies and Best Practices

How to Versioning an Image with Tags

I’ve lost count of how many times I’ve seen a production incident traced back to one root cause: someone deployed myapp:latest and it wasn’t the version they thought it was. Tagging seems trivial until it isn’t, and getting it right is one of the highest-leverage things you can do for a Docker-based delivery pipeline. In this guide I’ll cover exactly how Docker tags work under the hood, the tagging strategies I actually use in production, and the pitfalls that cause the most pain.

What a Docker Tag Actually Is

A Docker image is identified internally by a content-addressable SHA256 digest — that’s the real, immutable identity of an image. A tag is just a human-readable pointer, stored in the registry’s manifest index, that maps a string like myapp:1.4.2 to one specific digest at a point in time. Critically, tags are mutable. You can push a new image under an existing tag and the old mapping is simply overwritten — the previous image still exists in the registry (assuming nothing garbage-collects it), but the tag no longer points to it.

This mutability is the source of almost every tagging problem people run into, and it’s why the digest, not the tag, is the only thing that guarantees you’re running exactly the bits you think you are.

You can see this yourself:

docker pull nginx:1.27
docker inspect --format='{{index .RepoDigests 0}}' nginx:1.27

Expected output:

nginx@sha256:0d17b565c37bcbd895e9d92315a05c1c3c9a29f762b011a10c54a66cd53c9b31

That sha256:... string is the real identity. nginx:1.27 is just a label someone attached to it.

The Anatomy of a Tag Name

The full reference format for a tagged image is:

[registry-host[:port]/]namespace/repository[:tag]

For example:

registry.example.com:5000/my-team/myapp:2.3.1

If you omit the registry host, Docker assumes Docker Hub (docker.io). If you omit the tag entirely, Docker assumes :latest.

Basic Tagging Commands

Tag an image you just built:

docker build -t myapp:1.0.0 .

Add an additional tag to an already-built image:

docker tag myapp:1.0.0 myapp:latest

Tag for a specific registry so you can push it there:

docker tag myapp:1.0.0 registry.example.com/my-team/myapp:1.0.0
docker push registry.example.com/my-team/myapp:1.0.0

Verify what tags exist locally for an image:

docker images myapp
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
myapp        1.0.0     3f9a1c2b4d5e   2 minutes ago   142MB
myapp        latest    3f9a1c2b4d5e   2 minutes ago   142MB

Notice both tags share the same IMAGE ID — that confirms they point to the identical underlying image, just under two different names.

Why :latest Is Dangerous

:latest is not a special “always the newest” tag maintained automatically by Docker — it’s just the default tag applied when you don’t specify one. Nothing stops you (or a teammate, or a CI job) from pushing an old image under :latest by accident. The problems I see most often:

  • Non-reproducible deployments. If your Kubernetes manifest says image: myapp:latest, two nodes pulling at different times can end up running two different actual images, because the tag moved in between pulls.
  • No rollback story. If production breaks and you only ever push :latest, you have no way to know what the previous “latest” even was unless you kept separate records.
  • Cache confusion. With imagePullPolicy: IfNotPresent in Kubernetes, a node that already has an older :latest cached will keep using it silently, while a node that pulls fresh gets the new one.

My rule: :latest is fine for local development and quick experiments, but it should never be what your production deployment pipeline references.

Tagging Strategies That Actually Work

1. Semantic Versioning (SemVer)

The most common strategy for libraries and applications with a clear release cadence:

docker build -t myapp:1.4.2 .
docker tag myapp:1.4.2 myapp:1.4
docker tag myapp:1.4.2 myapp:1

This gives consumers a choice of how much stability versus freshness they want — pinning to 1.4.2 for exact reproducibility, 1.4 to get patch updates automatically, or 1 to get minor updates automatically. This is the same pattern official images like node, python, and postgres use on Docker Hub.

2. Git-SHA-Based Tags

For internal services deployed many times a day, I tag with the Git commit SHA so every image is traceable directly back to the exact source code that produced it:

GIT_SHA=$(git rev-parse --short HEAD)
docker build -t myapp:${GIT_SHA} .
docker push registry.example.com/my-team/myapp:${GIT_SHA}

This is extremely useful for debugging — if a pod is misbehaving, kubectl describe pod shows you the exact image tag, and from that you know precisely which commit is running.

3. Timestamp-Based Tags

Useful for artifacts built on a schedule (nightly builds, data pipeline snapshots):

TAG=$(date +%Y%m%d%H%M%S)
docker build -t myapp:${TAG} .

4. Environment-Qualified Tags

Some teams encode environment into the tag itself, though I generally prefer keeping environment out of the image tag and instead controlling it via separate deployment manifests, since the same image artifact should ideally be promoted unchanged from staging to production:

docker tag myapp:1.4.2 myapp:1.4.2-staging

5. Combining Strategies (My Preferred Approach)

In most CI pipelines I set up, a single build produces multiple tags simultaneously:

GIT_SHA=$(git rev-parse --short HEAD)
VERSION=$(cat VERSION)

docker build -t myapp:${GIT_SHA} .
docker tag myapp:${GIT_SHA} myapp:${VERSION}
docker tag myapp:${GIT_SHA} myapp:latest

docker push myapp:${GIT_SHA}
docker push myapp:${VERSION}
docker push myapp:latest

This gives you an immutable, traceable tag (GIT_SHA) for audits and rollbacks, a human-readable version tag for release notes, and a convenience latest tag for local pulls — without ever relying on latest for anything that matters operationally.

Tagging in a Dockerfile-Driven CI Pipeline

Here’s a GitHub Actions workflow I use as a template for tag automation on release:

name: build-and-push

on:
  push:
    tags:
      - 'v*'

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set version from tag
        run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV

      - name: Log in to registry
        run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login registry.example.com -u "${{ secrets.REGISTRY_USER }}" --password-stdin

      - name: Build and tag
        run: |
          docker build -t registry.example.com/my-team/myapp:${VERSION} .
          docker tag registry.example.com/my-team/myapp:${VERSION} registry.example.com/my-team/myapp:latest

      - name: Push
        run: |
          docker push registry.example.com/my-team/myapp:${VERSION}
          docker push registry.example.com/my-team/myapp:latest

Expected result after a tagged push like v2.3.1:

registry.example.com/my-team/myapp:2.3.1
registry.example.com/my-team/myapp:latest

Using Docker Compose with Version Tags

services:
  web:
    image: registry.example.com/my-team/myapp:2.3.1
    ports:
      - "8080:80"

Pinning the exact tag here means docker compose pull && docker compose up -d behaves identically across every machine that runs it — no ambiguity about what “current” means.

Referencing Images by Digest for Maximum Reproducibility

For the strictest reproducibility guarantees (common in regulated environments or GitOps pipelines), you can skip tags entirely and reference the digest:

services:
  web:
    image: registry.example.com/my-team/myapp@sha256:0d17b565c37bcbd895e9d92315a05c1c3c9a29f762b011a10c54a66cd53c9b31

This is the only reference format that is truly immutable — even if someone re-pushes a tag, the digest reference is unaffected because it points directly at content, not at a mutable pointer.

Kubernetes Tagging Best Practices

In Kubernetes manifests, always avoid :latest combined with imagePullPolicy: IfNotPresent (the default when a tag other than latest is used, but Kubernetes forces Always specifically when the tag is latest — a detail worth knowing):

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  template:
    spec:
      containers:
        - name: myapp
          image: registry.example.com/my-team/myapp:2.3.1
          imagePullPolicy: IfNotPresent

Because the tag is immutable-by-convention (2.3.1 should never be overwritten once published), IfNotPresent is safe here and avoids unnecessary registry pulls on every pod restart.

Cleaning Up Old Tags

Registries fill up fast when every CI run pushes a new tag. Most registries (Docker Hub, GitHub Container Registry, Harbor, ECR) support retention policies. As an example, an AWS ECR lifecycle policy that keeps only the last 10 images per repository:

{
  "rules": [
    {
      "rulePriority": 1,
      "description": "Keep last 10 images",
      "selection": {
        "tagStatus": "any",
        "countType": "imageCountMoreThan",
        "countNumber": 10
      },
      "action": { "type": "expire" }
    }
  ]
}

Troubleshooting Common Tagging Problems

  • “manifest unknown” on pull — the tag was deleted or never pushed to that registry; double check docker images locally versus what’s actually present remotely with docker manifest inspect.
  • Pod running old code despite a new push — you likely pushed to :latest with imagePullPolicy: IfNotPresent somewhere the pod was already cached; force a repull or switch to immutable, unique tags.
  • CI overwrote a production tag by accident — this is exactly why many teams enforce tag immutability at the registry level (ECR, GHCR, and Harbor all support “tag immutability” settings that reject a push to an existing tag).

Summary

Tags are just mutable pointers to immutable content — once you internalize that single fact, most tagging mistakes become obvious in hindsight. Use semantic versioning or Git-SHA tags for anything that matters, reserve latest for convenience and local development only, and reference images by digest wherever true immutability matters most. Combine multiple tags per build so you get both human readability and machine traceability, and enable registry-side tag immutability if your team has ever been burned by an accidental overwrite.

References

  • Docker Documentation — Image Tags: https://docs.docker.com/engine/reference/commandline/tag/
  • Docker Documentation — Content Trust and Digests: https://docs.docker.com/engine/security/trust/
  • OCI Image Format Specification: https://github.com/opencontainers/image-spec
  • Kubernetes Documentation — Image Pull Policy: https://kubernetes.io/docs/concepts/containers/images/#image-pull-policy
  • AWS ECR Lifecycle Policies: https://docs.aws.amazon.com/AmazonECR/latest/userguide/LifecyclePolicies.html
Total
0
Shares

Leave a Reply

Previous Post
How to Optimize Your Dockerfile

How to Optimize Your Dockerfile: Best Practices for Smaller, Faster, and More Secure Images

Next Post
How to Use Packer to Create a Docker Image

How to Use Packer to Create a Docker Image: Automated Image Building and Provisioning Guide

Related Posts