Ship the binary. Not the build environment.
On this page
A Go service doesn’t need a Go compiler to run.
That sounds obvious, but it’s easy to lose sight of once a Dockerfile works. The simplest Dockerfile for a Go service starts from the official Go image, copies in the source, runs go build and starts the result. The image that goes to production then carries the Go toolchain, the module cache, the source code, a full Linux userland and whatever else the build pulled in along the way.
Only the compiled binary actually runs. The rest is still stored in a registry, pulled onto every node that runs the service, and scanned and patched like everything else in the image.
So rather than asking how to make the image smaller, I find it more useful to ask what the service needs at run time. For a typical Go service the answer is short: the binary and a handful of files it reads while running. Everything else belongs to the build.
Why does a smaller image matter?
Because an image keeps costing something after it’s built. Each time the system pulls, unpacks, stores or scans it, every file in it adds to the work, whether the service uses that file or not.
The most visible cost is the pull. A registry stores an image as compressed layers. When a pod lands on a node, the container runtime downloads the layers that node doesn’t have yet and unpacks them, and the container can’t start until that’s done. If the pull policy isn’t set, a tag other than :latest defaults to IfNotPresent, so an image that’s already on the node isn’t pulled again.
That’s why size matters most on nodes that have never seen the image: one the cluster autoscaler just added, a replaced instance, a fresh node pool, or a rollout that lands where the service hasn’t run before. There, every build-environment layer sits on the path to a ready pod. By default the kubelet also sends one image pull request at a time, so a large image can hold up other pods scheduled on the same node. On a warm node the base layers are usually cached, and a multi-stage image mostly needs only the new layer with the binary. Whether any of this shows up in rollout or scale-up times depends on how much of startup is spent pulling, which is something to measure rather than assume.
The layers also take up space. The registry keeps them, nodes keep them unpacked on disk, and the kubelet deletes unused images when disk usage crosses a threshold or the node runs short of disk. Identical layers are shared between images, which helps, but a layer that exists only because of the build is stored, transferred and unpacked for nothing. Smaller images leave more headroom on the node. Whether the registry bill goes down depends on the provider’s pricing, retention policies and how much was already shared.
Then there is maintenance. A compiler, a shell and a package manager have no job once the binary is running, but while they’re in the image, vulnerability scanners report them and someone has to keep them patched. Leaving them out removes those findings and gives an attacker less to work with inside a compromised container. It does nothing about vulnerabilities in your own code or in the Go modules compiled into the binary, and whatever base image remains still needs updates.
Removing the build environment also exposes what the service was quietly relying on, such as CA certificates or time zone data that happened to be in the build image. In a minimal runtime image each dependency is either added on purpose or missing, and a missing one fails when the service runs, not when it builds. That’s more work up front, but the Dockerfile ends up documenting what the service actually needs.
What a smaller image doesn’t change is the application. The same binary runs the same code, so CPU usage, memory and latency stay where they were. Pulling is also only one step towards a ready pod: container start, application startup and the readiness probe come after it, and image size doesn’t affect them.
Build it here. Run it there.
Docker’s multi-stage builds are made for this. A Dockerfile can contain several FROM instructions, each starting a new stage with its own base image, and a later stage can copy files out of an earlier one. Only the last stage becomes the image you ship. For a Go service two stages are usually enough: a build stage with the toolchain, the source and the module cache, and a runtime stage that receives the binary.
Here’s a minimal example. It’s a starting point, not a drop-in production configuration: it assumes the main package lives in ./cmd/app, the service builds without cgo, and it reads no files from disk at run time.
# syntax=docker/dockerfile:1
# Build stage: the Go toolchain, the module cache and the source code.
FROM golang:1.27 AS build
WORKDIR /src
# Module files first: this layer is reused until go.mod or go.sum change.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/app ./cmd/app
# Runtime stage: only what the compiled service needs.
FROM alpine:3.24
RUN apk add --no-cache ca-certificates \
&& addgroup -S -g 10001 app \
&& adduser -S -D -H -u 10001 -G app app
COPY --from=build /out/app /usr/local/bin/app
USER 10001:10001
ENTRYPOINT ["/usr/local/bin/app"]
Only /out/app crosses from one stage to the other. A few of the remaining lines matter more than they look.
Copying go.mod and go.sum before the rest of the source lets Docker reuse the go mod download layer until the dependencies change. A .dockerignore that excludes .git and local build output keeps unrelated changes from invalidating COPY . ..
golang:1.27 and alpine:3.24 follow the latest patch releases of those versions. Pin by digest if you need fully repeatable builds, and update deliberately either way. I build on the default Debian-based Go image rather than golang:1.27-alpine, because the image’s documentation describes the Alpine variant as experimental and not officially supported by the Go project. With cgo disabled, the builder’s C library doesn’t end up in the binary anyway.
alpine:3.24 already ships a CA bundle. Installing ca-certificates anyway states the dependency, so a later change of base image can’t silently remove the trusted roots that outbound HTTPS needs.
The service runs as an unprivileged user, set with a numeric USER. On Kubernetes that matters: with runAsNonRoot: true and no runAsUser in the pod spec, the kubelet can only verify a numeric user ID, and it refuses to start a container whose image names its user instead.
ENTRYPOINT uses the exec form, the JSON array, which starts the binary as the container’s main process. In shell form it would run under /bin/sh -c, which doesn’t pass signals on, so the service would never see the SIGTERM sent when the container stops.
A few Go flags worth knowing
Three options show up in almost every “small Go image” Dockerfile. They do different things, and only one of them is really about size.
CGO_ENABLED=0
cgo lets Go packages call C code. It’s enabled by default for native builds on systems where it’s expected to work, and disabled when cross-compiling or when no C compiler can be found. CGO_ENABLED=0 turns it off explicitly, and files that import "C" are simply not built.
On Linux the result is typically a statically linked binary with no dependency on the system C library, so it doesn’t care whether the image underneath uses glibc, musl or nothing at all. That’s what keeps the choice of runtime image open. A cgo binary linked against glibc in a Debian-based builder, on the other hand, won’t start on Alpine, which uses musl.
The catch is that some code needs cgo. SQLite drivers that wrap the C library, Kafka clients built on librdkafka and bindings to native image or crypto libraries either won’t build or have to be swapped for pure Go alternatives that may behave differently. Parts of the standard library change quietly as well: net always uses Go’s own DNS resolver, which is the default on Linux anyway but doesn’t implement every option a system resolver can be configured with, and os/user reads /etc/passwd and /etc/group itself instead of asking the C library.
Disabling cgo removes the dependency on a C library and nothing more. Any file the service reads still has to be in the image.
-trimpath
-trimpath removes file system paths from the compiled binary. Instead of something like /home/me/src/app/internal/server/handler.go, the file names recorded in the binary start with the module path and version, or with the plain import path for the standard library.
Paths from the build machine, which can include user names and directory layouts, no longer leak into the artifact. The output also stops depending on where the source happened to be checked out, which is one step towards reproducible builds, and stack traces get shorter. It isn’t a size optimization, and it doesn’t make a build reproducible on its own: the Go version, build flags and dependencies have to match too. Debuggers that map file names back to source files on disk may need to be told where the source lives.
-ldflags="-s -w"
These flags are passed to the Go linker. -s omits the symbol table and debug information, and -w omits the DWARF symbol table. -s already implies -w, so the pair is redundant but harmless. The binary gets smaller; how much depends on the program, so measure it.
Go’s own panic stack traces still show function names and line numbers, because the runtime keeps its own metadata for them. What you lose is information that tools outside the process rely on, and whether that matters depends on how you observe the service. That’s why the example above leaves these flags out.
Don’t optimize away your observability
Stripping symbols is often presented as free size. But observability covers several mechanisms, and they don’t depend on the binary in the same way:
- Infrastructure metrics such as CPU, memory, network and restarts come from the container runtime, cgroups and the orchestrator. They never read the binary.
- Application metrics and SDK-based tracing, for example through a Prometheus or OpenTelemetry library, are code compiled into the program. Stripping symbols doesn’t change them.
- Profiling and debugging depend on the tool. Profiles collected inside the process are symbolized by the Go runtime, but external profilers such as
perfneed symbols, and debuggers such as Delve need DWARF. - eBPF auto-instrumentation observes the process from the kernel and can attach probes to functions inside the binary. To find those functions, it may need the symbol table.
Go makes the eBPF case concrete. Go implements TLS itself, in crypto/tls, instead of using the system’s TLS library, so data is already encrypted by the time it reaches a socket. To see the plaintext, a sensor has to hook Go’s own TLS functions, and it finds them through the binary’s symbols. groundcover documents exactly this in its Go guide: its sensor hooks the crypto/tls read and write functions by symbol offset, binaries built with stripped symbols lose TLS visibility, and the guide recommends keeping -ldflags="-s" out of production builds.
That’s one vendor and one feature, and other tools work differently. Before you strip production binaries, run the stripped image where your real agents run and check what still works.
Why not just FROM scratch?
scratch is Docker’s empty image: a runtime stage built on it contains exactly the files you copy in. A statically linked Go binary can run there, but nothing else is provided:
- no CA certificates, so outbound HTTPS fails until you copy a bundle in;
- no time zone database, so
time.LoadLocationfails unless you copy one in or embed it with thetime/tzdatapackage (or thetimetzdatabuild tag), which Go documents as adding about 450 KB; - no
/etc/passwd, so a numericUSERworks but looking users up by name doesn’t; - no
/tmp, no shell and no package manager.
Without a shell there’s no docker exec … sh during an incident. Kubernetes ephemeral containers (kubectl debug) fill that gap, but it’s a workflow to learn before you need it.
The time zone point applies to the Alpine example too: alpine:3.24 has no /usr/share/zoneinfo. If the service uses named time zones, install tzdata or embed the database.
Between an empty image and a full distribution there are two common choices. Alpine is a small distribution built on musl libc and BusyBox, with a shell, a package manager and a CA bundle. It suits services that need a few extra packages or a shell for debugging, but it’s still a distribution with packages to keep updated, and musl differs from glibc in ways that matter for binaries built with cgo.
Distroless images have no package manager and no shell. gcr.io/distroless/static-debian13 includes CA certificates, time zone data, an /etc/passwd entry for root and a /tmp directory, which is what a static Go binary usually needs. There’s a nonroot variant that runs as an unprivileged user, and debug variants that add a BusyBox shell. gcr.io/distroless/base-debian13 adds glibc and libssl for binaries that need them, such as those built with cgo.
None of these is better in general. The deciding questions are practical: does the binary need a C library, which files does it read while running, will you need a shell when something breaks, and who updates the base image, and how quickly? Running as non-root, keeping the base image current and knowing what’s inside it matter more than the last few megabytes.
Measure before celebrating
Several numbers are easy to confuse when you measure the change:
- Image size: the unpacked size on disk.
- Compressed size: what the registry stores and serves.
- Transferred bytes: the compressed layers a particular node is missing.
- Pull time: the transfer plus registry latency, decompression and unpacking.
- Pod readiness: pull time plus container start, application startup and the readiness probe.
To compare a single-stage build with a multi-stage one locally:
docker build -t app:single -f Dockerfile.single .
docker build -t app:multi .
docker image ls app
docker image inspect --format '{{.Size}}' app:single app:multi
docker image inspect prints the local size of each image in bytes. To see where that size comes from, list the layers:
docker history app:multi
docker history --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' app:multi
Each row is a build step and the size it added. The multi-stage image should show the base image layers, the small runtime additions and one layer with the binary, and nothing from the build stage.
The registry’s view is in the pushed image’s manifest, which lists the compressed size of every layer:
docker buildx imagetools inspect --raw registry.example.com/app:multi
For a multi-platform image this prints an index of per-platform manifests instead; inspect the one for your nodes’ platform by its digest.
None of these commands tells you how long a pull takes. Local sizes are uncompressed and reported differently by different Docker image stores, and layers shared between images are counted in each of them. For pull time, measure on a representative node with a cold cache, repeat the measurement, and record how you did it. The kubelet’s pod events include how long each image pull took. If you publish numbers, publish the method with them.
Size is the easy part. The real check is whether the slimmer image still runs the service correctly:
- Startup: the binary starts as the non-root user and finds every file it reads.
- Health checks: liveness and readiness probes pass. If a probe runs a command inside the container, that command has to exist in the new image.
- Outbound HTTPS: TLS calls succeed, which proves the CA bundle is in place.
- Other runtime dependencies: time zones, DNS resolution, writable directories, configuration files and any native libraries.
- Shutdown: the service receives
SIGTERMand exits cleanly within the termination grace period. - Observability: dashboards, traces, profiles and eBPF-based views still show what they showed before.
Try it on your own Go service
Want to check whether your own service ships more than it needs? Open Claude Code or Codex in the repository and give it the prompt below. It keeps the agent read-only, asks it to separate what it verified from what it assumed, and ends with a verdict: optimize, keep as is, or needs evidence.
Show the prompt Hide the prompt
# Audit My Go Service's Docker Build
Act as a senior Go and platform engineer reviewing this repository's container build.
Goal: Determine whether this Go service ships unnecessary build-time dependencies in its production image, and whether a smaller runtime image would be safe and worthwhile.
Start read-only. Do not modify files, install dependencies, push images, or deploy anything.
## 1. Inspect the repository
Review actual Dockerfiles, `.dockerignore`, `go.mod`, build scripts, CI configuration, Go compiler flags, application entrypoints, and deployment configuration where available.
Distinguish verified facts from assumptions. Do not trust outdated comments without checking the source.
## 2. Identify runtime requirements
Determine whether production contains unnecessary build tools, source code, or caches.
Identify required runtime files and capabilities, including CA certificates, timezone data, templates, static assets, migrations, configuration, cgo/native libraries, user permissions, and signal handling.
Do not recommend `scratch`, Alpine, or distroless automatically.
## 3. Review Go build flags
Evaluate `CGO_ENABLED=0`, `-trimpath`, and `-ldflags="-s -w"`.
Explain actual benefits, compatibility risks, and verification methods.
Check whether debugging, profiling, tracing, or eBPF-based observability depends on information removed by binary stripping.
Do not assume all monitoring stops working when symbols are stripped.
## 4. Recommend the smallest justified change
If optimization is worthwhile, propose a minimal multi-stage Dockerfile that preserves required runtime behavior.
If the current implementation is already appropriate, explicitly say so.
Do not modify the repository during this audit.
## 5. Define independent verification
Provide reproducible commands and pass/fail criteria for:
- Image sizes and layer composition.
- Runtime startup and health checks.
- Outbound HTTPS and DNS.
- Required runtime assets.
- Native library compatibility.
- Shutdown and signal handling.
- Application tests.
- Logs, metrics, traces, and profiling where relevant.
- Image pull and deployment behavior where measurable.
Distinguish local image size, compressed registry transfer size, pull duration, and application readiness.
Never invent measurements.
## 6. Report findings
Return:
- Current state, with repository file references.
- Confirmed issues.
- Recommended changes, if justified.
- Compatibility and security risks.
- Independent verification plan.
- Final verdict: OPTIMIZE / KEEP AS IS / NEEDS EVIDENCE.
An image that builds successfully is not necessarily a working production image.
Principle: Ship what you need. Leave the rest behind. Treat the answer like any other review: check its evidence before acting on it.
Conclusion
Build tools belong in the build stage. The runtime image should hold what the binary uses and nothing else, and a multi-stage build makes that boundary explicit. Every step past that, from stripping symbols to choosing scratch, trades something for size. The smallest image is only an improvement if the service still starts, reaches what it depends on, shuts down cleanly and can still be observed.
Ship what you need. Leave the rest behind.
References
- Docker: Multi-stage builds, Building best practices (base images, unnecessary packages), Understanding image layers, Dockerfile reference (
ENTRYPOINTforms),docker image history,docker buildx imagetools inspect - Go: cgo,
gocommand (-trimpath,-ldflags), linker (-s,-w),netname resolution,time/tzdata, release history - Kubernetes: Images (pull policy, serial and parallel image pulls), Garbage collection (container image lifecycle), Node-pressure eviction
- Base images:
golangDocker Official Image, Alpine Linux releases, distroless and its base image contents - groundcover: Datadog for Golang: APM setup, dd-trace-go, and the eBPF alternative