Skip to content
Container Management

Pinning Base Images When AI Agents Author Dockerfiles

Coding agents reach for `:latest` by reflex. Here is the SHA-pinning, Renovate-driven workflow that lets agents touch Dockerfiles without breaking your supply chain.

Intermediate 8 min read Updated May 2026

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:

  1. 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.
  2. 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.
  3. 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 legacy matchPackagePatterns field 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:

  1. Pull the digest the Dockerfile names.
  2. Verify the digest's signature with cosign verify (Chainguard images are Sigstore-signed by default).
  3. 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 warn mode. Audit which Dockerfiles fail; fix them.
  • Land the pin-dockerfile.sh script and document it in agent instructions.
  • Switch the gate to error mode. New PRs cannot introduce floating tags.
  • Enable Renovate with pinDigests: true. Process the resulting flood of pinning PRs in batches.
  • Add cosign verify to 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.


References

This article is part of the Container Management knowledge series (6 articles) Browse all Container Management articles →
Related Use Case

Software Compliance — Your last compliance vendor

Don't fake the evidence. Trust it.

Explore Use Case →