One of the genuine joys of Go is that cross-compilation mostly just works. I have a small daemon that runs on a clutch of ARM boxes around the house, some 64-bit, one ancient 32-bit board I refuse to retire, and I build all of it on an amd64 laptop without a toolchain in sight. No Docker cross builders, no QEMU, no afternoon lost to a C toolchain that hates me. Set two environment variables and go build does the rest.
That is the headline, and for pure-Go programs it is the whole story. But there are a few corners that bite, and I have lost enough hours to them to want it written down.
The reason it works at all is worth understanding, because it explains where the limits are. The Go toolchain ships compilers for every supported target, written in Go itself, so there is no separate cross-toolchain to install. The runtime and the standard library are recompiled for the target as part of the build. When you change GOARCH, you are not invoking a different gcc, you are telling the same Go compiler to emit instructions for a different chip. That is why it is a setting and not an installation.
Listing what you can target
Before I memorised the common ones I leaned on the toolchain to tell me what was possible. It will list every valid pair:
go tool dist list
That spits out everything from linux/arm64 to darwin/amd64 to a handful of platforms I have never knowingly touched. For my purposes the interesting lines are the linux/arm and linux/arm64 ones, but it is reassuring to see the whole catalogue, and it settles arguments about whether something is supported at all.
The two variables that matter
The whole thing hinges on GOOS and GOARCH. For a 64-bit ARM board running Linux:
GOOS=linux GOARCH=arm64 go build -o myapp ./cmd/myapp
That's it. Copy the binary across, it runs. The compiler is part of the standard toolchain, so there is nothing extra to install. You can confirm what you're producing with file:
$ file myapp
myapp: ELF 64-bit LSB executable, ARM aarch64, statically linked, ...
Statically linked is the key phrase. As long as you are not pulling in C, Go links everything it needs into the one file, which is exactly what you want when the target is a board you would rather not install dependencies on.
The GOARM trap
The 32-bit ARM world is where the smugness ends. GOARCH=arm is not enough on its own, because "ARM" covers a wide span of chips with different floating-point support. That is what GOARM is for, and getting it wrong gives you a binary that either crashes with an illegal instruction or quietly runs slowly in software float.
GOOS=linux GOARCH=arm GOARM=7 go build -o myapp ./cmd/myapp
The values map roughly to:
GOARM=5: software floating point, for very old chips with no FPUGOARM=6: VFPv1 hardware floatGOARM=7: VFPv3, which is what most boards from the last decade actually want
My rule is to default to 7 and only drop down if the binary refuses to run. The illegal-instruction crash is the tell. If you see SIGILL on startup, you have asked for floating-point hardware the chip does not have, so step down a number and rebuild.
CGO is where the dream dies
Everything above assumes pure Go. The moment you import something that needs C, typically via a database driver, an image library, or anything wrapping a system library, CGO_ENABLED flips the rules. Now you genuinely do need a C cross-compiler for the target, and the easy life is over.
My first move is always to check whether I can avoid it. A surprising amount of the ecosystem has pure-Go alternatives now: a pure-Go SQLite, pure-Go DNS, pure-Go crypto. If I can set CGO_ENABLED=0 and still build, I do, and I keep the deployment trivial.
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o myapp ./cmd/myapp
When CGO is genuinely unavoidable, I reach for a cross toolchain like the aarch64-linux-gnu-gcc packages and point Go at it:
CGO_ENABLED=1 \
GOOS=linux GOARCH=arm64 \
CC=aarch64-linux-gnu-gcc \
go build -o myapp ./cmd/myapp
It works, but it is a different category of effort, and it is the point where I start asking whether I actually need that dependency.
Keep it in a Makefile
I never remember any of this under pressure, so it lives in a Makefile and never leaves my head as anything but a target name:
.PHONY: build-arm64 build-armv7
build-arm64:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
go build -ldflags="-s -w" -o dist/myapp-arm64 ./cmd/myapp
build-armv7:
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 \
go build -ldflags="-s -w" -o dist/myapp-armv7 ./cmd/myapp
The -ldflags="-s -w" strips the symbol table and debug info, which shaves a useful chunk off the binary. On a board with limited storage that matters more than you would think, and I have never needed those symbols on the device itself. If I want to debug, I do it on the build host.
I run both targets in one go and drop the results into dist/, then a small scp loop pushes the right binary to the right box. The whole build-and-deploy cycle is under a minute, which is the bit that still slightly amazes me given how miserable cross-compilation used to be.
Verifying you built the right thing
The failure mode I most want to avoid is shipping an amd64 binary to an ARM board, because the error you get is cannot execute binary file: Exec format error, which is clear enough but only tells you after you have copied it across and tried to run it. So I check on the build host before deploying. file is the quickest:
$ file dist/myapp-arm64
dist/myapp-arm64: ELF 64-bit LSB executable, ARM aarch64, ...
If I want to be thorough I also check it is genuinely static with ldd, which on a properly static binary reports "not a dynamic executable". That one line is my confidence that the board needs nothing installed to run it. Skip it and you find out the hard way, on the device, when it complains about a missing shared library that the build host had and the board does not.
A note on reproducibility
One thing I appreciate is that these builds are deterministic enough to be useful in CI. The same source, the same Go version, the same flags, and you get the same binary. I pin the Go version in the build and stamp the version and commit into the binary with -ldflags:
go build -ldflags="-s -w -X main.version=$(git describe --tags)" \
-o dist/myapp-arm64 ./cmd/myapp
Now myapp --version on the board tells me exactly what is running, which has saved me more than once when I was convinced a fix was deployed and it quietly was not.
The thing I keep coming back to is how unusual this ease is. I cut my teeth on languages where targeting another architecture meant a sysroot, a toolchain file, and a quiet afternoon of swearing. Go made it a two-variable problem, and for the common case it stays a two-variable problem. The corners are real, but they are corners, not the whole room. Set GOOS and GOARCH, mind your GOARM, keep CGO at arm's length, and the little box across the room runs your code by teatime.