Build and supply chain

Digest pinning in GitOps, the secure way

Your Git history says production runs app 1.4.2, and it is right, in the way that a label on a moving box is right. If the manifest names a tag, Git records a wish. Pin the digest and it records a fact.

The short answer

Write images into Git as name:tag@sha256:digest, for example with kustomize images and a digest field. The tag is for people, the digest decides what runs. Render every overlay in CI and fail when any container image lacks an @sha256 digest, including init containers and sidecars.

Updated Houssam Hammoudi, CTOTested with kustomize v5.8.1, crane, registry:2

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

What goes wrong

GitOps promises that Git describes what runs. With image tags, it does not. A tag is resolved by each node when it pulls, so:

  • A re-pushed tag reaches production with no commit, no review and no rollback point.
  • Two nodes can run different code under the same tag, depending on when they pulled.
  • A scan or signature approved one digest; the tag may now point to another.

Pinning the main image is not enough. The init container, the sidecar that a chart adds, and the job image in a CronJob are the ones people forget.

What the docs say

Digests are a unique identifier for a specific version of an image. Digests are hashes of the image's content, and are immutable. Tags can be moved to point to different images, but digests are fixed.

Source: Kubernetes docs, Images

Specifying an image by digest pins the code that you run so that a change at the registry cannot lead to that mix of versions.

Source: Kubernetes docs, Images

Image name with tag and digest. Only the digest will be used for pulling.

Source: Kubernetes docs, Images

The digest for an image may be set by specifying digest and the name of the container image.

Source: kustomize reference, images

The docs explain how to pin one image. They do not tell you how to prove that every image in every overlay is pinned, which is what stops the forgotten sidecar.

The secure configuration

Manifests use a placeholder name; the kustomization sets the full reference.

yaml
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  replicas: 2
  selector:
    matchLabels: { app: app }
  template:
    metadata:
      labels: { app: app }
    spec:
      containers:
        - name: app
          image: app            # a placeholder name; kustomization.yaml sets the real reference
yaml
# kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - deployment.yaml
images:
  - name: app
    newName: registry.example.com/team-a/app
    newTag: 1.4.2                 # for people: which release this is
    digest: sha256:<64 hex>       # for the kubelet: exactly what runs

Set it with a command, not by hand, from the digest your build recorded:

bash
DIGEST=$(cat out/app.digest)     # kaniko --digest-file, or: crane digest <image>:<tag>
kustomize edit set image "app=registry.example.com/team-a/app:1.4.2@${DIGEST}"

Then gate every change in CI on the rendered output, not on the source files:

sh
#!/bin/sh
# check-digests.sh RENDERED.yaml
# Fail if any rendered container image is not pinned by sha256 digest.
# Matches "image:" and "- image:" (kustomize renders the list form).
bad=$(grep -E '^[[:space:]]*(-[[:space:]]+)?image:' "$1" | grep -v -E '@sha256:[0-9a-f]{64}' || true)
[ "$(grep -c -E '^[[:space:]]*(-[[:space:]]+)?image:' "$1")" -gt 0 ] || { echo "no images found: check the pattern"; exit 1; }
if [ -n "$bad" ]; then echo "NOT PINNED:"; echo "$bad"; exit 1; fi
echo "all images pinned by digest"
bash
for overlay in overlays/*/; do
  kustomize build "$overlay" > rendered.yaml
  sh check-digests.sh rendered.yaml
done

The same check works for Helm (helm template ... > rendered.yaml). The second grep -c line is there on purpose: a gate whose pattern matches nothing passes everything.

Prove it

A digest keeps meaning the same image after the tag moves:

bash
crane digest registry:5000/team-a/app:1.4.2          # what CI resolved and pinned
# someone re-pushes 1.4.2 with different content
crane digest registry:5000/team-a/app:1.4.2
crane export registry:5000/team-a/app@sha256:12be14a1... - | tar -xO app/version
text
sha256:12be14a1a6cd5e3de4b33768cc0b54d937e1ab073ea15743434454f4ba086c21
sha256:e67fd6fc9112bce07a2931b75ee9f068627e80b36552bf13fd3c90111f3a7a79
build 1

Setting and rendering the pinned reference:

bash
kustomize edit set image app=registry.example.com/team-a/app:1.4.2@sha256:12be14a1...
cat kustomization.yaml
kustomize build . | grep image:
text
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
images:
- digest: sha256:12be14a1a6cd5e3de4b33768cc0b54d937e1ab073ea15743434454f4ba086c21
  name: app
  newName: registry.example.com/team-a/app
  newTag: 1.4.2
      - image: registry.example.com/team-a/app:1.4.2@sha256:12be14a1a6cd5e3de4b33768cc0b54d937e1ab073ea15743434454f4ba086c21

The gate passes, then a pull request adds a sidecar by tag and the gate fails:

bash
sh check-digests.sh rendered.yaml
# a pull request adds a sidecar by tag
kustomize build . > rendered.yaml && sh check-digests.sh rendered.yaml
text
all images pinned by digest
NOT PINNED:
      - image: docker.io/example/log-shipper:2.1
exit code 1

A first version of this gate matched only lines starting with image:. It printed "all images pinned by digest" for the sidecar too, because kustomize writes - image:. That is why the gate now fails when it finds no images at all.

Mistakes people make

Pinning in the source, not the render

Charts and remote bases add containers you never see in your own files. Check the rendered output of every overlay.

Digest without a tag

app@sha256:... works, but nobody can tell which release it is during an incident. Keep name:tag@sha256:digest; the kubelet uses only the digest.

Copying digests by hand

A pasted digest from the wrong architecture or the wrong build passes review because nobody reads 64 hex characters. Write digests from the build's --digest-file or crane digest, in automation.

Multi-arch confusion

For a multi-arch image, pin the index digest (what crane digest returns for the tag) so each node pulls its own platform. Pinning one platform's manifest digest breaks nodes of the other architecture.

Pinning and then never updating

A pinned digest does not get security fixes. Pair pinning with an automated pull request that proposes new digests after a scan.

Checklist

  • Every image in Git is written as name:tag@sha256:digest.
  • Digests are written by automation from the build or crane digest, never pasted.
  • CI renders every overlay (kustomize or Helm) and fails on any image without @sha256:.
  • The gate fails if it finds no images, so a broken pattern cannot pass.
  • Init containers, sidecars, Jobs and CronJobs are covered by the same check.
  • Multi-arch images are pinned by index digest.
  • New digests arrive by pull request after a scan.

A tag tells you what someone meant to deploy. A digest tells you what actually runs. Git should hold the second one.

H2-CSDE

Learn it on a live range

Policy gates and scanning, in DevSecOps and Supply Chain: a real host in your browser, and every objective checked on the machine.

Start free

H2 Scanner

Want this caught before it merges?

The H2 Scanner runs in your CI and flags the weaknesses pages like this one warn about, on every pull request.

Talk to us