Docker Image Optimization

Techniques for building small, efficient Docker images with focus on Alpine, scratch, and Go binaries.


Multi-Stage Builds

Basic Multi-Stage Build

 1# Build stage
 2FROM golang:1.21-alpine AS builder
 3WORKDIR /app
 4COPY go.mod go.sum ./
 5RUN go mod download
 6COPY . .
 7RUN go build -o myapp
 8
 9# Final stage
10FROM alpine:latest
11WORKDIR /app
12COPY --from=builder /app/myapp .
13CMD ["./myapp"]

Benefits:

  • Build dependencies not included in final image
  • Smaller final image size
  • Faster deployment

Alpine-Based Images

Using Alpine

 1# Alpine is ~5MB vs Ubuntu ~70MB
 2FROM alpine:3.19
 3
 4# Install packages
 5RUN apk add --no-cache \
 6    ca-certificates \
 7    tzdata
 8
 9WORKDIR /app
10COPY myapp .
11
12CMD ["./myapp"]

Alpine with Build Tools

 1FROM alpine:3.19
 2
 3# Install build dependencies
 4RUN apk add --no-cache \
 5    gcc \
 6    musl-dev \
 7    go
 8
 9# Build your app
10WORKDIR /app
11COPY . .
12RUN go build -o myapp
13
14# Clean up build dependencies (if not using multi-stage)
15RUN apk del gcc musl-dev go
16
17CMD ["./myapp"]

Scratch Images (Minimal)

Go Binary on Scratch

 1# Build stage
 2FROM golang:1.21-alpine AS builder
 3WORKDIR /app
 4COPY go.mod go.sum ./
 5RUN go mod download
 6COPY . .
 7
 8# Build statically linked binary
 9RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o myapp .
10
11# Final stage - scratch (empty image)
12FROM scratch
13COPY --from=builder /app/myapp /myapp
14
15# Copy CA certificates for HTTPS
16COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
17
18# Copy timezone data if needed
19COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
20
21ENTRYPOINT ["/myapp"]

Scratch Image:

  • Literally empty - no OS, no shell
  • Final image = your binary + dependencies
  • Smallest possible size (~5-20MB for Go apps)
  • No shell for debugging (use multi-stage with debug variant)

Go-Specific Optimizations

Static Binary Build

 1FROM golang:1.21-alpine AS builder
 2
 3WORKDIR /app
 4COPY go.mod go.sum ./
 5RUN go mod download
 6
 7COPY . .
 8
 9# Build flags for minimal binary
10RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
11    -a \
12    -installsuffix cgo \
13    -ldflags="-w -s" \
14    -o myapp .
15
16FROM scratch
17COPY --from=builder /app/myapp /myapp
18ENTRYPOINT ["/myapp"]

Build Flags:

  • CGO_ENABLED=0: Disable CGO (pure Go, static linking)
  • -a: Force rebuild of packages
  • -installsuffix cgo: Add suffix to package directory
  • -ldflags="-w -s": Strip debug info and symbol table
    • -w: Disable DWARF generation
    • -s: Disable symbol table

With UPX Compression

 1FROM golang:1.21-alpine AS builder
 2
 3WORKDIR /app
 4COPY go.mod go.sum ./
 5RUN go mod download
 6COPY . .
 7
 8# Build binary
 9RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o myapp .
10
11# Compress with UPX
12RUN apk add --no-cache upx
13RUN upx --best --lzma myapp
14
15FROM scratch
16COPY --from=builder /app/myapp /myapp
17ENTRYPOINT ["/myapp"]

UPX Compression:

  • Can reduce binary size by 50-70%
  • Slight startup time increase (decompression)
  • Trade-off: size vs startup speed

musl vs glibc

Understanding musl

Alpine uses musl libc instead of glibc (used by Debian/Ubuntu).

musl characteristics:

  • Smaller (~1MB vs ~6MB for glibc)
  • Simpler, more standards-compliant
  • Slightly different behavior in some edge cases
  • Better for static linking

Pure Go (No CGO) - No libc Needed

 1# Best approach for Go
 2FROM golang:1.21-alpine AS builder
 3WORKDIR /app
 4COPY . .
 5
 6# CGO_ENABLED=0 means no C dependencies, no libc needed
 7RUN CGO_ENABLED=0 go build -o myapp .
 8
 9FROM scratch
10COPY --from=builder /app/myapp /myapp
11ENTRYPOINT ["/myapp"]

With CGO (Needs musl)

 1FROM golang:1.21-alpine AS builder
 2
 3# Install musl-dev for CGO
 4RUN apk add --no-cache gcc musl-dev
 5
 6WORKDIR /app
 7COPY . .
 8
 9# Build with CGO (dynamically linked to musl)
10RUN go build -o myapp .
11
12FROM alpine:3.19
13RUN apk add --no-cache ca-certificates
14COPY --from=builder /app/myapp /myapp
15ENTRYPOINT ["/myapp"]

