Scratch vs Distroless vs Alpine as a Runtime Image for Go Containers
Quick answer
Go makes container images unusually small, because a statically linked binary does not need much around it. That freedom creates a choice that many teams make...
Go makes container images unusually small, because a statically linked binary does not need much around it. That freedom creates a choice that many teams make by habit: scratch, a distroless image, or Alpine. Each option works, but each one removes or keeps different things, and those differences show up at 2 a.m. when you need to debug a production pod. This post compares the three on the criteria that actually matter.
Start With How You Build the Binary
The base image decision begins at compile time. By default, Go may link against the C library when your code (or a dependency) uses cgo, for example through the default DNS resolver on some platforms or a database driver that wraps a C library. A binary built with cgo enabled expects a matching libc at runtime, and it will fail to start on an image that does not provide one.
If your service does not need cgo, disable it and build a fully static binary. Combined with a multi-stage build, the final image contains only what you copy into it:
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/api /api
ENTRYPOINT ["/api"]
Pin the Go version and the base image tag to versions your project actually targets, and check the image project's documentation for the currently supported tags, since they change over time.
What Each Base Image Gives You
scratch is an empty image. It produces the smallest result and the smallest attack surface, but it also lacks things that Go programs often assume exist. There are no CA certificates, so outbound HTTPS calls fail certificate verification unless you copy a certificate bundle in. There is no timezone database, so time.LoadLocation can fail unless you embed it with the time/tzdata package or copy the files. There is no /etc/passwd entry, so running as a named non-root user requires creating one or using a numeric user ID.
Distroless static images fill exactly those gaps without adding a shell or package manager. They typically include CA certificates, timezone data, and a minimal user setup, with a nonroot variant so the process does not run as root. The result is slightly larger than scratch but far fewer surprises. For a statically linked Go service, this is a strong default.
Alpine is a small distribution with a shell, a package manager, and common utilities. It uses musl instead of glibc, which matters only if you use cgo or load shared libraries, since behavior can differ subtly from glibc-based systems. Alpine is convenient when you want to exec into the container and inspect things, and it is a reasonable choice when a tool you depend on needs a real userland.
Trade-offs That Show Up in Operations
- Debugging: scratch and distroless have no shell, so docker exec into a shell fails. Distroless publishes debug variants that include a minimal shell, and orchestrators offer ephemeral debug containers that attach tooling to a running pod without changing the image. Decide on your debugging story before you ship, not during an incident.
- Health checks: without curl or wget in the image, an HTTP health check command inside the container is not possible. Build a small healthcheck subcommand into your own binary, or rely on the orchestrator's HTTP probes from outside.
- Vulnerability scanning: fewer packages generally means fewer findings. Scratch and distroless images tend to produce short scanner reports, while Alpine or Debian bases pull in packages that may need updates even though your program never uses them.
- Size: the differences are measured in megabytes. They matter for cold start and pull time at scale, but for a service that deploys a few times a day the practical impact is small.
- Reproducibility: pin base images by digest where your tooling supports it, so a rebuild next month does not silently change the runtime layer.
A Practical Decision Guide
If your binary is static and you want a small, safe default, choose distroless static with the nonroot variant. If you want the absolute minimum and are prepared to manage certificates, timezone data, and users yourself, scratch is fine, and it is common in tightly controlled pipelines. If your service needs cgo, a shell-based entrypoint, or extra tools, use a slim glibc-based image or Alpine, and test the binary on that exact image rather than assuming it behaves like your build machine.
Whatever you choose, run the process as a non-root user, make the filesystem read-only where you can, and avoid copying build tooling or source code into the final stage. Those habits improve security more than the base image alone.
Frequently Asked Questions
Why does my Go binary fail with "no such file or directory" on scratch?
That message commonly means the binary was dynamically linked and the dynamic loader or libc is missing from the image. Rebuild with CGO_ENABLED=0 or choose a base image that includes the required libraries.
How do I make HTTPS calls work on scratch?
Copy a CA certificate bundle from your build stage into the final image, or embed one through a supported mechanism in your dependencies. Distroless images already include certificates.
Is Alpine less secure than distroless?
Not inherently, but it ships more software, which means a larger attack surface and more packages to patch. Distroless removes most of that by design, at the cost of convenient debugging tools.
Can I use the same image for development and production?
You can, but many teams use a fuller image in development for tooling and a minimal one in production. Keep both built from the same Dockerfile using separate stages to avoid drift.
Summary
Scratch is the smallest and most manual, distroless static is the best balanced default for static Go binaries, and Alpine trades some minimalism for a usable shell and package manager. Decide based on whether you use cgo, how you plan to debug, and how much work you want to do for certificates and users. Always verify tags and behavior against the official documentation for the images and Go version you use.