Golang #

Golang is often called the most “container-friendly” language. That claim isn’t wrong — Go can produce a single static binary with no runtime dependencies. But that claim is also often quietly misunderstood: many Go images in production are still 200-400 MB, even though the real potential is 5-15 MB. The gap between potential and reality isn’t because Go fails, but because the Dockerfile fails to manage the boundary between build and runtime.

This article discusses in detail, realistically, and production-oriented how to build small, secure, operational Go Docker images. We’ll look at why Go images bloat easily, the correct strategies to fix that, and when to stop optimizing because it’s already enough.

1. The Reality of Go Image Sizes #

Before diving into strategies, let’s look at the realistic numbers in production. These aren’t theoretical — they’re what usually shows up on teams just starting optimization.

SetupImage Size
golang:latest + default runtime800-900 MB
golang:alpine runtime300-400 MB
Multi-stage + alpine20-40 MB
Multi-stage + distroless10-25 MB
Static binary + scratch5-15 MB

Key insight: The difference between a “careless” Go image (800 MB) and an “optimal” Go image (5-15 MB) is two orders of magnitude. This isn’t a 10% optimization — it’s a fundamental difference. If your Go image is > 100 MB, something is almost certainly wrong with the Dockerfile: a compiler leaking into runtime, unstripped dependencies, or an oversized base image.

A quick audit method:

docker history myapp:latest

Look at which layer is the biggest. Usually the culprit is the base image or build dependencies carried along.

2. Why Go Can Be Very Small #

To appreciate an optimal Go image, you need to understand the Go features that make it possible.

Single Static Binary #

Unlike Java (needs a JRE), Node.js (needs a runtime + node_modules), or Python (needs an interpreter + packages), Go compiles the application into one executable containing all the application code. There’s no separate runtime to include.

Optional Static Linking #

By default, Go links binaries to glibc (on Linux). But you can compile with CGO_ENABLED=0 to produce a fully static binary that doesn’t need any OS library. This enables images as small as scratch (0-byte base image, just your binary).

No Runtime Dependency Tree #

No node_modules, no site-packages, no vendor/. A Go binary contains everything.

The Compiler Stays in the Toolchain, Not the Output #

Go has a large toolchain (go, compile, link, asm), but none of it ends up in the compiled binary. The compiled binary is a clean final product.

3. The Main Principle: Build vs Runtime Boundary #

An ideal Go runtime image contains only the application binary + CA certificates (for HTTPS) + time zone data (optional).

Full stop. No compiler, no source code, no build cache, no package manager. Contrast with the images we often see:

// ANTI-PATTERN: all phases mixed together
FROM golang:latest
WORKDIR /app
COPY . .
RUN go build -o app
CMD ["./app"]
// The image now has: go toolchain, source code, build cache, GOPATH

Images like this work in development, but for production they carry thousands of files irrelevant to runtime. Size bloats, the attack surface widens, and every deployment pulls more data than actually needed.

4. Multi-Stage Build Strategies #

Multi-stage builds are the unavoidable foundation. Without multi-stage, a Go image will always carry build tools into runtime. There are three main strategies commonly used, from the most common to the most extreme.

4.1 Alpine Runtime — The Pragmatic Default #

The most common industry approach. The build stage uses golang:alpine (already small), the runtime stage uses alpine (even smaller).

# Build stage
FROM golang:1.22-alpine AS builder

WORKDIR /app

# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download

# Build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-s -w" -o app

# Runtime stage
FROM alpine:3.19

RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /app/app .

EXPOSE 8080
USER nonroot:nonroot
CMD ["./app"]

Typical size: 20-40 MB.

Important notes:

  • alpine uses musl libc, but because we use CGO_ENABLED=0, our binary doesn’t depend on any libc. musl/glibc is irrelevant.
  • ca-certificates is installed in the runtime image so HTTPS requests can verify certificates. Without it, HTTPS calls will fail.
  • USER nonroot:nonroot makes the container run as a non-root user.

When to use: The default for production Go services. The size vs debugging capability trade-off is still balanced — alpine has a shell and package manager, so you can still docker exec -it container sh for debugging.

4.2 Distroless Runtime — Mature Production #

Distroless images only carry runtime essentials — glibc, CA certs, and /etc/passwd for the nonroot user. No shell, no package manager, no OS utilities.

# Build stage
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 -ldflags="-s -w" -o app

# Runtime stage — distroless
FROM gcr.io/distroless/base-debian12

WORKDIR /app
COPY --from=builder /app/app .

USER nonroot:nonroot
EXPOSE 8080
CMD ["./app"]

Typical size: 10-25 MB.

When to use: High-maturity production, security-first. Fits applications whose observability is already solid (logs to STDOUT, metrics, tracing).

