7 docker tags mistakes in GitHub Actions you must fix in 2026

By Kate Baker
7 docker tags mistakes in GitHub Actions you must fix in 2026

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: immutable

Tag 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:latest

When 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.latest from Monday and latest from Friday look identical in docker ps.
  • Caching lies. A node that already has api:latest may 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:latest

Better 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:

  1. Branch names are not valid tags.feature/login-fix contains a /, which is not allowed in a tag. Very long branch names can exceed 128 characters.
  2. Repository names are not always lowercase. If your org is AcmeCorp, then ghcr.io/AcmeCorp/api fails, 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:

  • latest is moved by every branch push, so a half-finished feature branch becomes "latest" for ten minutes.
  • latest is moved by a release candidate tag like v2.0.0-rc.1, and anyone running docker pull acme/api gets a pre-release.
  • latest is moved by a hotfix on an old release line (v1.8.3 pushed after v2.1.0), which quietly downgrades latest.

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:

  1. Push to main builds api:staging, which gets deployed to staging and tested.
  2. Tag v1.4.2triggers a fresh build and deploys api:1.4.2 to 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: true

With 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

  1. Never ship latest alone. Always push a unique SHA tag.
  2. Generate tags with docker/metadata-action and lowercase your image name.
  3. Build on PRs, push only from trusted events.
  4. Define exactly when latest moves, and disable the default.
  5. Promote tested images with imagetools create. Do not rebuild.
  6. Deploy by digest or by immutable tag.
  7. Add a concurrency group 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.

Keep reading

More posts tagged “Tutorials”.
Be first in line for updates
and special pricing
Get early access to new features and exclusive discounts delivered straight to your inbox