DEV Community

Cover image for Why Your Production Image Shouldn't Contain a Compiler
Yar Khan
Yar Khan

Posted on

Why Your Production Image Shouldn't Contain a Compiler

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Builder stage — has the compiler and everything needed to produce the binary.
  2. 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"]
Enter fullscreen mode Exit fullscreen mode

Let's go through it line by line.

Stage 1: The Builder

FROM golang:1.26 AS builder
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 . .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: -s strips the symbol table, and -w strips debugging information. Both reduce the final binary size.
  • -o server — names the output binary server.
  • ./cmd/server — tells Go where the main.go entry 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
Enter fullscreen mode Exit fullscreen mode

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 /
Enter fullscreen mode Exit fullscreen mode

Sets the working directory to the filesystem root.

COPY --from=builder /app/server .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)