Module 5: Docker in CI/CD

5. Image Tagging Strategies

Overview

Your pipeline builds and pushes images automatically. But with what tag? If everything gets tagged as latest, you have a problem: when your application fails in production, which version of the code is running? With latest, you don't know. You can't roll back to a previous version because you don't know which one it was. You can't correlate the bug with a specific commit. And if two people push latest at the same time, the last one wins and the previous one is lost.

Tagging isn't cosmetic — it's the foundation of your system's traceability, rollbacks, and operability in production. A good tag system lets you: given a container running in production, know exactly which commit generated it; given a bug, know which image to deploy to roll back; given a release, have a semantic version that communicates the nature of the change.

This capsule teaches you the four main tagging strategies — SHA, semver, latest, and branch-based — when to use each one, and how to implement them in GitHub Actions.


Why tagging matters

The scenario without tags

Monday:    Push → build → tag latest → push → deploy
Tuesday:   Push → build → tag latest → push → deploy
Wednesday: A bug in production!

Developer: "Which version is running?"
Ops: "latest"
Developer: "Which commit is that?"
Ops: "... I don't know. latest."
Developer: "Can we roll back to Monday's version?"
Ops: "We only have latest, which is Tuesday's."
Developer: "..."

The scenario with tags

Monday:    Push → build → tag sha-abc1234 → push → deploy
Tuesday:   Push → build → tag sha-def5678 → push → deploy
Wednesday: A bug in production!

Developer: "Which version is running?"
Ops: "sha-def5678"
Developer: "That's commit def5678 — Tuesday's change to the embeddings endpoint."
Developer: "Roll back to sha-abc1234"
Ops: "docker pull ghcr.io/user/app:sha-abc1234 → deploy → done."
Developer: "Production is stable. Now I'll investigate the bug."

The difference: full traceability. Every image points to a commit. Every commit has a diff. Incident resolution time drops from hours to minutes.


The four tagging strategies

1. SHA Tags — Traceability

The commit's SHA is the most important tag. It's unique, immutable, and directly correlatable with the code.

ghcr.io/user/ai-api:sha-abc1234def5
ghcr.io/user/ai-api:sha-567890abcdef

The format:

tags: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}

The ${{ github.sha }} is the commit's full SHA (40 characters). You can use the first 7 characters for readability:

- name: Short SHA
  id: sha
  run: echo "short=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT

- name: Build and push
  uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:sha-${{ steps.sha.outputs.short }}

When to use it: Always. The SHA tag should be on every image you build. It's your anchor of traceability.

The advantages:

  • 📋 Unique per commit — there are never collisions
  • 📋 Immutable — the same SHA is always the same code
  • 📋 A direct correlation with Git — git show abc1234 shows you the diff
  • 📋 Precise rollback — you can go back to any commit

The disadvantages:

  • 📋 It doesn't communicate "which version" in human terms
  • 📋 Hard to remember — sha-abc1234 says nothing about the version

2. Semver Tags — Releases

Semantic Versioning (semver) communicates the nature of the change: MAJOR.MINOR.PATCH.

ghcr.io/user/ai-api:v1.0.0    # The initial release
ghcr.io/user/ai-api:v1.1.0    # A new feature (minor)
ghcr.io/user/ai-api:v1.1.1    # A bug fix (patch)
ghcr.io/user/ai-api:v2.0.0    # A breaking change (major)

The format in Actions:

on:
  push:
    tags: ["v*"]

# ...

- name: Get version from tag
  id: version
  run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT

- name: Build and push
  uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: |
      ghcr.io/${{ github.repository }}:${{ steps.version.outputs.tag }}
      ghcr.io/${{ github.repository }}:sha-${{ github.sha }}

When to use it: For formal releases. When you want to communicate to your team or to external users what kind of change the version contains.

The advantages:

  • 📋 Clear communication — v2.0.0 indicates a breaking change
  • 📋 A universal convention — every developer understands semver
  • 📋 Sortable — v1.2.3 < v1.3.0 < v2.0.0

The disadvantages:

  • 📋 It requires discipline to label correctly
  • 📋 It doesn't get generated automatically — someone decides when it's v1.1.0 vs v1.0.1

3. The Latest Tag — Convenience

latest always points to the most recent image of the main branch.

ghcr.io/user/ai-api:latest    # The most recent one from main

The format in Actions:

- name: Build and push
  uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: |
      ghcr.io/${{ github.repository }}:latest
      ghcr.io/${{ github.repository }}:sha-${{ github.sha }}

When to use it: For convenience in development. When you want docker pull myapp to always bring the latest version without remembering the SHA.

The advantages:

  • 📋 Convenient — docker pull myapp:latest always works
  • 📋 No need to remember specific tags

The disadvantages:

  • 📋 Mutablelatest points to a different image every time you push
  • 📋 No traceability — you don't know which commit latest is
  • 📋 Don't use it in production — if you deploy latest, you can't roll back precisely
  • 📋 Race conditions — two simultaneous pushes compete for latest

4. Branch-Based Tags — Development

Tags based on the branch's name. Useful for development and staging.

ghcr.io/user/ai-api:main           # The latest image from main
ghcr.io/user/ai-api:develop         # The latest image from develop
ghcr.io/user/ai-api:feature-auth    # The latest image from feature/auth

The format in Actions:

- name: Get branch name
  id: branch
  run: |
    BRANCH="${GITHUB_REF#refs/heads/}"
    BRANCH_CLEAN=$(echo "$BRANCH" | sed 's/[^a-zA-Z0-9._-]/-/g')
    echo "name=$BRANCH_CLEAN" >> $GITHUB_OUTPUT

- name: Build and push
  uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: |
      ghcr.io/${{ github.repository }}:${{ steps.branch.outputs.name }}
      ghcr.io/${{ github.repository }}:sha-${{ github.sha }}

When to use it: When your team needs images per branch for testing. A tester can say "give me the image from the feature-auth branch" and get it without knowing the SHA.

The advantages:

  • 📋 Development context — you know which branch it came from
  • 📋 Easy to ask for — "give me the develop image"
  • 📋 Natural staging — main for staging, tags for production

The disadvantages:

  • 📋 Mutable — like latest, it gets overwritten on every push to the branch
  • 📋 Branch names with special characters need sanitization

A comparison of the strategies

AspectSHASemverLatestBranch
Immutable✅ Yes✅ Yes (by convention)❌ No❌ No
Traceable✅ The exact commit⚠️ A release, not a commit❌ No⚠️ A branch, not a commit
Rollback✅ Precise✅ To a version❌ Impossible❌ Impossible
Readabilitysha-abc1234v1.2.3latestmain
Automatable✅ 100%⚠️ Partially✅ 100%✅ 100%
Use in production✅ Yes✅ Yes❌ No❌ No

The recommended combination

Development:  sha + branch
  ghcr.io/user/app:sha-abc1234
  ghcr.io/user/app:develop

Staging:      sha + main
  ghcr.io/user/app:sha-def5678
  ghcr.io/user/app:main

Production:   sha + semver
  ghcr.io/user/app:sha-abc1234
  ghcr.io/user/app:v1.2.3

The SHA is always present. The additional tags add context depending on the environment.


docker/metadata-action: Automatic tags

docker/metadata-action generates tags automatically depending on the event (push, PR, tag). It's the most professional way to handle tagging:

- name: Docker metadata
  id: meta
  uses: docker/metadata-action@v5
  with:
    images: ghcr.io/${{ github.repository }}
    tags: |
      type=sha,prefix=sha-
      type=ref,event=branch
      type=semver,pattern=v{{version}}
      type=semver,pattern=v{{major}}.{{minor}}
      type=raw,value=latest,enable={{is_default_branch}}

- name: Build and push
  uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ${{ steps.meta.outputs.tags }}
    labels: ${{ steps.meta.outputs.labels }}

What it generates depending on the event

A push to main:

ghcr.io/user/ai-api:sha-abc1234
ghcr.io/user/ai-api:main
ghcr.io/user/ai-api:latest

A push to feature/auth:

ghcr.io/user/ai-api:sha-def5678
ghcr.io/user/ai-api:feature-auth

The tag v1.2.3:

ghcr.io/user/ai-api:sha-ghi9012
ghcr.io/user/ai-api:v1.2.3
ghcr.io/user/ai-api:v1.2

Breaking down each type

tags: |
  type=sha,prefix=sha-
  # sha-abc1234 → always, on every build

  type=ref,event=branch
  # main, develop, feature-auth → the branch's name

  type=semver,pattern=v{{version}}
  # v1.2.3 → only when the trigger is a v* Git tag

  type=semver,pattern=v{{major}}.{{minor}}
  # v1.2 → a rolling tag that includes patches

  type=raw,value=latest,enable={{is_default_branch}}
  # latest → only on the default branch (main)

The complete professional tagging workflow

# .github/workflows/docker.yml
name: Docker Build & Push

