Stop shipping Go binaries by hand – use GoReleaser
If you’ve ever manually run GOOS=linux GOARCH=amd64 go build five times in a row for different platforms, created tarballs, then uploaded them to GitHub — you know the pain. GoReleaser removes all of that. It builds your Go binaries for multiple platforms, creates checksums, packages them, and publishes releases to GitHub, GitLab, or Homebrew. All from a single command.
But I don’t want this to be just a setup tutorial. We’ll look at how GoReleaser works with Go’s build system, how it handles cross-compilation, and how you can customize builds using Go-specific flags like -ldflags, build tags, and module-aware builds.
What GoReleaser actually does
GoReleaser is a Go program that reads a .goreleaser.yaml config file and orchestrates the release process. It calls go build for you, once per target platform, then packages the results and pushes them wherever you need.
Here’s a minimal config:
version: 2
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
archives:
- format: tar.gz
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
changelog:
sort: asc
Install GoReleaser, tag your commit, and run:
git tag v1.0.0
git push origin v1.0.0
goreleaser release --clean
That’s it. Six binaries (3 OS × 2 arch), packaged in tarballs, with a changelog pulled from your git history.
Cross-compilation and CGO_ENABLED=0
Notice CGO_ENABLED=0 in the config above. This matters. Go’s cross-compilation works beautifully when you’re building pure Go code. The moment you enable CGO, you need a C cross-compiler for each target platform, and that’s a headache you don’t want.
GoReleaser sets environment variables before calling go build. For each combination of GOOS and GOARCH, it spawns a separate build. Here’s what it does for one target under the hood:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o dist/myapp_linux_arm64 ./cmd/myapp
If you genuinely need CGO (say, for SQLite), GoReleaser supports cross-compilation Docker images or tools like zig cc as the C compiler. But for most Go projects, disabling CGO is the right move.
Injecting version info with ldflags
One of the most useful patterns for Go CLI tools is embedding version information at build time using -ldflags. GoReleaser makes this dead simple.
First, set up a variable in your Go code:
package main
import "fmt"
var (
version = "dev"
commit = "none"
date = "unknown"
)
func main() {
fmt.Printf("myapp %s, commit %s, built at %s\n", version, commit, date)
}
Then configure GoReleaser to inject those values:
builds:
- ldflags:
- -s -w
- -X main.version={{ .Version }}
- -X main.commit={{ .Commit }}
- -X main.date={{ .Date }}
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
goarch:
- amd64
- arm64
GoReleaser uses Go’s template syntax to populate these values. {{ .Version }} comes from the git tag. {{ .Commit }} is the full SHA. The -s -w flags strip debug info and DWARF symbols, shrinking your binary.
This is a standard Go build pattern. GoReleaser just makes it declarative. If you’ve ever written a Makefile with go build -ldflags "-X main.version=$(git describe --tags)", this replaces that.
Build hooks and go generate
Sometimes you need code generation before building. GoReleaser supports build hooks:
builds:
- hooks:
pre:
- go generate ./...
main: ./cmd/myapp
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
goarch:
- amd64
The pre hook runs before each build. Useful if you use go generate for things like embedding SQL migrations with sqlc, generating mock implementations, or running stringer for enum types.
Watch out for this gotcha though: if your go generate step produces platform-specific output, you’ll want to run it once before GoReleaser starts rather than once per build. Use before hooks at the top level instead:
before:
hooks:
- go mod tidy
- go generate ./...
builds:
- main: ./cmd/myapp
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
goarch:
- amd64
Building multiple binaries from one module
Go modules often contain multiple commands in a cmd/ directory. GoReleaser handles this with multiple build entries:
// Project structure:
// ├── cmd/
// │ ├── server/
// │ │ └── main.go
// │ └── cli/
// │ └── main.go
// ├── internal/
// │ └── ...
// ├── go.mod
// └── .goreleaser.yaml
builds:
- id: server
main: ./cmd/server
binary: myapp-server
env:
- CGO_ENABLED=0
goos:
- linux
goarch:
- amd64
- arm64
- id: cli
main: ./cmd/cli
binary: myapp-cli
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
Notice the id field. It lets you reference specific builds in other sections. Maybe you only want the CLI binary in Homebrew and only the server binary in a Docker image:
brews:
- ids:
- cli
repository:
owner: myorg
name: homebrew-tap
dockers:
- ids:
- server
image_templates:
- "myorg/myapp-server:{{ .Version }}"
Build tags and conditional compilation
Go’s build tags let you include or exclude files based on conditions. GoReleaser supports this through the tags field:
builds:
- tags:
- production
- netgo
env:
- CGO_ENABLED=0
goos:
- linux
goarch:
- amd64
This translates to go build -tags "production netgo". The netgo tag forces Go’s pure Go DNS resolver instead of the system’s C resolver, which is what you want for truly static binaries.
You might use a custom production tag to swap implementations:
//go:build production
package config
func DefaultLogLevel() string {
return "info"
}
//go:build !production
package config
func DefaultLogLevel() string {
return "debug"
}
Standard Go conditional compilation. GoReleaser just passes the tags through to go build.
Running GoReleaser in CI
Most teams run GoReleaser in GitHub Actions. Here’s a workflow that triggers on new tags:
name: Release
on:
push:
tags:
- 'v*'
jobs:
goreleaser:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: '1.23'
- uses: goreleaser/goreleaser-action@v6
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 is the one that’ll bite you. GoReleaser needs the full git history to generate changelogs and determine the version from tags. Without it, you get confusing errors about missing tags.
Testing your config locally
Before pushing a tag and hoping for the best, validate your config:
goreleaser check
And do a dry run that builds everything but doesn’t publish:
goreleaser release --snapshot --clean
The --snapshot flag skips validation that requires a clean git state and a proper tag. It’s great for checking that your builds actually compile for all target platforms before you commit to a release.
Go module proxy and reproducible builds
GoReleaser respects your go.sum file and module cache. Builds are reproducible because Go modules pin exact versions. If you want to make sure builds use the Go module proxy, set it in the environment:
env:
- GOPROXY=https://proxy.golang.org,direct
- CGO_ENABLED=0
This means that even if a dependency’s repository disappears, your builds still work as long as the module proxy has the version cached. If you’re interested in how Go handles dependency management more broadly, check out the post on functional options — it touches on API design patterns that affect how your module’s public API evolves.
Common pitfalls
Forgetting go mod tidy: GoReleaser won’t run this for you unless you add it as a hook. If your go.sum is out of date, builds will fail.
Wrong main path: The main field must point to a directory containing a package main, not to a specific .go file. Use ./cmd/myapp, not ./cmd/myapp/main.go.
Binary naming on Windows: GoReleaser automatically appends .exe for Windows builds. Don’t add it yourself.
Snapshot versioning: When using --snapshot, the version will be something like 1.0.1-SNAPSHOT-abcdef. Make sure your ldflags templates handle this gracefully.
When to use GoReleaser vs. a Makefile
For small personal projects, a Makefile with a few go build lines is fine. GoReleaser earns its keep when you need cross-platform builds for multiple OS/arch combos, automated changelog generation, publishing to Homebrew or Docker registries, reproducible release artifacts with checksums, or integration with GitHub/GitLab releases.
If you’re building Go services that get deployed as containers, you might not need GoReleaser at all. A multi-stage Dockerfile does the job. But for CLI tools and libraries that ship binaries, GoReleaser saves you hours of tedious, error-prone scripting.
For more on structuring Go projects well, the post on Go regex patterns shows how even small Go programs benefit from clean project layout. And if you’re building APIs where context management matters during builds and tests, the context in Go post covers slice internals that come up when processing build configurations.
Where to go from here
Start with a minimal .goreleaser.yaml, run goreleaser check, and iterate. Once you’ve got the basics working, the GoReleaser docs are worth reading through — there’s a lot of power in the config that I haven’t covered here, like signing artifacts, SBOM generation, and custom publishers.