Go binaries are surprisingly small, but Docker images aren’t just binaries.
Let’s build a tiny Go app and see how small we can make its Docker image.
package main
import "fmt"
func main() {
fmt.Println("Hello, Docker!")
}
Compile this with go build -ldflags="-w -s" -o app main.go. The -w flag disables DWARF debugging information, and -s strips the symbol table. This gets our binary down to about 1.5MB.
Now, a naive Dockerfile:
FROM alpine:latest
COPY app /app
CMD ["/app"]
This image is about 5.5MB. Why so big? Because alpine:latest is a full-fledged Linux distribution, even if minimal. It includes many libraries and system utilities your tiny Go binary doesn’t need.
The trick is to realize that Go binaries, when statically linked (which is the default for go build unless you’re on cgo-enabled systems and cross-compiling), don’t need an operating system’s C library. They are self-contained.
So, we can use an even more minimal base. scratch is an empty image. It’s literally nothing.
FROM scratch
COPY app /app
CMD ["/app"]
This image is now ~1.5MB, the size of the binary itself! This is the absolute minimum. No shell, no libraries, just your executable.
What if your app needs to do more, like access the network or read files? You’ll need some system calls. That’s where distroless images come in. These are Google-created images that contain only your application and its runtime dependencies. They don’t have package managers, shells, or other standard Linux utilities.
For Go, the gcr.io/distroless/static-debian11 image is excellent. It provides the necessary glibc and basic system libraries that Go might need, without the bloat.
FROM gcr.io/distroless/static-debian11
COPY app /app
CMD ["/app"]
This image will be slightly larger than the scratch version, probably around 10-15MB, but it gives you more flexibility if your Go application starts interacting with the OS in more complex ways. It’s a great balance between size and functionality.
Multi-stage builds are key here. You can use a larger image with the Go toolchain to build your app, and then copy only the compiled binary into a minimal scratch or distroless image.
# Stage 1: Build the application
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -ldflags="-w -s" -o app main.go
# Stage 2: Create the minimal runtime image
FROM scratch
COPY --from=builder /app/app /app
CMD ["/app"]
This multi-stage approach keeps your build environment separate from your runtime environment. The final image is still ~1.5MB because we only copy the compiled binary.
The most surprising thing is how little the Go runtime itself actually needs. Most of the size in traditional Docker images comes from the base OS layers and the utilities they bundle, not the application executable itself. Your Go binary is often the largest part of its own image if you’re using scratch.
If you need to use CGO in your Go application (e.g., to link against a C library), the -ldflags="-w -s" optimization won’t work, and your binary will be larger. More importantly, a scratch image won’t work because your binary will now depend on glibc. In that case, you’ll need a distroless/cc image or a minimal distribution like Alpine.
The next challenge is managing secrets and configuration in these minimal images.