Multi-Stage Docker Builds for Go
If you've ever built a Docker image for a Go service and been surprised to see it weighing in at 800MB-1GB+, this post is for you. We'll walk through exactly why that happens, and how a technique called multi-stage builds fixes it.
The Problem: What a "Normal" Dockerfile Looks Like
Here's a straightforward, single-stage Dockerfile for a Go app:
FROM golang:1.26
WORKDIR /app
COPY . .
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux \
go build \
-trimpath \
-ldflags="-s -w" \
-o server \
./cmd/server
ENTRYPOINT ["/app/server"]
This works. It compiles your code and runs it. But there's a hidden cost.
The base image, golang:1.26, is built for developing and compiling Go code. It ships with the Go compiler, the full standard library, Git, a shell, a package manager, and various Linux utilities — often adding up to somewhere between 800MB and over 1GB.
The problem is that none of that is needed to run your application. Once your binary is compiled, the compiler, Git, and everything else are dead weight. But because this Dockerfile only has one stage, everything that was used to build the image also ends up shipped in it:
+--------------------------------+
| Go Compiler |
| Go Toolchain |
| Git |
| Module Cache |
| Build Cache |
| Source Code |
| go.mod / go.sum |
| server (your actual binary) |
| migrations |
+--------------------------------+
≈ 800 MB – 1+ GB
Your production container is carrying around an entire development environment just to run one binary. That's slower to pull, slower to deploy, and a larger attack surface than necessary.
The Solution: Multi-Stage Builds
A multi-stage build splits the Dockerfile into two (or more) stages:
- Builder stage — has the compiler and everything needed to produce the binary.
- Runtime stage — starts from a nearly empty image and copies over only the compiled binary (and any files it truly needs at runtime).
Everything from the builder stage that isn't explicitly copied over gets discarded. Here's the full production Dockerfile we're working with:
FROM golang:1.26 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 \
GOOS=linux \
go build \
-trimpath \
-ldflags="-s -w" \
-o server \
./cmd/server
FROM gcr.io/distroless/static-debian12
WORKDIR /
COPY --from=builder /app/server .
# The server runs migrations at boot (MIGRATIONS_PATH must point here).
COPY --from=builder /app/migrations /migrations
USER nonroot:nonroot
ENTRYPOINT ["/server"]
Let's go through it line by line.
Stage 1: The Builder
FROM golang:1.26 AS builder
This starts from the official Go image — the same heavy one from before. The difference is the AS builder label. That names this stage builder, so later on we can reference it directly with COPY --from=builder instead of tracking it by number.
WORKDIR /app
Sets the working directory for every command that follows. Docker creates /app if it doesn't already exist, and remembers it so we don't have to repeat cd /app in every command.
Copying dependency files first
COPY go.mod go.sum ./
RUN go mod download
This copies only go.mod and go.sum before touching the rest of the source code — and it's intentional. Docker caches each layer in a Dockerfile, and a layer is only rebuilt if its inputs change.
If you instead wrote:
COPY . .
RUN go mod download
then any change to any source file — even a single comment in main.go — would invalidate the COPY layer, forcing go mod download to re-run on every build. Since dependencies change far less often than source code, separating them means Docker can reuse the cached dependency layer on almost every rebuild, and only recompile what actually changed. This is one of the simplest and most effective Docker performance wins for Go projects.
COPY . .
Now the rest of the project — cmd/, internal/, pkg/, config/, migrations/, everything — gets copied in.
The actual compile step
RUN CGO_ENABLED=0 \
GOOS=linux \
go build \
-trimpath \
-ldflags="-s -w" \
-o server \
./cmd/server
Each flag here is doing something specific:
-
CGO_ENABLED=0— disables Cgo, which lets Go code call C libraries. Turning it off produces a fully static binary with no dependency on the system's C library (libc). That's exactly what you want for a minimal, portable container image. -
GOOS=linux— explicitly targets Linux as the output OS. This matters if you're building on macOS or Windows; without it, you might end up with a binary that can't run inside a Linux container at all. -
-trimpath— strips local filesystem paths (like/home/yourname/projects/backend) out of the compiled binary. This makes builds more reproducible and avoids leaking your local directory structure. -
-ldflags="-s -w"— passes flags to the linker:-sstrips the symbol table, and-wstrips debugging information. Both reduce the final binary size. -
-o server— names the output binaryserver. -
./cmd/server— tells Go where themain.goentry point lives; Go compiles that package and pulls in everything it imports.
At the end of this stage, the builder contains the full source tree, the module cache, the Go toolchain, and the compiled server binary — but we're about to throw almost all of that away.
Stage 2: The Runtime Image
FROM gcr.io/distroless/static-debian12
This is a completely fresh starting point — Docker doesn't carry anything over from the builder stage automatically. Think of it as switching to a brand-new, nearly empty machine.
"Distroless" images are deliberately minimal: no shell, no package manager, no compiler, not even basic Linux utilities. Just enough to run a static binary. That's exactly what we need here, since CGO_ENABLED=0 already gave us a self-contained executable with no external dependencies.
WORKDIR /
Sets the working directory to the filesystem root.
COPY --from=builder /app/server .
This is the key line that makes multi-stage builds work. It reaches back into the builder stage and copies out just the compiled binary — nothing else. No compiler, no source code, no Git history, no module cache comes along for the ride.
COPY --from=builder /app/migrations /migrations
Some applications, this one included, run database migrations automatically on startup, so the migration files need to be present in the final image alongside the binary.
USER nonroot:nonroot
By default, containers run as root. Running as root means that if an attacker manages to compromise your application, they immediately have root-level access inside the container. Switching to a non-root user removes that easy escalation path — a small change with an outsized security benefit.
ENTRYPOINT ["/server"]
This tells Docker what to execute when the container starts: your compiled binary, and nothing else.
The End Result
The final image contains only:
/
├── server
└── migrations/
No compiler. No Git. No shell. No source code. No build cache. Just your application and the files it needs at runtime.
Builder (discarded after build)
────────────────────────────────
Go Compiler
Go Toolchain
Git
Source Code
Build Cache
Module Cache
│
│ COPY server + migrations only
▼
Runtime Image (what actually ships)
────────────────────────────────
server
migrations/
Distroless base
Why This Matters
The difference in outcome is significant:
| Single-stage | Multi-stage | |
|---|---|---|
| Final image size | ~800MB – 1GB+ | ~15–25MB |
| Contains compiler/toolchain | Yes | No |
| Contains source code | Yes | No |
| Runs as root by default | Yes | No (with USER nonroot) |
| Attack surface | Large | Minimal |
A smaller image pulls faster, deploys faster, and scales faster — which matters a lot if you're running in Kubernetes or any environment that spins up new containers frequently. Just as importantly, a distroless image with no shell and no package manager gives an attacker far less to work with if they ever find a way in. There's no bash to drop into, no apt to install tools with, and no source code sitting around to inspect.
Takeaway
The core idea behind multi-stage builds is simple: separate the environment you need to build your application from the environment you need to run it. Your build stage can be as heavy as it needs to be — full of compilers, toolchains, and caches — because none of that has to leave the builder stage. Your runtime stage only needs to carry the finished artifact.
For Go specifically, this pairs especially well with static compilation (CGO_ENABLED=0), since a fully static binary has no runtime dependencies at all — meaning it can run in an image that has almost nothing else in it.
If you're currently shipping Go services in single-stage Docker images, this is one of the highest-leverage changes you can make: a few extra lines in your Dockerfile for a dramatically smaller, faster, and more secure production image.
Top comments (0)