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 Image | Size | Use Case |
|---|---|---|
scratch | ~0MB | Static binaries (Go with CGO_ENABLED=0) |
alpine:3.19 | ~5MB | Need shell, basic tools |
gcr.io/distroless/static | ~2MB | Static binaries, more secure than Alpine |
gcr.io/distroless/base | ~20MB | Dynamic binaries (CGO) |
debian:bookworm-slim | ~70MB | Need glibc, more packages |
ubuntu:22.04 | ~80MB | Maximum 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
- Docker Commands Cheatsheet
Essential Docker commands for daily container management. Quick reference for … - Docker Compose Commands & Setup
Docker Compose commands and configuration patterns for multi-container …