Build and supply chain

Chainguard and Wolfi base images, the secure way

Your scanner printed 156 findings for a Python base image, and your application has not even been copied in yet. Chainguard images make that number small. Using them well is mostly about the tag you cannot pin and the shell you no longer have.

The short answer

Build on Chainguard images: a -dev variant in the build stage, the shell-less variant at runtime, running as uid 65532. The free tier only offers latest, so resolve it to a digest, verify the cosign signature against Chainguard's release workflow identity, mirror it, and move the digest forward on a schedule after a scan.

Updated Houssam Hammoudi, CTOTested with cgr.dev/chainguard/python:latest (Python 3.14), python:3.14-slim, cosign v3.0.6, grype 0.119.0 (DB built 2026-09-24), crane

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

What goes wrong

A general-purpose base image carries a whole distribution: a shell, a package manager, and dozens of libraries your application never loads. Each of them shows up in the scanner, and many of those findings will never be fixed by the distribution. The team learns to ignore the report, and then misses the one finding that matters.

Chainguard images, built on the Wolfi distribution, cut that down. They bring their own traps:

  • The free images offer only the latest and latest-dev tags. Teams use :latest in production, and the image changes under them every night.
  • The runtime images have no shell. Teams switch to -dev in production so that kubectl exec works, and bring back everything they removed.
  • Teams assume "signed by Chainguard" without checking it, so a typo in the registry name or a mirror that serves something else goes unnoticed.

What the docs say

Free images are limited to the latest build of a given image, tagged as latest and latest-dev.

Source: Chainguard Academy, Chainguard Containers overview

Chainguard Containers are rebuilt nightly to ensure they are completely up-to-date and contain all available security patches.

Source: Chainguard Academy, Chainguard Containers overview

Designed to be as minimal as possible, Chainguard's standard container images do not contain package managers such as apk, shells such as b/a/sh, or development utilities such as Git or text editors.

Source: Chainguard Academy, Container variants

Wolfi is a community Linux undistro designed for the container and cloud-native era.

Source: Chainguard Academy, Wolfi overview

Nightly rebuilds and a single free tag mean :latest is a different image every day. The docs present that as freshness. For a production deploy it is also a tag that moves, so you need your own pinning and promotion.

The secure configuration

A multi-stage Dockerfile: build with the -dev variant, run on the minimal one, both pinned by digest.

dockerfile
# Build stage: -dev has a shell, apk and pip.
FROM cgr.dev/chainguard/python:latest-dev@sha256:<dev-digest> AS build
WORKDIR /app
COPY requirements.txt .
# Hash-checked install into a virtual environment we can copy.
RUN python -m venv /app/venv \
 && /app/venv/bin/pip install --no-cache-dir --require-hashes -r requirements.txt
COPY src/ ./src/

# Runtime stage: no shell, no apk, uid 65532.
FROM cgr.dev/chainguard/python:latest@sha256:<runtime-digest>
WORKDIR /app
COPY --from=build /app/venv /app/venv
COPY --from=build /app/src /app/src
ENV PATH=/app/venv/bin:$PATH
USER 65532:65532
ENTRYPOINT ["python", "-m", "src.main"]

Keep the Python minor version the same in both stages: resolve both digests in the same job, and check that python --version matches in both.

Resolve and verify the digests in the job that updates them, not by hand:

bash
# 1. Resolve the moving tag to a digest, once.
DIGEST=$(crane digest cgr.dev/chainguard/python:latest)

# 2. Verify Chainguard signed that exact digest from its release workflow.
cosign verify \
  --certificate-oidc-issuer=https://token.actions.githubusercontent.com \
  --certificate-identity=https://github.com/chainguard-images/images/.github/workflows/release.yaml@refs/heads/main \
  "cgr.dev/chainguard/python@${DIGEST}"

# 3. Copy it into your own registry by digest, scan it, sign it with your key.
crane copy "cgr.dev/chainguard/python@${DIGEST}" "registry.example.com/mirror/python@${DIGEST}"

Step 3 is covered in full on the mirroring page. Then open a pull request that changes the digest in the Dockerfile, so the new base goes through the same tests and review as any other change. Run that job weekly, or daily for internet-facing services.

