DevOps

Optimizing Docker Images for Go: Multi-Stage Builds, Distroless, and Minimal Attack Surface

Ruslan Ismailov Published 14 min read
O

Introduction: Why Docker Image Size and Security Are Critical in 2026

In 2026, containerization is no longer just a convenient tool — it's the de facto standard for production deployments. Kubernetes clusters serve thousands of pods, CI/CD pipelines run hundreds of builds per day, and compliance requirements (SOC 2, PCI DSS, ISO 27001) directly impact the contents of container images. In this context, three things become critical.

First — deployment speed. An 800 MB image versus an 8 MB image means a difference of tens of seconds when pulling from a registry, which directly affects service degradation time during a Kubernetes rolling update. Second — attack surface. Every unnecessary package in an image is a potential CVE vulnerability. An image based on ubuntu:22.04 contains 200+ packages, most of which your Go application will never need. Third — compliance with security policies. Vulnerability scanners in CI/CD pipelines block deployments when critical CVEs are detected, and the fewer components in the image, the smaller the attack surface.

In this article, we'll go from a naive Dockerfile to a production-ready image under 10 MB with zero CVE vulnerabilities.

Basic Multi-Stage Build for a Go Application

The most common beginner mistake is using a single image for both building and running the application. This results in images weighing 300–900 MB, containing the entire Go toolchain, source code, and all build dependencies.

Multi-stage builds solve this problem: in the first stage we compile the binary, in the second — we copy only the binary into a minimal runtime image.

The key point is static linking via CGO_ENABLED=0. This disables CGO and creates a fully statically linked binary that requires no system libraries in the runtime image. Without this setting, the Go binary will dynamically link against glibc, creating a dependency on a specific libc version in the runtime image.

# Dockerfile.basic — basic multi-stage build

# ── Stage 1: build ──────────────────────────────────────────────
FROM golang:1.23-alpine AS builder

WORKDIR /app

# Copy only dependency files first — this is important for caching
COPY go.mod go.sum ./
RUN go mod download

# Now copy the source code
COPY . .

# Static linking: CGO_ENABLED=0 and GOOS=linux
# -ldflags="-w -s" strips debug info and symbol table
# -trimpath removes absolute paths from the binary (important for reproducible builds)
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
    -ldflags="-w -s" \
    -trimpath \
    -o /app/server \
    ./cmd/server

# ── Stage 2: minimal runtime ─────────────────────────────────────
FROM alpine:3.20 AS runtime

# Update packages to reduce CVEs
RUN apk update && apk upgrade --no-cache

# Copy only the binary
COPY --from=builder /app/server /server

EXPOSE 8080
ENTRYPOINT ["/server"]

This Dockerfile produces an image of ~12–15 MB instead of ~300 MB with golang:1.23. The -w (no DWARF) and -s (no symbol table) flags reduce the binary size by an additional 20–30%.

Evolution of Base Images: scratch vs alpine vs distroless

Choosing the base image for the runtime is a key architectural decision that affects size, security, and operational convenience.

scratch — absolute minimum

scratch is an empty image with no files whatsoever. Your binary is the sole content of the container. This gives the smallest possible size and zero attack surface from system packages.

