Build and Deploy Strategies Across 52 Go Projects#

Summary#

GitHub Actions and GNU Make form the dominant CI/build stack across this corpus, used by roughly 85% and 60% of projects respectively. The sharpest divide in deployment strategy is between service projects (containerized via multi-stage Dockerfiles, pushed to registries) and tool/binary projects (cross-compiled with GoReleaser, distributed as GitHub Releases). Pure libraries stand apart from both — minimal CI, no deployment artifacts, no Docker. Code generation (protobuf via buf, sqlc, Wire) is increasingly embedded in the build pipeline of infrastructure-tier projects, adding a pre-compile layer that many libraries never need.


Taxonomy#

Approach 1: GNU Make + Docker (Infrastructure/Service tier)#

  • Projects using it: kubernetes, moby, etcd, prometheus, traefik, minio, consul, vault, nomad, dapr, k3s, istio, argo-cd, tekton-pipeline, gitea, gogs, drone, syncthing, rclone, tailscale, grafana, hugo (partial), terraform (partial)
  • How it works: A Makefile orchestrates go build behind environment variables (GOOS, GOARCH, CGO_ENABLED=0). Release targets build Docker images via multi-stage Dockerfiles and push to container registries. Kubernetes manifests or Helm charts describe deployment.
  • When it’s appropriate: Long-lived services that run in containers. Operations teams need stable container image tags, reproducible builds, and OCI distribution. The Makefile provides a human-friendly vocabulary (make build, make docker-push) over raw docker buildx and go build invocations.

Notable sub-patterns:

  • Distroless / minimal base images: etcd uses gcr.io/distroless/static, prometheus uses a scratch-based multi-stage build. The goal is attack-surface reduction — the resulting image has no shell, no package manager.
  • Multi-arch via buildx: traefik, minio, consul and grafana all target linux/amd64 and linux/arm64 in the same image manifest using docker buildx build --platform. Kubernetes targets arm, arm64, ppc64le, s390x, and mips64le via its cross-compilation matrix.
  • Embedded frontends: traefik (Vue.js dashboard), gitea (pnpm + Vite), grafana (Dagger CI driving a 35-module Go workspace + React build), nomad (Ember.js). The Makefile orchestrates both the frontend and Go build before constructing the final image.
  • Code generation as a build prerequisite: etcd, consul, dapr, istio, tekton-pipeline all run buf generate or protoc as a Makefile target before go build. This means the build system must manage toolchain dependencies (buf, protoc, protoc-gen-go) in addition to the Go toolchain.

Approach 2: GoReleaser (Tool/Binary tier)#

  • Projects using it: caddy, argo-cd, gin, fzf, gh, frp, headscale, delve, air, pop, pocketbase, nats-server, temporal, crush
  • How it works: .goreleaser.yml describes build matrix (goos × goarch), archive formats (tar.gz, zip), package manager manifests (Homebrew formulae, APT/RPM nfpms, AUR PKGBUILDs, Scoop manifests), and GitHub Release publication. A single goreleaser release invocation replaces dozens of Makefile targets.
  • When it’s appropriate: Binaries that end users install directly — CLI tools, standalone servers, desktop-adjacent tools. GoReleaser handles the entire distribution pipeline from compilation to package index publication.

Configuration sophistication spectrum:

ProjectGoReleaser complexityNotable features
caddyVery highPre-hooks vendor+copy to temp dir (avoids Git dirty), xcaddy build for Windows .syso embedding, bash completions, manpages, includes from charmbracelet/meta
ghHighPer-OS build IDs (macos/linux/windows), macOS notarization script, PowerShell .syso generation, nfpm for deb/rpm
crushHighcharmbracelet/meta include for notarization, nightly release track, manpages, shell completions
headscaleMediumvendor mode, freebsd target, source tarball alongside binaries
nats-serverMediumPinned Go toolchain via GOTOOLCHAIN env, loong64/s390x/ppc64le targets
pocketbaseMediumCGO=0 and CGO=1 builds (separate IDs), arm/s390x/ppc64le targeting
temporalMediumMultiple build IDs (temporal-server, cassandra-tool, sql-tool, tdbg), config files included in archives
airLowStandard pattern, ldflags version injection only
delveLowStandard pattern