The trade-offs to understand:

  • Can’t docker exec -it container sh — interactive debugging isn’t possible.
  • HEALTHCHECK must be HTTP-based (no shell commands available).
  • Logging must natively go to STDOUT — there are no log files to tail.
  • All observability must go through log aggregators, metrics endpoints, and tracing.

4.3 Scratch — Extreme But Pure #

scratch is an empty base image — 0 bytes. Your final image only contains the binary you copy into it.

FROM golang:1.22-alpine AS builder

WORKDIR /app

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

# Build a static binary
RUN CGO_ENABLED=0 GOOS=linux \
    go build -ldflags="-s -w" -o app

# Runtime stage — empty
FROM scratch
COPY --from=builder /app/app /app
USER nonroot:nonroot
EXPOSE 8080
CMD ["/app"]

Typical size: 5-15 MB.

When to use: Very static applications that don’t need external HTTPS (or embed CA certs in the binary), don’t need DNS, don’t need time zones. This is a narrow case — usually CLI tools, agents, or internal services.

Important notes:

  • scratch has no /etc/passwdUSER nonroot will error unless you also copy a passwd file.
  • scratch has no /etc/ssl/certs/ca-certificates.crt — external HTTPS calls will fail.
  • scratch has no DNS resolver — scratch can’t even resolve hostnames.

Solutions for scratch:

# Add CA certs
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Or build with certs embedded
// certdata.go
//go:embed certs/ca-certificates.crt
var CACerts []byte

Most people prefer distroless over scratch for production services. Distroless already has ca-certificates, /etc/passwd with the nonroot user, and glibc — everything usually needed without significant overhead.

5. CGO: The Silent Enemy of Small Images #

CGO lets Go call C code. The problem: if you use CGO, your Go binary depends on libc (the C standard library), and libc must be present in the runtime image.

# Check whether your binary depends on a C library
ldd myapp
# Output:
#   not a dynamic executable  ← good, static
#   libc.musl-x86_64.so.1     ← depends on musl
#   libc.so.6                 ← depends on glibc

The default solution: disable CGO.

ENV CGO_ENABLED=0

With CGO_ENABLED=0, Go produces a static binary that doesn’t depend on any libc. This makes images as small as scratch possible.

When must CGO be ON? When your application:

  • Uses SQLite via mattn/go-sqlite3 (a CGO wrapper).
  • Uses image processing via C libraries like imaging, bimg (binding to libvips).
  • Uses system-call libraries that need C wrappers.

The CGO solution: Use a base image with libc — alpine (musl) or debian-slim/distroless/cc (glibc). But remember, these images are bigger than scratch/distroless/base.

6. Binary Size Optimizations #

A default-built Go binary still has debug symbols and DWARF info that add size but aren’t needed at runtime. Stripping can shrink the binary by up to 20-30%.

Strip with ldflags #

go build -ldflags="-s -w" -o app
  • -s — omit the symbol table and debug info.
  • -w — omit DWARF debugging information.

This combination doesn’t affect runtime functionality, only removes debugging information. For production, it’s almost always safe. For development, you may want to keep debug info.

Strip with upx (Optional) #

upx (Ultimate Packer for eXecutables) can compress the binary further, up to 30-50% smaller.

# In the build stage
RUN apk add --no-cache upx
RUN go build -ldflags="-s -w" -o app \
 && upx --best --lzma app

UPX trade-offs:

  • Smaller binary.
  • Startup time increases because the binary must be decompressed at start (usually <100ms).
  • Some monitoring systems may falsely flag UPX-compressed binaries as malware.

Recommendation: Use UPX only for CLI tools or agents where startup time isn’t critical. For production services, -ldflags="-s -w" is enough.

7. Build Cache Strategy #

The Go module cache (go mod download) is very effective for Docker layer caching when used correctly.

// ✓ A pattern that maximizes caching
FROM golang:1.22-alpine AS builder
WORKDIR /app

# Layer 1: dependency files (rarely change)
COPY go.mod go.sum ./
RUN go mod download

# Layer 2: source code (often changes)
COPY . .

# Layer 3: build
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o app

How it works:

  • As long as go.mod/go.sum don’t change, go mod download isn’t re-run.
  • As long as the source code doesn’t change, the build isn’t re-run.
  • Only source code changes = only layers 2 and 3 are rebuilt.

An extra trick — a fake entrypoint for dependency caching:

# Force caching for dependency compilation
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /tmp/app ./...

# The real build (faster because dependencies are already cached)
RUN go build -o app ./cmd/server

This is an old pattern sometimes still used for large projects, but with modern Docker build caching, it’s usually unnecessary.