Drawbacks: no CA certificates (HTTPS requests will fail), no timezone data, no /etc/passwd (you can't run as an unprivileged user without extra steps), and absolutely no debugging tools.

alpine — a popular compromise

Alpine Linux weighs ~5 MB and includes a minimal set of utilities. It uses musl libc instead of glibc, which can sometimes cause compatibility issues. It includes the apk package manager, which is convenient for adding dependencies. Typical CVE count per image: 0–5 with regular updates.

distroless — the gold standard for production

Google's distroless project provides images containing only the application's runtime dependencies — no shell, no package manager, no unnecessary utilities. For Go, use gcr.io/distroless/static-debian12 (for static binaries) or gcr.io/distroless/base-debian12 (if glibc is needed).

Size and CVE comparison (data from a typical Go service, 2024–2025):

  • golang:1.23 — ~600 MB, 50–150 CVEs (including critical)
  • ubuntu:22.04 — ~75 MB, 20–80 CVEs
  • alpine:3.20 — ~8–12 MB, 0–5 CVEs
  • gcr.io/distroless/static-debian12 — ~2–5 MB, 0–2 CVEs
  • scratch — <1 MB overhead, 0 OS CVEs (but requires CA certificates manually)

For most production Go applications, distroless/static is the optimal choice: it includes CA certificates, timezone data, an /etc/passwd file with a nonroot user, but contains no shell or package manager.

Practical Migration to Distroless

Migrating from alpine to distroless requires solving several practical challenges.

# Dockerfile.distroless — production-ready image

# ── Stage 1: build ──────────────────────────────────────────────
FROM golang:1.23-bookworm AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s" \
    -trimpath \
    -o /app/server \
    ./cmd/server

# ── Stage 2: distroless runtime ──────────────────────────────────
# :nonroot tag runs the image as the nonroot user (uid=65532)
# :debug variant includes a busybox shell for debugging
FROM gcr.io/distroless/static-debian12:nonroot AS runtime

# distroless/static already contains:
# - /etc/ssl/certs/ca-certificates.crt (CA certificates)
# - /usr/share/zoneinfo (timezone data)
# - /etc/passwd with the nonroot user
# - /tmp directory

COPY --from=builder /app/server /server

# USER is already nonroot thanks to the :nonroot tag,
# but we set it explicitly for clarity
USER nonroot:nonroot

EXPOSE 8080
ENTRYPOINT ["/server"]

Debugging without a shell

The absence of a shell in distroless is a feature, not a bug. There are several approaches for debugging.

The first is to use the :debug image tag, which includes busybox. Important: :debug is for local debugging only — never use it in production.

The second is kubectl debug in Kubernetes: it lets you attach an ephemeral container with the tools you need to a running pod without restarting it.

# Debug a running pod in Kubernetes
kubectl debug -it my-pod \
  --image=busybox \
  --target=my-container \
  -- sh

# Or use the debug image variant locally
docker run --rm -it \
  --entrypoint sh \
  gcr.io/distroless/static-debian12:debug

If timezone data is needed in code

The distroless/static-debian12 image already includes /usr/share/zoneinfo. If you use distroless/base or scratch, timezone data must be copied explicitly:

# Copying timezone data for a scratch image
FROM builder AS tz-builder
RUN apt-get install -y tzdata

FROM scratch AS runtime
# CA certificates
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Timezone data
COPY --from=tz-builder /usr/share/zoneinfo /usr/share/zoneinfo
# /etc/passwd for a non-root user
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /app/server /server
USER nobody
ENTRYPOINT ["/server"]

Optimizing Layer Caching

The order of instructions in a Dockerfile critically affects rebuild time. Docker and BuildKit cache layers — when one layer changes, all subsequent layers are invalidated.

The golden rule: from least-changed to most-changed. Dependencies (go.mod/go.sum) change rarely; source code changes often.

# Dockerfile.optimized — cache optimization with BuildKit
# syntax=docker/dockerfile:1.9

FROM golang:1.23-bookworm AS builder

WORKDIR /app

# STEP 1: Dependency files only
# This layer is cached as long as go.mod/go.sum don't change
COPY go.mod go.sum ./

# BuildKit cache mount: caches Go's module cache between builds
# Especially useful in CI/CD — go mod download won't re-download packages
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go mod download

# STEP 2: Copy source code only after that
COPY . .

# Compilation also uses cache mounts for the build cache
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s" \
    -trimpath \
    -o /app/server \
    ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot AS runtime
COPY --from=builder /app/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]

The # syntax=docker/dockerfile:1.9 directive enables the latest BuildKit frontend with cache mount support. The --mount=type=cache flag creates a persistent cache between builds — go mod download and compilation use the local cache without re-downloading packages.

BuildKit cache mounts performance gain on a typical project: first build — no change; rebuild with only source file changes — 3–10x speedup (from 2–3 minutes down to 15–30 seconds).

Enabling BuildKit for local builds:

DOCKER_BUILDKIT=1 docker build -t myapp:latest .
# Or using modern docker buildx:
docker buildx build -t myapp:latest .

