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.
| Setup | Image Size |
|---|---|
golang:latest + default runtime | 800-900 MB |
golang:alpine runtime | 300-400 MB |
Multi-stage + alpine | 20-40 MB |
| Multi-stage + distroless | 10-25 MB |
Static binary + scratch | 5-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:
alpineusesmusl libc, but because we useCGO_ENABLED=0, our binary doesn’t depend on any libc. musl/glibc is irrelevant.ca-certificatesis installed in the runtime image so HTTPS requests can verify certificates. Without it, HTTPS calls will fail.USER nonroot:nonrootmakes 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:
scratchhas no/etc/passwd—USER nonrootwill error unless you also copy apasswdfile.scratchhas no/etc/ssl/certs/ca-certificates.crt— external HTTPS calls will fail.scratchhas 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.sumdon’t change,go mod downloadisn’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:latestgrype myapp:latestdocker 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.
| Condition | Choice | Reason |
|---|---|---|
| Common production API | distroless | Small size, secure, observability assumed solid |
| Internal / dev-friendly service | alpine | Needs a shell for debugging, still small |
| CLI / agent / sidecar | scratch | Smallest, fast startup, minimal dependencies |
| Applications with CGO | alpine or distroless/cc | Needs libc |
| Serverless (Lambda, Cloud Run) | scratch or distroless | Cold-start time matters |
| High-security environments | distroless or scratch | Minimal 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-debian12is the static variant — no glibc, no busybox. Onlyca-certificatesandtzdata. ~2 MB.-trimpathin build flags — removes absolute paths from the binary, for reproducibility.USER nonroot:nonroot— distroless has this user built in.ENTRYPOINTin exec form — signals reach the binary directly.
Final size: ~10-15 MB for a 5-10 MB binary.
16. Strategy Comparison #
| Strategy | Size | Debugging | Security | Best for |
|---|---|---|---|---|
golang:alpine runtime | 300-400 MB | Easy | Low | Don’t use in production |
alpine runtime | 20-40 MB | Easy | Medium | Dev / staging / observability not yet solid |
distroless | 10-25 MB | Hard | High | Production default |
scratch (static) | 5-15 MB | Very hard | Very high | CLI / 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:alpineruntime — 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.
scratchfor CLI/agents.alpinefor 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 explicitly —
golang:1.22.5-alpine3.20, notlatest. Build reproducibility matters for auditing and rollbacks.- Caching strategy — copy
go.mod/go.sumfirst, download dependencies, then copy the source. This maximizes Docker’s layer cache.