🔄 Update — June 2026. CI no longer runs on GitHub Actions but on Gitea Actions (self-hosted runner on the NAS). The digest-pin pattern below is unchanged in substance; only the final
git pushnow targets Gitea. See Self-hosted CI with Gitea Actions.
The lie of :latest
:latest does not mean what you think it means. It is not “the newest stable version”. It is not “the image I built yesterday”. It is a mutable pointer that anyone can move at any time.
Imagine your registry hosts ghcr.io/yourname/app:latest. You push a new image this morning — :latest moves. Your colleague reboots a node this afternoon — Kubernetes re-pulls :latest, which is now a different image from the one running this morning. No signal, no trace in git log, no ArgoCD event. The cluster has silently diverged, and nobody signed anything.
It is even worse with imagePullPolicy: Always. Without that option, Kubernetes only pulls the image if the node does not have it in its local cache — so at least your pod restarts with whatever it had at the last pull. With imagePullPolicy: Always, every pod restart re-queries the registry. If :latest has moved between two restarts, your pods run different images depending on when they last booted. A permanently staggered cluster.
The promise of GitOps is that Git is the single source of truth. With a floating tag in the manifest, that promise is broken. What is in Git does not reflect what is actually running.
Why an immutable digest
A SHA256 digest is not a human-readable name — it is the cryptographic hash of the image’s content. It is computed by the registry at push time and it is immutable by construction: if the content changes by one byte, the digest changes. It is impossible for a malicious actor, or for a simple automatic update, to substitute a different image behind the same digest.
# Floating tag: what you wrote yesterday is not what is running today
image: ghcr.io/yellowbeanie/homelab-blog:latest
# Immutable digest: what is in Git is exactly what is running
image: ghcr.io/yellowbeanie/homelab-blog@sha256:4525bb37c02127dedd76f36e2deedfefb0fdcc9cda5560fec39f1846a7ca58a8
The bottom line designates a unique blob in the universe. If your registry burns down and you rebuild it, if Bitnami deprecates its images, if someone overwrites the :latest tag — that digest, as long as it exists somewhere, points to exactly that image. It is the only reference you can commit to Git with the certainty that the commit will still mean something in six months.
And that is precisely why we talk about supply chain: knowing, verifiably, which image is running and where it comes from. A digest lets you answer “what was running on the cluster on 3 January?” by simply reading git log.
The complete pattern
The mechanism is simple: after each build, GitHub Actions captures the resulting digest and rewrites it directly into the Kubernetes manifest, then commits that change. ArgoCD detects the commit and deploys. Here is the blog’s full workflow:
name: Build and push blog image
on:
workflow_dispatch:
push:
branches: [actual-state]
paths:
- 'blog/src/**'
- 'blog/public/**'
- 'blog/package*.json'
- 'blog/astro.config.mjs'
- 'blog/Dockerfile'
- '.github/workflows/blog.yml'
jobs:
build-push:
runs-on: ubuntu-latest
permissions:
contents: write # required for the final git push
packages: write # required to push to GHCR
steps:
- uses: actions/checkout@v4
with:
ref: actual-state
- name: Set up QEMU (for ARM64 emulation)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- 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
id: build
uses: docker/build-push-action@v6
with:
context: ./blog
platforms: linux/arm64
push: true
tags: |
ghcr.io/yellowbeanie/homelab-blog:latest
ghcr.io/yellowbeanie/homelab-blog:${{ github.sha }}
no-cache: true
- name: Pin new digest in deployment manifest
run: |
set -eu
DIGEST="${{ steps.build.outputs.digest }}"
echo "New digest: ${DIGEST}"
sed -i -E "s|image: ghcr.io/yellowbeanie/homelab-blog@sha256:[a-f0-9]+|image: ghcr.io/yellowbeanie/homelab-blog@${DIGEST}|" blog/deployment.yaml
if git diff --quiet blog/deployment.yaml; then
echo "manifest already up to date"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add blog/deployment.yaml
git commit -m "chore(blog): pin image digest [skip ci]"
git push origin actual-state
Let’s unpack the key step, “Pin new digest in deployment manifest”:
steps.build.outputs.digest is the native output of docker/build-push-action@v6. It exposes the SHA256 digest of the image that was just pushed — format sha256:abc123…. No need to compute it by hand or inspect the registry after the fact.
The sed is deliberately anchored on the full prefix image: ghcr.io/yellowbeanie/homelab-blog@sha256: followed by hexadecimal characters. It only replaces the exact line preceded by that prefix, with no risk of touching other YAML fields if the manifest evolves.
git diff --quiet makes the step idempotent: if the build produces exactly the same digest (cache hit, identical content), no commit is created. CI does not make noise for nothing.
github-actions[bot] as the author clearly marks in the history that this commit comes from CI, not from a human. Handy for git blame and for future searches.
The result in the manifest:
containers:
- name: blog
image: ghcr.io/yellowbeanie/homelab-blog@sha256:4525bb37c02127dedd76f36e2deedfefb0fdcc9cda5560fec39f1846a7ca58a8
And in git log:
c3f7a21 chore(blog): pin image digest [skip ci]
a9d4b88 feat(blog): add article on sealed secrets
Every release is signed by a commit. The history is human-readable, script-queryable, revertable in a single command.
platforms: linux/arm64platforms: linux/arm64
pousse :latest + tag du SHA commit sur GHCRpushes :latest + the commit SHA tag to GHCR
4525bb37…
steps.build.outputs.digeststeps.build.outputs.digest
sha256:4525bb37… capturé nativement par build-push-actionsha256:4525bb37… captured natively by build-push-action
:latest → @sha256:…:latest → @sha256:…
regex ancrée sur le préfixe complet, idempotent via git diff --quietregex anchored on the full prefix, idempotent via git diff --quiet
chore: pin image digest [skip ci]chore: pin image digest [skip ci]
sans [skip ci] → boucle infinie ; github-actions[bot] comme auteurwithout [skip ci] → infinite loop ; github-actions[bot] as the author
brise la bouclebreaks the loopgit push origin actual-stategit push origin actual-state
commit visible dans git log — registre de déploiements sans outil tierscommit visible in git log — a deployment registry with no third-party tool
@sha256:…
image@sha256:… immuableimage@sha256:… immutable
ArgoCD détecte le commit et déploie exactement ce blob, pour toujoursArgoCD detects the commit and deploys exactly that blob, forever
sed remplace :latest (mutable) par @sha256:… (immuable) directement dans le manifest —
le [skip ci] sur le commit de pin casse la boucle de re-trigger.
ArgoCD déploie exactement ce blob, pour toujours.
sed replaces :latest (mutable) with @sha256:… (immutable) directly in the manifest —
the [skip ci] on the pin commit breaks the re-trigger loop.
ArgoCD deploys exactly that blob, forever.
The pitfalls that hurt
The infinite loop without [skip ci]
This is pitfall number one. The workflow pushes a “pin image digest” commit to actual-state. GitHub Actions detects that push on actual-state. GitHub Actions re-runs the workflow. The workflow rebuilds, pushes a new commit. GitHub Actions detects it again. The loop runs until the month’s GitHub Actions minutes are exhausted — or until you cut it off by hand.
The solution is the [skip ci] suffix in the commit message. GitHub Actions recognises this convention and does not trigger a new run on that commit. It is non-negotiable. Without it, you have an infinite loop.
git commit -m "chore(blog): pin image digest [skip ci]"
# ^^^^^^^^^
# without this, you rebuild your image in a loop
Missing permissions
The native GITHUB_TOKEN has had restrictive permissions by default since 2023. If you forget to declare permissions: contents: write at the job level, the git push fails — and GitHub Actions sometimes reports a cryptic error (“remote: Permission to repo denied”) rather than “you don’t have write access”. Same thing for packages: write, without which the GHCR push fails silently.
jobs:
build-push:
permissions:
contents: write # for the git push of the digest commit
packages: write # for the docker push to GHCR
Multi-arch and QEMU — a Pi cluster does not forgive
A 100% Raspberry Pi (ARM64) cluster cannot run an image built only for linux/amd64. The pod starts, Kubernetes attempts the pull, and you get:
exec format error
The digest is valid. The image really does exist in the registry. But the architecture does not match. The solution: docker/setup-qemu-action@v3 for emulation, then platforms: linux/arm64 (or linux/amd64,linux/arm64 if you want to support both).
Without QEMU configured, multi-arch buildx fails at cross-compilation. Order matters: QEMU has to be set up before buildx.
The race condition on git push
If two commits land in parallel on actual-state (a human pushes a fix while CI is running), the workflow’s git push origin actual-state can be rejected with rejected (non-fast-forward). The manifest is not updated. ArgoCD stays on the old digest.
The pragmatic solution for a homelab: tolerate the clean failure. The next workflow (triggered by the next push) will fix the situation. If you want more robustness, a git pull --rebase origin actual-state before the push is enough in the vast majority of cases.
The digest lives, the tag dies — a true story
In August 2025, Bitnami carried out its “Bitnami Secure Images” migration: community (non-VMware) images were withdrawn from Docker Hub and old tags progressively deleted. Setups referencing bitnami/kubectl:1.34.3 started hitting ImagePullBackOff — the tag no longer existed.
Those who had pinned a digest and stored the image in their own registry or in a mirror had no problem. The digest still referenced a valid blob. The others had to urgently hunt down a replacement tag, update the manifests, redeploy — all of it under stress.
A digest does not protect you if the entire registry disappears. But it protects you from silent mutation, from tag deprecation, and from accidental overwrites. That is already the majority of incidents observed in practice.
The cross-repo variant
Everything above works when the Dockerfile and the manifests live in the same repo. But some apps have their source code elsewhere — that is the case for tapaz (the prsm-studio site, in YellowBeanie/prsm-studio).
The principle stays identical: after the build, we patch the manifest. The difference: the source repo’s workflow clones the homelab repo, patches with sed, commits, pushes via a PAT with contents: write scope limited to the homelab repo.
- name: Bump digest in k3s-homelab
env:
DIGEST: ${{ steps.push.outputs.digest }}
SRC_SHA: ${{ github.sha }}
HOMELAB_REPO_TOKEN: ${{ secrets.HOMELAB_REPO_TOKEN }}
run: |
git clone --depth 1 --branch actual-state \
https://x-access-token:${HOMELAB_REPO_TOKEN}@github.com/YellowBeanie/k3s-homelab.git homelab
cd homelab
sed -i -E "s|(image: ghcr\.io/yellowbeanie/tapaz@)sha256:[a-f0-9]+|\1${DIGEST}|" tapaz/deployment.yaml
if git diff --quiet; then exit 0; fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add tapaz/deployment.yaml
git commit -m "tapaz: bump image to ${DIGEST} (prsm-studio@${SRC_SHA})"
git push origin actual-state
Note the absence of [skip ci] here: the commit is pushed to k3s-homelab, not to prsm-studio — so there is no risk of looping back on the source workflow. But if k3s-homelab has its own workflows on actual-state, check that they do not rebuild the tapaz image on that push (paths: filter to the rescue).
The commit message tapaz: bump image to ${DIGEST} (prsm-studio@${SRC_SHA}) is particularly valuable: it links the image digest to the SHA of the source commit that produced it. End-to-end traceability from source code down to the pod.
This pattern is covered in detail in the article Cross-repo GitOps — deploying an app whose source lives elsewhere.
The real cost
One extra commit per release. That’s it.
That commit is signed by github-actions[bot], suffixed with [skip ci], and appears clearly in git log for anyone who knows how to read it. It is not noise — it is the trace of every deployment. A readable git log --oneline:
c3f7a21 chore(blog): pin image digest [skip ci]
a9d4b88 feat(blog): add article on sealed secrets
f2e1c44 chore(silex): pin image digest [skip ci]
9b3a771 fix(silex): correct nginx cache headers
e8d0f23 chore(blog): pin image digest [skip ci]
Every pin image digest line corresponds to an actual release on the cluster. It is a deployment ledger inside git log, with no extra tooling, no external tracking service. ArgoCD can see exactly which image it deployed and when — because it is literally in the commit.
A rollback comes down to:
git revert c3f7a21 # undo the last digest pin
git push origin actual-state # ArgoCD redeploys the previous image
No kubectl command by hand. No digging through the registry’s logs. Just standard Git.
What it really changes
The difference between :latest and @sha256: is not a matter of style — it is the difference between a deployment you can audit and a deployment you hope to reproduce.
With a digest pinned in Git:
- You know exactly which image was running at any moment (read the git history)
- You can reproduce any past state (checkout the commit + ArgoCD sync)
- You immediately detect any divergence (ArgoCD compares the manifest’s digest with the pod’s digest)
- You have a verifiable supply chain with no third-party tooling
The CI pattern described here — build, capture digest, sed, commit, push — is about forty lines of YAML. It has no external dependency, no service to maintain, no cluster controller. It slots into any existing Docker workflow in five minutes.
There is no valid reason to deploy a floating tag on a GitOps cluster. None.