8. Binary Reproducibility #

For production, reproducible builds matter for auditing and rollbacks. Several ways to achieve it:

Tag images with the git commit hash:

docker build -t myapp:$(git rev-parse --short HEAD) .

Always commit go.sum. go.sum contains checksums for all dependencies, so go mod download will always fetch the same versions.

Avoid go get without explicit versions in go.mod. Always write go.mod explicitly, and let go mod tidy manage go.sum.

9. Security Hardening #

9.1 Non-Root Users Are Always Mandatory #

The default is for containers to run as root. Add USER in the Dockerfile.

USER nonroot:nonroot

For distroless, the nonroot user is built in with UID 65532. For alpine, you can use the nobody user or create a custom one.

Creating a custom user in alpine:

RUN addgroup -g 1001 -S appgroup \
 && adduser -u 1001 -S appuser -G appgroup
USER appuser

A fixed UID (>= 1000) matters for consistent permissions on volume mounts.

9.2 Vulnerability Scanning #

Small Go images are easier to scan, but must still be scanned. Tools:

  • trivy image myapp:latest
  • grype myapp:latest
  • docker scout cves myapp:latest

Integrate into CI:

- name: Build
  run: docker build -t myapp:${{ github.sha }} .
- name: Scan
  run: trivy image --exit-code 1 --severity CRITICAL myapp:${{ github.sha }}

9.3 Read-Only Filesystems #

For extra hardening, run containers with a read-only root filesystem:

docker run --read-only --tmpfs /tmp myapp

Well-written Go applications work with a read-only root, because they don’t write to the filesystem (everything goes through env vars, logs to STDOUT, etc.).

9.4 Drop Capabilities #

Unneeded Linux capabilities should be dropped. Docker drops some by default, but for maximum hardening:

docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp

10. Production-Grade Logging #

Production containers must not write logs to files. Go applications should log to os.Stdout and os.Stderr.

// ✓ Use the standard library
log.SetOutput(os.Stdout)
log.Println("request received")

// ✓ Use a structured logger (zap, zerolog, slog)
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("request received", "path", r.URL.Path, "status", 200)

JSON structured logs are easier for log aggregators to parse (Loki, ELK, Datadog, etc.).

A note for 12-factor apps: 12-factor applications must treat logs as an event stream. Writing logs to files in a container violates this and makes logs disappear when the container restarts. Always log to STDOUT/STDERR.

11. The Right Healthcheck #

For Go applications, healthchecks should be HTTP-based (checking a /health or /ready endpoint) and should distinguish between liveness (does it need a restart?) and readiness (is it ready for traffic?).

// A minimal example
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
    // Check dependencies (DB, cache, etc.)
    if !db.Ping() {
        w.WriteHeader(http.StatusServiceUnavailable)
        return
    }
    w.WriteHeader(http.StatusOK)
})

In the Dockerfile (for alpine, not distroless):

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1

For distroless, a Dockerfile HEALTHCHECK is difficult — move it to the orchestrator (Kubernetes liveness/readiness probes).

12. Signal Handling #

Production containers must shut down cleanly when receiving SIGTERM. Go applications handle SIGTERM correctly by default (process exit), but there are some things to ensure:

// ✓ Set up a context listening for signals
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

// Server.Shutdown with a context
go func() {
    <-ctx.Done()
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    if err := server.Shutdown(shutdownCtx); err != nil {
        log.Printf("HTTP server Shutdown: %v", err)
    }
}()

Also make sure:

  • Exec form in CMD/ENTRYPOINT (not shell form), so signals reach the binary directly.
  • A sufficient grace period (30 seconds is usually enough) to drain connections.
  • Database connections are closed properly.

13. When to Use Which Strategy #

The strategy choice depends on your application’s context. No choice is universally correct.

ConditionChoiceReason
Common production APIdistrolessSmall size, secure, observability assumed solid
Internal / dev-friendly servicealpineNeeds a shell for debugging, still small
CLI / agent / sidecarscratchSmallest, fast startup, minimal dependencies
Applications with CGOalpine or distroless/ccNeeds libc
Serverless (Lambda, Cloud Run)scratch or distrolessCold-start time matters
High-security environmentsdistroless or scratchMinimal attack surface

Rule of thumb: Start with alpine for development, evaluate distroless for staging, and use scratch only for CLI tools or very performance-sensitive services.

14. Anti-Patterns to Avoid #

✗ Copying the Whole Repo Up Front #

// ✗ Cache invalidated on every change
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o app

Solution: Copy go.mod and go.sum first, download dependencies, then copy the source.

✗ Not Stripping the Binary #

// ✗ Binary 30% larger
RUN go build -o app

Solution: Always use -ldflags="-s -w".

