Cross-Compiling Go
Cross-Compiling Go
Go cross-compiles without a sysroot when CGO is off. The boring default is: CGO_ENABLED=0 and GOOS/GOARCH via buildGoModule env, or a native remote builder — not qemu-user for every PR.
pkgsCross still matters if CGO is on (cgo + glibc for the target). For the desk API, turn CGO off first. This is not the C pkgsCross chapter: the compiler is still the build go; it merely emits another GOARCH.
Mental model
| Need | Tool |
|---|---|
| linux/amd64 binary from a Mac | GOOS=linux GOARCH=amd64, CGO=0 |
| linux/arm64 on x86_64, CGO=0 | GOARCH=arm64 |
| CGO + glibc for aarch64 | pkgsCross.aarch64-multiplatform.buildGoModule or an aarch64 builder |
| NixOS test VM | Native aarch64-linux builder + KVM |
buildGoModule {
env.CGO_ENABLED = "0";
env.GOOS = "linux";
env.GOARCH = "arm64";
}
file the result. Flake packages.x86_64-linux.desk-api-arm64 means built on x86_64, not that the ELF is amd64.
Worked examples
Case 1: linux/arm64 from x86_64, no CGO
Save as go-arm.nix:
# go-arm.nix
{ pkgs ? import <nixpkgs> { } }:
pkgs.buildGoModule {
pname = "desk-api";
version = "1.0.0";
src = ./.;
vendorHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
env.CGO_ENABLED = "0";
env.GOOS = "linux";
env.GOARCH = "arm64";
}nix-build go-arm.nix
file result/bin/desk-apiOutput (shape):
result/bin/desk-api: ELF 64-bit LSB executable, ARM aarch64, statically linked, …
Do not run it on the x86_64 laptop. Copy to a Pi or use qemu-user once. Static + CGO=0 is why there is no target glibc in the closure.
Case 2: Flake packages per arch
Save as flake.nix:
# flake.nix
{
description = "desk-api multi-arch";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
outputs = { self, nixpkgs }:
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
srcGo = {
pname = "desk-api";
version = "1.0.0";
src = ./.;
vendorHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
env.CGO_ENABLED = "0";
env.GOOS = "linux";
};
in
{
packages.x86_64-linux = {
desk-api = pkgs.buildGoModule (srcGo // { env.GOARCH = "amd64"; });
desk-api-arm64 = pkgs.buildGoModule (srcGo // { env.GOARCH = "arm64"; });
};
};
}nix build .#desk-api-arm64
file result/bin/desk-apiCI: nix build .#desk-api-arm64 on ubuntu-amd64. Seconds, not a QEMU VM.
Case 3: CGO — use pkgsCross or a builder
# go-cgo-arm.nix
{ pkgs ? import <nixpkgs> { } }:
pkgs.pkgsCross.aarch64-multiplatform.buildGoModule {
pname = "desk-api";
version = "1.0.0";
src = ./.;
vendorHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
env.CGO_ENABLED = "1";
}Now you have a target gcc. Slow. Prefer ssh-ng to an ARM machine and native buildGoModule. Mixing GOARCH=arm64 with host gcc is Case 5’s failure mode.
Case 4: Windows / Darwin as GOOS
# go-darwin.nix fragment
{
env.GOOS = "darwin";
env.GOARCH = "arm64";
env.CGO_ENABLED = "0";
}nix-build
file result/bin/desk-apiMach-O 64-bit executable arm64
Shipping a Mac binary from Linux CI is possible with CGO off. Notarization is not Nix’s job. GOOS=windows GOARCH=amd64 yields a .exe the same way.
Case 5: file and go version -m
file result/bin/desk-api
go version -m result/bin/desk-api | headdesk-api: go1.24.x
path desk/cmd/desk-api
mod desk v0.0.0
build GOARCH=arm64
build GOOS=linux
build CGO_ENABLED=0
If linux/amd64 leaked, env.GOARCH did not apply (wrong attr, or CGO forced host).
The trap
The trap is CGO on + GOARCH=arm64 on an x86_64 host without a cross gcc. The build uses the host gcc and produces a confused binary or a compile error. CGO off, or pkgsCross, or a native builder.
The other trap is qemu-user in every GitHub job. Minutes. Native GOARCH with CGO=0 is seconds.
The boring rule
- CGO off for services you cross.
env.GOOS/env.GOARCHonbuildGoModule. ProbevendorHash; never leavefakeHashon main.fileandgo version -mthe output.- CGO on → pkgsCross or remote native builder.
- Do not qemu the whole NixOS closure to get a Go binary.
Try this
- Case 1;
file; confirm aarch64. - Same drv with
GOARCH=amd64;file; two different hashes. - Set
CGO_ENABLED=1without a cross gcc; read the error. go version -mon both binaries and confirmGOARCH.