Static Binaries and CGO
CGO_ENABLED=0 gives you a binary with no C library to find at runtime, which is what scratch and Alpine images need.
CGO_ENABLED=0 go build -o bin/app ./cmd/appA static binary is one file holding everything the program needs to run. Nothing is loaded from the machine it runs on. The opposite is a dynamic binary, which asks the operating system at startup for shared libraries, above all the C library, usually called libc.
Where cgo comes in
Cgo is the part of Go that lets Go code call C code. The documentation says cgo is enabled by default for native builds on systems where it is expected to work, and disabled when cross compiling or when no C compiler can be found. Two standard library packages use it when it is available: net, to look up host names through the system's own resolver, and os/user, to read user accounts. If cgo is used, the binary is linked against the host's libc and now needs that libc wherever it runs.
CGO_ENABLED=0 turns cgo off. Those packages fall back to pure Go code and the result is static. CGO_ENABLED=0 go build ./cmd/app sets it for one command. go env -w CGO_ENABLED=0 records the same setting for every later build on this machine; they do the same thing at different scopes.
Why images care
Debian-based images ship glibc. Alpine ships musl, a different C library. A glibc-linked binary started inside an Alpine or scratch image fails with exec /app: no such file or directory, although /app is plainly there. The file that is missing is the loader.
What you give up
Packages that must call C, such as C-based SQLite drivers, will not build. The pure Go resolver reads /etc/hosts and /etc/resolv.conf and cannot use resolvers plugged into the system through nsswitch.conf, such as mDNS or LDAP. The race detector requires cgo, so keep it enabled for tests.
Example
// Command cgocheck reports whether the binary was built with cgo.
package main
import (
"fmt"
"runtime/debug"
)
func main() {
info, ok := debug.ReadBuildInfo()
if !ok {
fmt.Println("no build information available")
return
}
// The go command records the effective CGO_ENABLED value in the binary.
cgo := "unrecorded"
for _, s := range info.Settings {
if s.Key == "CGO_ENABLED" {
cgo = s.Value
break
}
}
fmt.Println("CGO_ENABLED:", cgo)
}
// Built with `CGO_ENABLED=0 go build .`:
//
// Output:
// CGO_ENABLED: 0When to use it
- Produce a binary that runs unchanged in a scratch, distroless, Alpine or Debian image.
- Diagnose a container that exits immediately with "no such file or directory" on a file that exists.
- Decide whether a dependency that needs C is worth the base-image constraint it brings.
More examples
Build it, then prove it is static
"statically linked" from file and "not a dynamic executable" from ldd are the two answers you want before putting a binary in a scratch image. Run this check once in your pipeline, because the failure it prevents only shows up at container start time, with a misleading message.
CGO_ENABLED=0 GOOS=linux go build -o bin/app ./cmd/app
file bin/app
# bin/app: ELF 64-bit LSB executable, x86-64, statically linked, Go BuildID=...
# ldd lists the shared libraries a file needs.
ldd bin/app
# not a dynamic executable
# For comparison, a cgo build on a Debian machine:
CGO_ENABLED=1 GOOS=linux go build -o bin/app-cgo ./cmd/app
ldd bin/app-cgo
# linux-vdso.so.1 ...
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 ...An Alpine builder with cgo off
The Alpine toolchain image is small, and with cgo off its C library never enters the picture, so the same binary also runs on Debian, on distroless and on scratch. If you did need cgo here you would have to install a C toolchain in the builder and then run the result on a musl-based base image, which is how most Alpine build failures start.
FROM golang:1.27-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Alpine has musl, not glibc. With cgo off, the binary needs neither.
RUN CGO_ENABLED=0 go build -trimpath -o /out/app ./cmd/app
FROM scratch
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]cgo on for tests, off for the release build
The race detector requires cgo to be enabled, and on non-Darwin systems an installed C compiler. The shipped binary wants the opposite. Setting the variable per step instead of once for the whole job keeps both true, and avoids the common pipeline where turning cgo off globally breaks the test build.
steps:
- run: go test -race ./...
env:
CGO_ENABLED: "1" # the race detector requires cgo
- run: go build -trimpath -o bin/app ./cmd/app
env:
CGO_ENABLED: "0" # the shipped binary needs no libc
Discussion