Introduction
You've built a solid Go API that runs perfectly on your development machine, but now you need to deploy it to an AWS Graviton instance, a customer's Kubernetes cluster on ARM, and maintain a downloadable binary for Windows users. Cross-compiling Go binaries: what developers should know is not just about running a single command—it's about understanding the toolchain, automating the process reliably, and ensuring your releases are consistent across every platform. In this article, we'll walk through the mechanisms that make Go's cross-compilation straightforward and show you how to build for any platform from one machine.
Understanding GOOS and GOARCH environment variables
Go's cross-compilation is built on two core environment variables: GOOS (operating system) and GOARCH (architecture). When you run go build, the compiler reads these variables to determine what platform to target. By default, they're set to your current machine's OS and architecture, which is why building on macOS produces a macOS binary.
The beauty of this system is its simplicity. Unlike C or C++, you don't need to install separate toolchains for each platform. Go ships with the standard library pre-compiled for most common combinations, so switching targets is just setting environment variables.
Common platform combinations you'll encounter:
GOOS=linux GOARCH=amd64– Standard Linux servers and most cloud instancesGOOS=linux GOARCH=arm64– AWS Graviton, Apple Silicon Docker hosts, modern ARM serversGOOS=darwin GOARCH=arm64– Apple Silicon MacsGOOS=windows GOARCH=amd64– Windows desktops and serversGOOS=linux GOARCH=386– 32-bit Linux (rarely needed in modern production)
Here's a practical build for Linux x86:
GOOS=linux GOARCH=amd64 go build -o api-linux-amd64 ./cmd/apiThis single command produces a statically-linked binary with no dependencies. You can then scp it to your production server and run it immediately. Compare this to Node.js or Python, where you'd need to replicate your entire dependency tree on the target machine.
A common gotcha: if your code uses cgo (C bindings), cross-compilation becomes harder because you need a C compiler targeting the destination platform. For production APIs, prefer pure Go libraries when possible. If you must use cgo, you'll need to set up a cross-compilation toolchain or build in containers.
Building for Linux ARM64 and AWS Graviton
AWS Graviton instances are increasingly common in production because they offer better price-performance than x86. Many teams have adopted them for API servers, worker processes, and background jobs. Building for ARM64 is straightforward with Go, but there are practical considerations you should know.
Graviton instances run linux/arm64, so the build command is:
GOOS=linux GOARCH=arm64 go build -o api-arm64 ./cmd/apiLet's say you're building an API server and you want to support both amd64 and arm64 in a Makefile so your team can build either architecture easily:
.PHONY: build-linux-amd64 build-linux-arm64 build-all
build-linux-amd64:
GOOS=linux GOARCH=amd64 go build -o ./dist/api-linux-amd64 ./cmd/api
build-linux-arm64:
GOOS=linux GOARCH=arm64 go build -o ./dist/api-linux-arm64 ./cmd/api
build-all: build-linux-amd64 build-linux-arm64
@echo "Built for both architectures"When you deploy to a Graviton instance, make sure you verify the binary is actually running. You can check the architecture of a compiled binary using file:
file ./api-arm64
# Output: ELF 64-bit LSB executable, ARM aarch64, version 1One real-world scenario: your team develops on x86 Macs and Linux, but your production is on Graviton. If you forget to build for arm64 and ship an amd64 binary, it won't run—you'll get an "exec format error" at deployment time. Automate this in CI/CD to catch it early.
Go's standard library is fully supported on arm64, and most well-maintained third-party packages are too. Before adopting a dependency, check its GitHub issues or go.mod for any platform-specific caveats.
Automating releases with GoReleaser
Building a single binary for one platform is fine for internal testing, but production releases need consistency and speed. GoReleaser is a tool that automates multi-platform builds, creates release notes, and publishes to GitHub, Docker registries, and other repositories—all from a single configuration file.
Install GoReleaser:
go install github.com/goreleaser/goreleaser@latestCreate a .goreleaser.yaml file in your repository root:
version: 2
project_name: myapi
builds:
- id: api
main: ./cmd/api
binary: myapi
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X main.Version={{.Version}} -X main.Commit={{.Commit}}
archives:
- format: tar.gz
builds:
- api
name_template: >-
{{ .ProjectName }}_
{{- .Version }}_
{{- .Os }}_
{{- .Arch }}
checksum:
name_template: "checksums.txt"
release:
github:
owner: yourorg
repo: myapi
draft: false
prerelease: falseRunning goreleaser release --snapshot builds binaries for all platforms locally without publishing. In CI/CD, you'd run it on tagged commits:
goreleaser releaseThis creates a GitHub release with binaries for Linux (amd64 and arm64), macOS (amd64 and arm64), and Windows (amd64 and arm64), along with checksums so users can verify integrity. Your team gets consistent builds, and you avoid the human error of manually running platform-specific builds.
GoReleaser also handles Docker image creation if you add a dockers section, which is useful for multi-arch container deployments. When your release is tagged, the entire pipeline runs once, and all artifacts are published simultaneously.
Embedding version info with ldflags
When you ship a binary to production, you need to know which version is running. Go binaries don't carry version metadata by default—they're just compiled code. The solution is to use linker flags (ldflags) to inject version, build time, and commit hash at compile time.
In your main.go, define variables that will receive version info:
package main
var (
Version = "dev"
Commit = "unknown"
BuildTime = "unknown"
)
func main() {
// Use these in your API response or logging
log.Printf("Starting API version=%s commit=%s build_time=%s\n", Version, Commit, BuildTime)
}Build with ldflags to inject values:
go build \
-ldflags="-X main.Version=1.2.3 -X main.Commit=abc1234 -X main.BuildTime=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
-o api \
./cmd/apiWhen the binary runs, it logs the exact version. This is invaluable for debugging production issues: you can correlate a crash or misbehavior to a specific commit and code review.
In your API, expose version via an endpoint:
func versionHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"version": Version,
"commit": Commit,
"build_time": BuildTime,
})
}This endpoint becomes a quick way for on-call engineers to verify the deployed version without SSH-ing into servers.
GoReleaser's ldflags example (from earlier) shows the pattern: -X main.Version={{.Version}} tells the linker to set the variable main.Version to the Git tag at build time. The -s -w flags strip debug symbols, reducing binary size (useful for large releases).
Common mistakes
- Forgetting CGO_ENABLED=0: If your code or dependencies use cgo, cross-compilation fails unless you have the right C toolchain. For production APIs, set
CGO_ENABLED=0to disable cgo and ensure pure Go compilation. - Building cgo code without a cross-compiler: If you must use cgo, you need to install a C compiler that targets the destination architecture (e.g.,
aarch64-linux-gnu-gccfor ARM64). It's complex and slow; consider refactoring if possible. - Not testing the target binary: Just because a binary compiles doesn't mean it runs. Always test the built artifact on the target platform or in a container before deploying. A quick smoke test (e.g., checking
/healthor/version) catches problems early. - Inconsistent builds between machines: If developers build locally and CI/CD builds differently, bugs emerge that are hard to reproduce. Use GoReleaser or a consistent Makefile so all builds follow the same process.
- Ignoring platform-specific behavior: Go abstracts file paths and signals, but some code paths differ. Test on the actual target OS, especially for Windows if your API runs there.
Key takeaways
GOOSandGOARCHenvironment variables control the target platform; building for any OS from your development machine requires just setting these and runninggo build.- AWS Graviton and other ARM64 deployments are straightforward with Go; ensure your CI/CD builds for both amd64 and arm64 to support diverse production environments.
- GoReleaser automates multi-platform builds and releases, eliminating manual steps and reducing the risk of shipping the wrong binary to the wrong platform.
- Embed version, commit, and build-time info using ldflags so every binary carries metadata; expose it via an API endpoint for quick verification in production.
- Set
CGO_ENABLED=0for cross-compilation unless you specifically need cgo, which significantly complicates the build process. - Always test binaries on their target platform before deploying, even if compilation succeeded.






Comments
Loading comments…