Skip to content

Container Image Digest Pin Policy

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:

  • FROM lines in every Dockerfile in the workspace.
  • image: and services.image: fields in every GitLab CI job, in every repository.
  • image: fields in every Kubernetes manifest, Helm values file and Helm chart template.
  • image fields in every docker compose file.
  • Image references quoted in documentation when they describe what the runtime pulls.

It does NOT apply to:

  • Workstation docker run commands 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.

The digest is bound to a specific image manifest. For the same tag, the digest must not change. Three consequences flow from this:

  1. Version upgrades: a new release means a new tag and a new digest. The upgrade replaces both atomically in one commit.
  2. 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.
  3. 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 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.

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.

Each repository ships a .plumber.yaml aligned with weather-app/.plumber.yaml. Three controls handle the digest pin policy directly:

  • containerImagesMustBePinnedByDigest: true rejects every image reference without an @sha256: suffix.
  • containerImageMustNotUseForbiddenTags rejects latest, dev, staging, main, master, HEAD, production (any context-mobile tag).
  • containerImageMustComeFromAuthorizedSources enforces 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.

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.

A new application that joins the workspace adopts the policy in three steps:

  1. Copy .plumber.yaml from weather-app or YourBOX into the new repository.
  2. Add renovate.json5 extending the workspace preset from gitlab-ci-templates.
  3. 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.