Docker Multi-Stage Builds for Go: From 1GB to 12MB Production Images
Most Go Docker images are built wrong. A typical FROM golang:1.22 image weighs 800MB+. Your compiled Go binary is probably 10-15MB. Here's how to use multi-stage builds to ship minimal, secure images — and avoid the common pitfalls.
The Problem
# DON'T do this
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["./server"]
This image includes the entire Go toolchain, build cache, and source code. Result: ~850MB image containing ~840MB of stuff you don't need in production.
The Basic Multi-Stage Fix
# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .
# Stage 2: Run
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/server /server
CMD ["/server"]
This gets you to ~20MB. But we can do better, and there are production concerns to address.
The Production Dockerfile
# syntax=docker/dockerfile:1
# ============ Stage 1: Dependencies ============
FROM golang:1.22-alpine AS deps
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download && go mod verify
# ============ Stage 2: Build ============
FROM golang:1.22-alpine AS builder
WORKDIR /app
# Copy cached modules
COPY --from=deps /go/pkg/mod /go/pkg/mod
# Copy source
COPY . .
# Build with optimizations
ARG VERSION=dev
ARG COMMIT=unknown
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build \
-ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \
-trimpath \
-o /server \
./cmd/server
# ============ Stage 3: Production ============
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /server /server
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]
Let's break down what each piece does and why.
Key Decisions Explained
CGO_ENABLED=0
Go can link against C libraries (CGo). Disabling it produces a fully static binary — no libc dependency, no shared library issues, runs on any Linux. If you need CGo (SQLite, certain crypto), you'll need a different base image.
-ldflags="-s -w"
-s strips the symbol table, -w strips DWARF debug information. Together they reduce binary size by 20-30%. You lose go tool pprof symbol names, but production binaries shouldn't need them.
-trimpath
Removes local file paths from the binary. Without this, panic stack traces reveal your build directory structure — a minor security concern.
--mount=type=cache
BuildKit cache mounts persist the Go module cache and build cache across builds. First build downloads all modules; subsequent builds reuse the cache. On a project with 200 dependencies, this cuts build time from 90s to 15s.
Distroless vs Alpine vs Scratch
# Option 1: scratch (smallest, ~12MB total)
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /server /server
# Option 2: distroless (recommended, ~14MB total)
FROM gcr.io/distroless/static-debian12:nonroot
# Option 3: alpine (~22MB total)
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
Scratch has no shell, no tools, no users — just your binary. Debugging is hard (can't exec into the container).
Distroless adds CA certificates, timezone data, a nonroot user, and /tmp. Still no shell or package manager. This is the sweet spot for most production services.
Alpine adds a shell, package manager, and musl libc. Use it when you need to debug containers in production or when you have CGo dependencies.
Non-Root User
USER nonroot:nonroot
Never run containers as root. Distroless provides a nonroot user (UID 65532). If using scratch:
FROM scratch
COPY --from=builder /etc/passwd /etc/passwd
USER 65532:65532
Handling Static Files and Config
If your Go binary needs to serve static files or read config:
FROM gcr.io/distroless/static-debian12:nonroot
# Copy binary
COPY --from=builder /server /server
# Copy static files (embedded in binary is better, but sometimes you need files)
COPY --from=builder /app/static /static
COPY --from=builder /app/migrations /migrations
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]
Better approach — embed files in the binary:
//go:embed static/*
var staticFiles embed.FS
//go:embed migrations/*.sql
var migrations embed.FS
Now your binary is self-contained. No files to copy, no paths to get wrong.
CI/CD Integration
Build with version info from Git:
docker build \
--build-arg VERSION=$(git describe --tags --always) \
--build-arg COMMIT=$(git rev-parse --short HEAD) \
-t myapp:$(git describe --tags --always) \
.
In your main.go:
var (
version = "dev"
commit = "unknown"
)
func main() {
slog.Info("starting server", "version", version, "commit", commit)
// ...
}
Health Check for Orchestrators
# If using alpine (has wget)
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
For distroless (no wget), implement the health check in your Go binary:
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
And let Kubernetes/ECS probe the endpoint directly.
Multi-Architecture Builds
Ship for both AMD64 and ARM64:
docker buildx create --name multiarch --use
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg VERSION=$(git describe --tags) \
-t myregistry/myapp:latest \
--push \
.
Update your Dockerfile to use the build arg:
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -ldflags="-s -w" -trimpath -o /server ./cmd/server
Docker sets TARGETOS and TARGETARCH automatically during multi-platform builds.
Security Scanning
# Scan with trivy
trivy image myapp:latest
# Scan with grype
grype myapp:latest
Distroless and scratch images typically have zero CVEs because there's nothing to scan — no OS packages, no libraries. Alpine images usually have a few low-severity findings in musl or busybox.
Size Comparison
| Base Image | Go Binary | Total Image | CVEs |
|---|---|---|---|
| golang:1.22 | 15MB | ~850MB | 100+ |
| alpine:3.19 | 15MB | ~22MB | 0-3 |
| distroless/static | 12MB | ~14MB | 0 |
| scratch | 12MB | ~12MB | 0 |
The binary is smaller with -ldflags="-s -w" (15MB → 12MB).
Common Mistakes
1. Copying go.sum but not go.mod — Docker layer caching works on go.mod and go.sum together. Copy both before go mod download.
2. Not separating dependency download from build — If you copy all source before downloading modules, every code change invalidates the module cache layer.
3. Forgetting CA certificates — TLS connections fail silently or with cryptic errors. Distroless includes them. Scratch and alpine need manual handling.
4. Building for the wrong architecture — Always set GOOS and GOARCH explicitly. Your CI might be ARM but your target is AMD64.
5. Including .git in the build context — Add .git to .dockerignore. A large git history can add hundreds of MB to the build context.
# .dockerignore
.git
*.md
docs/
**/*_test.go
Conclusion
A production Go Docker image should be under 15MB, run as non-root, and contain nothing except your binary and its required certificates. Multi-stage builds make this straightforward — one stage for building, one for running. The build cache flags (--mount=type=cache) keep iteration fast.
Start with distroless for most services. Drop to scratch if you need the absolute minimum. Use alpine only when you need a shell for debugging.
If this was helpful, you can support my work at ko-fi.com/nopkt
If this article helped you, consider buying me a coffee on Ko-fi! Follow me for more production backend patterns.
Top comments (0)