Cross-Compiling Go Binaries Inside Containers

You develop on one platform but ship binaries for several. This page cross-compiles Go for other OS and architecture targets from a single devcontainer using GOOS and GOARCH, covering the pure-Go case (trivial) and the cgo case (needs a cross toolchain).

This matters because the machine you code on rarely matches every machine your code runs on. You might write on an amd64 Linux devcontainer while shipping to arm64 cloud instances, an Apple Silicon laptop, and a Raspberry Pi fleet all at once. Go's toolchain was designed around exactly this split: the compiler, assembler, and linker are all cross-aware, so a single go build invocation can emit a binary for any supported GOOS/GOARCH pair without a second install of the toolchain. You reach for this whenever a release pipeline has to produce more than one artifact from one source tree, or when you want your local devcontainer to smoke-test a foreign-architecture build before it ever hits CI.

The mental model is a clean two-tier split. In the first tier — pure Go — the standard library and your code compile straight to machine code for the target, and the target's operating system and CPU are described entirely by two environment variables. Nothing on the host has to know anything about the target beyond those strings. The second tier — cgo — breaks that symmetry the moment your program calls into C, because a C compiler is architecture-specific and your host's gcc only knows how to emit host objects. Everything difficult about cross-compiling Go collapses into that one distinction, so the first question to ask of any build is simply whether it touches cgo.

Prerequisites

You need a Go devcontainer and the target OS/arch matrix you ship.

  • A Go Feature pinned to a version.
  • The list of GOOS/GOARCH targets you build for.
  • For cgo builds, a cross C toolchain installed.

Pinning the Go Feature to an explicit version is not a formality here. Cross-compilation output is only reproducible if the compiler that produced it is fixed, because linker behaviour, default build tags, and even the set of supported GOOS/GOARCH pairs shift between releases. If one developer's devcontainer runs Go 1.22 and another's floats to 1.23, the arm64 binaries they emit can differ in ways that only surface on the target, long after the build succeeded. Nail the version in the Feature so every contributor and every CI runner compiles with the same toolchain.

The detail people most often get wrong is assuming they need the target's operating system anywhere in the loop. You do not. A Linux devcontainer cross-compiles a darwin/amd64 binary without a macOS machine present, and it produces a windows/amd64 executable without touching Windows, because Go links against its own runtime rather than the host system's. The only prerequisite that genuinely depends on the target is the cross C toolchain, and that dependency exists solely for cgo — pure-Go targets need nothing installed beyond the two environment variables you already have.

Cross-compile prerequisitesKnow your targets; pure-Go needs only env vars, cgo needs a cross toolchain.TargetsGOOS/GOARCHPure Gojust set envcgocross toolchainVerifyfile output

Step-by-Step Implementation

  1. Cross-compile pure Go by setting GOOS and GOARCH.
GOOS=linux GOARCH=arm64 go build -o dist/app-linux-arm64 ./cmd/app
GOOS=darwin GOARCH=amd64 go build -o dist/app-darwin-amd64 ./cmd/app

Each go build here reads GOOS and GOARCH from the environment prefix and selects the matching target-specific standard library, which Go ships prebuilt for every supported pair. The explicit -o dist/app-linux-arm64 name encodes the target into the filename so a matrix of builds never overwrites itself in a shared dist/ directory — a common cause of shipping the wrong architecture when a loop reuses one output path. Because these are pure-Go builds, nothing about the host amd64 Linux devcontainer leaks into the artifact; the same two commands run identically on an Apple Silicon host and produce byte-for-byte comparable output given a pinned toolchain.

  1. Disable cgo for portable static binaries when you don't need C.
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o dist/app ./cmd/app

Setting CGO_ENABLED=0 tells the toolchain to refuse any package that would pull in cgo, forcing a fully static binary that links no external C libraries. That is the property you want for containers: the resulting dist/app runs on a scratch or distroless base image with no libc present, and it will not fail at startup hunting for a shared object that the minimal image never shipped. The trade-off is that any dependency genuinely requiring C — certain SQLite drivers, some crypto or DNS resolvers — will fail to compile rather than silently link, which is the failure mode this flag is meant to make loud and early instead of a runtime surprise on the target.

  1. For cgo builds, install a cross toolchain and point the C compiler at it.
