Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 052 / 100

Cross-compiling Go binaries: what developers should know

Build for any platform from one machine.

Expona Admin7 min read

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 instances
  • GOOS=linux GOARCH=arm64 – AWS Graviton, Apple Silicon Docker hosts, modern ARM servers
  • GOOS=darwin GOARCH=arm64 – Apple Silicon Macs
  • GOOS=windows GOARCH=amd64 – Windows desktops and servers
  • GOOS=linux GOARCH=386 – 32-bit Linux (rarely needed in modern production)

Here's a practical build for Linux x86:

go
GOOS=linux GOARCH=amd64 go build -o api-linux-amd64 ./cmd/api

This 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:

go
GOOS=linux GOARCH=arm64 go build -o api-arm64 ./cmd/api

Let'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:

go
.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:

go
file ./api-arm64
# Output: ELF 64-bit LSB executable, ARM aarch64, version 1

One 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
go install github.com/goreleaser/goreleaser@latest

Create a .goreleaser.yaml file in your repository root:

go
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: false

Running goreleaser release --snapshot builds binaries for all platforms locally without publishing. In CI/CD, you'd run it on tagged commits:

go
goreleaser release

This 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:

go
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
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/api

When 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:

go
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=0 to 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-gcc for 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 /health or /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

  • GOOS and GOARCH environment variables control the target platform; building for any OS from your development machine requires just setting these and running go 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=0 for 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles