One Binary, Every Machine: Building for Release
Cross-compiling a Go binary for another OS and architecture from your laptop, stamping a version into it at link time, producing a static binary with CGO_ENABLED=0, selecting files per platform with build tags, and stripping the result. Compiled and run against Go 1.26.5.
The reason a Go service is pleasant to ship is that go build hands you one file. No interpreter to match, no runtime to install on the target box, no node_modules to carry along — you copy the binary and run it. This chapter is about making that binary a release rather than a debug artifact: building it for a machine that isn’t yours, stamping a version into it so you can tell which build is running in production, making it genuinely self-contained, and trimming it down. Every command and every byte-count below was run against Go 1.26.5 on a darwin/amd64 laptop.
Cross-compilation is two environment variables
Most languages treat “build for a different platform” as a project. Go treats it as two environment variables. GOOS names the target operating system, GOARCH names the target CPU architecture, and the toolchain that shipped with your Go install already contains everything needed to emit code for the common combinations. There is no cross-toolchain to download — and no C compiler to configure.
Here is a program that reports what it was built for, using the constants the compiler bakes in:
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Printf("built for %s/%s\n", runtime.GOOS, runtime.GOARCH)
}
Built and run natively, it prints the host:
$ go build -o app-native . && ./app-native
built for darwin/amd64
Now build the same source for a 64-bit ARM Linux box, the shape of most cloud instances today, without leaving the laptop:
$ GOOS=linux GOARCH=arm64 go build -o app-linux-arm64 .
That produced no output — which is go build telling you it worked. The proof the target arch is real is in file, which reads the binary’s header:
$ file app-linux-arm64
app-linux-arm64: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV),
statically linked, Go BuildID=..., with debug_info, not stripped
An ELF executable for ARM aarch64 — produced on an x86-64 Mac that cannot run it. Set the two variables, get a binary for that platform. To see every pair your toolchain knows, run go tool dist list — the list is long, and it includes Windows, the BSDs, WebAssembly, and a dozen architectures.
Stamping a version at link time
A binary in production should be able to tell you which build it is. You could hard-code a version string in the source and remember to bump it, but the version you want is the one from CI at build time — usually a git tag or commit. Go’s linker can write a value into a package-level string variable as it links, which keeps the number out of the source entirely.
Declare the variable with an empty default:
package main
import "fmt"
// version is overwritten at link time with -ldflags "-X main.version=...".
// Its default is the empty string.
var version string
func main() {
if version == "" {
fmt.Println("version: (unset)")
return
}
fmt.Println("version:", version)
}
Build it normally and the default stands:
$ go build -o app . && ./app
version: (unset)
Now hand the linker a value with -ldflags and the -X importpath.name=value form. For a main package the import path you name is simply main:
$ go build -ldflags "-X main.version=1.4.0" -o app . && ./app
version: 1.4.0
Same source, same variable — a different string welded in at link time. Two things are worth knowing before you rely on this. The target must be a string variable, not a constant and not any other type — the linker can only overwrite string data. And the variable must not be initialized to a computed value, because -X replaces the linker’s idea of its contents, and an initializer that runs at startup would overwrite what you injected. An empty var version string is exactly right. In a real pipeline the value comes from the build: -X main.version=$(git describe --tags).
CGO_ENABLED=0: the genuinely static binary
Go binaries are self-contained by reputation, and mostly they are — but there is one seam. A couple of standard-library packages, net and os/user chief among them, can call into the host’s C library to resolve hostnames and look up users the way the operating system does. When they do, your “static” binary quietly grows a dependency on the system libc. CGO_ENABLED controls whether that door is open, and on a native build it defaults to on:
$ go env CGO_ENABLED
1
Setting it to 0 tells the toolchain to use Go’s own pure-Go implementations instead of calling C. The most visible effect is on DNS — with cgo off, hostname lookups go through Go’s own resolver, which reads /etc/resolv.conf and speaks DNS directly rather than calling the system’s getaddrinfo. For the overwhelming majority of services that behaves identically, and it is what you want for a container that ships without a libc at all.
The cleanest place to see the difference is Linux, where a cgo-free build is genuinely static:
$ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o app .
$ file app
app: ELF 64-bit LSB executable, x86-64, ..., statically linked, ..., not stripped
statically linked with no interpreter listed — this binary depends on nothing on the target filesystem. Drop it into a FROM scratch image and it runs. One honest caveat about macOS — on Darwin every binary links libSystem, cgo or not, because macOS has no stable raw syscall interface, so CGO_ENABLED=0 there does not produce a “no libraries” result the way it does on Linux. The setting still changes which resolver your code uses — it just can’t sever libSystem. For release artifacts you almost always build the Linux target anyway, so CGO_ENABLED=0 GOOS=linux is the pair that matters, and it is the one the containers chapter leans on.
Build tags: choosing files per platform
Cross-compilation raises a question: what if some code only makes sense on one platform? Go answers with build constraints, and there are two ways to write them. The first is a filename suffix. A file named banner_linux.go is compiled only when GOOS is linux, with no directive inside it at all — the name is the constraint. The recognized suffixes are _GOOS, _GOARCH, and _GOOS_GOARCH.
// banner_linux.go — compiled only on Linux, purely because of the file name.
package main
const banner = "running on Linux"
The second is an explicit //go:build line at the top of a file, above the package clause, which can express boolean logic the filename can’t:
//go:build !linux
package main
const banner = "running on a non-Linux host"
Between them, exactly one banner is defined for any target, so the package always compiles. You do not have to build for a platform to see which files it would use; go list reports the selection:
$ go list -f '{{.GoFiles}}' .
[banner_other.go main.go]
$ GOOS=linux GOARCH=arm64 go list -f '{{.GoFiles}}' .
[banner_linux.go main.go]
On the Mac the toolchain sees banner_other.go; targeting Linux it swaps in banner_linux.go and drops the other. That is the constraint doing real work — excluding a file from the build entirely, not compiling it and discarding the result. One rule that catches everyone once: a //go:build line must be near the top of the file with a blank line between it and package, or the toolchain treats it as an ordinary comment and silently ignores the constraint.
Trimming the binary with -s -w
A Go binary carries its symbol table and DWARF debugging information by default, which is what lets a debugger and a panic traceback name your functions. For a release image where nobody will attach a debugger, you can drop both with two linker flags: -s removes the symbol table, -w removes the DWARF data. Measured on the Linux build above:
$ go build -o app-plain . # 3,271,188 bytes
$ go build -ldflags "-s -w" -o app-stripped . # 2,199,714 bytes
Roughly 3.2 MB down to 2.2 MB, about a third gone from a hello-world-sized program — and the ratio holds up on real services. The cost is that a crash from the stripped binary gives less-legible tracebacks and the binary is harder to debug — so the usual practice is to keep an unstripped copy for your own diagnostics and ship the stripped one. Combined, the release incantation for a container is a single line:
$ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
go build -ldflags "-s -w -X main.version=1.4.0" -o app .
Static, stripped, version-stamped, built for the target from your laptop.
Stamping the build automatically
The -ldflags "-X" trick above is the manual way to weld a version in, and it’s the right tool when the value comes from outside the build — a git tag your CI computed, a release number from a spreadsheet. But Go can also stamp identity in by itself, with no flags at all, and read it back at runtime through runtime/debug.ReadBuildInfo. Since 1.18 the toolchain auto-embeds VCS metadata whenever it builds a VCS-tracked main package (this is the -buildvcs setting, on by default). ReadBuildInfo hands you the module path and version plus a list of build settings:
info, _ := debug.ReadBuildInfo()
fmt.Println("main.version:", info.Main.Version)
for _, s := range info.Settings {
if strings.HasPrefix(s.Key, "vcs") || s.Key == "-trimpath" {
fmt.Printf("%-14s %s\n", s.Key+":", s.Value)
}
}
There is a real gotcha here, and it’s worth seeing honestly. In a plain go run — or a go build from a directory that isn’t under version control — there is no VCS to read, so the version comes back as the placeholder (devel) and the vcs.* settings are simply absent:
$ go run .
main.version: (devel)
Build the same source from inside a git repository, though, and the stamps appear on their own:
$ go build -o app . && ./app
main.version: v0.0.0-20260801005518-998d1f2350bb
vcs: git
vcs.revision: 998d1f2350bbc1f78604cc24696998a09a061ab3
vcs.time: 2026-08-01T00:55:18Z
vcs.modified: false
Nothing was passed to the linker. Because the package lives in a clean git tree, Go synthesized a pseudo-version from the commit time and hash, and recorded the exact revision, its timestamp, and whether the tree was dirty. That last field is the useful one in an incident: edit a tracked file without committing and rebuild, and vcs.modified flips to true while the version gains a +dirty suffix — proof the running binary doesn’t match any commit.
$ echo "// local edit" >> main.go && go build -o app . && ./app
main.version: v0.0.0-20260801005518-998d1f2350bb+dirty
vcs.modified: true
The two approaches compose rather than compete: -X main.version=$(git describe --tags) gives you a human-readable release name, while the auto-embedded vcs.revision pins the exact commit for forensics — and you get the second one for free.
The companion flag for a release build is -trimpath. By default the binary embeds absolute paths from the build machine — /Users/you/src/... — into file names for panics and DWARF, which leaks your directory layout and, worse, makes the build non-reproducible: the same source on two machines produces different bytes. -trimpath strips those paths down to the module-relative form, and records that it did so as its own setting:
$ go build -trimpath -o app . && ./app
-trimpath: true
For anything you ship, -trimpath belongs in the build line next to -ldflags "-s -w" — reproducible bytes, no local paths baked in, and a ReadBuildInfo that still tells you exactly which commit you’re looking at.
Final thoughts
Shipping a Go binary is deliberately boring, and every knob is a flag or an environment variable rather than a config file or a plugin. GOOS/GOARCH retarget the build with a toolchain you already have. -ldflags "-X main.version=..." writes the build’s identity into it without touching the source. CGO_ENABLED=0 on Linux makes it genuinely static — at the cost of the C resolver you almost never want in a container. Build tags let a file exist only where it applies, and go list shows you the selection without a full build. -s -w trims what a release doesn’t need. None of this is a separate build system; it is the one go command with the right arguments, which is the whole point. Next we look at the toolchain writing source code for you, on purpose, before the build ever runs.
Next: code that writes code — how go generate turns a comment into committed, reviewable source.
Comments