Vulnerability Scanning: Trivy and Docker Scout

Scanning images for vulnerabilities is a mandatory step in a production workflow. The two most common tools are Trivy by Aqua Security and Docker Scout.

Trivy — fast local and CI scanning

# Install Trivy
brew install aquasecurity/trivy/trivy  # macOS
# or
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh

# Scan an image
trivy image myapp:latest

# Only HIGH and CRITICAL vulnerabilities
trivy image --severity HIGH,CRITICAL myapp:latest

# JSON output for CI
trivy image --format json --output trivy-report.json myapp:latest

# Exit with non-zero code on CRITICAL findings (blocks CI)
trivy image --exit-code 1 --severity CRITICAL myapp:latest

Integrating Trivy into GitHub Actions

# .github/workflows/build.yml
name: Build and Scan

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build-and-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build image
        uses: docker/build-push-action@v6
        with:
          context: .
          file: Dockerfile.optimized
          push: false
          load: true
          tags: myapp:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Scan with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: myapp:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: HIGH,CRITICAL
          exit-code: 1

      - name: Upload Trivy results to GitHub Security
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: trivy-results.sarif

Docker Scout

Docker Scout is built into Docker Desktop and Docker Hub. For a quick check:

# Analyze a local image
docker scout cves myapp:latest

# Compare with a base image
docker scout compare myapp:latest --to myapp:previous

# Get recommendations for updating the base image
docker scout recommendations myapp:latest

Typical scan results after optimization: golang:1.23 builder — 87 CVEs (including 12 HIGH, 3 CRITICAL); final distroless/static image — 0 CVEs.

Configuring Non-Root User, Read-Only Filesystem, and Capabilities

A minimal image size is only half the battle. Proper runtime security settings are equally important.

# Dockerfile.secure — complete production-ready example with security
# syntax=docker/dockerfile:1.9

FROM golang:1.23-bookworm AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go mod download

COPY . .

RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s" \
    -trimpath \
    -o /app/server \
    ./cmd/server

# ── Runtime: distroless with nonroot ────────────────────────────
FROM gcr.io/distroless/static-debian12:nonroot

# distroless:nonroot runs as uid=65532 gid=65532
# No need to create a user manually

COPY --from=builder --chown=nonroot:nonroot /app/server /server

USER nonroot:nonroot

# Image metadata (OCI Labels)
LABEL org.opencontainers.image.source="https://github.com/myorg/myapp" \
      org.opencontainers.image.revision="${GIT_COMMIT}" \
      org.opencontainers.image.created="${BUILD_DATE}"

EXPOSE 8080
ENTRYPOINT ["/server"]

In the Kubernetes manifest, additionally configure securityContext:

# kubernetes-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      # Prevent running as root at the pod level
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
        runAsGroup: 65532
        fsGroup: 65532
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: myapp
          image: myregistry/myapp:latest
          ports:
            - containerPort: 8080
          securityContext:
            # Read-only root filesystem
            readOnlyRootFilesystem: true
            # Prevent privilege escalation
            allowPrivilegeEscalation: false
            # Drop all capabilities
            capabilities:
              drop:
                - ALL
          # If the app needs write access — mount a tmpfs
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir:
            medium: Memory
            sizeLimit: 64Mi

The combination of readOnlyRootFilesystem: true, allowPrivilegeEscalation: false, and capabilities.drop: ALL maximally limits an attacker's options even if the application is compromised.

Building Multi-Platform Images with Docker Buildx

Kubernetes clusters increasingly use ARM nodes (AWS Graviton, Azure Ampere) alongside x86_64. Multi-platform images let you use a single tag for both architectures.

# Set up buildx with multi-platform support
docker buildx create \
  --name multiplatform-builder \
  --driver docker-container \
  --platform linux/amd64,linux/arm64 \
  --use

# Check available platforms
docker buildx inspect --bootstrap

# Build and push a multi-platform image
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --file Dockerfile.secure \
  --tag myregistry/myapp:latest \
  --tag myregistry/myapp:1.2.3 \
  --push \
  .

# Inspect the manifest
docker buildx imagetools inspect myregistry/myapp:latest

