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
Makefileorchestratesgo buildbehind 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 rawdocker buildxandgo buildinvocations.
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 generateorprotocas a Makefile target beforego 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.ymldescribes 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 singlegoreleaser releaseinvocation 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:
| Project | GoReleaser complexity | Notable features |
|---|---|---|
| caddy | Very high | Pre-hooks vendor+copy to temp dir (avoids Git dirty), xcaddy build for Windows .syso embedding, bash completions, manpages, includes from charmbracelet/meta |
| gh | High | Per-OS build IDs (macos/linux/windows), macOS notarization script, PowerShell .syso generation, nfpm for deb/rpm |
| crush | High | charmbracelet/meta include for notarization, nightly release track, manpages, shell completions |
| headscale | Medium | vendor mode, freebsd target, source tarball alongside binaries |
| nats-server | Medium | Pinned Go toolchain via GOTOOLCHAIN env, loong64/s390x/ppc64le targets |
| pocketbase | Medium | CGO=0 and CGO=1 builds (separate IDs), arm/s390x/ppc64le targeting |
| temporal | Medium | Multiple build IDs (temporal-server, cassandra-tool, sql-tool, tdbg), config files included in archives |
| air | Low | Standard pattern, ldflags version injection only |
| delve | Low | Standard 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
./devCLI written in Go../dev build,./dev test,./dev linttranslate to Bazel invocations with pre-configured toolchains. Gazelle generatesBUILD.bazelfiles fromgo.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
./devbut 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 viago 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 ./...andgo test ./...suffice. GitHub Actions runs these directly. No release pipeline because the project is a library consumed viago 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#
| System | Projects | Count |
|---|---|---|
| GitHub Actions | moby, 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 / Prow | kubernetes, istio, go (language), wireguard-go | 4 |
| CircleCI | frp, hugo | 2 |
| Buildkite | buildkite-agent | 1 |
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#
| Strategy | Projects | Mechanism |
|---|---|---|
| Container image → registry | kubernetes, etcd, consul, vault, prometheus, traefik, grafana, minio, nomad, dapr, k3s, istio, argo-cd, gitea, drone, syncthing, tailscale | Docker Hub, GHCR, GCR |
| Cross-platform binary → GitHub Release | caddy, gh, fzf, headscale, nats-server, temporal, delve, air, pop, pocketbase, crush | GoReleaser |
| Package managers (brew, apt, rpm, AUR) | caddy, gh, fzf, headscale, air | GoReleaser nfpm / brew tap |
| go get (library) | gin, echo, fiber, gorm, viper, cobra, buffalo, beego, sqlc, fyne | Go module proxy |
| Static binary with embedded assets | pocketbase (SQLite), caddy, nats-server | CGO_ENABLED=0 + embed |
| Source tarball | restic, syncthing, rclone | Custom 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 debugginggcr.io/distroless/static: etcd, k3s — CA certificates and timezone data included without a shellalpine:3.N: consul, vault, nomad, traefik — adds ~5MB but providesshfor debugging andapkfor runtime packagesubi8-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#
| Generator | Projects | Purpose |
|---|---|---|
| buf / protoc | etcd, consul, dapr, istio, temporal, tekton-pipeline, sqlc, headscale | gRPC service stubs, message types |
| sqlc | sqlc (itself), pocketbase, pop, crush | Type-safe database query code |
| Wire (google/wire) | drone (120+ WireSets) | Compile-time dependency injection |
| mockery / gomock | consul, vault, temporal, dapr | Interface mocks for tests |
| swag | pocketbase | OpenAPI spec from Go annotations |
| msgp | fiber | Optimized MessagePack serialization |
| deepcopy-gen, informer-gen | kubernetes | Controller/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).
Trends#
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#
- CGO_ENABLED=0 + static binary as the default for any cross-platform binary. Nearly universal.
-ldflags "-s -w -X pkg.Version={{.Version}}"for stripping debug symbols and injecting build metadata.- Multi-stage Dockerfile for all containerized services.
- GitHub Actions matrix for OS × architecture testing (
strategy: matrix: os: [ubuntu, macos, windows]). go mod tidyas a CI check — failing CI if tidy produces a diff ensures the go.mod/go.sum stays clean.buf generateas a Makefile prerequisite for any project with .proto files.- 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#
Use
CGO_ENABLED=0for release binaries. Unless you have a specific C library requirement (SQLite, OpenGL), static binaries are simpler, more portable, and produce smaller containers.Use GoReleaser for multi-platform binary distribution. For CLI tools and standalone servers, GoReleaser eliminates the build matrix script sprawl. The
.goreleaser.ymlis a readable, versionable specification of your entire release pipeline.Multi-stage Dockerfiles with minimal base images. For services, use
scratchor distroless as the runtime stage. Reserve Alpine for cases where you genuinely need a shell at runtime.Embed assets with
//go:embed. Eliminates asset installation steps, simplifies container images, makes single-binary distribution natural.Run
buffor protobuf, not raw protoc. buf provides dependency management, linting, and breaking-change detection that raw protoc lacks. It integrates cleanly into Makefiles and CI.Gate CI on
go mod tidy. A tidy check (go mod tidy && git diff --exit-code go.mod go.sum) prevents dependency drift.Pin Go toolchain versions. nats-server’s pattern of
GOTOOLCHAIN={{ envOrDefault "GORELEASER_TOOLCHAIN" "go1.26.1" }}in GoReleaser and tailscale’s./tool/gopinned toolchain shim both ensure release builds use a known toolchain. This is especially important for security-sensitive projects.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#
Bazel for a pure-Go project. The overhead of BUILD files and Gazelle is not justified when
go buildalready has a precise dependency graph. Adopt Bazel only when mixing Go with C++, Rust, or Java in the same repo.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.
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.
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.
Hardcoded toolchain versions in Makefiles without a strategy for updates. Projects that hardcode
go1.21without a mechanism to update will silently fall behind security patches. Better: usegodirective ingo.modas the authoritative version, with CI reading it viago env GOVERSION.No
go mod tidycheck in CI. Without this guard,go.modandgo.sumdrift 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.