on:
  push:
    branches: [main]
    tags: ["v*"]
  pull_request:
    branches: [main]

jobs:
  docker:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to GHCR
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha,prefix=sha-
            type=ref,event=branch
            type=semver,pattern=v{{version}}
            type=semver,pattern=v{{major}}.{{minor}}
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Summary
        run: |
          echo "### Docker Image Built" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "**Tags:**" >> $GITHUB_STEP_SUMMARY
          echo '```' >> $GITHUB_STEP_SUMMARY
          echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
          echo '```' >> $GITHUB_STEP_SUMMARY

The Summary step writes the generated tags into the workflow's summary — visible in GitHub Actions' UI without opening the logs.


Tagging and rollbacks

How a rollback works with SHA tags

# Production is running sha-def5678 (with the bug)
# You want to go back to sha-abc1234 (the stable version)

# Option 1: Redeploy the previous image
docker pull ghcr.io/user/ai-api:sha-abc1234
docker stop production-container
docker run -d --name production-container ghcr.io/user/ai-api:sha-abc1234

# Option 2: In your deployment system (e.g., docker-compose)
# Change the tag in docker-compose.yml:
#   image: ghcr.io/user/ai-api:sha-abc1234
# And re-deploy

How a rollback works with semver tags

# Production is running v1.2.0 (with the bug)
# You want to go back to v1.1.3 (the last stable version)

docker pull ghcr.io/user/ai-api:v1.1.3
# Deploy...

Why latest doesn't allow a rollback

# Production is running latest (with the bug)
# Which version do you roll back to?
# latest already points to the current version (with the bug)
# The previous version of latest was lost — it got overwritten
# There's no way to go back

Troubleshooting

1. metadata-action generates unexpected tags

Symptom: The tags don't match what you expect — for example, pr-42 instead of sha-abc1234.

Cause: docker/metadata-action generates different tags depending on the event. On PRs, it generates pr-<number> by default.

Solution: Verify which event triggers your workflow and configure the types accordingly:

- name: Docker metadata
  id: meta
  uses: docker/metadata-action@v5
  with:
    images: ghcr.io/${{ github.repository }}
    tags: |
      type=sha,prefix=sha-
      type=ref,event=branch
      type=ref,event=pr

Check the step's output in the logs to see exactly which tags it generates.

2. Semver tags don't get generated on a push to main

Symptom: You push to main but you don't see tags like v1.2.3.

Cause: Semver tags only get generated when the trigger is a Git tag (refs/tags/v*), not a push to a branch.

Solution: Create a Git tag to generate the semver:

git tag v1.2.3
git push origin v1.2.3

And make sure your workflow listens for tags:

on:
  push:
    branches: [main]
    tags: ["v*"]        # Necessary for semver tags

3. A branch tag with special characters fails

Symptom: The tag feature/auth fails because / isn't valid in Docker tags.

Cause: Docker tags don't allow /. Branches like feature/auth need sanitization.

Solution: docker/metadata-action sanitizes automatically (feature/authfeature-auth). If you use manual tags:

- name: Sanitize branch name
  id: branch
  run: |
    BRANCH="${GITHUB_REF#refs/heads/}"
    echo "name=$(echo "$BRANCH" | sed 's/[^a-zA-Z0-9._-]/-/g')" >> $GITHUB_OUTPUT

Exercises

Exercise 1: Manual tags — SHA + latest

Create a workflow that generates two tags: sha-<short> and latest. Use a manual cut of the SHA (the first 7 characters).

See solution
name: Docker Build
on:
  push:
    branches: [main]

jobs:
  docker:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3

      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Get short SHA
        id: sha
        run: echo "short=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:sha-${{ steps.sha.outputs.short }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

Key points:

  • cut -c1-7 extracts the first 7 characters of the full SHA
  • Multi-line tags with | generate two tags for the same image
  • latest gets overwritten on every push — only for convenience, not for production

Exercise 2: Tags with metadata-action

Configure docker/metadata-action to generate: SHA, the branch name, semver (on releases), and latest (only on main).

See solution
name: Docker Build with Metadata
on:
  push:
    branches: [main, develop]
    tags: ["v*"]
  pull_request:
    branches: [main]

jobs:
  docker:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3

      - name: Login to GHCR
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha,prefix=sha-
            type=ref,event=branch
            type=semver,pattern=v{{version}}
            type=semver,pattern=v{{major}}.{{minor}}
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Print generated tags
        run: |
          echo "Generated tags:"
          echo "${{ steps.meta.outputs.tags }}"

