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 abc1234shows 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-abc1234says 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.0indicates 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:latestalways works - 📋 No need to remember specific tags
The disadvantages:
- 📋 Mutable —
latestpoints to a different image every time you push - 📋 No traceability — you don't know which commit
latestis - 📋 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 —
mainfor 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
| Aspect | SHA | Semver | Latest | Branch |
|---|---|---|---|---|
| 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 |
| Readability | ❌ sha-abc1234 | ✅ v1.2.3 | ✅ latest | ✅ main |
| 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/auth → feature-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-7extracts the first 7 characters of the full SHA- Multi-line tags with
|generate two tags for the same image latestgets 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-abc1234is always the same code git log sha-abc1234..sha-def5678shows 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-actionautomates 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
- docker/metadata-action — The official action for automatically generating tags and labels
- Semantic Versioning 2.0.0 — Semver's official specification
- OCI Image Spec — Annotations — Standard labels for images
- Docker tagging best practices — Docker Inc's best practices
- GitHub Actions contexts — github — Variables like
github.sha,github.ref - Container image versioning — Google Cloud's best practices for tagging