Homelab
Image Digest Pinning
Pinning Container Images by Digest: Preventing Upstream Tampering
Overview
This article replaces mutable image tags with immutable SHA256 digests across all HelmRelease values and sidecar containers. A tag can be re-pushed upstream with different content; a digest can't.
Before You Begin
Prerequisites
- Secure Node Re-image completed (apps deployed with pinned image tags)
What We're Pinning
| App | Current Image | Type |
|---|---|---|
| Plex | 1.42.2.10156-f737b826c | Helm values tag |
| FileBrowser | filebrowser/filebrowser:v2.31.2 | Sidecar (postRenderers) |
| playit-agent | ghcr.io/playit-cloud/playit-agent:latest | Sidecar (postRenderers) |
Why Digests Over Tags
Tags are mutable pointers1 — the same tag can point to different content over time:
Day 1: plex:1.42.2 → sha256:abc123 (legitimate)
Day 2: plex:1.42.2 → sha256:def456 (compromised) Your pod restarts and pulls def456 without warning. Digests are immutable:
plex@sha256:abc123 → always the same content The playit-agent:latest tag is the most urgent — :latest changes with every release.
How to Update Digests
When upgrading an image version, look up the new digest and update both the tag comment and digest. This is a manual process that replaces checking for tag updates.
Look Up Digests
Install: crane
crane2 is a tool for interacting with container registries without pulling full images.
brew install crane Lookup: All Current Digests
crane digest docker.io/plexinc/pms-docker:1.42.2.10156-f737b826c
crane digest docker.io/filebrowser/filebrowser:v2.31.2
crane digest ghcr.io/playit-cloud/playit-agent:latest Note each digest (format: sha256:...).
Pin Plex Image
HelmRelease: Pin Plex Digest
k8s/apps/plex/helmrelease.yaml:
# ... existing HelmRelease header ...
values:
image:
tag: "1.42.2.10156-f737b826c@sha256:<PLEX-DIGEST>"
# ... existing extraEnv, service, pms ... The tag@sha256: format preserves readability while enforcing the digest. Kubernetes pulls by digest when both are present3.
Pin Sidecar Images
HelmRelease: Pin FileBrowser Digest
In the postRenderers section of k8s/apps/plex/helmrelease.yaml:
- op: add
path: /spec/template/spec/containers/-
value:
name: filebrowser
image: filebrowser/filebrowser:v2.31.2@sha256:<FILEBROWSER-DIGEST>
# ... existing args, ports, volumeMounts, resources ... HelmRelease: Pin playit-agent Digest (Minecraft)
k8s/apps/minecraft/helmrelease.yaml:
- op: add
path: /spec/template/spec/containers/-
value:
name: playit-agent
image: ghcr.io/playit-cloud/playit-agent:latest@sha256:<PLAYIT-DIGEST>
# ... existing env ... HelmRelease: Pin playit-agent Digest (Factorio)
k8s/apps/factorio/helmrelease.yaml:
- op: add
path: /spec/template/spec/containers/-
value:
name: playit-agent
image: ghcr.io/playit-cloud/playit-agent:latest@sha256:<PLAYIT-DIGEST>
# ... existing env ... Deploy Digest Pinning
Git: Commit Changes
cd ~/homelab
git add k8s/apps/plex/helmrelease.yaml k8s/apps/minecraft/helmrelease.yaml k8s/apps/factorio/helmrelease.yaml
git commit -m "security(apps): pin all container images by digest"
git push Flux: Reconcile
flux reconcile source git flux-system && flux reconcile kustomization sync Verify Digest Pinning
Verify: Pods Running
kubectl get pods -n plex
kubectl get pods -n minecraft
kubectl get pods -n factorio Expected: All pods running with same container count as before.
Verify: Images Use Digests
kubectl get pod -n plex plex-plex-media-server-0 -o jsonpath='{.status.containerStatuses[*].imageID}' Expected: Output contains sha256: digests matching what you pinned.
Addendum: Completing the Sweep
A July 2026 audit found the app HelmReleases carrying bare tags — the digests this article specifies hadn't survived into the running manifests, and the Factorio playit sidecar had regressed to :latest, the exact case flagged above as most urgent. Version bumps are how it happens: a tag gets updated, the digest doesn't come along, and nothing fails — the manifests keep working, just unpinned.
Two states complete the sweep:
- v1.13.3 restores digest pins across every app image: Plex, FileBrowser, both playit sidecars, and the Factorio server image.
- v1.13.4 catches the invisible one: the Minecraft server image never appeared in the HelmRelease at all — it came from the chart's default, which was
:latest. Charts can pull images your values never mention.
Audit what's actually running, not what your values say:
kubectl get pods -A -o jsonpath='{range .items[*].status.containerStatuses[*]}{.imageID}{"
"}{end}' | grep -cv sha256 Expected: 0 - every running container resolves to a digest.
When bumping an image version, look up and update the digest in the same edit. A tag change without a digest change is the drift starting over.
Next Steps
With every image pinned by digest, the remaining supply-chain gap is who can change what deploys - close it with a durable identity and a deploy gate.
See: GPG Identity
Resources
Footnotes
Docker, "Content trust in Docker," docs.docker.com. Accessed: Apr. 13, 2026. [Online]. Available: https://docs.docker.com/engine/security/trust/ ↩
Google, "crane - a tool for interacting with remote images and registries," github.com. Accessed: Apr. 13, 2026. [Online]. Available: https://github.com/google/go-containerregistry/tree/main/cmd/crane ↩
Kubernetes, "Images - Using image digests," kubernetes.io. Accessed: Apr. 13, 2026. [Online]. Available: https://kubernetes.io/docs/concepts/containers/images/#image-pull-policy ↩