For debugging a shell-less pod, use an ephemeral debug container (kubectl debug -it <pod> --image=cgr.dev/chainguard/wolfi-base --target=<container>) in the environments where you allow it, instead of shipping a shell in every image.

Prove it

Resolve the tag and verify the signature on that digest:

bash
crane digest cgr.dev/chainguard/python:latest
cosign verify --certificate-oidc-issuer=https://token.actions.githubusercontent.com \
  --certificate-identity=https://github.com/chainguard-images/images/.github/workflows/release.yaml@refs/heads/main \
  cgr.dev/chainguard/python@sha256:f23c2b7cd3d6b18aed6ad6e1099d79668ff62ba49078e81bb558e5a1c7581fd8
text
sha256:f23c2b7cd3d6b18aed6ad6e1099d79668ff62ba49078e81bb558e5a1c7581fd8
1 signature(s) for sha256:f23c2b7cd3d6b18aed6ad6e1099d79668ff62ba49078e81bb558e5a1c7581fd8
  - The cosign claims were validated
  - Existence of the claims in the transparency log was verified offline
  - The code-signing certificate was verified using trusted certificate authority certificates

(The JSON that cosign verify prints is summarized to one line here.) The runtime image has no shell and does not run as root:

bash
docker run --rm --entrypoint /bin/sh cgr.dev/chainguard/python@sha256:... -c id
docker run --rm cgr.dev/chainguard/python@sha256:... -c "import os; print(os.getuid(), os.getgid())"
text
exec: "/bin/sh": stat /bin/sh: no such file or directory
65532 65532

Size (compressed, linux/amd64) and a grype scan of both images, same day, same database:

text
cgr.dev/chainguard/python:latest     29.8 MB, 11 layers
python:3.14-slim                     43.5 MB, 4 layers

$ grype registry:cgr.dev/chainguard/python@sha256:f23c2b7c... -o json   (summarized)
  matches: 5  unique CVEs: 5
  by severity: {'Low': 1, 'Medium': 4}
  by fix state: {'': 2, 'unknown': 3}
$ grype registry:python:3.14-slim -o json   (summarized)
  matches: 156  unique CVEs: 69
  by severity: {'High': 49, 'Low': 9, 'Medium': 54, 'Negligible': 44}
  by fix state: {'': 2, 'fixed': 4, 'not-fixed': 50, 'wont-fix': 100}

Read the second result carefully. Most of the Debian findings are marked wont-fix by the distribution, and many sit in packages such as util-linux and login that a Python service never calls. They are still files an attacker could use, and still lines every audit asks you to explain. The Chainguard image simply does not ship them.

Mistakes people make

Deploying :latest

The free tier gives you one tag and rebuilds it nightly. Deploying the tag means production changes without a commit. Pin the digest; move it with a reviewed pull request.

Using -dev at runtime "for debugging"

The -dev variant adds a shell, apk and pip. That is what an attacker wants after a remote code execution bug. Use it in the build stage; debug with an ephemeral container when you must.

Pulling straight from cgr.dev in every build

Every build then depends on an outside registry being up and serving what you expect. Mirror the verified digest into your own registry and build from the mirror.

Verifying the signature without the identity

cosign verify with a loose identity (a regular expression that matches any workflow, or any repository) accepts signatures from any GitHub Actions run. Pin the exact workflow path and branch.

Expecting zero findings forever

Five findings is not zero. New CVEs appear daily. Scan the pinned digest on a schedule, not only when you change it.

Checklist

  • Every FROM uses a Chainguard image pinned by @sha256: digest.
  • The runtime stage uses the shell-less variant; -dev appears only in build stages.
  • The image runs as uid 65532 (or another non-root uid).
  • The update job verifies the signature with the exact Chainguard release workflow identity.
  • Verified digests are mirrored into your own registry before use.
  • Digest updates arrive as pull requests with tests.
  • Pinned digests are rescanned on a schedule.
  • Debug access uses ephemeral containers, not shells in images.

A smaller image is fewer findings to explain. A pinned, verified digest is knowing which small image you actually shipped.

H2-CSDE

Learn it on a live range

Building without Docker, in DevSecOps and Supply Chain: a real host in your browser, and every objective checked on the machine.

Start free

H2 Scanner

Want this caught before it merges?

The H2 Scanner runs in your CI and flags the weaknesses pages like this one warn about, on every pull request.

Talk to us