Multi-Stage Docker Builds for Go
Multi-stage Dockerfiles compile Go in a full SDK image, then copy only the binary (and required trust roots) into a minimal runtime stage.
The result is small, fast-pulling images with fewer OS packages to patch.
Summary
Go's static binaries pair naturally with distroless or scratch runtimes.
A typical pipeline uses a golang build stage, emits CGO_ENABLED=0 output, and copies the executable into gcr.io/distroless/static or a custom scratch image with CA certs.
HTTPS clients need the system certificate bundle unless you embed roots another way.
BuildKit cache mounts for go mod download shrink CI time without bloating layers.
Recipe
Quick-reference recipe card - copy-paste ready.
# syntax=docker/dockerfile:1
FROM golang:1.26-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath \
-ldflags="-s -w" -o /out/api ./cmd/api
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/api /api
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/api"]When to reach for this:
- Shipping microservices where image pull time affects HPA scale-out.
- Reducing CVE surface by avoiding shells and package managers in production images.
- Building reproducible artifacts in CI with pinned base image digests.
Working Example
Production-oriented Dockerfile with CA certs for outbound TLS:
# syntax=docker/dockerfile:1
FROM golang:1.26-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
ARG VERSION=dev
ARG COMMIT=none
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
-ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \
-o /out/api ./cmd/api
FROM debian:bookworm-slim AS certs
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& update-ca-certificates
FROM gcr.io/distroless/base-debian12:nonroot
COPY --from=build /out/api /api
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/api"]docker build \
--build-arg VERSION=1.0.0 \
--build-arg COMMIT=$(git rev-parse --short HEAD) \
-t myorg/api:1.0.0 .What this demonstrates:
- BuildKit cache mount accelerates repeated
go mod downloadin CI. - A dedicated
certsstage copies onlyca-certificates.crtinto distroless. nonrootuser avoids running as UID 0 inside the container.- Build args inject version metadata without editing source before compile.
Deep Dive
How It Works
- Stage isolation: only artifacts you
COPY --from=appear in the final image history. - Static linking:
CGO_ENABLED=0avoids glibc dynamic loader requirements on Linux amd64/arm64. - Distroless bases: Google-maintained images with libc and certs but no shell or apt.
- Scratch variant: empty filesystem plus your binary; you must add CA file manually for HTTPS.
Base Image Choices
| Base | Shell | libc | CA certs | Typical size |
|---|---|---|---|---|
| scratch | No | No | Manual | Smallest |
| distroless/static | No | No | No | ~2 MB + binary |
| distroless/base | No | Yes | Yes | ~20 MB + binary |
| alpine | Yes | musl | Yes | Larger, different libc |
Go Notes
# Verify binary is static before choosing scratch
CGO_ENABLED=0 go build -o api ./cmd/api
file api # expect "statically linked"
ldd api # expect "not a dynamic executable" on Linux- Musl-based alpine can break some
CGO_ENABLED=1builds; prefer debian bookworm builders for cgo. -trimpathkeeps$PWDout of binaries for supply-chain audits.- Use
GOAMD64=v3only when you control CPU baseline and want newer SIMD instructions.
BuildKit and CI
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -o /out/api ./cmd/api- Cache mounts are not layers; they persist across CI runs on the same builder.
- Pin base images by digest in production pipelines:
golang:1.26-bookworm@sha256:.... - Run
docker scout cvesor your scanner on the final stage tag, not the builder.
Gotchas
- HTTPS without certs: distroless/static images fail TLS handshakes until you copy
ca-certificates.crt. - CGO surprises: importing packages that use cgo silently enables dynamic linking unless you set
CGO_ENABLED=0. - Timezone data: scratch images lack
/usr/share/zoneinfo; setTZor copy zoneinfo if logs need local time. - Debugging: no
kubectl execshell in distroless; rely on logs, metrics, and ephemeral debug pods with tooling. - USER directive: ensure the binary listens on ports >= 1024 or grant
CAP_NET_BIND_SERVICEdeliberately.
Alternatives
- ko / buildpacks: build and push images without maintaining a Dockerfile; good for standardized platforms.
- Bazel rules_oci: hermetic builds for monorepos already on Bazel.
- Fat single-stage image: acceptable for dev clusters; avoid for production registries.
- Bare binary on VM: skip Docker entirely when orchestration is systemd and images are unnecessary.
FAQs
scratch or distroless/static?
Choose scratch when the binary makes no outbound HTTPS and needs the smallest possible image.
Choose distroless/base when you need libc, DNS resolver behavior, and CA certs without maintaining a custom scratch layout.
How do I copy only CA certs without apt in the final image?
Run apt-get install ca-certificates in a throwaway debian stage, then COPY --from=certs /etc/ssl/certs/ca-certificates.crt into distroless.
Never run apt in the runtime stage.
Why does my image still show hundreds of MB in docker images?
You may be tagging the build stage accidentally, or copying test fixtures and build caches.
Inspect with docker history myorg/api:tag and ensure the published tag points to the final stage.
Do I need UPX compression?
Rarely for cloud microservices.
UPX can trigger antivirus false positives and adds startup decompress cost.
Prefer -ldflags="-s -w" and smaller dependency graphs first.
How do I run as non-root when binding port 80?
Listen on 8080 inside the container and let Ingress map port 443/80.
Alternatively use a rootless capability setup, but high ports are the common Kubernetes pattern.
Related
- Deploying Go: Single Binary as a Superpower - why static binaries matter
- Deployment Basics - first Dockerfile and kubectl apply
- Kubernetes Deployments, Services & Ingress - running minimal images in clusters
- CI/CD Pipelines for Go Services - build and push in pipeline
- Deployment Best Practices - immutable tags and image signing
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).