Zero-CVE Go Containers with Chainguard Images: A Migration Guide
New! Listen to Concept to Cloud - Real stories from the trenches of software engineering
Zero-CVE in Ten Minutes: Migrating a Go Service to Chainguard Images
Supply Chain Security

Zero-CVE in Ten Minutes: Migrating a Go Service to Chainguard Images

TB
Tom Barber
July 14, 2026
0 min read

Most container vulnerabilities have nothing to do with your code, they come from the base image. Here's the migration I reach for: rebuild a normal Go service on Chainguard Images and watch the CVE count fall to near zero, plus the friction most tutorials skip.

Most container vulnerabilities have nothing to do with your code. They come from the base image, the operating system and packages you inherited the moment you wrote FROM golang:1.22. You can ship a flawless Go binary and still hand your security team a report with a hundred CVEs in it, none of which you introduced.

I have built platforms at NASA JPL and in financial services, where “a wall of CVEs” is not something a security review waves through. So here is the migration I reach for: rebuild a normal Go service on Chainguard Images, and watch the vulnerability count fall to near zero, without changing a line of application code.

I will also show you where it bites, because the friction is the part most tutorials skip and the part that actually stalls teams.

Where we start

A typical, reasonable Dockerfile:

FROM golang:1.22 AS build
WORKDIR /app
COPY . .
RUN go build -o /app/server ./cmd/server

FROM debian:bookworm-slim
COPY --from=build /app/server /server
ENTRYPOINT ["/server"]

Scan the result:

grype my-service:before

On a fresh build of this, expect somewhere north of 100 known vulnerabilities, most of them in the OS layer: glibc, coreutils, the shell, the package manager, the dozens of libraries a general-purpose distro ships whether you use them or not. None of them come from the code you wrote, they are all inherited, from the OS packages to the Go toolchain baked into the base image.

The change

We do two things: build on Chainguard’s Go image, and run on a minimal, distroless Chainguard base that contains almost nothing but your binary.

# Build stage: Chainguard Go image. Has the toolchain and a shell,
# so this is where all the "normal" build work happens.
FROM cgr.dev/chainguard/go:latest AS build
WORKDIR /app

# Cache-friendly: pull deps before copying the whole tree.
COPY go.mod go.sum ./
RUN go mod download

COPY . .

# CGO disabled so we get a static binary that runs on a distroless base.
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server

# Runtime stage: minimal, distroless, non-root by default.
# No shell, no package manager, no OS cruft. Just enough to run a static binary.
FROM cgr.dev/chainguard/static:latest
COPY --from=build /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

Rebuild and scan the same way:

grype my-service:after

Put the two reports side by side. The typical result is a drop from three figures to zero or low single digits. Same application, same binary, a fraction of the attack surface.

Why it works

  • Minimal by construction. The runtime image is distroless. There is no shell, no apt, no package manager, none of the general-purpose OS that carries most of the CVEs. If it is not there, it cannot be vulnerable.
  • Non-root by default. The image runs as an unprivileged user out of the box, so you are not one forgotten USER directive away from a container running as root.
  • Built on Wolfi. Chainguard Images are built on Wolfi, a glibc-based distro designed for containers. Because it is glibc rather than musl, you avoid the subtle runtime surprises people hit moving to Alpine.
  • Signed, with an SBOM. Each image is signed and ships a software bill of materials, which matters enormously the moment an enterprise security team is the one deciding whether you ship.

Where it bites (the part worth reading)

This is the friction that actually stalls migrations. None of it is a dealbreaker, but you want to meet it on your terms, not in production.

  1. No shell in the runtime image. kubectl exec -it ... -- sh will not work, because there is no sh. That feels alarming the first time. The fix: attach an ephemeral debug container (kubectl debug), so your production image stays minimal and your debugging does not depend on it. (The -dev variant trick, a shell plus apk in the same image, applies to language images like go, not to the static base, which has no -dev variant.)
  2. Non-root file permissions. Because the container is not root, anything your app writes at runtime (a cache dir, a temp file) needs to live somewhere the unprivileged user can write. Set that up explicitly rather than assuming /.
  3. You must actually go static. CGO_ENABLED=0 matters. If you link against C, the static base will not have what you need, and you will want a different runtime image. Know which one your binary is.
  4. Multi-stage is not optional. The builder image and the runtime image are different on purpose: full toolchain to compile, near-empty base to run. Copy the binary across, do not try to build in the minimal image.
  5. Pin for production. latest is great for a demo and a bad idea for a pipeline. Pin to a specific digest so a rebuild is reproducible, digest pinning works on any tier, whereas pinning to a specific version tag generally needs a paid Chainguard subscription (the free tier ships :latest and :latest-dev).

If I were running developer relations for this, points 1 and 2 are the top of my “why teams stall” list, and each one is a short, high-traffic guide of its own.

Proving it, for the security review

The minimal image is half the story. The other half is provenance:

# Confirm the image is really from Chainguard and untampered (Sigstore keyless).
cosign verify cgr.dev/chainguard/static 
  --certificate-oidc-issuer=https://token.actions.githubusercontent.com 
  --certificate-identity=https://github.com/chainguard-images/images/.github/workflows/release.yaml@refs/heads/main 
  | jq

# Pull the signed SBOM (SPDX) attestation so security can see exactly what is inside.
cosign download attestation cgr.dev/chainguard/static 
  --platform=linux/amd64 
  --predicate-type=https://spdx.dev/Document 
  | jq -r .payload | base64 -d | jq .predicate

“Signed, with a real SBOM, provably minimal” is, in my experience with regulated buyers, the difference between a yes and a no from security. That is the enterprise story, and it is worth telling in that language.

Bringing it into CI

The last mile is making this the default, not a one-off. In your pipeline: build on the Chainguard builder, scan with grype or trivy and fail the build over a threshold, verify signatures on any base images you pull, and publish the SBOM as a build artifact. Now every image your team ships starts from near zero instead of climbing back down from a hundred.

The takeaway

You went from a hundred-plus inherited vulnerabilities to near zero with a Dockerfile change and no application changes, you understand exactly where the migration friction is, and you can hand security a signed image with an SBOM. That is a strong default for any team, and a genuinely easy one for a regulated one, the kind of enterprise security story that turns a maybe into a yes. Wiring hardening like this into a team’s build defaults is the sort of hands-on work our engineering services are built around.

TB
Written by Tom Barber

Ex-NASA engineer and cloud architect with over a decade of experience building scalable systems for startups and enterprises.

Work with Tom →

Related Articles

Strategy

AWS Summit DC: The AI Tag Isn't a Strategy

Two days at AWS Summit in Washington, DC, big, well attended, informative, and underwhelming. In the rush to bolt an LLM onto everything, a lot of vendors forgot to do anything novel. Notes on what impressed, what didn't, and why it's still the data that decides the outcome.

Read More →
Strategy

Cloud Stack Decisions for Early-Stage Startups: What Actually Matters

Most early-stage cloud stack debates are arguments about the wrong question. The tool doesn't matter nearly as much as the cost of switching, who fixes it at 3am, and whether your second engineer can read it.

Read More →
Information

Quest for New Banking Security

Concept to Cloud partnered with Consilient to engineer a secure cross-bank fraud detection system using federated learning, overcoming strict banking security requirements and data inconsistencies.

Read More →

Ready to Build Your Product?

Let's discuss how we can help you bring your vision to life with expert cloud solutions

Get Started