Go's toolchain can build a binary for one operating system and CPU architecture while running on another. You can sit on a Mac and produce a Linux binary that runs on a production server, or kick off a release build that emits binaries for Linux, macOS, and Windows in one shot. This chapter covers the two environment variables that drive it (GOOS and GOARCH), why pure-Go binaries cross-compile cleanly while CGO complicates things, how to shrink and version the output with -ldflags, and how to wire all of that into a release pipeline for an order service.
Most teams deploy Go programs to Linux servers, but write code on Mac or Windows laptops. Without cross-compilation, you'd need a Linux VM, a remote build server, or a Docker container just to produce the artifact you're going to ship. Go removes that step. The same go build you use locally can emit a Linux binary directly, and it will run on a server that has no Go toolchain installed at all.
The other reason is shipping to users on different platforms. A CLI tool, a server agent, or a developer utility often needs to run on macOS (Apple Silicon and Intel), Linux (amd64 and arm64), and Windows. With Go, one CI job can produce all six binaries from a single source tree. No platform-specific build agents, no conditional compilation tooling, no separate release pipelines.
Here's the order-service example we'll come back to throughout the chapter. A small program that prints the build target and a fake order summary:
Built on a Mac, it normally produces a Mac binary. Built with two environment variables flipped, it produces a Linux binary instead, without changing a line of code. This is the whole feature.
Cross-compilation itself is free. You're not running a slower emulator; you're just asking the linker to produce a different output format. The only thing that gets slow is when CGO is involved, which we'll cover below.
GOOS selects the target operating system. GOARCH selects the target CPU architecture. Both are environment variables the Go toolchain reads before it builds. They default to the values of the machine running the build, so most of the time you don't think about them.
| Variable | Common values | Meaning |
|---|---|---|
GOOS | linux, darwin, windows | Target operating system |
GOARCH | amd64, arm64, 386 | Target CPU architecture |
darwin is the historical name for macOS; both Intel Macs (darwin/amd64) and Apple Silicon Macs (darwin/arm64) use it. amd64 is the 64-bit Intel/AMD instruction set that most servers and desktops run. arm64 covers Apple Silicon, AWS Graviton instances, Raspberry Pi 4 and 5, and most modern phones. 386 is 32-bit Intel, rare outside legacy hardware.
To see every pair Go supports on your toolchain version:
You'll only ever care about a handful of those: the three or four targets your users actually run on. The rest are there because Go was designed for portability from the start.
Setting them inline before go build produces a binary for the chosen target. The build doesn't pollute your shell; the variables apply to that one command:
Run that same line on a Mac and you get an ELF 64-bit LSB executable, which is Linux's binary format. The output is ready to copy to a Linux server and run.
Go programs that don't call C code can be cross-compiled with a single environment variable change. The compiler emits machine instructions for the target CPU, the linker writes the right binary format, and you're done. No external toolchain needed.
The moment your program imports a package that uses cgo (the bridge between Go and C), the build needs a C compiler that targets the destination platform. That compiler isn't shipped with Go. On a Mac, your cc builds for macOS; it can't produce Linux object files without a cross-toolchain installed. This is why a CGO-enabled program fails to cross-compile out of the box:
The fix, when your program doesn't need C at all, is to disable cgo:
CGO_ENABLED=0 tells Go to skip every C-dependent path. Standard library packages have pure-Go fallbacks for net, os/user, and a few others, so most programs work fine with cgo off. The exceptions are programs that explicitly need C libraries: SQLite drivers, certain image libraries, GUI bindings. For those, you either bring in a cross-toolchain (zig, musl-cross, or osxcross) or switch to a pure-Go alternative (modernc.org/sqlite instead of mattn/go-sqlite3, for example).
The trade-off is real. With cgo off, the binary is statically linked: every Go-side dependency is baked in, and there's no dynamic linker call when the program starts. With cgo on, the binary often depends on the target system's libc, which means it'll only run on systems where that libc version is present.
CGO_ENABLED=1 (default on host) | CGO_ENABLED=0 | |
|---|---|---|
| Cross-compiles without extra toolchain | No | Yes |
| Binary is statically linked | Sometimes | Always (pure Go) |
| Can use C libraries | Yes | No |
Works in FROM scratch Docker images | No (usually) | Yes |
| Binary size | Often smaller | Slightly larger |
The decision is almost always "set CGO_ENABLED=0 for cross-compiled builds". The order service we're working with is pure Go, so we'll keep cgo off for the rest of the chapter.
Pure-Go DNS resolution (the fallback when cgo is off) doesn't always match the host's /etc/nsswitch.conf exactly. For most server programs this doesn't matter. If you need glibc's resolver behavior, you'll need cgo on and a matching toolchain on the build host.
A statically linked Go binary contains every function it needs. Drop it onto a Linux server with nothing but a kernel and it runs. That property is what makes Go popular for container images, single-binary tools, and on-premise software where you don't control the target environment.
Here's what "static" means in practice. On Linux, dynamically linked binaries have entries that say "find libc.so.6 at runtime and call it". Static binaries don't. You can check with ldd:
That output is what you want. Compare it to a typical C program:
curl won't run on a system that's missing those .so files. Your Go binary doesn't have that problem.
The downside is binary size. A static Go binary includes the runtime, the garbage collector, and every standard library package you imported. A "hello, world" with fmt weighs about 2 MB. A small server with net/http, encoding/json, and database/sql is typically 8 to 15 MB. That sounds large until you remember the alternative is a 200 MB Docker image with Ubuntu, libc, and a Python interpreter.
Two -ldflags patterns come up in nearly every production Go build. The first shrinks the binary by removing debug symbols. The second injects a version string at build time so the binary can report its own provenance.
-s -w are linker flags passed through -ldflags. -s strips the symbol table. -w strips DWARF debug info. Together they typically cut binary size by 25 to 30 percent.
The trade-off is that stripped binaries can't be debugged with dlv or analyzed with pprof symbol resolution. In production server binaries this is fine; you'd debug a separate unstripped build locally. For libraries or tools that may need post-mortem debugging, leave the symbols in.
The version injection trick uses -X to set a package variable at link time:
When you build without flags, you get the defaults:
When you pass -X flags, the linker writes real values into those variables:
A few rules for -X:
string. Constants don't work; the linker writes into the variable's storage at link time.package.VariableName, where package is the full import path or main for the entry-point package.-ldflags string. Use ISO date formats and short commit hashes to avoid that headache.-s -w and injecting with -X are compatible; combine them in one flag string: -ldflags="-s -w -X main.version=1.4.0".This is the same mechanism Kubernetes, Docker, Terraform, and most production Go binaries use to report their version. There's no special "version" package or build tag involved; it's just one linker flag and a string variable.
-X happens at link time, not compile time. That means your version metadata doesn't invalidate the build cache. The compiler caches the same object files across builds with different version strings, so each release build is still fast.
A statically linked Go binary can run in a Docker image with literally nothing else inside. The scratch base image is an empty filesystem; you copy your binary in and that's the whole image.
The first stage uses the official Go image to build. The second stage starts from scratch and copies just the binary. The resulting image is around the size of the binary itself: 5 to 10 MB total.
Compare to the typical python:3.12-slim or node:20-alpine base image, which is 50 to 150 MB before you add a line of your own code. Smaller images push faster, pull faster on cold deploys, and shrink the attack surface dramatically (no shell, no package manager, no curl).
FROM scratch has two practical limitations. First, there's no /etc/ssl/certs directory, so HTTPS calls fail unless you copy CA certificates from the builder image. Second, there's no shell, so you can't docker exec into the running container to poke around. The fix for the first is one extra line in the Dockerfile:
The fix for the second is gcr.io/distroless/static, Google's minimal base image. It's still very small (about 2 MB), still has no shell, but ships with CA certs and a few standard files most servers expect. Many production Go services use distroless as the default.
The nonroot variant runs as UID 65532 instead of root, which is the kind of detail your security team will ask about during the first container audit. Better to set it from the start.
When you ship a container image, users on different platforms need different binaries. An x86 server can't run an arm64 image. A Mac developer running Docker Desktop on Apple Silicon can't pull an amd64 image without emulation. The fix is multi-arch images: one image tag that serves the right binary based on the puller's architecture.
Docker's buildx builder handles this. It builds binaries for each target platform, packs them into separate image layers, and creates a manifest list that ties them together under one tag.
The --platform flag lists the targets. Each becomes a separate image inside the manifest. --push is needed because multi-arch images can't sit in the local Docker daemon's store; they have to live in a registry.
Inside the Dockerfile, the build args TARGETOS and TARGETARCH tell you what buildx is currently building. Wire them into go build so each platform gets the right binary:
When buildx builds the linux/arm64 slice, it sets TARGETOS=linux and TARGETARCH=arm64, and your Go build line produces an arm64 binary. When it builds linux/amd64, it gets the amd64 binary. The final manifest tag points to both, and Docker picks the right one for each puller automatically.
buildx is a deep topic on its own. The point here is that Go's cross-compilation makes the build step trivial: no QEMU emulation, no per-platform build agents, just two environment variables.
Three of the four most common Go deployment targets in 2026 are linux/amd64, linux/arm64, and darwin/arm64. The first is most cloud servers, the second is AWS Graviton and any modern ARM Linux host, and the third is every Mac sold since late 2020.
Building for Apple Silicon from an Intel Mac (or vice versa):
Both run on the matching Mac without any extra setup. The macOS platform doesn't transparently emulate the wrong architecture (Rosetta 2 will translate Intel binaries on Apple Silicon, but it has a cold-start delay). Ship a native binary for each.
For Linux ARM, the same pattern works for AWS Graviton, Oracle Ampere, and most modern ARM cloud instances:
Raspberry Pi gets slightly trickier because the Pi family spans architectures. A Pi 4 or Pi 5 running 64-bit Raspberry Pi OS uses linux/arm64. An older Pi 3 running 32-bit Raspbian uses linux/arm, which requires one more variable, GOARM, to pick the ARM version:
| Hardware | OS bits | GOOS | GOARCH | GOARM |
|---|---|---|---|---|
| Raspberry Pi 4 / 5 (64-bit OS) | 64 | linux | arm64 | (not used) |
| Raspberry Pi 3 (32-bit OS) | 32 | linux | arm | 7 |
| Raspberry Pi Zero / 1 | 32 | linux | arm | 6 |
Most teams don't worry about 32-bit ARM anymore unless they're targeting embedded hardware or older edge devices.
Windows builds work the same way as the others. GOOS=windows and the right GOARCH. The output file should end in .exe, which Windows uses to identify executables:
PE32+ is Windows's binary format, the equivalent of ELF on Linux or Mach-O on macOS. You can copy that .exe to any matching Windows machine and run it.
Three things to know about Windows builds:
-ldflags="-H windowsgui".filepath.Join works on every platform; code that hardcodes / separators breaks on Windows.mingw-w64 installed on the host. Pure-Go programs cross-compile to Windows cleanly with CGO_ENABLED=0.For Windows on ARM (Surface Pro X, some newer Windows laptops):
That target is still uncommon, but it's there if you need it.
Here's where the pieces add up. The order service needs releases for three targets: Linux amd64 (cloud servers), Linux arm64 (AWS Graviton fleet), and macOS arm64 (developer machines running CLI tools that talk to the API). Every release embeds the git tag as the version and the build timestamp.
A simple shell script that does all three from one source tree:
Run it with a version tag:
Three binaries, one source tree, one command. Each one runs natively on its target with no shared library dependencies. The version metadata is baked in:
The matrix that pipeline implements:
The diagram captures what every Go release pipeline boils down to: one source tree, multiple GOOS/GOARCH pairs, one set of artifacts. In CI, this is usually a single job (Linux runner) rather than three matrix jobs, because cross-compilation makes per-platform runners unnecessary.
For larger projects, goreleaser automates the whole flow: it reads a YAML config listing the targets, builds them, creates archives (tar.gz for Unix, zip for Windows), generates checksums, and uploads to a GitHub release. The underlying mechanics are the same go build invocations you'd write by hand, just orchestrated.
Each go build invocation re-runs the linker but reuses cached compilation output where possible. Three targets typically take 1.2x to 1.5x the time of a single target, not 3x. The compile cache is keyed on source plus build settings, and most settings are shared across targets.
A handful of mistakes come up repeatedly when teams first set up cross-compilation. Each has a one-line fix.
Forgetting `CGO_ENABLED=0` in CI. Local builds work because your laptop has a C compiler. CI builds fail when cgo gets pulled in by a transitive dependency. The fix is to set CGO_ENABLED=0 explicitly in every cross-compile build line, even when you're sure you don't use cgo. Imported libraries change. Future-you will thank present-you.
Hardcoding paths with `/`. Code like configPath := "./config/" + name + ".yaml" works on Mac and Linux but breaks on Windows because the directory separator is \. Use filepath.Join("config", name+".yaml") instead. filepath is OS-aware; path is for URL paths.
Building without `-trimpath` in release artifacts. By default, Go embeds the absolute path to your source tree in the binary, so the file lists in stack traces show /Users/you/projects/order-service/cmd/api/main.go. That's fine locally but reveals your directory structure (and sometimes your username) in shipped binaries. Add -trimpath to strip those paths to relative ones: go build -trimpath -ldflags="-s -w"....
Forgetting to test the binary you built. A linux/arm64 binary built on a Mac is unusable until you've actually run it on Linux ARM. Most teams have a small smoke-test step in CI: spin up a Linux ARM container or VM, run the binary with --version, and check the exit code. Catches missing dependencies before they reach production.
Mixing version strings. If you have var version string in main, the -X target is main.version. If you move it to an internal package like internal/build, the target becomes example.com/order-service/internal/build.Version (full import path, exported name). The linker ignores -X flags that don't match any variable, so a typo means your release ships with version = "dev" and you don't notice until someone reports the bug.
That if version == "dev" check is a cheap way to catch the silent-ignore bug. If a release somehow shipped without the -X flags, the warning fires and someone notices.
10 quizzes