34.3 Docker Multi-Stage Builds for Minimal Go Images
Right, so you’ve got this beautiful, statically compiled Go binary. It’s a single, self-contained marvel of engineering. And your first instinct is to throw it into a ubuntu:latest Docker image and call it a day. Don’t. I’ve seen it. We’ve all seen it. That image is going to be a whopping 800MB, and 799.9MB of that is stuff your binary will never, ever touch. It’s like buying a private jet to commute to your neighbor’s house.
The solution is so elegant it almost feels like cheating: the multi-stage build. This is Docker’s way of letting you be a build chef in a professional kitchen. You make a mess in one area (the build stage), and then you plate the final dish in a pristine, minimalist serving area (the final stage), leaving all the dirty pots and pans behind. It’s glorious.
The Basic Blueprint: Builder and Runner
Here’s the simplest, most effective pattern. You define one stage to build your application and a second, final stage to run it. The build stage can be based on the full Go image, with all the compilers and tools. The final stage needs only what’s required to run the binary. For a static Go binary, that’s basically nothing. We use scratch—the empty, base Docker image. It doesn’t even have a shell.
# Build Stage: Where the magic (and the mess) happens
FROM golang:1.22-alpine AS builder
# Set our working directory inside the container
WORKDIR /app
# Copy the Go module files first - this layer is cached separately
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy the rest of the source code
COPY . ./
# Build the binary, explicitly setting flags for a truly static binary.
# CGO_ENABLED=0 is the secret handshake here.
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/my-awesome-service
# Final Stage: The pristine serving plate
FROM scratch
# Copy *only* the binary from the builder stage. Nothing else comes along for the ride.
COPY --from=builder /app/my-awesome-service /app/my-awesome-service
# Tell Docker what to run when the container starts
ENTRYPOINT ["/app/my-awesome-service"]
Build it with docker build -t my-service .. The resulting image? It’s your binary size plus a tiny bit of Docker metadata. We’re talking megabytes, not gigabytes. It’s small, secure (no extra tools for an attacker to leverage), and fast to upload and download.
Why alpine and scratch? And What’s With CGO_ENABLED=0?
You’ll notice I used golang:1.22-alpine for the builder. Alpine is a minimalist Linux distro, so it’s smaller than the default golang:1.22 image. Since we’re just compiling and then throwing the entire environment away, the size of the build stage doesn’t matter for the final product. It just makes the build slightly faster to pull.
Now, the CGO_ENABLED=0 part is non-optional if you’re targeting scratch. Go can link against C libraries (this is called cgo). If your code does this, your binary is no longer static—it depends on those system libraries existing. The scratch image has no libraries. Zero. Zilch. Setting CGO_ENABLED=0 tells the Go compiler to pretend cgo doesn’t even exist, forcing it to link everything statically. It’s the final piece to ensure perfect, hermetic portability.
Leveling Up: Adding CA Certificates (The Classic “Gotcha”)
Here’s the first thing that will bite you: your tiny, beautiful scratch-based application might try to call out to HTTPS endpoints and fail spectacularly. Why? Because scratch doesn’t have any SSL certificate authority (CA) certificates. The net/http package can’t validate the certificate of the remote server without them.
The fix is to borrow the CA certs from a known-good image and add them to your final stage. We modify the Dockerfile:
# Build Stage (unchanged)
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . ./
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/my-awesome-service
# Final Stage: Now with 100% more trust
FROM scratch
# Copy the CA certificates from the Alpine image used in the builder stage.
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy our binary
COPY --from=builder /app/my-awesome-service /app/my-awesome-service
ENTRYPOINT ["/app/my-awesome-service"]
This is the real-world, production-grade pattern. It keeps the image incredibly lean while providing the one thing a modern application truly needs from the underlying OS: the ability to trust other machines on the internet.
Best Practices and Pitfalls
- Tag Your Builder: If your project gets complex with multiple binaries, you can have multiple builder stages. Name them with
AS:FROM golang:1.22-alpine AS my-builder. Then reference them specifically in yourCOPY --fromcommands. .dockerignoreis Your Friend: Create a.dockerignorefile to exclude unnecessary files from your build context (likenode_modules, local.envfiles, and thebin/directory). This drastically speeds up your builds and prevents you from accidentally leaking secrets into your image.- The Shell Gotcha: You can’t
docker exec -it my-container /bin/shinto ascratch-based container because there is no shell. Your debugging is limited to logs and whatever observability you baked into the application itself. For a development build, you might want to usealpineas the final stage instead to keep a shell around for debugging, but always default toscratchfor production. - User Permissions: The binary is copied as root. For better security, consider creating a non-root user in the final stage (if you’re not using
scratch) and using theUSERdirective. Withscratch, you can’t add a user, so you have to live with root. This is a trade-off of its incredible minimalism.