DEV Community

Young Gao
Young Gao

Posted on

Docker Multi-Stage Builds for Go: From 1GB to 12MB Production Images

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"]
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

Better approach — embed files in the binary:

//go:embed static/*
var staticFiles embed.FS

//go:embed migrations/*.sql
var migrations embed.FS
Enter fullscreen mode Exit fullscreen mode

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) \
  .
Enter fullscreen mode Exit fullscreen mode

In your main.go:

var (
    version = "dev"
    commit  = "unknown"
)

func main() {
    slog.Info("starting server", "version", version, "commit", commit)
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"))
})
Enter fullscreen mode Exit fullscreen mode

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 \
  .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)