The Charmbracelet meta-include pattern (crush): includes: from_url: charmbracelet/meta/main/notarize.yaml is a GoReleaser Pro feature where a centralized org-level YAML fragment is fetched at release time. This allows Charm to maintain macOS notarization configuration in one place and have it propagate to all their tools (crush, pop, etc.) without per-repo duplication.

Approach 3: Bazel (Specialized/Large-scale)#

  • Projects using it: cockroach (primary), buildkite-agent (secondary — Bazel for BUILD generation, plain go build also supported)
  • How it works: CockroachDB wraps Bazel behind a custom ./dev CLI written in Go. ./dev build, ./dev test, ./dev lint translate to Bazel invocations with pre-configured toolchains. Gazelle generates BUILD.bazel files from go.mod. Remote build caching (RBE) via Google Cloud is configured for CI.
  • When it’s appropriate: Multi-language monorepos where reproducibility and incremental build correctness are worth the steep setup cost. CockroachDB mixes Go, C++, and Rust in a single repo — Bazel is one of few tools that handles this combination. For pure Go, Bazel provides little benefit over go build’s own dependency graph.
  • The cost: The learning curve for BUILD files, Gazelle maintenance, and Bazel version pinning is substantial. CockroachDB hides this behind ./dev but it leaks when contributors need to add new packages.

Approach 4: Custom Build Scripts (Language Bootstrapping / Special cases)#

  • Projects using it: go (language itself), kubernetes (kube-cross builder), restic, syncthing, gh (script/build.go)
  • How it works: A Go or shell script replaces or supplements Make. Examples:
    • go/src/make.bash: Shell bootstrap that builds the Go toolchain from an older Go version before building the current one.
    • restic/build.go: Cross-compilation orchestrator in pure Go, called via go run build.go.
    • syncthing/build.go: Build + test runner with custom flags, version injection, and release packaging.
    • gh/script/build.go + build.sh: Go script wrapped in a shell shim, giving portable command-line syntax with Go’s build semantics.
  • When it’s appropriate: Projects that can’t assume Make (Windows-first tools), projects bootstrapping themselves (the Go toolchain), or projects where the build logic is complex enough to warrant a real programming language over Make macros.

Approach 5: go-task / Mage (Modern Make alternatives)#

  • Projects using it: pocketbase (go-task), crush (go-task), gogs (go-task as “task”), hugo (Mage)
  • How it works: go-task uses a Taskfile.yml (YAML syntax, Make semantics). Mage uses Go source files where each exported function is a build target — no DSL, just Go.
  • When it’s appropriate: Teams that find Makefile syntax brittle or unportable (Windows), or that want IDE-navigable build targets (Mage). Hugo chose Mage specifically for its three-edition build (none/extended/withdeploy) where conditional logic is cleaner in Go than in Make conditionals.
  • Adoption signal: go-task appears in newer projects (crush, pocketbase, gogs’ modernization effort). Mage remains niche. Neither has displaced Make in large established projects.

Approach 6: Standard go build / Library-only#

  • Projects using it: fyne, buffalo (partially), gorm, nats-server, cobra, viper, echo, gin, fiber, beego, wireguard-go
  • How it works: go build ./... and go test ./... suffice. GitHub Actions runs these directly. No release pipeline because the project is a library consumed via go get.
  • When it’s appropriate: Pure libraries where the consumer controls the build. wireguard-go is unusual here — it’s a functional network daemon but takes a minimal build approach because it’s typically embedded in larger projects (tailscale) rather than deployed standalone.

Comparison Dimensions#

CI System#

SystemProjectsCount
GitHub Actionsmoby, etcd, prometheus, traefik, caddy, minio, consul, vault, terraform, nomad, dapr, k3s, helm, argo-cd, tekton-pipeline, gin, echo, fiber, buffalo, beego, gorm, sqlc, viper, cobra, fzf, gh, gitea, gogs, drone, restic, syncthing, rclone, headscale, tailscale, delve, air, pop, pocketbase, nats-server, temporal, crush, grafana, fyne, cockroach~44
Self-hosted / Prowkubernetes, istio, go (language), wireguard-go4
CircleCIfrp, hugo2
Buildkitebuildkite-agent1

Grafana’s 92-workflow count is an outlier — its CI is effectively a monorepo pipeline where each of its 35 Go modules, plus frontend builds, plus integration test suites, plus enterprise variants each have dedicated workflows. This reflects the cost of the go.work monorepo approach: each module needs independent CI gates.