apt-get install -y gcc-aarch64-linux-gnu
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build ./cmd/app

The apt-get install gcc-aarch64-linux-gnu step provides a cross C compiler that emits aarch64 objects while running on your amd64 host, and the CC=aarch64-linux-gnu-gcc assignment overrides Go's default choice of the host gcc. With CGO_ENABLED=1 set explicitly, cgo is active and every C file in your dependency graph is handed to that cross compiler instead. Skip the CC override and the build either fails with a linker error about incompatible object files, or worse, silently produces an amd64 object that will not run on the arm64 target — which is precisely why the compiler triple in the package name (aarch64-linux-gnu) must match your GOOS/GOARCH pair exactly.

  1. Verify the produced binary's target.
file dist/app-linux-arm64   # ELF 64-bit ... ARM aarch64

Verification with file is not optional busywork; it is the only cheap check that the binary is actually built for the architecture you intended before it travels to a machine you may not be able to easily debug on. The output line confirms three things at once: the container format (ELF for Linux, Mach-O for macOS, PE for Windows), the word size, and the CPU (ARM aarch64). If file reports x86-64 when you asked for arm64, the environment prefix did not take effect for that build — usually because a shell wrapper or Makefile dropped the variables — and catching it here saves a failed deploy and a confusing "exec format error" on the target.

Pure-Go vs cgo cross-compilePure-Go cross-compiles with just env vars; cgo needs a matching cross C toolchain.Pure GocgoSetupGOOS/GOARCHcross C compilerToolchainnonegcc-<arch>PortabilitystaticlinkedEfforttrivialmoderate

Common Pitfalls

Cross-compile failures are almost always cgo without the matching C cross toolchain.

A quieter class of trouble comes from the build cache and any module volume you share across builds. When your devcontainer mounts a named volume for GOCACHE or the module download cache to speed up repeated cross builds, the files inside it are written as whatever user the go process runs as. If one build runs as root and the next runs as your non-root devcontainer user, the second sees permission-denied errors on cache directories it cannot write, and the failure looks nothing like a cross-compilation problem even though that is where it surfaced. Keep the build user consistent, or chown the cache volume to your devcontainer user once at setup, so a switch between a root CI step and an interactive session does not poison the cache.

The other topic-specific trap is assuming CGO_ENABLED has a fixed default. It does not: cgo is enabled by default when compiling for the host platform and disabled by default when GOOS/GOARCH name a different platform and no CC is set. That means a package that builds fine natively can quietly drop its cgo code path when cross-compiled, changing behaviour — a pure-Go DNS resolver instead of the system one, for instance — without any error at all. When the behaviour of a cross-built binary diverges from the native one, set CGO_ENABLED explicitly to whichever value you actually want rather than trusting the shifting default.

Cross-compile triageA triage path from a failing cross-build to the right toolchain setup.Does the build use cgo?NOSet GOOS/GOARCH — doneIs a cross C toolchain installed?YESSet CC to the cross compilerBinary built for the target

SymptomRoot CauseRemediation
Cross build fails with cgo errorNo cross C toolchainInstall gcc- and set CC
Binary won't run on targetWrong GOARCH/GOOSMatch GOOS/GOARCH to the target
Dynamic-link failure on targetcgo produced a linked binaryUse CGO_ENABLED=0 for static builds
Works on host arch onlyEnv not set per buildSet GOOS/GOARCH explicitly each build

Conclusion

Cross-compiling Go is trivial when it's pure Go — set GOOS and GOARCH and build. cgo is the only complication: it needs a matching cross C toolchain and the right CC. Prefer CGO_ENABLED=0 for portable static binaries where you can, and reserve the cross toolchain for when C is genuinely required.

