Abik Maharjan · Backend Developer

WritingKathmandu--:--:--

← Writing

Your Go Docker Image Is 900MB and You Know It

A static binary needs a compiler exactly as much as a baked cake needs an oven

There’s a particular kind of shame in running docker images after your first Go build.


You wrote a CLI. One file, a couple of packages under internal/. The compiled binary is maybe twelve megabytes. Then you shipped it in a container and the container is nine hundred and sixty-two megabytes, and you're now paying to move a Debian userland, a C toolchain, git, and the entire Go compiler across a network several thousand times a day so that a twelve-megabyte binary can print --help.

I’ve watched other do it. I’ve done it. The fix takes about eleven minutes and it’s the single highest leverage Dockerfile change most Go teams will ever make. So let’s do it properly, and let’s argue about the parts everyone gets wrong.


First, earn the baseline

Do not skip this. If you go straight to the good Dockerfile you’ll have a nice image and no visceral understanding of why, and in six months you’ll cheerfully add RUN apt-get install build-essential to a runtime stage because someone on Stack Overflow said it fixes a linker error.

Write the naive one on purpose:

# Dockerfile.single
FROM golang:1.26
WORKDIR /src
COPY . .
RUN go build -o /usr/local/bin/gopull .
ENTRYPOINT ["gopull"]

Note that I’m building against source that lives somewhere else entirely. The build context and the Dockerfile do not have to be roommates — that’s the whole reason
-f exists, and it's a flag far too many people discover in year three:

docker build -f Dockerfile.single -t ex4-single ~/Development/projects/gopull
docker images ex4-single

Write the number down. Actually write it down. Mine came out around 960MB. Yours will be in the same neighborhood, because golang:1.26 is roughly 850MB before your code touches it.

That image works. It runs. It would pass code review at a distressing number of companies. It is also 98% packaging material.


The insight: build-time and run-time are different machines

Here’s the mental model that makes multi-stage builds click, and it isn’t “a Docker feature.” It’s older than Docker.

Compiling and running are two different jobs with two different dependency sets. Compiling Go needs the toolchain, the module cache, and every line of your source. Running the result needs… the result. One file. That’s it. Go’s whole pitch is a single statically-linked binary with no runtime, no interpreter, no shared libraries to chase.

Shipping the compiler alongside the binary is like mailing someone a cake and including the oven.

A multi-stage build just says: use this image to produce a thing, then use a different, nearly empty image and copy only the thing across. Everything in the first stage — the layers, the caches, the source code, your .env if you were careless — is discarded. That last part matters more than the size win, and I'll come back to it.

# Dockerfile.multi
FROM golang:1.26 AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/gopull .
 
FROM alpine:3.20
COPY --from=builder /out/gopull /usr/local/bin/gopull
ENTRYPOINT ["gopull"]

CGO_ENABLED=0 is not decoration. Without it, Go may link against the host's libc for DNS resolution and user lookups, and your binary — built on Debian, deployed on Alpine's musl — will die with an error message that sends you on a two-hour goose chase. Turn cgo off, get a truly static binary, and the binary stops caring what's underneath it.

Build it. Compare.

docker image sizes of the same application

Roughly fifty times smaller, and identical behavior:

docker run --rm ex4-multi --help

Same binary. Same output. Nine hundred and forty megabytes of oven, gone.


Now squeeze, and understand what you’re trading

Twenty megabytes is mostly Alpine. Swap the runtime stage for scratch — the empty image, the literal absence of an operating system — and you're left with the binary and nothing else. Call it twelve megabytes, all of it yours.

Then try this:

docker run --rm -it --entrypoint /bin/sh ex4-scratch

It fails. There is no sh. There is no /bin. There is no filesystem to speak of. That's the trade, and it's a real one: you cannot exec into a scratch container to poke around, because there is nothing in there to poke with. Your debugging story becomes logs, metrics, and docker cp — or attaching a debug sidecar that shares the process namespace.