The tags generated depending on the event:

  • Push to main: sha-abc1234, main, latest
  • Push to develop: sha-def5678, develop
  • The v1.2.3 tag: sha-ghi9012, v1.2.3, v1.2
  • PR: sha-jkl3456 (it doesn't get pushed, it only gets built)

Exercise 3: A release workflow with semver

Create a workflow that triggers ONLY on v* Git tags. It generates semver + SHA + latest tags. It pushes to GHCR.

See solution
name: Release Docker Image
on:
  push:
    tags: ["v*"]

jobs:
  release:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3

      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Get version
        id: version
        run: |
          VERSION="${GITHUB_REF#refs/tags/}"
          echo "full=$VERSION" >> $GITHUB_OUTPUT
          echo "major_minor=$(echo $VERSION | grep -oP 'v\d+\.\d+')" >> $GITHUB_OUTPUT

      - name: Get short SHA
        id: sha
        run: echo "short=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:${{ steps.version.outputs.full }}
            ghcr.io/${{ github.repository }}:${{ steps.version.outputs.major_minor }}
            ghcr.io/${{ github.repository }}:sha-${{ steps.sha.outputs.short }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Release summary
        run: |
          echo "### Release ${{ steps.version.outputs.full }}" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "**Image:** ghcr.io/${{ github.repository }}" >> $GITHUB_STEP_SUMMARY
          echo "**Tags:** ${{ steps.version.outputs.full }}, ${{ steps.version.outputs.major_minor }}, sha-${{ steps.sha.outputs.short }}, latest" >> $GITHUB_STEP_SUMMARY

To make a release:

git tag v1.2.3 -m "Release v1.2.3: fix embedding endpoint"
git push origin v1.2.3

The tags generated: v1.2.3, v1.2, sha-abc1234, latest

Exercise 4: Simulate a rollback

Describe (in YAML comments) how you would roll back from sha-def5678 (buggy) to sha-abc1234 (stable). The deployment uses docker-compose.

See solution
# docker-compose.yml on your production server
#
# The current state (with the bug):
#   image: ghcr.io/user/ai-api:sha-def5678
#
# To roll back:
# 1. Change the tag to the stable version's SHA:
#   image: ghcr.io/user/ai-api:sha-abc1234
#
# 2. Re-deploy:
#   docker compose pull
#   docker compose up -d
#
# The rollback is instant because the sha-abc1234 image
# already exists in the registry — it needs no rebuild.

services:
  ai-api:
    image: ghcr.io/user/ai-api:sha-abc1234    # The rollback tag
    ports:
      - "8000:8000"
    environment:
      - OPENAI_API_KEY=${OPENAI_API_KEY}
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 5s
      retries: 3

The rollback process:

# 1. Identify the stable SHA
# In GitHub → Actions → find the successful run → copy the SHA tag

# 2. Update docker-compose.yml with the stable SHA
sed -i 's/sha-def5678/sha-abc1234/' docker-compose.yml

# 3. Pull and redeploy
docker compose pull
docker compose up -d

# 4. Verify
curl http://localhost:8000/health
# {"status": "healthy"}

# 5. Once it's stable, investigate the bug in sha-def5678
git log sha-abc1234..sha-def5678 --oneline

Key points:

  • The rollback does NOT require a rebuild — the previous image already exists in the registry
  • SHA tags are immutable — sha-abc1234 is always the same code
  • git log sha-abc1234..sha-def5678 shows the commits between the two versions — the bug is in there

Summary

  • SHA tags are mandatory — direct traceability to the commit, precise rollbacks
  • Semver tags communicate the nature of the change — v1.2.3 = major.minor.patch
  • Latest is only for convenience — never use it in production
  • Branch tags are useful for development — main, develop, feature-auth
  • docker/metadata-action automates tag generation depending on the event
  • The recommended combination: SHA always + branch in dev + semver on releases + latest on main
  • Rollback with SHA: change the tag in docker-compose, pull, redeploy — with no rebuild
  • Without immutable tags, there's no reliable rollback — this is why tagging matters

Additional resources

  1. docker/metadata-action — The official action for automatically generating tags and labels
  2. Semantic Versioning 2.0.0 — Semver's official specification
  3. OCI Image Spec — Annotations — Standard labels for images
  4. Docker tagging best practices — Docker Inc's best practices
  5. GitHub Actions contexts — github — Variables like github.sha, github.ref
  6. Container image versioning — Google Cloud's best practices for tagging