The problem with shipping the whole kitchen
You compile a Go web server locally. It works. You wrap it in a Docker image, push it to a registry, and deploy it. The image weighs eight hundred megabytes. Your CI pipeline takes forever to push it. Your security scanner flags three dozen vulnerabilities inside the Go toolchain itself. You did not ask for a full compiler, a standard library source tree, and a Linux package manager in your production container. You only asked for a single binary and the certificates it needs to talk to the internet.
What a multi-stage build actually does
Multi-stage builds solve this by treating your Dockerfile like a factory floor with separate rooms. The first room contains heavy machinery: the Go compiler, build tools, and source code. It assembles the product and hands it through a pass-through window. The second room is a clean shipping crate. It contains only the finished product and the bare minimum to run it. Docker discards the heavy machinery room after the build finishes. The final image never sees the compiler.
This works because Docker builds are additive. Each RUN or COPY instruction creates a new filesystem layer. Normally, every layer stacks on top of the previous one until the final image is complete. Multi-stage builds break that chain. You can reference artifacts from an earlier stage using COPY --from=stage_name, then abandon that stage entirely. The final image starts fresh from a new base. The heavy layers never make it into the registry.
The minimal two-stage Dockerfile
Here is the simplest working pattern. It compiles a static Go binary in one stage and copies it into a scratch image in the second.
# Stage 1: compile the binary with the full Go toolchain
FROM golang:1.22-alpine AS builder
WORKDIR /src
# Copy dependency manifests first to leverage Docker layer caching
COPY go.mod go.sum ./
RUN go mod download
# Copy source code after dependencies are cached
COPY . .
# Build a statically linked binary for Linux
RUN CGO_ENABLED=0 go build -o /out/server .
# Stage 2: ship only the binary
FROM scratch
# Copy the compiled binary from the builder stage
COPY --from=builder /out/server /server
# Run the binary directly
CMD ["/server"]
Walking through the build pipeline
The first stage pulls the official Go image. It sets the working directory to /src. Copying go.mod and go.sum before the rest of the source code is a caching trick. Docker caches each layer. If you change a single .go file but leave the dependencies untouched, Docker reuses the cached go mod download layer. The build skips downloading modules and only recompiles your changed files. This saves minutes on large projects.
The CGO_ENABLED=0 flag tells the Go compiler to skip the C compiler. Go can call C libraries through CGO, but that requires gcc and system headers in the build environment. Disabling it produces a single static binary with no external shared library dependencies. The -o /out/server flag places the output in a predictable path.
The second stage uses FROM scratch. This is a special empty base image provided by Docker. It contains zero files, zero packages, and zero shells. When you copy the binary into it, the image contains exactly one file. The CMD instruction tells Docker how to start the process when a container launches.
Convention aside: Docker best practices recommend using specific version tags like golang:1.22-alpine instead of latest. Pinning versions prevents a silent upstream update from breaking your CI pipeline three months from now. Treat your base image tags like library dependencies. Lock them down.
A production-ready pattern
Production containers rarely use scratch. Most applications need TLS certificates to make HTTPS requests, timezone data for logging, or a shell for debugging. Alpine Linux is a common choice because it weighs roughly five megabytes. Here is a more realistic setup that includes certificates and a non-root user for security.
# Stage 1: build the application
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Build with deterministic flags and strip debug symbols
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/app .
# Stage 2: prepare the runtime environment
FROM alpine:3.19
# Install only the certificates needed for HTTPS calls
RUN apk --no-cache add ca-certificates
# Create a dedicated user to avoid running as root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /home/appuser
# Copy binary and set ownership to the new user
COPY --from=builder /out/app ./app
RUN chown appuser:appgroup ./app
# Switch to the non-root user before starting
USER appuser
CMD ["./app"]
The ldflags="-s -w" flag strips the symbol table and DWARF debug information. This shrinks the binary by twenty to forty percent. The apk --no-cache add ca-certificates command installs the root certificates your application needs to verify TLS handshakes. Without them, every http.Get call to external APIs fails with a certificate verification error. The addgroup and adduser commands create a restricted account. Running containers as root is a common security anti-pattern. If an attacker escapes the container, a non-root user limits their access to the host filesystem.
Convention aside: Go projects should always set CGO_ENABLED=0 unless you explicitly need C interop. The Go community treats static binaries as the default deployment target. It removes runtime dependency hell and makes your container truly portable.
Common pitfalls and how Docker complains
Multi-stage builds introduce a few friction points. The most common is forgetting to copy the binary from the correct stage. If you reference a stage name that does not exist, Docker stops with failed to solve: failed to compute cache key: failed to calculate checksum of ref: no such file or directory. Double check the AS name in the first FROM line matches the --from= flag in the second stage.
Another trap is dynamic linking. If you leave CGO_ENABLED=1 and build against Alpine, the resulting binary expects musl libc. Copying that binary into a Debian or Ubuntu base image breaks at runtime with error while loading shared libraries: libc.musl-x86_64.so.1: cannot open shared object file. Always set CGO_ENABLED=0 for Go projects, or match the base image exactly to the build environment.
Layer caching also breaks if you copy everything at once. Placing COPY . . before COPY go.mod go.sum ./ forces Docker to invalidate the module cache on every single file change. Your builds will download dependencies from scratch every time. Keep dependency manifests separate from source code.
Convention aside: The go mod download step should always run before copying source files. This is a standard Go Docker pattern. It separates volatile application code from stable dependency graphs. The cache survives code changes and only rebuilds when your module requirements actually shift.
When to use multi-stage builds
Use a multi-stage build when you want to separate compilation dependencies from runtime dependencies. Use a single-stage build with golang:1.22-alpine when you are prototyping locally and do not care about image size. Use FROM scratch when your binary is fully static and does not need system libraries or certificates. Use FROM alpine or FROM debian-slim when your application requires TLS verification, timezone data, or a POSIX shell for debugging. Use build arguments and --mount=type=cache when you want to speed up iterative builds without sacrificing the final image size.
Keep the compiler out of production. Ship only what runs.