Docker tags look like the least interesting part of a CI pipeline. You pick a name, put it after a colon, move on. Then one day production runs code nobody can identify, a rollback pulls the same broken image again, or a workflow fails on a branch called feature/Login-Fix and nobody understands why.
This is not another "tagging strategy" guide. It is a list of seven specific mistakes we keep seeing in GitHub Actions workflows, why each one hurts, and the YAML that fixes it. A complete working workflow is at the end.
The examples use current major versions at the time of writing: actions/checkout@v5, docker/setup-buildx-action@v3, docker/login-action@v3, docker/metadata-action@v5 and docker/build-push-action@v6. Check each action's releases page for newer majors before copying.
A 30-second refresher on docker tags
A tag is a mutable pointer to an image manifest. The image itself is identified by its digest (sha256:...), which never changes. Tags can be moved, overwritten and deleted at any time.
ghcr.io/acme/api:1.4.2 # tag: human-friendly, mutable
ghcr.io/acme/api@sha256:9f86d08... # digest: immutableTag names may contain letters, digits, _, . and -. They cannot start with . or - and are limited to 128 characters. Keep those rules in mind for mistake 2.
Mistake 1: shipping only latest
# Don't do this
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/acme/api:latestWhen the only tag is latest:
- You cannot roll back. The previous image still exists, but nothing points to it by name. Good luck finding its digest during an incident.
- You cannot tell what is running.
latestfrom Monday andlatestfrom Friday look identical indocker ps. - Caching lies. A node that already has
api:latestmay not pull the new one unless the pull policy forces it, so different servers can run different code under the same tag.
Fix: always push at least one unique tag per build, usually derived from the commit SHA. latest can exist as a convenience, but nothing should deploy from it.
tags: |
ghcr.io/acme/api:sha-${{ github.sha }}
ghcr.io/acme/api:latestBetter still, let docker/metadata-action generate these (mistake 2).
Mistake 2: building tag strings by hand
This is where the mysterious failures come from:
# Breaks on real-world branch names and repo owners
tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}Two problems hide in that one line:
- Branch names are not valid tags.
feature/login-fixcontains a/, which is not allowed in a tag. Very long branch names can exceed 128 characters. - Repository names are not always lowercase. If your org is
AcmeCorp, thenghcr.io/AcmeCorp/apifails, because image repository names must be lowercase.
You end up with errors like invalid reference format that only show up on some branches.
Fix: use docker/metadata-action, which sanitizes refs into valid docker tags and generates a consistent set from Git context:
- name: Set lowercase image name
run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha${GITHUB_REPOSITORY,,} is Bash's lowercase expansion, which works on GitHub's Ubuntu runners. Then pass the generated values to the build:
- uses: docker/build-push-action@v6
with:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}As a bonus, labels adds standard OCI labels (org.opencontainers.image.revision, .source, .created). Any image can then be traced back to its commit with docker inspect.
Mistake 3: pushing images from pull requests
Two things go wrong when every pull_request run pushes:
- Fork PRs fail. Workflows triggered by PRs from forks do not get your repository secrets and get a read-only
GITHUB_TOKEN, so the login or push step fails and the PR shows a red X that has nothing to do with the code. - Your registry fills with junk. Every PR commit becomes a stored image nobody will ever pull.
Some teams "fix" the fork problem by switching to pull_request_target. That runs untrusted PR code with write permissions and secrets. Do not do that for builds.
Fix: build on PRs to prove the Dockerfile works, but only push from trusted events:
- uses: docker/build-push-action@v6
with:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}If you need preview environments per PR, push from same-repo PRs only (github.event.pull_request.head.repo.full_name == github.repository) and add a cleanup job when the PR closes.
Mistake 4: latest on the wrong events
Even teams that avoid mistake 1 often get latest wrong in subtler ways:
latestis moved by every branch push, so a half-finished feature branch becomes "latest" for ten minutes.latestis moved by a release candidate tag likev2.0.0-rc.1, and anyone runningdocker pull acme/apigets a pre-release.latestis moved by a hotfix on an old release line (v1.8.3pushed afterv2.1.0), which quietly downgradeslatest.
Fix: decide what latest means and encode it explicitly instead of relying on defaults. Two common choices:
# Option A: latest = tip of the default branch
flavor: |
latest=false
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha# Option B: latest = most recent stable release, only on version tags
flavor: |
latest=false
tags: |
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}Option B skips anything with a - (pre-release) in the tag. It does not protect against an out-of-order hotfix on an old line. If you maintain several release lines, publish latest from a manual workflow_dispatch job instead.
Mistake 5: rebuilding for production instead of promoting
A very common pipeline looks like this:
- Push to
mainbuildsapi:staging, which gets deployed to staging and tested. - Tag
v1.4.2triggers a fresh build and deploysapi:1.4.2to production.
The image in production was never tested. The rebuild pulled a newer base image, a dependency without a lockfile resolved differently, or apt-get fetched a new package version. Usually nothing breaks. Sometimes production breaks in a way staging never did.
Fix: build once, then promote the same digest by adding tags. docker buildx imagetools create does this registry-side without pulling or rebuilding, and it keeps multi-arch manifests intact:
promote:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
packages: write
steps:
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Promote tested image
run: |
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}"
docker buildx imagetools create \
--tag "$IMAGE:${GITHUB_REF_NAME#v}" \
--tag "$IMAGE:prod" \
"$IMAGE:sha-${GITHUB_SHA::7}"This assumes the commit you tagged was already built on main with type=sha, whose default is the 7-character short SHA with a sha- prefix. The release tag now points at exactly the bytes you tested.
Mistake 6: deploying a tag instead of a digest
Even with good tags, deploying api:main or api:1.4 means "whatever that tag points to when the server pulls." If the tag moves between your deploy and a restart, an autoscaled replica or a node replacement, you get mixed versions in production.
Fix: capture the digest from the build and deploy by digest, or at least by a tag you never overwrite (the SHA tag).
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
- name: Record digest
run: |
echo "Deployed ${IMAGE}@${{ steps.build.outputs.digest }}" >> "$GITHUB_STEP_SUMMARY"Then reference ghcr.io/acme/api@sha256:... in your deploy step or manifest. Pinning by digest also belongs in your FROM lines for base images when reproducibility matters, together with a tool like Dependabot or Renovate to bump them on purpose.
Tags are for humans. Digests are for machines. Let people read 1.4.2 in the dashboard, but let your deploy system pin the digest.Mistake 7: racing workflows overwriting the same tag
Push two commits to main 30 seconds apart. Two workflow runs start. The second run gets a warm cache and finishes first. Then the first run finishes and pushes api:main, overwriting the newer image with the older one. Your "latest main" is now one commit behind, and nothing in the logs looks wrong.
Fix: serialize runs per ref with a concurrency group, and cancel outdated ones:
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: trueWith cancel-in-progress: true, the older run is cancelled as soon as a newer one starts on the same branch. Leave cancellation off for tag and release workflows, because you want every release built. The concurrency group alone still prevents overlap.
Unique SHA tags, from mistakes 1 and 6, are immune to this race. It only affects moving tags like main and latest, which is one more reason not to deploy from them.
The complete workflow
Here is everything above combined into one file, .github/workflows/docker.yml:
name: docker
on:
push:
branches: [main]
tags: ["v*.*.*"]
pull_request:
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set lowercase image name
run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
flavor: |
latest=false
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
id: build
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
if: github.event_name != 'pull_request'
run: echo "Pushed ${IMAGE}@${{ steps.build.outputs.digest }}" >> "$GITHUB_STEP_SUMMARY"This version still rebuilds on version tags. That is fine with a fully reproducible build, meaning pinned base image digests and lockfiles. If you want the strict build-once model, drop the tags: trigger here and use the promote job from mistake 5 in a separate workflow.
One housekeeping note: SHA tags accumulate. GHCR and most registries let you set retention or cleanup policies for old or untagged versions. Set one up before your package page reaches 4,000 versions.
Where this fits with a deployment platform
Clean docker tags matter most at the point where an image turns into a running service. With Granite.so, you can deploy from a Docker image you publish, in which case the practices above decide exactly what goes live and what you can roll back to. Or you can connect the Git repository and let the platform auto-build on every push, which removes the tagging workflow for that app entirely. Either way, your app runs next to managed databases and S3-compatible storage, starting on the Hobby plan at $9/month with $15 of usage credits.
Quick recap
- Never ship
latestalone. Always push a unique SHA tag. - Generate tags with
docker/metadata-actionand lowercase your image name. - Build on PRs, push only from trusted events.
- Define exactly when
latestmoves, and disable the default. - Promote tested images with
imagetools create. Do not rebuild. - Deploy by digest or by immutable tag.
- Add a
concurrencygroup so old runs cannot overwrite new tags.
Fix these seven and your docker tags go back to being boring, which is exactly what you want from them.