Right, so you’ve written your Go masterpiece on your MacBook, it works perfectly, and now you need to run it on a Linux server somewhere in the cloud. You could try to install the entire Go toolchain on that server, clone your code, and run go build there. But let’s be honest, that’s a faff. It’s also a fantastic way to introduce inconsistencies and violate the principle of building once and deploying that same artifact everywhere.

This is where Go’s party trick comes in: cross-compilation. It’s the process of building a binary for one operating system and architecture (the target) on a completely different one (the host). And Go makes this almost stupidly simple. I say “almost” because there are a few sharp edges you need to know about, mostly thanks to C.

The Basic Incantation: GOOS and GOARCH

The magic is performed using two environment variables: GOOS (target operating system) and GOARCH (target architecture). You set them, then you run go build as usual. The Go toolchain reads these and says, “Ah, you want that kind of binary. Got it.”

To build for a standard Linux x86_64 machine from your macOS or Windows terminal, you’d run:

GOOS=linux GOARCH=amd64 go build -o my-awesome-app .

That’s it. Seriously. The -o my-awesome-app bit names the output file, and the . tells it to build the package in the current directory. You’ll now find a my-awesome-app file in your directory. It won’t be executable on your Mac (zsh: exec format error), but scp that thing over to a Linux box and watch it purr like a well-oiled machine.

Want to build for a Raspberry Pi or a cheap cloud ARM instance? Swap out the architecture:

GOOS=linux GOARCH=arm64 go build -o my-awesome-app .

You can see the full list of valid combinations your installed Go version supports by running go tool dist list. It’s a long list. You can build for AIX on PowerPC if you’re feeling particularly nostalgic.

The CGO Problem: Or, Why This Isn’t Always Magic

Notice I said your Go code. If your code is pure Go, the previous commands are all you need. The wheels fall off the moment you introduce cgo—when your code imports a C library or uses a dependency that does (a very common example is the popular go-sqlite3 database driver).

Here’s why: when you use cgo, the build process can’t just use Go’s cross-compilation superpower anymore. It needs to call a C compiler (gcc or clang) to compile the C code and link it into the final binary. And it needs a C compiler that is itself a cross-compiler, capable of building C code for linux/amd64 while running on darwin/amd64. Your system’s default gcc is built to target your host machine, not some random Linux target.

So, if you try to cross-compile a cgo-enabled project, you’ll be greeted with a classic, frustrating error:

# runtime/cgo
cgo: C compiler "gcc" not found: exec: "gcc": executable file not found in $PATH

The Go tool is looking for a C compiler and can’t find one that works. Even if you do have gcc installed, it’s probably the wrong kind.

Taming CGO with a Cross-Compiler

To solve this, you need to get the right cross-compiler toolchain. On macOS, the easiest way is via Homebrew:

brew install FiloSottile/musl-cross/musl-cross

This installs musl-cross, which provides the x86_64-linux-musl-gcc compiler (among others). musl is a lightweight, standards-compliant libc, and it’s often a better choice for producing truly static binaries than the more common glibc (more on that in a second).

Once you have it, you need to tell the Go build system to use this specific compiler by setting the CC environment variable alongside GOOS and GOARCH:

GOOS=linux GOARCH=amd64 CC=x86_64-linux-musl-gcc CGO_ENABLED=1 go build -o my-awesome-app .

Notice we also explicitly set CGO_ENABLED=1. This is usually the default, but it’s good to be explicit in your build scripts. This command tells the Go tool: “Use the x86_64-linux-musl-gcc compiler for any C code, and then do your usual cross-compilation magic for the Go parts.”

The Glibc vs. Musl Can of Worms

Ah, the dreaded libc divide. Most mainstream Linux distributions (Ubuntu, Debian, CentOS, RHEL) use glibc. Others, like Alpine Linux (the base for most tiny Docker images), use musl-libc. The binaries produced by a compiler targeting one are not compatible with the other.

If you use the musl-gcc cross-compiler as shown above, you’ll produce a binary that depends on musl. This is perfect for Alpine-based Docker containers. If you try to run it on an Ubuntu server, it will fail with a not found error for the musl libraries.

To build for a standard Ubuntu server, you’d need a glibc-based cross-compiler, which is a more involved setup. This is the single biggest “gotcha” in Go cross-compilation. The easiest and most reliable way to build a glibc-compatible binary for Linux is often to… actually build it on Linux. This is where Docker itself becomes your best friend for building.

You can use a Docker container to create a pristine, consistent glibc-based build environment without leaving your Mac. This is my preferred method for anything non-trivial:

docker run --rm -v "$PWD":/app -w /app golang:1.21-bookworm \
    go build -o my-awesome-app .

This command mounts your current directory into a container running the official golang Docker image (which is Debian-based, so glibc), and runs go build inside it. The resulting binary, written to your local directory, is now perfectly built for any glibc system.

So, the rule of thumb is: for pure Go, cross-compile natively. For cgo, either use a musl toolchain and target Alpine, or use a Docker build container to target everything else. It’s a tiny bit of extra setup that saves you from a world of “but it worked on my machine!” pain.