When to use CGO:

  • Need C libraries (e.g., SQLite with go-sqlite3)
  • Performance-critical C code
  • Legacy C code integration

When to avoid CGO:

  • Pure Go is faster to compile
  • Easier cross-compilation
  • Smaller binaries
  • Better portability

Distroless Images

Google Distroless

1FROM golang:1.21 AS builder
2WORKDIR /app
3COPY . .
4RUN CGO_ENABLED=0 go build -o myapp .
5
6# Distroless: minimal runtime, no shell, no package manager
7FROM gcr.io/distroless/static-debian12
8COPY --from=builder /app/myapp /myapp
9ENTRYPOINT ["/myapp"]

Distroless variants:

  • static-debian12: For static binaries (like Go with CGO_ENABLED=0)
  • base-debian12: Includes glibc (for CGO)
  • cc-debian12: Includes libgcc (for C++ dependencies)

Benefits:

  • Smaller than Alpine in some cases
  • More secure (fewer packages = smaller attack surface)
  • No shell (can't exec into container)

Layer Optimization

Bad: Many Layers

1FROM alpine:3.19
2RUN apk add --no-cache ca-certificates
3RUN apk add --no-cache tzdata
4RUN apk add --no-cache curl
5RUN apk add --no-cache wget
6COPY myapp /app/
7WORKDIR /app

Good: Fewer Layers

 1FROM alpine:3.19
 2
 3# Combine RUN commands
 4RUN apk add --no-cache \
 5    ca-certificates \
 6    tzdata \
 7    curl \
 8    wget
 9
10WORKDIR /app
11COPY myapp .

Best: Multi-Stage with Minimal Layers

 1FROM golang:1.21-alpine AS builder
 2WORKDIR /app
 3COPY go.mod go.sum ./
 4RUN go mod download
 5COPY . .
 6RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o myapp .
 7
 8FROM scratch
 9COPY --from=builder /app/myapp /myapp
10COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
11ENTRYPOINT ["/myapp"]

.dockerignore

Essential .dockerignore

 1# Git
 2.git
 3.gitignore
 4
 5# Build artifacts
 6*.exe
 7*.dll
 8*.so
 9*.dylib
10bin/
11obj/
12target/
13
14# Dependencies
15node_modules/
16vendor/
17
18# IDE
19.vscode/
20.idea/
21*.swp
22*.swo
23
24# Documentation
25README.md
26LICENSE
27*.md
28
29# Tests
30*_test.go
31test/
32tests/
33
34# CI/CD
35.github/
36.gitlab-ci.yml
37Jenkinsfile
38
39# Docker
40Dockerfile*
41docker-compose*.yml
42.dockerignore

Benefits:

  • Faster builds (less context to send)
  • Smaller build context
  • Avoid accidentally copying secrets

Cache Optimization

Leverage Build Cache

 1FROM golang:1.21-alpine AS builder
 2WORKDIR /app
 3
 4# Copy dependency files first (cached if unchanged)
 5COPY go.mod go.sum ./
 6RUN go mod download
 7
 8# Copy source code (invalidates cache only if code changes)
 9COPY . .
10RUN CGO_ENABLED=0 go build -o myapp .
11
12FROM scratch
13COPY --from=builder /app/myapp /myapp
14ENTRYPOINT ["/myapp"]

Key: Copy dependency files before source code so go mod download is cached.

BuildKit Cache Mounts

 1# syntax=docker/dockerfile:1
 2FROM golang:1.21-alpine AS builder
 3WORKDIR /app
 4COPY go.mod go.sum ./
 5
 6# Use cache mount for go modules
 7RUN --mount=type=cache,target=/go/pkg/mod \
 8    go mod download
 9
10COPY . .
11RUN --mount=type=cache,target=/go/pkg/mod \
12    --mount=type=cache,target=/root/.cache/go-build \
13    CGO_ENABLED=0 go build -o myapp .
14
15FROM scratch
16COPY --from=builder /app/myapp /myapp
17ENTRYPOINT ["/myapp"]

Enable BuildKit:

1export DOCKER_BUILDKIT=1
2docker build -t myapp .

Security Hardening

Non-Root User

 1FROM golang:1.21-alpine AS builder
 2WORKDIR /app
 3COPY . .
 4RUN CGO_ENABLED=0 go build -o myapp .
 5
 6FROM alpine:3.19
 7
 8# Create non-root user
 9RUN addgroup -g 1000 appgroup && \
10    adduser -D -u 1000 -G appgroup appuser
11
12WORKDIR /app
13COPY --from=builder /app/myapp .
14
15# Change ownership
16RUN chown -R appuser:appgroup /app
17
18# Switch to non-root user
19USER appuser
20
21ENTRYPOINT ["./myapp"]

Read-Only Filesystem

1FROM scratch
2COPY myapp /myapp
3USER 65534:65534
4ENTRYPOINT ["/myapp"]

Run with:

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

Size Comparison

Example: Go HTTP Server

 1# 1. Full Golang image: ~800MB
 2FROM golang:1.21
 3WORKDIR /app
 4COPY . .
 5RUN go build -o myapp .
 6CMD ["./myapp"]
 7
 8# 2. Alpine: ~15MB
 9FROM golang:1.21-alpine AS builder
10WORKDIR /app
11COPY . .
12RUN go build -o myapp .
13
14FROM alpine:3.19
15COPY --from=builder /app/myapp /myapp
16CMD ["./myapp"]
17
18# 3. Scratch: ~8MB
19FROM golang:1.21-alpine AS builder
20WORKDIR /app
21COPY . .
22RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o myapp .
23
24FROM scratch
25COPY --from=builder /app/myapp /myapp
26ENTRYPOINT ["/myapp"]
27
28# 4. Scratch + UPX: ~3MB
29FROM golang:1.21-alpine AS builder
30WORKDIR /app
31COPY . .
32RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o myapp .
33RUN apk add --no-cache upx && upx --best --lzma myapp
34
35FROM scratch
36COPY --from=builder /app/myapp /myapp
37ENTRYPOINT ["/myapp"]

Complete Example: Optimized Go App

 1# syntax=docker/dockerfile:1
 2
 3# Build stage
 4FROM golang:1.21-alpine AS builder
 5
 6# Install build dependencies
 7RUN apk add --no-cache git ca-certificates tzdata
 8
 9WORKDIR /app
10
11# Cache dependencies
12COPY go.mod go.sum ./
13RUN --mount=type=cache,target=/go/pkg/mod \
14    go mod download
15
16# Build
17COPY . .
18RUN --mount=type=cache,target=/go/pkg/mod \
19    --mount=type=cache,target=/root/.cache/go-build \
20    CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
21    -ldflags="-w -s -X main.version=${VERSION}" \
22    -o myapp .
23
24# Optional: Compress with UPX
25# RUN apk add --no-cache upx && upx --best --lzma myapp
26
27# Final stage
28FROM scratch
29
30# Copy CA certificates for HTTPS
31COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
32
33# Copy timezone data
34COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
35
36# Copy binary
37COPY --from=builder /app/myapp /myapp
38
39# Non-root user
40USER 65534:65534
41
42# Metadata
43LABEL org.opencontainers.image.source="https://github.com/user/repo"
44LABEL org.opencontainers.image.description="My optimized Go app"
45
46ENTRYPOINT ["/myapp"]

Build:

1export DOCKER_BUILDKIT=1
2docker build --build-arg VERSION=1.0.0 -t myapp:1.0.0 .

Quick Reference

Base ImageSizeUse Case
scratch~0MBStatic binaries (Go with CGO_ENABLED=0)
alpine:3.19~5MBNeed shell, basic tools
gcr.io/distroless/static~2MBStatic binaries, more secure than Alpine
gcr.io/distroless/base~20MBDynamic binaries (CGO)
debian:bookworm-slim~70MBNeed glibc, more packages
ubuntu:22.04~80MBMaximum compatibility

Tips

  • Use multi-stage builds - Keep build deps out of final image
  • CGO_ENABLED=0 - For pure Go, enables scratch/distroless
  • -ldflags="-w -s" - Strip debug info (smaller binary)
  • Alpine for flexibility - Small with package manager
  • Scratch for minimal - Smallest possible (Go, Rust)
  • Distroless for security - No shell, minimal attack surface
  • Cache go mod download - Faster rebuilds
  • Order matters - Copy dependencies before source
  • .dockerignore - Exclude unnecessary files
  • Non-root user - Security best practice
  • UPX compression - Optional, 50-70% smaller (trade-off: startup time)

Common Gotchas

musl vs glibc

 1# ❌ Bad: Build on Debian, run on Alpine (glibc vs musl)
 2FROM golang:1.21 AS builder
 3RUN go build -o myapp .
 4
 5FROM alpine:3.19
 6COPY --from=builder /go/myapp /myapp
 7CMD ["./myapp"]
 8# Error: not found (looking for glibc)
 9
10# ✅ Good: Match libc or use CGO_ENABLED=0
11FROM golang:1.21-alpine AS builder
12RUN CGO_ENABLED=0 go build -o myapp .
13
14FROM alpine:3.19
15COPY --from=builder /go/myapp /myapp
16CMD ["./myapp"]

Missing CA Certificates

 1# ❌ Bad: HTTPS calls will fail
 2FROM scratch
 3COPY myapp /myapp
 4CMD ["/myapp"]
 5
 6# ✅ Good: Copy CA certs
 7FROM scratch
 8COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
 9COPY myapp /myapp
10CMD ["/myapp"]

Timezone Issues

 1# ❌ Bad: No timezone data
 2FROM scratch
 3COPY myapp /myapp
 4CMD ["/myapp"]
 5# time.LoadLocation will fail
 6
 7# ✅ Good: Copy timezone data
 8FROM scratch
 9COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
10COPY myapp /myapp
11CMD ["/myapp"]

Related Snippets