Kubernetes and Istio use Prow, the CNCF’s own CI system that predates GitHub Actions’ maturity. It runs on GKE and orchestrates thousands of jobs per day at a scale GitHub Actions runners cannot match economically. New CNCF projects (dapr, argo-cd, tekton) have shifted to GitHub Actions, suggesting Prow’s adoption has peaked.

Artifact Distribution Strategy#

StrategyProjectsMechanism
Container image → registrykubernetes, etcd, consul, vault, prometheus, traefik, grafana, minio, nomad, dapr, k3s, istio, argo-cd, gitea, drone, syncthing, tailscaleDocker Hub, GHCR, GCR
Cross-platform binary → GitHub Releasecaddy, gh, fzf, headscale, nats-server, temporal, delve, air, pop, pocketbase, crushGoReleaser
Package managers (brew, apt, rpm, AUR)caddy, gh, fzf, headscale, airGoReleaser nfpm / brew tap
go get (library)gin, echo, fiber, gorm, viper, cobra, buffalo, beego, sqlc, fyneGo module proxy
Static binary with embedded assetspocketbase (SQLite), caddy, nats-serverCGO_ENABLED=0 + embed
Source tarballrestic, syncthing, rcloneCustom build scripts

CGO Usage in Builds#

Almost universally, Go projects targeting cross-platform distribution set CGO_ENABLED=0 in their release builds. This produces fully statically linked binaries that run without glibc on the target host — critical for minimal container images and cross-compilation.

Exceptions where CGO is required:

  • fyne: Requires CGO for GLFW/OpenGL backend. Cannot be statically linked; requires windowing system libraries on the target.
  • pocketbase: Has separate CGO=1 (with mattn/go-sqlite3) and CGO=0 (with modernc SQLite pure-Go) builds in GoReleaser, targeting different deployment environments.
  • pop: GoReleaser cross-compiles with CGO for SQLite (uses a Docker-based cross-compilation environment).
  • cockroachdb: Mixed CGO; Bazel manages the C++ dependencies.

Multi-stage Dockerfile Patterns#

Projects that ship containers universally use multi-stage builds. The common pattern:

Stage 1 (builder): golang:1.N-alpine or golang:1.N
  - go build -o /binary ./cmd/...
Stage 2 (runtime): scratch / distroless / alpine:3.N
  - COPY --from=builder /binary /binary
  - ENTRYPOINT ["/binary"]

Distroless vs scratch vs alpine:

  • scratch: prometheus, smallest possible, no shell for debugging
  • gcr.io/distroless/static: etcd, k3s — CA certificates and timezone data included without a shell
  • alpine:3.N: consul, vault, nomad, traefik — adds ~5MB but provides sh for debugging and apk for runtime packages
  • ubi8-minimal / ubi9-minimal: consul enterprise variant, for Red Hat compatibility requirements

K3s embedded binary archive: K3s takes an unusual approach — it builds a single binary (k3s) that contains a zstd-compressed archive of all dependent binaries (containerd, runc, kubectl, etc.) extracted at first run. This makes deployment a single-binary copy rather than a full container orchestration stack installation.

Code Generation Pipeline#

GeneratorProjectsPurpose
buf / protocetcd, consul, dapr, istio, temporal, tekton-pipeline, sqlc, headscalegRPC service stubs, message types
sqlcsqlc (itself), pocketbase, pop, crushType-safe database query code
Wire (google/wire)drone (120+ WireSets)Compile-time dependency injection
mockery / gomockconsul, vault, temporal, daprInterface mocks for tests
swagpocketbaseOpenAPI spec from Go annotations
msgpfiberOptimized MessagePack serialization
deepcopy-gen, informer-genkubernetesController/informer boilerplate

The buf trend: buf has largely replaced raw protoc invocations in newer projects. It provides dependency management for .proto files (buf.yaml), linting, breaking-change detection, and managed plugin execution. Projects using buf: etcd, consul, dapr, headscale, temporal, sqlc. Projects still using raw protoc/Makefile scripts: kubernetes, istio (older codebases).


GitHub Actions has won CI for Go projects. Projects started before 2018 that used CircleCI (frp, hugo) or self-hosted CI (kubernetes, istio) have not migrated en masse, but every project started after 2019 in this corpus uses GitHub Actions. The availability of GOOS/GOARCH matrix builds, reusable workflows, and free tier for open source makes it the obvious default.