The strategic payoff is that one devcontainer becomes the single source of truth for every artifact you ship, no matter how many architectures the fleet spans. Instead of maintaining a build machine per target — a macOS runner, an arm64 box, a Windows agent — you keep one pinned Go toolchain and describe the target matrix as a list of GOOS/GOARCH strings that any contributor can reproduce. That is the same discipline that makes the rest of a Go devcontainer trustworthy: pin the compiler, cache what is expensive to fetch, and make the environment describe its outputs precisely enough that two people get identical results.

This ties directly into the broader pin-and-cache theme. The pinned Go Feature guarantees the compiler is fixed; a named module-cache volume keeps cross builds fast without re-downloading dependencies for each target; and the explicit per-target output names keep the matrix reproducible and self-documenting. Treat CGO_ENABLED as an intentional choice rather than a default, verify every artifact with file before it ships, and cross-compilation stops being a source of surprise and becomes a boring, repeatable step in the release — which is exactly what you want from infrastructure.

Pure Go vs cgoPure Go cross-compiles with env vars; cgo needs a cross toolchain and CC.Pure GoSet GOOS/GOARCHCGO_ENABLED=0Static, portablecgoCross C toolchainSet CCLinked binary

FAQ

How do I build a Go binary for a different OS/arch? Set GOOS and GOARCH for the target and run go build. For pure-Go programs that's all it takes — GOOS=linux GOARCH=arm64 go build produces an arm64 Linux binary from any host. Add CGO_ENABLED=0 for a static binary with no external dependencies. You do not need the target operating system present anywhere; Go ships prebuilt standard-library packages for every supported pair, so a Linux devcontainer can emit darwin, windows, and arm64 artifacts in the same loop. Encode the target into the output filename with -o so a matrix build never overwrites one artifact with another.

Why does cross-compiling fail when my program uses cgo? Because cgo invokes a C compiler for the target, and your host's compiler produces host-architecture objects. Install a cross toolchain (e.g. gcc-aarch64-linux-gnu) and set CC to it, or, if you don't actually need C, build with CGO_ENABLED=0 to sidestep cgo entirely. Remember the package triple must match the target exactly: gcc-aarch64-linux-gnu for linux/arm64, not the generic host gcc. If the error is a linker complaint about incompatible objects, that is the signature of a missing or mismatched CC rather than anything wrong with your Go code.

Should I prefer static or linked binaries? Static (CGO_ENABLED=0) when you can — they run anywhere on the target OS/arch with no library dependencies, which is ideal for containers and distribution. Use cgo-linked binaries only when you depend on a C library, and then ensure the target has the required shared libraries. A static binary drops straight onto a scratch or distroless image, whereas a linked one needs a base image that ships the matching libc and any other shared objects it references, or it fails at startup with a loader error.

Which GOOS/GOARCH values does my toolchain support? Run go tool dist list to print every supported pair the installed toolchain knows about, one os/arch per line. This is the authoritative source rather than memory, because the set grows between Go releases — which is another reason to pin the Go Feature so the list your CI sees matches the list you developed against. Piping it through grep is a quick way to confirm an exotic target like linux/riscv64 or freebsd/arm64 is actually available before you script a build around it.

How do I cross-compile a static cgo binary? It is possible but deliberate: you need a cross toolchain whose C library can link statically, then pass the static linking flags through, for example CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags '-linkmode external -extldflags "-static"'. This is finicky because not every cross libc supports full static linking, and musl-based toolchains behave differently from glibc ones. Where you can restructure to avoid the C dependency entirely, CGO_ENABLED=0 remains far simpler and is the recommended path for containers.

Does the module cache need to be rebuilt for each target? No. The Go module cache holds source, not compiled objects, so the same downloaded modules serve every GOOS/GOARCH build. Only the build cache (GOCACHE) is keyed by target, and Go manages that automatically, storing separate compiled artifacts per architecture under the same cache root. Sharing a named module volume across cross builds is therefore safe and fast — just keep the writing user consistent so permissions on the cache do not break between root and non-root runs.