One of the quietly brilliant things about Go is that cross-compiling is not a project. It is two environment variables. After years of fighting toolchains and sysroots to build C for ARM, the first time I cross-compiled a Go binary for my little SBC I genuinely double-checked that it had worked, because it could not possibly have been that easy.
It was that easy.
the whole trick
I have a small ARM board in the cupboard running a couple of services. It is a Cortex-A53, so 64-bit ARM, which Go calls arm64. Building for it from my x86-64 laptop is this:
GOOS=linux GOARCH=arm64 go build -o myservice ./cmd/myservice
scp myservice arm-box:/opt/myservice/
That is it. No cross-compiler to install, no sysroot, no fighting with --host triples. The Go toolchain ships with everything it needs to emit code for every platform it supports, and go tool dist list will show you the full set if you want to know what is possible.
For the older 32-bit boards it is a touch more specific, because ARMv6 and ARMv7 differ:
# 32-bit, ARMv7 (most Pi 2/3 class boards)
GOOS=linux GOARCH=arm GOARM=7 go build -o myservice ./cmd/myservice
# 32-bit, ARMv6 (the original Pi, Pi Zero)
GOOS=linux GOARCH=arm GOARM=6 go build -o myservice ./cmd/myservice
Get GOARM wrong for an old Pi Zero and you get an illegal instruction at runtime rather than a build error, which is a confusing five minutes the first time. Otherwise it is genuinely this boring.
where the magic stops
The one place the easy life ends is cgo. The moment your binary depends on a C library through cgo, CGO_ENABLED=1, you are back in cross-compiler land, because now you need a C toolchain that targets ARM and the matching headers. The pure-Go promise was always conditional on staying pure Go.
So my rule for anything destined for the cupboard board is: avoid cgo unless I genuinely cannot. In practice that means choosing the pure-Go SQLite driver over the cgo one, and setting CGO_ENABLED=0 explicitly in the build so I find out at compile time, on my laptop, rather than discovering a missing shared library when the service refuses to start on a box I have to dig out and plug a monitor into.
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o myservice ./cmd/myservice
With that set, the binary is fully static. It does not care what libc the board has, or whether it has one at all. I can scp it onto a minimal image and it just runs. For a service that lives in a cupboard and that I would rather not maintain a build environment for, that is exactly the property I want.
The whole thing still feels slightly like cheating, and I have made my peace with that.