In the Dockerfile for a multi-platform build, use TARGETPLATFORM and BUILDPLATFORM for Go cross-compilation:

# Dockerfile.multiplatform
# syntax=docker/dockerfile:1.9

FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS builder

# Cross-compilation variables
ARG TARGETPLATFORM
ARG TARGETOS
ARG TARGETARCH

WORKDIR /app

COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

COPY . .

# Go cross-compilation using GOOS/GOARCH from TARGETPLATFORM
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
    go build \
    -ldflags="-w -s" \
    -trimpath \
    -o /app/server \
    ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]

Important: use --platform=$BUILDPLATFORM for the builder stage so that compilation always runs natively on the build machine (not via QEMU emulation), while cross-compilation is handled by the Go toolchain. This gives a 5–20x speed improvement compared to QEMU emulation.

Measuring Results: Before and After

Here are concrete numbers for a typical Go HTTP service (~5,000 lines of code, several dependencies).

Image sizes

  • golang:1.23 (no multi-stage) — 862 MB
  • multi-stage + ubuntu:22.04 — 78 MB
  • multi-stage + alpine:3.20 — 13.2 MB
  • multi-stage + distroless/static — 7.8 MB
  • multi-stage + scratch (with CA certificates) — 6.1 MB

CVE count (Trivy, HIGH + CRITICAL)

  • golang:1.23 (no multi-stage) — 15 HIGH, 3 CRITICAL
  • multi-stage + ubuntu:22.04 — 8 HIGH, 1 CRITICAL
  • multi-stage + alpine:3.20 — 1 HIGH, 0 CRITICAL
  • multi-stage + distroless/static — 0 HIGH, 0 CRITICAL
  • multi-stage + scratch — 0 HIGH, 0 CRITICAL

Build time (CI, rebuild with a single file change)

  • Without BuildKit cache mounts — 3 min 20 sec
  • With BuildKit cache mounts (GHA cache) — 28 sec

Commands to verify results

# Image size
docker image ls myapp:latest --format "{{.Size}}"

# Detailed layer analysis
docker history myapp:latest

# In-depth inspection with dive (layer analysis tool)
dive myapp:latest

# Build time
time docker buildx build -t myapp:latest .

# Full Trivy report
trivy image --format table myapp:latest

# Verify the binary is statically linked
docker run --rm --entrypoint sh \
  gcr.io/distroless/static-debian12:debug \
  -c "file /server" \
  myapp:latest
# Expected output: /server: ELF 64-bit LSB executable, statically linked

# Verify the running user
docker run --rm myapp:latest id
# Expected output: uid=65532(nonroot) gid=65532(nonroot)

Conclusion and Production-Ready Checklist

Optimizing Docker images for Go is not a one-time task — it's a set of practices that should be established from the very first Dockerfile. Moving from a naive image to a production-ready solution reduces image size by 100x or more, completely eliminates OS-level CVEs, and speeds up the CI/CD pipeline several times over.

These optimizations are especially important in Kubernetes environments, where image pull speed directly affects pod startup time, and security policies increasingly block deployments of images with critical vulnerabilities.

Production-Ready Docker Image Checklist for Go

  1. Multi-stage build is used (builder + runtime)
  2. CGO_ENABLED=0 — static binary linking
  3. -ldflags="-w -s" and -trimpath flags to reduce binary size
  4. Base image is distroless/static-debian12:nonroot or scratch
  5. go.mod/go.sum are copied separately before source code for caching
  6. BuildKit cache mounts for /go/pkg/mod and /root/.cache/go-build
  7. Trivy scanning in CI with blocking on CRITICAL CVEs
  8. Application runs as a non-root user (USER nonroot)
  9. Kubernetes securityContext: readOnlyRootFilesystem, allowPrivilegeEscalation: false, capabilities.drop: ALL
  10. Multi-platform build (amd64 + arm64) via docker buildx
  11. OCI labels (LABEL org.opencontainers.image.*) for traceability
  12. Regular base image updates (automated PRs via Dependabot or Renovate)

Technologies

Tags

Ruslan Ismailov

Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →