Container Image Digest Pin Policy
Purpose
Section titled “Purpose”ADR-0009 mandates that every container image reference across the homelab
portfolio uses the immutable form
<registry>/<namespace>/<image>:<version>@sha256:<digest>. This
procedure documents how the policy is operated day to day: who pins
digests, who bumps versions, how unexpected digest changes are handled,
and how Plumber enforces compliance.
The policy applies to:
FROMlines in every Dockerfile in the workspace.image:andservices.image:fields in every GitLab CI job, in every repository.image:fields in every Kubernetes manifest, Helm values file and Helm chart template.imagefields in every docker compose file.- Image references quoted in documentation when they describe what the runtime pulls.
It does NOT apply to:
- Workstation
docker runcommands written inline in a runbook for a one off debug session. Document the command with a comment that calls out the volatility. - Third party screenshots or vendor documentation excerpts. Quote them verbatim.
Operating Principles
Section titled “Operating Principles”The digest is bound to a specific image manifest. For the same tag, the digest must not change. Three consequences flow from this:
- Version upgrades: a new release means a new tag and a new digest. The upgrade replaces both atomically in one commit.
- No digest refresh on unchanged tag: there is no maintenance task that “refreshes” a digest while the version stays the same. A digest that has changed under an unchanged tag is a supply chain signal, not a routine event.
- Trivy scans bind to a digest: a clean scan today on a given digest stays valid until the digest changes through a planned upgrade.
Renovate Configuration
Section titled “Renovate Configuration”Renovate is configured at the workspace level. The configuration lives in
renovate.json5 at each repository’s root and shares a preset committed
to gitlab-ci-templates.
{ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["local>gitlab-ci-templates:renovate"], "schedule": ["before 6am on monday"], "packageRules": [ { "matchUpdateTypes": ["digest"], "matchDatasources": ["docker"], "enabled": false, "description": "Reject digest mutation on unchanged tag. Bumps must be tag plus digest atomic." }, { "matchUpdateTypes": ["major"], "matchDatasources": ["docker"], "automerge": false, "labels": ["supply-chain", "major"] }, { "matchUpdateTypes": ["minor", "patch"], "matchDatasources": ["docker"], "automerge": false, "labels": ["supply-chain"] }, { "matchPackagePatterns": ["^docker.io/library/.*"], "extends": ["docker:pinDigests"] } ], "dockerfile": { "fileMatch": ["Dockerfile", "^infra/docker/.*\\.dockerfile$"] }, "kubernetes": { "fileMatch": ["^k8s/.*\\.ya?ml$"] }, "helm-values": { "fileMatch": ["^k8s/.*/values\\.yaml$"] }, "docker-compose": { "fileMatch": ["^infra/compose/.*\\.ya?ml$"] }}The "digest" update type is explicitly disabled. Renovate never opens
an MR that changes only a digest for an unchanged tag. Every MR raised
by the bot bumps the version tag and pins the matching new digest in the
same diff.
Dependabot serves as a fallback on GitHub mirrors with an equivalent
configuration committed under .github/dependabot.yml.
Workflow
Section titled “Workflow”sequenceDiagram participant Renovate participant Registry as Image registry participant Repo as App repository participant CI as GitLab CI participant Operator
Renovate->>Registry: list versions of <image> Registry-->>Renovate: latest = vX.Y.Z Renovate->>Registry: resolve digest for vX.Y.Z Registry-->>Renovate: sha256:<digest> Renovate->>Repo: open MR (bump tag + digest atomically) Repo->>CI: trigger pipeline CI->>CI: build app image on new base CI->>CI: Trivy scan HIGH+CRITICAL gate CI->>CI: Plumber controls CI->>CI: hadolint, smoke test CI-->>Operator: green pipeline Operator->>Repo: review release notes + diff Operator->>Repo: merge MR Repo->>CI: deploy stages (compose, VPS, Azure, k8s)The operator review focuses on the upstream release notes (linked by Renovate in the MR body), the Trivy report delta, and any architectural concern the new version introduces. CI handles the mechanical gates.
Plumber Enforcement
Section titled “Plumber Enforcement”Each repository ships a .plumber.yaml aligned with
weather-app/.plumber.yaml. Three controls handle the digest pin
policy directly:
containerImagesMustBePinnedByDigest: truerejects every image reference without an@sha256:suffix.containerImageMustNotUseForbiddenTagsrejectslatest,dev,staging,main,master,HEAD,production(any context-mobile tag).containerImageMustComeFromAuthorizedSourcesenforces the registry allowlist, which is shared across the workspace and updated through a deliberate MR with rationale.
CI fails when any control regresses. Operators do not adjust the controls per repository to silence a finding; the workspace policy review handles allowlist changes.
Digest Mismatch Incident Path
Section titled “Digest Mismatch Incident Path”A digest that changes under an unchanged tag is not a maintenance event. The detection path:
- Manual edits that try to pin a new digest without changing the
version: pre commit hook (
hooks/digest-pin-check) rejects the diff. - Renovate would never open such an MR because the digest update type is disabled in the configuration.
- A pipeline run that compares the in repository digest to the registry’s current digest for the same tag posts a warning on the MR (used as a tripwire).
The response runbook is services/yourbox/runbooks/bump-image-version.md
under the “Digest Mismatch Incident Path” section. The pattern is
identical for weather-app and any other application. The runbook lives
once per service section so the team finds it where they look.
Onboarding A New Application
Section titled “Onboarding A New Application”A new application that joins the workspace adopts the policy in three steps:
- Copy
.plumber.yamlfrom weather-app or YourBOX into the new repository. - Add
renovate.json5extending the workspace preset fromgitlab-ci-templates. - Convert every image reference to the immutable form, resolving the
digest with
docker buildx imagetools inspect <image:tag> --format '{{.Manifest.Digest}}'for the first commit.
The verification is plumber lint returning a score above the
threshold.