Go Deployment Patterns
Senior DevOps engineer deploying optimized Go applications with multi-stage Docker builds, binary sizing, cross-compilation, and deployment strategies. This skill covers building minimal binaries, container optimization, and production deployment patterns.
TL;DR Checklist
- Use multi-stage Docker builds — never include source code or build tools in the final image
- Enable Go build optimizations:
-ldflags "-s -w",CGO_ENABLED=0,GOOS=linux - Use a minimal base image (
distroless,scratch, oralpine) for the final stage - Cross-compile for target platforms — never build on the deployment machine
- Run the binary as a non-root user in the container
- Use build tags to conditionally include platform-specific code
When to Use
Use this skill when:
- Building Docker images for Go applications
- Optimizing binary size for deployment
- Cross-compiling for different platforms
- Setting up CI/CD pipelines for Go applications
- Configuring build tags for feature flags or platform-specific code
When NOT to Use
Avoid this skill for:
- Simple scripts that are run directly on the host (no containerization needed)
- Development builds where debugging symbols are needed (don't strip in dev)
- Applications that require CGO —
CGO_ENABLED=0is not compatible
Core Workflow
Optimize the Build — Enable compiler optimizations and strip debug symbols. Checkpoint: Binary is stripped and smaller than 20MB for simple applications.
Create a Multi-Stage Dockerfile — Build in one stage, copy only the binary in the final stage. Checkpoint: Final image contains only the binary and required certificates.
Cross-Compile for Target Platforms — Build for the deployment target, not the build machine. Checkpoint:
GOOS=linux GOARCH=amd64 CGO_ENABLED=0produces a portable binary.Configure the Runtime Environment — Set non-root user, health check, and resource limits. Checkpoint: Container runs as non-root, has a health check, and respects resource limits.
Use Build Tags for Variants — Conditionally include code for different environments. Checkpoint: Build tags don't create circular dependencies or break imports.
Implementation Patterns
Pattern 1: Multi-Stage Docker Build (❌ BAD vs ✅ GOOD)
Multi-stage builds produce minimal deployment images.
❌ BAD — Single-Stage Docker Build
# ❌ BAD: includes Go toolchain, source code, and all build dependencies
FROM golang:1.22
# Set working directory
WORKDIR /app
# Copy everything (including .git, tests, docs)
COPY . .
# Install dependencies (cache is not optimized)
RUN go mod download
# Build with no optimizations
RUN go build -o myapp
# Run the app (as root!)
CMD ["./myapp"]
What's wrong:
- Final image is ~1GB (includes Go toolchain, source code, all dependencies)
- Source code and
.gitdirectory are in the image — security risk - No optimization flags — binary includes debug symbols
- Runs as root — container security risk
- No health check — Kubernetes cannot monitor liveness
✅ GOOD — Multi-Stage Docker Build
# Stage 1: Build
FROM golang:1.22-alpine AS builder
# Install build dependencies (only what's needed)
RUN apk add --no-cache git ca-certificates
# Set working directory
WORKDIR /app
# Copy go mod files first (leverage Docker layer caching)
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build with optimizations
# -ldflags "-s -w" strips debug symbols and DWARF info
# -ldflags "-X main.Version=1.2.3" embeds version info
ARG VERSION=dev
ARG COMMIT=unknown
ARG BUILD_TIME=unknown
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build \
-ldflags="-s -w -X main.Version=${VERSION} -X main.Commit=${COMMIT} -X main.BuildTime=${BUILD_TIME}" \
-o /usr/local/bin/myapp \
./cmd/server/
# Stage 2: Runtime
FROM gcr.io/distroless/static-debian12:nonroot
# Copy the binary from the builder stage
COPY --from=builder /usr/local/bin/myapp /usr/local/bin/myapp
# Distroless images run as non-root by default (uid 1000)
# No shell, no package manager — minimal attack surface
# Expose the HTTP port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/usr/local/bin/myapp", "health"]
# Run the application
ENTRYPOINT ["/usr/local/bin/myapp"]
Why this works:
- Final image is ~20MB (binary + CA certificates) — 50x smaller than single-stage
- No source code, no Go toolchain, no shell in the final image
CGO_ENABLED=0produces a fully static binary — no libc dependency-ldflags="-s -w"strips debug symbols — smaller binaryEXPOSE 8080documents the portHEALTHCHECKenables Kubernetes liveness/probe- Runs as non-root (uid 1000) — container security
Pattern 2: Binary Optimization and Sizing (❌ BAD vs ✅ GOOD)
Build flags control binary size and runtime behavior.
❌ BAD — Default Build
# ❌ BAD: default build — includes debug symbols, DWARF info, and race detector
go build -o myapp ./cmd/server/
# Binary size: ~15MB with debug info
# No version info embedded
# Includes race detector overhead (if -race flag is used)
What's wrong:
- Binary includes debug symbols — unnecessarily large
- No version information — cannot identify which version is running
- No build metadata — no timestamp or git commit for traceability
-raceflag adds ~10x memory overhead — only for testingCGO_ENABLED=1(default) produces dynamically linked binary — not portable
✅ GOOD — Optimized Production Build
# Production build with all optimizations
VERSION=$(git describe --tags --always)
COMMIT=$(git rev-parse --short HEAD)
BUILD_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64 \
go build \
-trimpath \
-ldflags="-s -w \
-X main.Version=${VERSION} \
-X main.Commit=${COMMIT} \
-X main.BuildTime=${BUILD_TIME} \
-extldflags '-static'" \
-o dist/myapp \
./cmd/server/
# Binary size: ~8-12MB (stripped, static, no debug info)
Build flags explained:
| Flag | Effect |
|---|---|
-trimpath |
Removes file system paths from the binary — builds are reproducible |
-s |
Strips symbol table — smaller binary, no debug info |
-w |
Strips DWARF debug information — smaller binary |
-X main.Version=... |
Sets a main.Version variable at link time — embeds version info |
-extldflags '-static' |
Links all C libraries statically (requires CGO_ENABLED=0 for full static) |
CGO_ENABLED=0 |
Disables CGO — produces fully static, portable binary |
GOOS=linux GOARCH=amd64 |
Cross-compiles for Linux AMD64 |
// package: main
// Build-time variables set via -ldflags
var (
Version = "dev"
Commit = "unknown"
BuildTime = "unknown"
)
// VersionInfo returns structured version information for the /version endpoint.
func VersionInfo() map[string]string {
return map[string]string{
"version": Version,
"commit": Commit,
"build_time": BuildTime,
"go_version": runtime.Version(),
"os": runtime.GOOS,
"arch": runtime.GOARCH,
}
}
Why this works:
-trimpathmakes builds reproducible — same source produces the same binary-s -wstrips all debug info — binary is 30-40% smaller-Xflags embed build metadata — version info is available at runtimeCGO_ENABLED=0produces a fully static binary — no libc dependency, fully portableVersionInfo()serves structured version data for/versionendpoint
Pattern 3: Cross-Compilation and Build Tags (❌ BAD vs ✅ GOOD)
Cross-compilation and build tags enable platform-specific builds.
❌ BAD — Platform-Specific Build Scripts
# ❌ BAD: separate build scripts for each platform — hard to maintain
# build-linux.sh
go build -o myapp-linux ./cmd/server/
# build-darwin.sh
go build -o myapp-darwin ./cmd/server/
# build-windows.sh
go build -o myapp-windows.exe ./cmd/server/
# build-arm.sh
go build -o myapp-arm ./cmd/server/
What's wrong:
- Four separate scripts — changes to build flags must be applied in all four
- No build tag support — platform-specific code requires runtime checks
- Hard to automate — CI/CD needs to run four separate builds
- No version consistency — each script could use different flags
- Windows build produces a
.exe— Linux builds don't
✅ GOOD — Unified Cross-Compilation with Build Tags
# build.sh — unified cross-compilation script
#!/usr/bin/env bash
set -euo pipefail
VERSION=$(git describe --tags --always)
COMMIT=$(git rev-parse --short HEAD)
# Target platforms: os:arch:output
PLATFORMS=(
"linux:amd64:myapp"
"linux:arm64:myapp-arm64"
"darwin:amd64:myapp-darwin"
"darwin:arm64:myapp-darwin-arm64"
"windows:amd64:myapp.exe"
)
for platform in "${PLATFORMS[@]}"; do
IFS=':' read -r goos goarch output <<< "$platform"
# Normalize the architecture
case "$goarch" in
amd64) goarch="amd64" ;;
arm64) goarch="arm64" ;;
esac
# Construct the target name
target="${goos}_${goarch}"
echo "Building for ${target} -> ${output}..."
# Set environment variables for cross-compilation
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
go build \
-trimpath \
-ldflags="-s -w -X main.Version=${VERSION} -X main.Commit=${COMMIT}" \
-tags="${BUILD_TAGS:-}" \
-o "dist/${output}" \
./cmd/server/
# Show binary size
ls -lh "dist/${output}" | awk '{print " Size: " $5}'
done
echo "Build complete. Artifacts in dist/"
ls -lh dist/
// package: internal/platform
//go:build linux
// Platform-specific initialization for Linux.
func initPlatform() error {
// Linux-specific setup (e.g., cgroups, epoll)
return nil
}
//go:build darwin
// Platform-specific initialization for macOS.
func initPlatform() error {
// macOS-specific setup
return nil
}
//go:build windows
// Platform-specific initialization for Windows.
func initPlatform() error {
// Windows-specific setup
return nil
}
Why this works:
- Single build script handles all platforms — consistent build flags
- Build tags (
//go:build linux) enable platform-specific code CGO_ENABLED=0ensures all targets produce static binariesGOOSandGOARCHcontrol cross-compilation — no need for native toolchain- Build artifacts are version-consistent — same
-ldflagsacross all platforms - Binary sizes are shown — easy to spot unexpected bloat
Constraints
MUST DO
- MUST use multi-stage Docker builds — never include the Go toolchain in the final image
- MUST set
CGO_ENABLED=0for all production builds — produces static, portable binaries - MUST use
-ldflags="-s -w"for all production builds — strip debug symbols - MUST use
-trimpathfor reproducible builds — removes file system paths from the binary - MUST use a minimal base image (
distroless,scratch, oralpine) for the final Docker stage - MUST run the container as a non-root user
- MUST include a
HEALTHCHECKdirective in the Dockerfile
MUST NOT DO
- MUST NOT include source code,
.git, or test files in the Docker image - MUST NOT use
golangas the final base image — it includes the toolchain (~800MB) - MUST NOT run the container as root — use
USER 1000or distroless's default non-root - MUST NOT use
CGO_ENABLED=1in production unless absolutely necessary (CGO binaries are not portable) - MUST NOT commit build scripts that hardcode platform-specific paths
- MUST NOT skip the
-trimpathflag — builds must be reproducible
Related Skills
| Skill | Purpose |
|---|---|
cloud-development |
Cloud-native deployment with Kubernetes manifests and rolling updates |
best-practices |
Go idioms and code organization for maintainable applications |
modular-design |
Package structure that supports build tags and platform-specific code |
Live References
Authoritative documentation links for this skill's domain. The model follows markdown links at load time to resolve external references and inline content.