GoReleaser adoption correlates with project age and type. Projects started after 2018 that produce user-installed binaries almost universally use GoReleaser. Older projects (restic, syncthing) that predate GoReleaser built their own cross-compilation scripts and have not migrated — the migration cost of changing an established release pipeline is high.

Static binaries are the Go norm. CGO_ENABLED=0 with ldflags "-s -w" (strip debug info) appears in the majority of projects that produce binaries. The result: small binaries, no runtime dependencies, trivial Docker base images. This is one of Go’s most valuable deployment properties relative to languages requiring a runtime (JVM, Node.js, Python).

go-task is gaining ground for newer projects. Pocketbase, crush, and gogs’ modernization effort use go-task. It offers cross-platform compatibility (Windows-safe) and readable YAML syntax. It is unlikely to unseat Make in large established projects but is a credible default for projects started today.

Embedded assets via //go:embed are now mainstream. Pocketbase, crush, caddy, nats-server, and others embed UI assets, configuration templates, or SQL migration files directly into the binary. This eliminates the need for asset installation scripts and simplifies container images (no volume mounts for static files).


Common Patterns#

  1. CGO_ENABLED=0 + static binary as the default for any cross-platform binary. Nearly universal.
  2. -ldflags "-s -w -X pkg.Version={{.Version}}" for stripping debug symbols and injecting build metadata.
  3. Multi-stage Dockerfile for all containerized services.
  4. GitHub Actions matrix for OS × architecture testing (strategy: matrix: os: [ubuntu, macos, windows]).
  5. go mod tidy as a CI check — failing CI if tidy produces a diff ensures the go.mod/go.sum stays clean.
  6. buf generate as a Makefile prerequisite for any project with .proto files.
  7. Separate CI jobs for lint, test, and build — keeps feedback loops fast and blame unambiguous.

Divergent Choices#

Bazel vs Make: CockroachDB chose Bazel for multi-language support and incremental build correctness. Every other Go project in this corpus uses Make or a Make alternative. Bazel’s overhead (BUILD files, Gazelle, toolchain configuration) is not justified for single-language Go projects where go build already tracks dependencies precisely.

Self-hosted Prow vs GitHub Actions: Kubernetes and Istio run Prow because they need to run thousands of end-to-end tests against real clusters — a scale and cost profile that GitHub Actions hosted runners cannot serve. For most projects, GitHub Actions is superior (zero infrastructure, free for OSS, native YAML).

GoReleaser vs custom scripts for binary distribution: Both approaches work. GoReleaser wins on maintainability (one config file vs dozens of Makefile targets and shell scripts). Custom scripts win on flexibility for unusual requirements (syncthing’s build.go handles platform-specific signing and notarization that GoReleaser’s plugin system couldn’t cover when it was written). For new projects, GoReleaser is the right default.

Vendoring vs module proxy: Projects that need reproducible builds in air-gapped environments or that contribute to the Go toolchain vendor (kubernetes, caddy in its GoReleaser pre-hook, etcd in some CI steps). The majority use the module proxy. Vendoring adds maintenance overhead (repository size, go mod vendor in CI) but eliminates proxy availability as a build dependency.


Best Practices#

  1. Use CGO_ENABLED=0 for release binaries. Unless you have a specific C library requirement (SQLite, OpenGL), static binaries are simpler, more portable, and produce smaller containers.

  2. Use GoReleaser for multi-platform binary distribution. For CLI tools and standalone servers, GoReleaser eliminates the build matrix script sprawl. The .goreleaser.yml is a readable, versionable specification of your entire release pipeline.

  3. Multi-stage Dockerfiles with minimal base images. For services, use scratch or distroless as the runtime stage. Reserve Alpine for cases where you genuinely need a shell at runtime.

  4. Embed assets with //go:embed. Eliminates asset installation steps, simplifies container images, makes single-binary distribution natural.

  5. Run buf for protobuf, not raw protoc. buf provides dependency management, linting, and breaking-change detection that raw protoc lacks. It integrates cleanly into Makefiles and CI.

  6. Gate CI on go mod tidy. A tidy check (go mod tidy && git diff --exit-code go.mod go.sum) prevents dependency drift.

  7. Pin Go toolchain versions. nats-server’s pattern of GOTOOLCHAIN={{ envOrDefault "GORELEASER_TOOLCHAIN" "go1.26.1" }} in GoReleaser and tailscale’s ./tool/go pinned toolchain shim both ensure release builds use a known toolchain. This is especially important for security-sensitive projects.

  8. Keep CI jobs small and orthogonal. Grafana’s 92 workflows are an extreme, but the underlying principle (lint ≠ test ≠ build ≠ publish) is sound. Smaller jobs fail faster and are easier to debug.


