Best Practices for Go Docker Images in Production

Build Go apps in a multi-stage Dockerfile using alpine and a non-root user for secure, minimal production images.

The bakery problem

You spent the afternoon refactoring a Go service. The tests pass. You run docker build and push the image to production. The deployment hangs. The container starts, tries to write a log file, and crashes with a permission error. You check the image size: 850 megabytes. A security scanner flags forty-two vulnerabilities, mostly in libraries you don't even use. The problem isn't your Go code. The problem is the container image.

Go compiles to a single static binary. When you run go build, the compiler bundles your code, the standard library, and your dependencies into one executable file. That binary runs on the target machine without needing the Go toolchain, the source code, or a package manager. Docker images often include the compiler, the source, and the entire operating system just to run that one file. That's like shipping a bakery with every cake. You only need the cake.

Multi-stage builds separate build time from runtime

Docker supports multi-stage builds. You can define multiple FROM instructions in a single Dockerfile. Each FROM starts a new stage. You can copy artifacts from earlier stages into later stages. This lets you use a heavy image with the Go compiler to build your binary, then copy only the binary into a tiny runtime image. The final image contains the binary and nothing else. The Go compiler, source code, and build tools never make it into the runtime image.

# Stage 1: Build the binary
FROM golang:1.22-alpine AS builder
WORKDIR /src
# Copy dependency files first to leverage Docker layer caching.
# If go.mod hasn't changed, Docker skips re-downloading modules.
COPY go.mod go.sum ./
RUN go mod download
# Copy the rest of the source code.
COPY . .
# Build a static binary. CGO_ENABLED=0 ensures no C dependencies.
# -ldflags="-w -s" strips debug info to reduce size.
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o server .

# Stage 2: Runtime image
FROM alpine:3.19
# Add certificates for HTTPS requests.
RUN apk add --no-cache ca-certificates
# Create a non-root user to reduce attack surface.
RUN adduser -D -g '' appuser
WORKDIR /app
# Copy only the binary from the builder stage.
COPY --from=builder /src/server .
# Switch to the non-root user.
USER appuser
CMD ["./server"]

Multi-stage builds are the standard for Go. Don't ship the compiler.

Layer caching keeps builds fast

Docker caches build layers. If a layer hasn't changed, Docker reuses the cached result. The order of instructions matters. If you copy all source files before downloading modules, any code change invalidates the module cache. Docker re-downloads every dependency on every build, even if go.mod hasn't changed.

Copy go.mod and go.sum first. Run go mod download. Then copy the source code. When you change a .go file, Docker sees that go.mod is unchanged. It reuses the cached module download. The build only recompiles the code. This can cut build times from minutes to seconds.

The convention is clear: dependency files before source code. Cache the expensive parts.

Static binaries and ldflags

Go can use C libraries via CGO. If CGO is enabled, the compiler links against the C standard library. The resulting binary depends on libc being present at runtime. Alpine Linux uses musl libc, which is not fully compatible with glibc. A binary built with CGO on a glibc system might crash on Alpine with standard_init_linux.go:228: exec user process caused: no such file or directory.

Set CGO_ENABLED=0 to disable CGO. The compiler produces a fully static binary with no external dependencies. The binary runs on any Linux distribution, including minimal images like scratch or distroless.

The -ldflags argument controls linker behavior. The flag -w removes DWARF debug information. The flag -s removes the symbol table. Both flags reduce the binary size significantly. You can also inject build metadata using -X. This lets you embed version strings or commit hashes directly into the binary.

// main.go
package main

import (
    "fmt"
    "os"
)

// version is set by the linker during build.
var version = "dev"

func main() {
    if len(os.Args) > 1 && os.Args[1] == "version" {
        fmt.Println(version)
        return
    }
    fmt.Println("Server running")
}
# Build with version info injected via ldflags.
# This helps debugging in production without rebuilding the image.
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s -X main.version=${VERSION}" \
    -o server .

Inject build info with ldflags. Strip debug data for size.

Realistic production Dockerfile

Production images often need more than a binary. You might need health checks, port exposure, or specific signal handling. The exec form of CMD is critical. It sends signals directly to the process. The shell form wraps the command in /bin/sh, which can swallow signals. If you send SIGTERM to stop the container, the shell might not forward it. Your Go app won't shut down gracefully.

FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s -X main.version=${VERSION}" \
    -o server .

FROM alpine:3.19
RUN apk add --no-cache ca-certificates
RUN adduser -D -g '' appuser
WORKDIR /app
COPY --from=builder /src/server .
# Expose the port for documentation and orchestration.
EXPOSE 8080
# Use exec form to ensure signals are handled correctly.
# This allows graceful shutdown when the container stops.
USER appuser
CMD ["./server"]

Signals control the lifecycle. Use exec form or your app won't die.

Pitfalls and runtime errors

Running as root is a common mistake. If the container is compromised, the attacker gets root privileges inside the container. Container escapes can sometimes grant access to the host. Even without security concerns, apps often fail to write logs or data files when running as root due to filesystem permissions. The container crashes with open /app/logs/app.log: permission denied. Always create a non-root user and switch to it with USER.

Missing certificates break HTTPS. Minimal images like scratch contain no root certificates. If your app makes HTTPS requests, the TLS handshake fails. The runtime panics with x509: failed to load system roots and no roots provided. Add ca-certificates to Alpine images, or use distroless images which include certificates by default.

Layer caching mistakes slow down development. If you copy source code before downloading modules, every change triggers a full module download. Builds take longer. Developers get frustrated. Order your instructions to maximize cache hits.

CGO mistakes cause cryptic crashes. If you forget CGO_ENABLED=0, the binary links to libc. Deploying to an image without the matching libc causes immediate failure. The error no such file or directory appears even though the binary exists. The system cannot find the shared library the binary depends on. Disable CGO unless you explicitly need C bindings.

Goroutine leaks hide in containers. If a goroutine waits on a channel that never closes, it stays alive. The container never exits cleanly. The orchestrator kills it after a timeout. The worst goroutine bug is the one that never logs. Ensure all long-running goroutines respect context cancellation.

Root is for emergencies. Run as a user.

Decision matrix

Use golang:1.22-alpine as a builder when you need the Go toolchain and module caching. Use alpine:3.19 as a runtime when you need a shell or package manager for debugging or sidecar tools. Use gcr.io/distroless/static-debian12 when you want a minimal image with certificates but no shell, maximizing security. Use scratch when you have a fully static binary with no external dependencies and want the absolute smallest image. Use a non-root user in every production image to limit the blast radius of a container escape. Use exec form for CMD to ensure signals reach your process for graceful shutdown. Use CGO_ENABLED=0 unless your code requires C libraries. Use -ldflags="-w -s" to reduce binary size and strip debug information.

Smaller images ship faster and break less often.

Where to go next