I have a small ARM single-board computer living in a cupboard, running a couple of services I wrote in Go. For a long time I built those services on the board itself, which works right up until the board is busy, or the build needs more RAM than it has, or I just want a binary in ten seconds rather than ten minutes. Go cross-compiles beautifully, and once you've internalised about four facts it stops being something you look up every time.
The one-liner that does most of it
The headline feature of Go's toolchain is that the standard library is pure Go, so cross-compilation is just two environment variables and a normal go build. No separate toolchain to install, no --host and --target dance.
GOOS=linux GOARCH=arm64 go build -o myservice ./cmd/myservice
That produces a Linux ARM64 binary on whatever machine you're sitting at, Mac or amd64 Linux, it doesn't matter. Run file myservice and it'll tell you it's an ELF 64-bit ARM aarch64 binary. Copy it across, run it, done. The first time you do this it feels like cheating.
The GOARM trap, for the older 32-bit boards
Here's the bit that catches everyone. ARM64 (GOARCH=arm64, also called aarch64) is simple. But a lot of older or cheaper boards are 32-bit ARM, and there GOARCH=arm is not enough on its own, because 32-bit ARM has had several floating-point ABIs over the years. You also need GOARM.
# 32-bit ARM, modern-ish board with hardware float (most Pi-class boards)
GOOS=linux GOARCH=arm GOARM=7 go build -o myservice ./cmd/myservice
# really old or soft-float only
GOOS=linux GOARCH=arm GOARM=5 go build -o myservice ./cmd/myservice
GOARM=7 targets ARMv7 with hardware floating point, which is what you want for anything Raspberry Pi 2 and newer in 32-bit mode. Get this wrong and the binary either won't run at all or runs but does floating-point arithmetic in software at a crawl. If you're unsure what your board is, uname -m and cat /proc/cpuinfo on the target will tell you. armv7l means GOARM=7. Honestly, if your board is 64-bit capable, run a 64-bit OS on it and use arm64, life is simpler that way.
When CGO turns up and spoils the magic
Everything above assumes pure Go. The moment your build pulls in a package that uses CGO, that lovely two-variable story falls apart, because now you need a C cross-compiler for the target and CGO_ENABLED=1 with a CC pointing at it. The classic offender is anything touching SQLite via mattn/go-sqlite3, or some DNS resolvers that fall back to the system C library.
My first move is always to avoid it. Set CGO_ENABLED=0 explicitly and see if the build still passes:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o myservice ./cmd/myservice
If it does, you're golden, and as a bonus you get a fully static binary that doesn't care which libc the target has. For SQLite specifically there are now pure-Go drivers (modernc.org/sqlite) that let me keep CGO off entirely, and I switched to one precisely so I'd never have to think about a cross C toolchain again.
If you genuinely cannot avoid CGO, the least painful route I've found is to build inside a container that already has the cross toolchain set up, rather than wrangling it on the host. A docker buildx build with --platform linux/arm64 will emulate the target via qemu and let go build with CGO behave as if it were native. Slow, but it works and it's reproducible, which matters more than speed for something I build rarely.
Packaging so it actually runs
A binary on its own is fine for a quick test, but for the cupboard box I want something that survives reboots and that I can update without ceremony. My pattern:
- Build the binary with
CGO_ENABLED=0, version it with-ldflags "-X main.version=$(git describe --tags)"so the running service can tell me what it is. - Strip it with
-ldflags "-s -w"to shave a few megabytes off, which matters on a board with a small SD card. - Ship it alongside a tiny systemd unit and copy both across with
rsync.
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
go build -trimpath \
-ldflags "-s -w -X main.version=$(git describe --tags --always)" \
-o dist/myservice ./cmd/myservice
rsync dist/myservice cupboard:/usr/local/bin/myservice
ssh cupboard 'sudo systemctl restart myservice'
-trimpath is a small thing I add reflexively now: it strips the local filesystem paths out of the binary, so the build doesn't leak /home/johnm/... into stack traces and is a step closer to reproducible.
Catching the mismatch before the board does
The failure mode I want to avoid most isn't a build error, it's a build that succeeds and then refuses to run on the target, or runs wrong. A binary built for the wrong GOARM, or accidentally for amd64 because I forgot to set the variables, looks identical until you ship it. So I've got into the habit of two quick checks that take seconds and have saved me several round trips to the cupboard.
The first is file on the output. It reads the ELF header and tells you exactly what you built:
$ file dist/myservice
dist/myservice: ELF 64-bit LSB executable, ARM aarch64, ...
If that says x86-64 you've forgotten your environment variables, and you've found out on your laptop instead of after a copy and a confused systemd unit. For 32-bit builds it'll mention the float ABI too, which is the closest thing to a GOARM sanity check you'll get without running it.
The second is to put the build in a Makefile or a tiny script so the variables aren't something I retype and fat-finger each time. A leaked GOARM=5 from a previous command in the same shell is the kind of mistake that produces a working-but-slow binary, the worst kind, because nothing errors and everything is just mysteriously sluggish. Pinning the values in a script means the only way to build is the right way.
arm64:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
go build -trimpath -ldflags "-s -w" -o dist/myservice ./cmd/myservice
It's a small amount of ceremony that turns "did I set the variables?" from a question I have to remember to ask into something the build answers for me.
The summary I wish I'd had
For a 64-bit board: GOARCH=arm64, CGO_ENABLED=0, and you're done. For a 32-bit board: add GOARCH=arm GOARM=7 and check uname -m first. Keep CGO off if you possibly can, both to stay cross-compile-friendly and to get a static binary. And if a dependency drags CGO in, see whether there's a pure-Go alternative before you go and install a cross C toolchain, because there usually is now, and it's almost always the better trade.
The board in the cupboard hasn't been compiled on in months. It just receives finished binaries and gets on with its job, which is exactly how I want it.