Safeguard
Containers

Golang Docker Images: Building Them Right

How to build Golang Docker images that stay small, patch cleanly, and don't ship a compiler toolchain into production, using multi-stage builds done properly.

Safeguard Team
Product
6 min read

Go compiles to a single static binary, which makes it one of the easiest languages to get container images badly wrong in a very specific way: shipping the entire Go toolchain, source tree, and build cache into a production image that only ever needed to run one file. Golang Docker images done right use a multi-stage build — compiling in a full golang build image, then copying only the compiled static binary into a minimal or distroless runtime image — which routinely takes a multi-hundred-megabyte image down to under 20MB and removes most of the attack surface along with it.

Why does a Go binary not need a runtime base image at all?

Go compiles to a statically linked binary by default (when CGO_ENABLED=0), meaning it doesn't dynamically link against the host's C standard library or other system libraries the way a Python or Node.js application does against its interpreter. That means the final runtime image doesn't need a package manager, a shell, or even libc — it needs a filesystem to hold one file and, if the application makes outbound HTTPS calls, CA certificates to validate TLS connections. This is why Go is one of the few languages where FROM scratch (an entirely empty base image) is a genuinely practical option, not just a novelty.

What does the multi-stage Dockerfile actually look like?

The build stage uses a full golang image with the compiler toolchain, builds the binary, and the final stage copies only that binary out:

FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=builder /app /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/app"]

The build stage — with its several-hundred-megabyte Go toolchain, module cache, and source tree — never ships. Only the compiled binary and, if needed, CA certificates cross into the final image. This is the same pattern that matters for any compiled language, but Go's static-linking default makes it especially clean since there's no runtime dependency resolution to worry about in the final stage.

Should you use scratch, distroless, or Alpine as the final base?

scratch gives you the absolute smallest possible image — nothing but your binary — but it has no shell, no CA certificates, and no way to debug inside the running container if something goes wrong, which makes troubleshooting production issues harder. Distroless static images (gcr.io/distroless/static-debian12) add CA certificates and a minimal set of runtime files while still excluding a shell or package manager, which is a reasonable middle ground for most production services. Alpine-based Go images are larger than either but include a shell and apk, which some teams prefer for debugging convenience despite the larger surface. There's no universally correct answer — it's a tradeoff between minimal attack surface and operational convenience, and most teams should default to distroless unless they have a specific reason to need a shell.

Why does CGO_ENABLED=0 matter for the final image?

With CGO enabled, Go binaries dynamically link against libc for certain standard library functions (notably around DNS resolution and user/group lookups), which means the final binary needs a compatible libc present in the runtime image — breaking the "just copy the binary" simplicity and requiring a base image with the right shared libraries. Setting CGO_ENABLED=0 forces a fully static build with Go's own implementations of those functions, which is what makes scratch or distroless-static viable at all. The tradeoff is that CGO-dependent packages (some database drivers, certain crypto bindings) won't build this way, so it's worth checking your dependency tree before committing to a fully static build.

Does a smaller Go image actually reduce your CVE exposure?

Yes, proportionally to what got removed. A full golang:1.22 build image carries the entire Debian base it's built on top of — a shell, package manager, and dozens of OS-level packages, each with its own CVE surface — none of which exists in the final distroless or scratch-based image. Since the runtime image contains essentially only your binary and, optionally, CA certificates, there's very little left for a container image scanner to flag; most of the findings a scanner would report against a full base image simply don't apply once the OS layer is gone. That said, the binary itself still needs scanning — a vulnerable version of a Go module compiled into that binary is still a real CVE, which is where dependency-level scanning via SCA covers what image-layer scanning alone can't see, since the vulnerable code is compiled in rather than sitting as a separate installable package.

How do you keep the binary itself patched, not just the base image?

Rebuilding the runtime image regularly only helps if the compiled binary also gets rebuilt against updated Go modules — a static binary doesn't inherit security patches the way a dynamically linked runtime does, since everything is compiled in at build time. That means Go dependency updates need the same continuous scanning and patch cadence as any other ecosystem's dependencies: running go list -m all and cross-checking against the Go vulnerability database (govulncheck), or wiring dependency scanning into CI so a new CVE in a Go module used, directly or transitively, triggers a rebuild rather than sitting silent inside an already-shipped binary.

FAQ

Does FROM scratch work for every Go application?

Only for fully static binaries with CGO_ENABLED=0 and no runtime dependency on files like CA certificates, timezone data, or a shell. Applications making outbound TLS calls need at least the CA certificate bundle copied in, even in a scratch-based image.

Is Alpine's musl libc a problem for Go binaries?

It can be, for binaries built with CGO enabled against glibc — mixing glibc-linked binaries with an Alpine (musl) runtime causes runtime errors. Building with CGO_ENABLED=0 sidesteps this entirely, or building specifically against musl if CGO is required.

How much smaller is a properly multi-staged Go image compared to a naive single-stage build?

A naive single-stage Go Dockerfile that includes the compiler and source can easily exceed 800MB-1GB. A properly multi-staged build to distroless or scratch typically lands under 20-30MB for most services — often a 30-40x reduction.

Does image size reduction alone count as a security improvement?

Partially — smaller images generally mean less attack surface and fewer things a scanner needs to check, but size alone doesn't guarantee security. The binary's own dependencies still need scanning, and runtime configuration (non-root user, read-only filesystem) matters independent of image size.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.