I’ll give you my opinion, since that’s what you’re here for: scratch** is where people over-optimize.** The 8MB you save over Alpine is worth roughly nothing on a network with layer caching, and the first time production is on fire at 2am you will pay that 8MB back with interest for a shell.

My default is gcr.io/distroless/static. It's ~2MB, it has no shell either, but it ships CA certificates, timezone data, /etc/passwd, and a nonroot user already configured — the four things you always end up hand-copying into scratch anyway. It's the adult version of the same idea.

Which brings us to the classic failure.

The certificate thing that will bite you

If your program makes any HTTPS call — an API, an S3 upload, a webhook — scratch will fail with x509: certificate signed by unknown authority.

This confuses people because it looks like a networking bug. It isn’t. TLS verification requires a trust store: a file of root CA certificates on disk. Every normal base image has one. scratch has no files at all. Your binary is looking for /etc/ssl/certs/ca-certificates.crt, finding nothing, and correctly refusing to trust anyone.

One line fixes it — steal the trust store from the builder, which is a real distro and has one:

COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

Even if your tool doesn’t make network calls today, put this in your notes. The day it grows a self-update check, you’ll save yourself an afternoon.


Cache your modules or your builds will be slow forever

Docker caches layers, and it invalidates every layer after the first one that changed. So this ordering:

COPY . .
RUN go build ...

means that changing one comment in one .go file invalidates COPY . ., and every dependency gets re-downloaded from scratch. Every build. Forever.

Split it:

FROM golang:1.26 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/gopull .

Now go mod download only reruns when go.mod or go.sum actually change. Touch a source file, rebuild, and watch for CACHED on that step. That's the whole trick, and it's the same trick in every language — it's package.json before npm install, requirements.txt before pip install, Gemfile before bundle. Learn it once, apply it everywhere.

Two more things worth stealing while you’re in here:

-ldflags="-s -w" strips the symbol table and DWARF debug info. Costs you nothing except readable stack traces from a core dump — which you weren't going to get out of a distroless container anyway — and buys back 20–30% of binary size.

Version stamping. This is how every real Go CLI you’ve ever used implements --version:

ARG VERSION=dev
RUN CGO_ENABLED=0 go build \
      -ldflags="-s -w -X main.version=$VERSION" \
      -o /out/gopull .
docker build --build-arg VERSION=$(git describe --tags) -t gopull .

The value gets written into the binary at link time. No config file, no env var, no build script generating a version.go. It's clean, and it means every artifact can tell you exactly which commit produced it.


The part nobody puts in the changelog

The size win is what gets you to try multi-stage builds. It isn’t the best reason to keep them.

The best reason is that your source code never reaches production. Not the repo, not the git history, not the .env you forgot to add to .dockerignore, not the private module credentials in the builder's netrc, not the test fixtures with real customer data in them. The builder stage is a scaffold. It gets torn down and thrown away, and only the artifact walks out.

Second best reason: attack surface. There is no shell in that image. No package manager, no curl, no python. A CVE lands in bash and you scroll past it, because there is no bash. Half of container hardening advice is really just "stop shipping an operating system you never use," and multi-stage builds get you there as a side effect of trying to save disk space.


Go do it, then do it again

The full pipeline is about twenty lines: a builder stage with cached modules and stripped, statically-linked output, and a runtime stage that is one COPY and an ENTRYPOINT.

The real test isn’t writing it once with an article open in the other tab. It’s opening your next Go project and writing the same Dockerfile from memory in five minutes, because the shape is now obvious to you. Pick a second repo tonight and do exactly that. If it takes five minutes, it stuck.

And if you’re still shipping FROM golang to production — you know. You've known this whole time.


Numbers above are from a small Go CLI (a couple of internal packages, a handful of dependencies). Yours will differ; run it and record the real ones. The delta will not.

4 views