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.
On this page
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
latestandlatest-devtags. Teams use:latestin production, and the image changes under them every night. - The runtime images have no shell. Teams switch to
-devin production so thatkubectl execworks, 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
latestandlatest-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.
# 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:
# 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:
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:f23c2b7cd3d6b18aed6ad6e1099d79668ff62ba49078e81bb558e5a1c7581fd8sha256: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:
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())"exec: "/bin/sh": stat /bin/sh: no such file or directory
65532 65532Size (compressed, linux/amd64) and a grype scan of both images, same day, same database:
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
FROMuses a Chainguard image pinned by@sha256:digest. - The runtime stage uses the shell-less variant;
-devappears 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 freeH2 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