Anti-patterns#

  1. Bazel for a pure-Go project. The overhead of BUILD files and Gazelle is not justified when go build already has a precise dependency graph. Adopt Bazel only when mixing Go with C++, Rust, or Java in the same repo.

  2. Vendoring in CI without a reason. Vendoring everything into the repository makes PRs noisy and the repo large. The module proxy is reliable for open-source projects. Vendor only if you have genuine air-gap or compliance requirements.

  3. CGO in cross-compiled binaries without a cross-compile toolchain. CGO breaks GOOS=linux on darwin without a cross-compiler. pop’s GoReleaser config uses a Docker-based cross-compilation environment to work around this — this works but adds complexity. Prefer pure-Go SQLite (modernc.org/sqlite) unless you need mattn/go-sqlite3 specifically.

  4. Monolithic Makefiles with undocumented targets. Several projects have Makefiles where the only way to understand the build is to read 500 lines of shell. Hugo’s move to Mage and go-task’s adoption by pocketbase are partly reactions to this problem.

  5. Hardcoded toolchain versions in Makefiles without a strategy for updates. Projects that hardcode go1.21 without a mechanism to update will silently fall behind security patches. Better: use go directive in go.mod as the authoritative version, with CI reading it via go env GOVERSION.

  6. No go mod tidy check in CI. Without this guard, go.mod and go.sum drift from the actual import graph, making dependency audits unreliable.


Exemplars#

caddy — GoReleaser mastery#

Caddy’s .goreleaser.yml is the most sophisticated in the corpus. It vendors dependencies into a temporary directory to avoid dirtying the Git tree (goreleaser rejects dirty repos), pre-builds Windows .syso resource files for each architecture using xcaddy, generates man pages and shell completions, and publishes to multiple package managers. The pre-hook sequence (10+ steps) handles the inherent tension between xcaddy’s plugin-based architecture and goreleaser’s assumption of a simple go build. Worth studying for any project with a plugin system that complicates the release build.

temporal — Build pipeline at service scale#

Temporal’s build pipeline handles: multiple binary artifacts (temporal-server, cassandra-tool, sql-tool, elasticsearch-tool, tdbg), GoReleaser for binary distribution, buf for protobuf generation (7+ codegen binaries), SQL migration management across 5 database backends, and Docker Compose for local development. The Makefile sequences these into coherent targets. This is what a mature, complex service’s build pipeline looks like when done well — everything is automated, nothing is manual.

kubernetes — Make at industrial scale#

Kubernetes’s Makefile is a masterclass in organizing build logic for a multi-binary, multi-architecture, multi-consumer codebase. It separates concerns (build, test, lint, codegen, docker, release) into distinct targets, uses build variables for every platform-specific decision, and documents everything with make help. The kube-cross container image provides a known cross-compilation environment. Despite its complexity, it remains navigable because its structure is consistent. The pattern of a ./build/ directory containing all build scripts (rather than inline Makefile logic) is worth emulating.


Note on fyne and crush#

fyne requires CGO for its GLFW/OpenGL rendering backend, making it a meaningful outlier in the CGO_ENABLED=0 consensus. This is a domain constraint (GUI requires native system libraries), not a design choice. fyne’s build system (plain go build + build tags for platform selection) is deliberately minimal — it offloads platform-specific complexity to the operating system’s graphics stack rather than managing it in the build pipeline.

crush uses go-task and GoReleaser with charmbracelet’s shared notarization configuration. Consulting analysis/results/P51-crush--ai-development-profile.md: the build/deploy pipeline shows no AI-development signals — it follows Charm’s standard patterns (go-task, goreleaser with meta include) which predate this project. The nightly release track is a Charm-standard feature for beta distribution. crush’s build pipeline is representative of modern charmbracelet tooling conventions, not of any AI-influenced design.