The empty room problem
You push a Go service to your cluster and watch the container image pull. It takes forty seconds. The image is 750 megabytes. You are paying for storage, burning bandwidth, and slowing down every deployment. The obvious fix is to swap the base image for something smaller. You try Alpine. The image drops to 60 megabytes. You feel better until the binary crashes because it expects a C standard library that Alpine does not provide. You try distroless. It works, but debugging feels like staring into a void.
The real solution is an empty container. Go compiles to a single executable file. That file contains your code, the standard library, and every third-party dependency. It does not need a package manager. It does not need a runtime interpreter. It only needs the Linux kernel to schedule threads and open files. If you strip away everything else, you get a container that is exactly the size of your binary plus a few kilobytes of metadata.
Why Go binaries are different
Most languages ship code as text. Python needs the interpreter. Node needs V8. Java needs the JVM. The container must carry the runtime alongside your application. Go takes a different path. The compiler translates your source code directly into machine instructions for your target CPU. The linker stitches those instructions together into one file. By default, Go uses a pure Go implementation for networking, cryptography, and file I/O. The result is a statically linked binary that carries its own dependencies.
Think of a Go binary as a fully assembled appliance. You plug it in and it runs. Think of a Python container as a workshop. You need the tools, the workbench, and the power supply before you can build anything. Go gives you the appliance. Docker gives you the wall socket. You do not need to ship the workshop.
This design choice is intentional. The Go team optimized for deployment simplicity. They made static linking the default because it removes environment drift. The binary you build on your laptop runs identically on a bare metal server, a Kubernetes pod, or a Raspberry Pi. The only requirement is a compatible kernel.
The minimal builder pattern
Multi-stage builds solve the problem of carrying build tools into production. You compile in one container, extract the artifact, and drop it into a second container that contains nothing else. The final image never sees a compiler, a package manager, or a shell.
Here is the simplest working pattern:
# Stage 1: compile with build tools
FROM golang:1.23-alpine AS builder
# Install git so go mod can fetch private repos if needed
RUN apk add --no-cache git
WORKDIR /app
# Copy dependency files first to leverage Docker layer caching
COPY go.mod go.sum ./
RUN go mod download
# Copy source code after dependencies are cached
COPY . .
# Disable CGO to force pure Go static linking
RUN CGO_ENABLED=0 GOOS=linux go build -o main .
# Stage 2: empty runtime
FROM scratch
# Copy only the compiled binary from the builder stage
COPY --from=builder /app/main .
# Run the binary directly
CMD ["./main"]
The first stage downloads modules, compiles the code, and exits. The second stage starts from scratch, which is a special Docker keyword meaning an empty filesystem. The COPY --from=builder instruction pulls the binary out of the first stage and places it in the second. Docker discards the first stage entirely when you push the image. The final artifact contains only your executable.
Docker layer caching makes this fast. If you change a single line of Go code, Docker reuses the cached go mod download step. It only recompiles the changed files. You get incremental builds without manual cache management.
Build tools stay out of production. Empty images stay out of your way.
What actually happens at runtime
When the container starts, the kernel loads the binary into memory. The ELF header tells the kernel where the entry point lives. The Go runtime initializes itself, sets up the garbage collector, and starts the main goroutine. No shell is spawned. No init system runs. The process ID is one. Signals go straight to your program.
This direct mapping is why scratch works. The Linux kernel provides everything the binary needs: execve to start, mmap to load code, epoll for I/O multiplexing, and clone for goroutine scheduling. The container runtime isolates the process with namespaces and cgroups. The filesystem is read-only by default. Your binary writes to /tmp or to mounted volumes. It never touches a system library because it does not need one.
The CGO_ENABLED=0 flag is the mechanism that guarantees this behavior. CGO allows Go code to call C functions. When CGO is on, the linker dynamically links against libc. The binary expects libc.so.6 to exist at runtime. scratch has no libc. The container crashes immediately with a missing library error. Turning CGO off forces the Go linker to use its own pure Go implementations for everything. The binary becomes fully self-contained.
Static linking is the default. Dynamic linking breaks empty containers.
Real-world production setup
Production images need more than a binary. They need security boundaries, health checks, and metadata. You add a non-root user, mount configuration, and expose ports without adding operating system packages.
FROM golang:1.23-alpine AS builder
RUN apk add --no-cache git
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o main .
FROM scratch
# Create a non-root user and group for security isolation
COPY --from=builder /app/main /main
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 65532:65532
EXPOSE 8080
# Use exec form so signals reach the Go process directly
ENTRYPOINT ["/main"]
The -ldflags="-s -w" flag strips debug symbols and DWARF tables. This shrinks the binary by twenty to thirty percent. You lose stack traces with file names, but production services rarely need line numbers. They need structured logs and metrics.
The ca-certificates.crt copy is a common workaround. Go's TLS verifier needs root certificates to validate HTTPS connections. scratch has no certificate store. Copying the file from the builder stage gives the binary what it needs without installing a package manager.
The USER 65532:65532 instruction runs the process as nobody. Docker does not require a /etc/passwd file to set a numeric UID. The kernel accepts raw numbers. This keeps the image empty while following the principle of least privilege.
Security defaults matter more than convenience. Run as non-root. Strip symbols. Mount configs externally.
Pitfalls and silent failures
Minimal images remove escape hatches. You cannot docker exec into a scratch container because there is no shell. You cannot install strace or tcpdump to debug network issues. You cannot read /etc/resolv.conf to check DNS resolution. The container is a sealed box.
When things break, the errors are blunt. If you forget CGO_ENABLED=0, the runtime panics with exec format error or no such file or directory when it tries to load libc.so.6. If you use a library that relies on CGO, like github.com/mattn/go-sqlite3, the compiler rejects the build with cgo: C code not supported on this platform or the binary fails to link. You must switch to a pure Go alternative or accept a larger base image.
Missing certificates cause silent TLS failures. The Go HTTP client returns x509: certificate signed by unknown authority. The error looks like a server misconfiguration. The real cause is an empty certificate store. Copy the certs or use a base image that includes them.
Timezone data works the same way. Go reads /etc/localtime and /etc/timezone to set the default location. scratch has neither. Your logs show UTC. This is usually correct for distributed systems, but local debugging becomes confusing. Set the TZ environment variable explicitly if you need a different offset.
Debugging requires preparation. Add structured logging before you deploy. Export metrics. Use health checks. Assume you will never get a shell.
Choosing your base image
Pick the right foundation for your deployment constraints. Each option trades size for convenience.
Use scratch when you want the absolute smallest image and your code uses only pure Go libraries. Use gcr.io/distroless/static when you need a maintained, security-scanned empty image with automatic CVE patching from Google. Use alpine when you depend on CGO libraries or need a shell for debugging and interactive development. Use a full Debian or Ubuntu base when you run legacy binaries that require dynamic system libraries or complex OS utilities.
The smallest image is not always the fastest to build. Alpine pulls faster than scratch because it has fewer layers to verify. Distroless updates automatically when vulnerabilities are found. Scratch updates only when you rebuild. Choose based on your supply chain requirements, not just file size.
Goroutines are cheap. Base images are not magic.