✗ Building with CGO Unknowingly #

// CGO is ON by default; the binary depends on glibc
FROM golang:1.22
RUN go build -o app

Solution: Always set CGO_ENABLED=0 unless you truly need CGO.

latest Base Images #

// ✗ Non-deterministic builds
FROM golang:latest

Solution: Pin a specific tag: golang:1.22.5-alpine3.20.

✗ Using golang:alpine as the Final Image #

// ✗ ~400 MB image because it carries build tools
FROM golang:1.22-alpine
COPY --from=builder /app/app .

Solution: Use alpine, distroless, or scratch for the runtime stage — not golang:*.

✗ Containers Running as Root #

// ✗ Privilege escalation risk
FROM alpine
COPY --from=builder /app/app /app
CMD ["/app"]

Solution: Add USER nonroot:nonroot or a custom user.

15. A Production-Grade Dockerfile Example #

Here’s a Dockerfile combining all the best practices above:

# syntax=docker/dockerfile:1.7

# ==== Stage 1: Build ====
FROM golang:1.22.5-alpine3.20 AS builder

# Build dependencies
RUN apk add --no-cache git ca-certificates tzdata

WORKDIR /src

# Cache the dependency layer
COPY go.mod go.sum ./
RUN go mod download

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

# ==== Stage 2: Runtime ====
FROM gcr.io/distroless/static-debian12:nonroot

COPY --from=builder /out/app /app

# Default config
ENV APP_PORT=8080 \
    GIN_MODE=release

EXPOSE 8080

USER nonroot:nonroot
ENTRYPOINT ["/app"]

Notes:

  • distroless/static-debian12 is the static variant — no glibc, no busybox. Only ca-certificates and tzdata. ~2 MB.
  • -trimpath in build flags — removes absolute paths from the binary, for reproducibility.
  • USER nonroot:nonroot — distroless has this user built in.
  • ENTRYPOINT in exec form — signals reach the binary directly.

Final size: ~10-15 MB for a 5-10 MB binary.

16. Strategy Comparison #

StrategySizeDebuggingSecurityBest for
golang:alpine runtime300-400 MBEasyLowDon’t use in production
alpine runtime20-40 MBEasyMediumDev / staging / observability not yet solid
distroless10-25 MBHardHighProduction default
scratch (static)5-15 MBVery hardVery highCLI / agent / performance-critical

17. Go Dockerfile Review Checklist #

BASE IMAGE:
  □ Explicit tag (golang:1.22.5-alpine3.20, not latest)
  □ Runtime stage is not a golang:* image
  □ Runtime base image is as small as possible

BUILD:
  □ Multi-stage build
  □ CGO_ENABLED=0 (unless truly needed)
  □ go mod download separated from copying source
  □ go build with -ldflags="-s -w"
  □ go build with -trimpath (reproducibility)

RUNTIME:
  □ USER nonroot (UID >= 1000 or distroless's nonroot user)
  □ EXPOSE the port
  □ ENTRYPOINT in exec form
  □ HEALTHCHECK (if the image has a shell)

SIZE:
  □ < 50 MB for alpine runtime
  □ < 30 MB for distroless runtime
  □ < 20 MB for scratch static
  □ docker history shows no oddly large layers

SECURITY:
  □ No secrets in the image
  □ Strict .dockerignore
  □ Image scanned with trivy/grype in CI
  □ Base image up to date (CVE patched)

Summary #

  • Go is the most container-friendly language, but an optimal Go image only happens if the Dockerfile manages the build vs runtime boundary correctly.
  • Size reality: 5-15 MB (scratch static), 10-25 MB (distroless), 20-40 MB (alpine), 300-400 MB (golang:alpine runtime — anti-pattern), 800+ MB (golang:latest — anti-pattern).
  • Multi-stage builds are mandatory — compilers and build tools must not enter the runtime image.
  • CGO_ENABLED=0 is the default for static binaries. Only ON for C libraries that are genuinely needed.
  • Strip binaries with -ldflags="-s -w" — reduce 20-30% of size without sacrificing runtime functionality.
  • Distroless is the production default for Go services. scratch for CLI/agents. alpine for dev/staging.
  • USER nonroot is mandatory — whether in alpine, distroless, or scratch.
  • Small images need good observability — log to STDOUT, metrics endpoints, healthchecks. A small image + solid observability beats a large image + manual debugging.
  • Tag images explicitlygolang:1.22.5-alpine3.20, not latest. Build reproducibility matters for auditing and rollbacks.
  • Caching strategy — copy go.mod/go.sum first, download dependencies, then copy the source. This maximizes Docker’s layer cache.

← Previous: Image Size   Next: Python →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact