Hand a coding agent a prompt like "containerise this service" and you will reliably get back a Dockerfile that begins FROM node:latest or FROM python:3-slim. Both are floating tags. Both will silently change underneath you the next time the upstream maintainer pushes a new digest. And neither will trip a code review unless the reviewer happens to know that floating tags are the most common entry point for unintentional supply chain drift.
This article shows the pattern that lets autonomous coding agents continue to write Dockerfiles — because they will, whether you sanction it or not — while guaranteeing that every base image is pinned to an immutable digest, kept up to date by a deterministic bot, and verified at build time.
Why Agents Default to Floating Tags
Three reasons, all structural:
- Training data is full of floating tags. Most public Dockerfiles on GitHub use
:latest,:3.12, or:lts. The agent has seen ten thousand examples and one example of a SHA-pinned base. Pattern matching wins. - Agents optimise for "it works." A Dockerfile with a SHA-pinned base might fail to build six months later because the digest no longer exists in the registry. Floating tags hide that failure mode at build time.
- No corrective signal. Unless your CI explicitly rejects floating tags, the agent gets no feedback that pinning is required. It will keep producing the same code.
Agents are not malicious about this. They are doing exactly what their training distribution rewards. The fix is to change the distribution they are reasoning over — by making the rule explicit and enforced.
The Target Pattern: SHA + Tag Comment
The format that survives every supply chain audit and works with every base-image automation tool:
# syntax=docker/dockerfile:1.7
FROM cgr.dev/chainguard/python:latest@sha256:8a4f7e6c9d1b2e3a4f5d6c7b8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f AS runtime
Three properties matter:
- Digest is the source of truth. Docker resolves
image@sha256:...and ignores the tag if both are present. - Tag is preserved as a human-readable hint. Reviewers can see "ah, this is
latest" without doing a registry lookup. - Renovate (and similar tools) can find both. The bot uses the tag to discover updates and rewrites the digest atomically.
Multi-stage builds get the same treatment for every stage:
# syntax=docker/dockerfile:1.7
FROM golang:1.22.3-alpine@sha256:ace6cc3fe58d0c7b12303c57afe6d6724851152df55e08057b43990b927ad5e8 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app
FROM cgr.dev/chainguard/static:latest@sha256:dc3...
COPY --from=builder /out/app /app
USER 65532:65532
ENTRYPOINT ["/app"]
Every FROM is pinned. Every stage is reproducible. The agent can edit the Dockerfile freely; what it cannot do is escape the digest requirement.
Step 1: Make the Rule Machine-Checkable
A pre-commit hook and a CI gate, both running the same script, kill the floating-tag class entirely:
#!/usr/bin/env bash
# scripts/check-dockerfile-pins.sh
set -euo pipefail
EXIT=0
while IFS= read -r -d '' dockerfile; do
# Find every FROM line that is NOT a stage alias (FROM builder AS runtime)
while IFS= read -r line; do
# Skip stage aliases: "FROM something AS name" where "something" has no slash and no colon
if [[ "$line" =~ ^FROM[[:space:]]+([^[:space:]]+) ]]; then
ref="${BASH_REMATCH[1]}"
# Stage references are bare identifiers — skip them
if [[ "$ref" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]]; then
continue
fi
if [[ "$ref" != *"@sha256:"* ]]; then
echo "ERROR: $dockerfile: unpinned base image: $ref"
EXIT=1
fi
fi
done < <(grep -iE '^FROM[[:space:]]' "$dockerfile" || true)
done < <(find . -name 'Dockerfile*' -not -path './node_modules/*' -print0)
exit $EXIT
Wire it into .pre-commit-config.yaml:
repos:
- repo: local
hooks:
- id: dockerfile-pin-check
name: Dockerfile must use SHA-pinned base images
entry: scripts/check-dockerfile-pins.sh
language: script
files: ^.*Dockerfile.*$
And into CI:
# .github/workflows/dockerfile-pins.yml
name: Dockerfile pin check
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: scripts/check-dockerfile-pins.sh
When an agent opens a PR with FROM node:20-alpine, both the pre-commit hook and the CI gate fail. The agent sees the error, the developer reviewing the PR sees the error, and the PR cannot merge. The corrective signal is now in the loop.
Step 2: Give Agents the Tooling to Pin
A failing CI is corrective but slow. Faster: give agents (and humans) a one-shot script that resolves a tag to a digest and rewrites the Dockerfile in place:
#!/usr/bin/env bash
# scripts/pin-dockerfile.sh — usage: scripts/pin-dockerfile.sh path/to/Dockerfile
set -euo pipefail
DOCKERFILE="$1"
TMP=$(mktemp)
while IFS= read -r line; do
if [[ "$line" =~ ^FROM[[:space:]]+([^[:space:]@]+)(:([^[:space:]@]+))?([[:space:]]+AS[[:space:]]+.+)?$ ]]; then
image="${BASH_REMATCH[1]}"
tag="${BASH_REMATCH[3]:-latest}"
suffix="${BASH_REMATCH[4]:-}"
# Skip stage aliases
if [[ "$image" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]] && [[ "$image" != *"/"* ]] && [[ -z "${BASH_REMATCH[3]:-}" ]]; then
echo "$line" >> "$TMP"
continue
fi
digest=$(crane digest "${image}:${tag}")
echo "FROM ${image}:${tag}@${digest}${suffix}" >> "$TMP"
else
echo "$line" >> "$TMP"
fi
done < "$DOCKERFILE"
mv "$TMP" "$DOCKERFILE"
echo "Pinned: $DOCKERFILE"
Document it in CLAUDE.md (or your equivalent agent instructions file):
## Dockerfile rules
Every `FROM` directive in a Dockerfile MUST be SHA-pinned in the form:
FROM <image>:<tag>@sha256:<digest>
If you produce a Dockerfile with an unpinned base, run:
scripts/pin-dockerfile.sh <path-to-Dockerfile>
before opening the PR. CI will reject the PR otherwise.
Agents that read project conventions will follow them. Agents that do not will fail CI and self-correct on the next iteration.
Step 3: Automate Updates with Renovate
Pinning is not the same as freezing. A digest from six months ago is reproducible but probably has critical CVEs. Renovate handles digest updates with surgical precision:
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"dockerfile": {
"pinDigests": true
},
"packageRules": [
{
"matchManagers": ["dockerfile"],
"matchUpdateTypes": ["digest"],
"automerge": false,
"schedule": ["before 6am on monday"],
"groupName": "base image digests"
},
{
"matchManagers": ["dockerfile"],
"matchPackageNames": ["/^cgr\\.dev\\/chainguard\\//"],
"matchUpdateTypes": ["digest"],
"automerge": true,
"automergeType": "branch"
}
]
}
This configuration:
- Forces every Dockerfile reference to be digest-pinned (
pinDigests: true). - Groups all base image digest updates into a single weekly PR for human review.
- Auto-merges digest updates from your trusted hardened-image provider (Chainguard, in this example), because those images carry their own provenance and CVE-scanning guarantees. The slash-delimited string in
matchPackageNames(/^cgr\.dev\/chainguard\//) is Renovate's regex form (the legacymatchPackagePatternsfield was removed in Renovate v37+ — see Renovate config options).
The agent now sees a Dockerfile that is always pinned, and never has to invent the pinning syntax. It just edits the rest of the file.
Step 4: Verify at Build and Admission Time
Pinning gives you reproducibility. Verification gives you trust. After a Renovate PR merges, the next CI build should:
- Pull the digest the Dockerfile names.
- Verify the digest's signature with
cosign verify(Chainguard images are Sigstore-signed by default). - Reject the build if verification fails.
Chainguard images and Google Distroless images sign under different OIDC issuers and identities, so verification needs a separate regex pair per provider:
- name: Verify base image signatures
run: |
for image in $(grep -oE '[a-z0-9./-]+@sha256:[a-f0-9]{64}' Dockerfile); do
echo "Verifying $image"
case "$image" in
cgr.dev/chainguard/*)
# Chainguard images are Sigstore-signed with a Chainguard
# OIDC identity. The exact subject regex depends on the image
# family — check `cosign tree <image>` or the Chainguard
# transparency log entry for the issuer/identity to pin to.
cosign verify "$image" \
--certificate-identity-regexp='^https://(token\.actions\.githubusercontent\.com|issuer\.enforce\.dev)' \
--certificate-oidc-issuer-regexp='^https://(token\.actions\.githubusercontent\.com|accounts\.google\.com|issuer\.enforce\.dev)$' \
|| { echo "FAIL: $image"; exit 1; }
;;
gcr.io/distroless/*)
# Google Distroless images sign with a Google identity, not
# GitHub Actions. Verify against accounts.google.com as the
# OIDC issuer and the documented distroless signing identity.
cosign verify "$image" \
--certificate-identity-regexp='^keyless@distroless\.iam\.gserviceaccount\.com$' \
--certificate-oidc-issuer='https://accounts.google.com' \
|| { echo "FAIL: $image"; exit 1; }
;;
*)
echo "No signing-identity policy for $image — verify or allowlist explicitly"
exit 1
;;
esac
done
At admission time, pair this with the Kyverno policy described in the container provenance for AI-generated builds article — only signed, pinned base images should ever reach the cluster.
What Goes Wrong Without This
Three real failure modes that this workflow prevents:
- Silent CVE introduction. Agent adds
FROM ubuntu:22.04. Six weeks later, ubuntu:22.04 ships with a new openssl CVE. Your image inherits it on the next rebuild without a single source change. - Reproducibility loss during incident. During a Sev1, you try to rebuild a six-month-old image to compare it against runtime. The base image tag has moved. The new build is not bit-identical to the original. You can no longer prove what shipped.
- Provenance gap in audit. The EU CRA auditor asks for the SBOM of the base image you used in production six months ago. Without a pinned digest in git history, you cannot reconstruct it.
All three vanish when every FROM is digest-pinned and every digest is signed.
Rolling This Out
- Land the pre-commit hook and CI gate first, in
warnmode. Audit which Dockerfiles fail; fix them. - Land the
pin-dockerfile.shscript and document it in agent instructions. - Switch the gate to
errormode. New PRs cannot introduce floating tags. - Enable Renovate with
pinDigests: true. Process the resulting flood of pinning PRs in batches. - Add
cosign verifyto the build pipeline. Begin requiring signed bases in production namespaces.
Six weeks of disciplined rollout produces a build pipeline where coding agents can author Dockerfiles freely — because the surface area for them to make a supply chain mistake has been narrowed to zero.