How to Reduce Go Docker Image Size

Reduce Go Docker image size by using a multi-stage build with a scratch base image to include only the compiled binary.

The 1.2 gigabyte surprise

You push a fresh Go service to Docker. You run docker images and stare at a 1.1 gigabyte file. Your main.go is forty lines. The compiled binary is three megabytes. Somewhere between your laptop and the container registry, a full Linux distribution, a C compiler, and the entire Go toolchain got packed into your payload. Shipping that to production costs money, slows down deployments, and gives attackers a larger surface area. The fix is not a magic flag. It is a structural change to how you build and package the binary.

How container layers actually work

Docker images are stacks of filesystem layers. When you write FROM golang:1.23, you are pulling a base image that contains the Go compiler, the standard library source, build tools, and a minimal Alpine Linux system. That base image exists to compile code. It does not exist to run code. Once the compiler finishes its job, the source files, the toolchain, and the package cache are dead weight.

Multi-stage builds solve this by giving you two separate containers inside one Dockerfile. The first stage acts as a factory. It pulls the heavy toolchain, compiles your code, and outputs a single executable. The second stage acts as a delivery truck. It starts empty, copies only the executable from the factory, and runs it. The factory is discarded after the build. The delivery truck ships to production.

Go compiles to statically linked binaries by default. A static binary bundles every dependency it needs into one file. It does not rely on shared libraries like libc or libpthread at runtime. This means the binary can run in an environment with zero operating system files. Docker provides a special base image called scratch for exactly this purpose. scratch is literally empty. It has no shell, no package manager, and no filesystem. It only contains whatever you copy into it.

Static binaries are the default. You do not need to fight the compiler to get them. You just need to tell the build process to stop dragging the factory along for the ride.

Minimal example

Here is the smallest multi-stage Dockerfile that produces a production-ready Go container.

# Stage 1: Factory
FROM golang:1.23-alpine AS builder
# Set working directory inside the container
WORKDIR /app
# Copy dependency manifests first to leverage Docker layer caching
COPY go.mod go.sum ./
# Download modules before copying source code
RUN go mod download
# Copy the rest of the application source
COPY . .
# Disable C compilation and build a static Linux binary
RUN CGO_ENABLED=0 GOOS=linux go build -o main .

# Stage 2: Delivery
FROM scratch
# Copy only the compiled binary from the builder stage
COPY --from=builder /app/main .
# Run the binary directly
CMD ["./main"]

Walk through what happens

When Docker processes this file, it creates two independent build contexts. The first stage starts with golang:1.23-alpine. It sets up /app, copies go.mod and go.sum, and runs go mod download. This step is isolated from your source code. If you change a function in main.go but do not touch your dependencies, Docker reuses the cached module download layer. The build finishes seconds instead of minutes.

The RUN CGO_ENABLED=0 GOOS=linux go build -o main . line does three things. CGO_ENABLED=0 tells the Go compiler to skip any C code compilation. This forces pure Go networking and removes the need for a C standard library at runtime. GOOS=linux ensures the binary targets the Linux kernel, even if you are building on macOS or Windows. The go build command links everything statically and outputs a single executable named main.

The second stage starts with FROM scratch. There is no shell, no /bin, no /etc. The COPY --from=builder /app/main . instruction reaches back into the first stage, grabs the compiled binary, and places it in the root of the new empty filesystem. The CMD instruction tells Docker to execute that binary when the container starts. The final image contains only the binary and its ELF headers. It typically weighs between 5 and 15 megabytes.

Layer caching is the invisible engine behind fast builds. Docker hashes each instruction. If the hash matches a previous build, Docker skips the instruction and reuses the saved layer. Copying go.mod and go.sum before your source code ensures that dependency downloads only run when your module graph changes. Change a comment in server.go and the module layer stays cached. Change a version in go.mod and Docker re-downloads everything. Order matters.

Realistic example

Production services rarely run in a vacuum. They need TLS certificates to verify HTTPS requests, timezone data for logging, and sometimes a specific user for security. scratch provides none of these. Adding them manually is tedious. The industry standard solution is gcr.io/distroless/static. It is a Google-maintained image that contains only the bare minimum runtime libraries needed to run a statically linked binary, plus root certificates and timezone data. It adds roughly 2 megabytes but prevents TLS handshake failures and timestamp mismatches.

Here is a production-ready Dockerfile that balances size, security, and reliability.

# Stage 1: Build environment
FROM golang:1.23-alpine AS builder
WORKDIR /app
# Copy manifests first to cache dependency downloads
COPY go.mod go.sum ./
RUN go mod download
# Copy source code after dependencies are cached
COPY . .
# Build a static binary with stripped debug symbols
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o main .

# Stage 2: Minimal runtime
FROM gcr.io/distroless/static
# Copy the binary and set it as the entrypoint
COPY --from=builder /app/main /main
# Run as non-root for security compliance
USER nonroot:nonroot
ENTRYPOINT ["/main"]

The -ldflags="-s -w" flag strips the symbol table and DWARF debug information from the binary. This shaves off another few megabytes. The USER nonroot:nonroot line runs the process without root privileges. Containers that run as root can accidentally mount the host filesystem or escalate privileges if a vulnerability is found. Running as a restricted user follows the principle of least privilege.

Go convention dictates that production builds strip debug symbols. The -s flag removes the symbol table. The -w flag removes DWARF debugging data. You lose stack trace readability, but you gain security and size. If you need debugging later, build a separate debug image without the flags. Keep production lean.

Pitfalls and runtime surprises

The most common mistake is forgetting CGO_ENABLED=0. If you leave it enabled, the compiler links against Alpine's musl libc. The resulting binary expects libc.musl-x86_64.so.1 to exist at runtime. scratch and distroless/static do not contain it. The container starts and immediately crashes with exec format error or no such file or directory. The compiler does not warn you about this. It assumes you know what you are doing.

Another trap is layer caching invalidation. If you write COPY . . before RUN go mod download, Docker invalidates the module cache every time you change a single comment in your code. The build re-downloads every dependency from scratch. Always copy go.mod and go.sum first. Run go mod download. Then copy the rest of the source. This keeps the expensive network step cached across minor code changes.

Certificate expiration is a silent killer. scratch has no CA bundle. If your service calls an external API over HTTPS, the TLS handshake fails with x509: certificate signed by unknown authority. Switching to distroless/static or distroless/base solves this. The image includes the Mozilla CA certificate store and updates it periodically. If you must use scratch, you need to copy the certificates manually and mount them into the container, which defeats the purpose of a minimal image.

Debugging minimal images requires a different workflow. You cannot run docker exec -it <container> sh because there is no shell. You cannot install strace or curl inside the container. The standard approach is to run a temporary debug container alongside your service, or to build a debug variant of your Dockerfile that uses golang:1.23-alpine as the runtime stage. Keep the debug image out of production. Ship the minimal one.

Decision: when to use this vs alternatives

Use scratch when you are building a pure Go binary that makes no external network calls and needs the absolute smallest footprint. Use distroless/static when your service performs HTTPS requests, needs timezone data, or runs in an environment that requires root certificates. Use alpine when you need a shell for debugging, a package manager for runtime dependencies, or are compiling a language that requires dynamic linking. Use multi-stage builds for every production Go service. The factory stage isolates build tools from runtime dependencies, and the delivery stage ships only what is necessary.

Ship the binary, not the compiler.

Where to go next