Image Tags, Immutable References, and Release Versions
A Docker image tag like app:1.4.0 or app:latest is just a movable label. Anyone with push access can repoint it to a completely different set of bytes tomorrow. A digest, on the other hand, is a cryptographic hash of the image manifest, and it never changes for a given set of bytes. Production pipelines that care about reproducibility, auditability, and safe rollback need to understand exactly which of these two things they are actually deploying.
Overview / How it works
Every image a registry stores is addressed internally by its digest, a value such as sha256:9f1e... computed from the image manifest. Tags are a convenience layer on top: the registry keeps a mapping from human-readable tags to digests, and that mapping can be overwritten at any time by a new push. Two important consequences follow from this.
First, a tag is not a version in the strict sense — it is a pointer. myapp:1.4.0 today might point to a different digest than myapp:1.4.0 next week if someone rebuilds and force-pushes the same tag. Second, a digest is the only reference that guarantees you get exactly the bytes that were built, scanned, and tested. This is why production deployment steps should resolve a tag to a digest at build time and carry that digest forward, rather than re-resolving a tag name at deploy time.
Semantic version tags (MAJOR.MINOR.PATCH, for example 2.3.1) give humans a meaningful, ordered name for a release. In a CI/CD pipeline these tags are usually generated automatically from a Git tag pushed by a maintainer, not typed by hand into a Docker command. The workflow parses the Git tag, derives the Docker tags it should apply (the full version, the major.minor shorthand, a commit-based tag, and optionally latest), builds once, and pushes all of them to the same digest.
| Property | Tag (e.g. 1.4.0) |
Digest (e.g. sha256:9f1e...) |
|---|---|---|
| Mutability | Mutable — can be repointed by a later push | Immutable — always identifies the same bytes |
| Human readability | Readable, communicates intent | Opaque hash, not memorable |
| Best use | Discovery, changelogs, release notes | Deployment manifests, rollback targets, audit logs |
Syntax or workflow structure
The docker/metadata-action GitHub Action generates a consistent tag set from Git refs. Common patterns include type=semver,pattern={{version}} for a full version tag, type=semver,pattern={{major}}.{{minor}} for a rolling minor tag, type=sha,format=long for a traceable commit-based tag, and type=raw,value=latest,enable={{is_default_branch}} to apply latest only from the default branch. The docker/build-push-action step that follows accepts the generated tag list and, critically, exposes an outputs.digest value — the exact digest of what was just pushed. That digest is what later jobs and later deployments should reference, using the image@sha256:... syntax instead of image:tag.
Examples
Example 1: Commit-addressable build on every push to main. Every push to main builds and pushes an image tagged with the full commit SHA. This gives every commit a traceable, unique image without requiring a formal release.
name: Build and Tag Image
on:
push:
branches: [main]
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
Expected behavior: a push to main produces an image such as ghcr.io/acme/app:8f3a1c2b9e.... There is no latest tag here, so nothing ambiguous exists to accidentally deploy.
Example 2: Semantic version release from a Git tag. When a maintainer pushes a Git tag like v2.3.1, the workflow derives a matching set of Docker tags and pushes them all to one image, keeping the digest output for later use.
name: Release Image
on:
push:
tags:
- 'v*.*.*'
permissions:
contents: read
packages: write
jobs:
release:
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract tags and labels
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,format=long
- name: Build and push
id: build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
Expected behavior: pushing Git tag v2.3.1 pushes one image under three Docker tags — 2.3.1, 2.3, and a long commit SHA tag — all pointing at the same digest. The release job exposes that digest as a job output for downstream use.
Example 3: Deploying by digest, not by tag. The deploy job depends on the release job and reads its digest output, so the exact image that was built and pushed is what gets deployed — not whatever a mutable tag currently happens to point to.
deploy:
needs: release
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
steps:
- name: Deploy image pinned to digest
run: |
IMAGE="ghcr.io/${{ github.repository }}@${{ needs.release.outputs.digest }}"
echo "Deploying $IMAGE"
# Replace with your platform's deploy command, for example:
# kubectl set image deployment/app app=$IMAGE
Expected behavior: the production environment (configured with required reviewers in repository settings) gates this job, and the command that finally reaches your infrastructure always references an exact digest, so re-running the deploy later cannot silently pick up a newer, unreviewed build.
Step by step
- A maintainer creates and pushes a Git tag following semantic versioning, such as
v2.3.1. - The release workflow triggers on the tag pattern and checks out the code at that exact commit.
docker/metadata-actionparses the tag and produces the Docker tag list (full version, major.minor, commit SHA).docker/build-push-actionbuilds the image once and pushes it under every generated tag, then reports the resulting digest as a step output.- A separate
deployjob, gated by a protectedenvironment, reads that digest and passesimage@sha256:...to the deployment command — never a bare tag. - If the release turns out to be broken, rollback means pointing the deploy at the previous release’s recorded digest, not rebuilding or guessing which tag is safe.
Common Mistakes
Mistake 1: Deploying with the latest tag. latest is not a version; it is whatever was pushed most recently, and different registries and tools resolve it inconsistently. Deploying myapp:latest means your production environment can change contents without any corresponding entry in your deployment history.
# Mistake: production deploy references a mutable, ambiguous tag
docker pull ghcr.io/acme/app:latest
docker run ghcr.io/acme/app:latest
Correction: resolve the release tag to a digest during the build job and deploy that digest, as shown in Example 3. If you must reference something by name, use the immutable semantic version tag (2.3.1), never latest.
Mistake 2: Re-pushing to an already-published version tag. After publishing 1.4.0, a hotfix is built and pushed again under the same tag 1.4.0 instead of bumping to 1.4.1. Now 1.4.0 silently points to different bytes than it did an hour ago, and anyone who previously recorded "we’re running 1.4.0" has no way to know which build that actually was.
# Mistake: overwriting a published version tag instead of bumping it
docker build -t ghcr.io/acme/app:1.4.0 .
docker push ghcr.io/acme/app:1.4.0
# ...later, after a code change, without bumping the version...
docker build -t ghcr.io/acme/app:1.4.0 .
docker push ghcr.io/acme/app:1.4.0
Correction: treat every published version tag as immutable once pushed. Any new build, however small, gets a new version (1.4.1), driven by a new Git tag, never a re-push of an existing one.
Best Practices
- Generate Docker tags from Git tags with
docker/metadata-actionrather than typing version strings by hand — it keeps the semantic version, commit SHA, and any rolling tags consistent and traceable to a single build. - Capture the
outputs.digestfrombuild-push-actionand pass it between jobs (and across workflows via artifacts or deployment records) so every later step references exact bytes. - Reserve
latest, if you use it at all, strictly for the newest build off the default branch, and never reference it in deployment automation. - Grant the build job only
permissions: packages: writeandcontents: read— nothing broader is needed to push an image, and a compromised build step should not be able to modify repository contents or settings. - Put the deploy job behind a protected
environmentwith required reviewers, so pinning to a digest is paired with a human checkpoint before it reaches production. - Keep a record (a deployment log, a Git tag, or a release note) of which digest is currently deployed, so rollback is "redeploy the previous recorded digest" rather than a guess.
- Treat every example here as a template: registry names, image paths, and deploy commands must be adapted to your own infrastructure and credentials.
Practice Exercises
- Modify Example 2’s tag list to also emit a
type=raw,value=latest,enable={{is_default_branch}}tag, and explain in a comment why this tag should never appear in the deploy job. - Write a shell snippet (for local practice, not for a workflow) that pulls an image by tag, then uses
docker inspectto print its digest, to show how a tag resolves to an immutable reference. - Extend Example 3 so the deploy job writes the deployed digest to a file or artifact, creating a simple audit trail you could use for rollback later.
- Explain, in your own words, what would go wrong if the
deployjob in Example 3 usedneeds.release.outputs.tags(the semantic version tag) instead of the digest output.
Summary
Tags and digests solve different problems. Tags give humans a memorable, ordered way to talk about releases; digests give machines an immutable, verifiable reference to exact bytes. A solid CI/CD pipeline uses docker/metadata-action to generate semantic version tags automatically from Git tags, captures the digest that build-push-action produces, and threads that digest — not a tag — through every deployment step. Combined with minimal job permissions and a protected production environment, this gives you reproducible builds, an honest audit trail, and a rollback path that always points at something real.
