DEV Community

Pablo Lagos
Pablo Lagos

Posted on

🧊 How to Build a Fully Static Go Binary — Every Time (with go-builder and Docker)

Want your Go app to “just run” on any Linux server, regardless of glibc?
You need real static linking — and it’s surprisingly easy to get wrong.

In this guide you’ll learn:

✅ Why CGO can break static builds
✅ How to cross-compile with musl
✅ How to do it inside Docker for guaranteed reproducibility
✅ How to declare all this in a clean .gobuilder.yml

⚙️ The problem: CGO and glibc versions

By default, Go wants to make static binaries. But the second you use CGO_ENABLED=1 (for SQLite, image processing, or networking optimizations), the linker pulls in your system’s libc — and suddenly you’ve got a dynamic dependency.

Run your binary on a server with an older glibc, and boom:

libc.so.6: version `GLIBC_2.32' not found
Enter fullscreen mode Exit fullscreen mode

🫧 The solution: build with musl and force static linking

Musl is a lightweight C standard library designed for static builds.
Here’s the secret sauce:

CGO_ENABLED=1 CC=musl-gcc \
  go build -ldflags="-linkmode external -extldflags '-static'"
Enter fullscreen mode Exit fullscreen mode

Check your binary:

file myapp   # should say: statically linked
ldd myapp    # should say: not a dynamic executable
Enter fullscreen mode Exit fullscreen mode

✅ ✅ ✅


🗂️ Making this repeatable with go-builder

Manually remembering CGO_ENABLED, CC, and those scary -ldflags? Painful.
Let’s lock it down:

# .gobuilder.yml
build_dir: builds
source: ./cmd/myapp
output: myapp

env:
  CGO_ENABLED: "1"
  CC: musl-gcc

build:
  ldflags:
    - "-linkmode external -extldflags '-static'"
  vars:
    main.version: "${VERSION:-dev}"
  trimpath: true
  verify_static: true  # check with 'file' after build!

targets:
  - os: linux
    arch: amd64
  - os: linux
    arch: arm64
Enter fullscreen mode Exit fullscreen mode

Run it:

VERSION=1.4.0 go-builder
Enter fullscreen mode Exit fullscreen mode
  • ✅ You get binaries under builds/linux/amd64/ and builds/linux/arm64/.
  • ✅ If the linker produces a dynamic binary by mistake, the verify_static: true will fail the build.
  • ✅ No bash scripts, no fragile Makefiles.

🐳 What if you don’t have musl-gcc installed?

Use a container with musl ready to go. Here’s a docker block in the same YAML:

docker:
  image: golang:1.23-alpine
  workdir: /src
  shell: sh
  setup:
    - apk add --no-cache musl-dev build-base git
  env:
    CGO_ENABLED: "1"
    CC: gcc # gcc is the name of musl-gcc in alpine
Enter fullscreen mode Exit fullscreen mode

Now your build runs inside Alpine Linux, with musl preinstalled. The builds/ folder is shared with your host, so your static binaries are there immediately — no extra copy step.

Run:

go-builder
Enter fullscreen mode Exit fullscreen mode

And your machine stays clean: no local musl toolchain needed.


🧪 Dry-run: double-check before wasting time

Want to see exactly what go-builder will run?

go-builder --dry-run
Enter fullscreen mode Exit fullscreen mode

You’ll see:

  • Docker run command (if docker: is defined)
  • Environment variables
  • The go build line, with every tag, flag, and linker trick.

✅ Key takeaways

musl makes true static linking predictable
CGO_ENABLED=1 CC=musl-gcc + -extldflags '-static' are the magic trio
verify_static: true guarantees your binary is really static
✔ Using Docker means zero “it works on my machine” moments
go-builder locks your build config in YAML: repeatable, documented, and easy to share


🔗 Next steps

📄 Repo: github.com/pablolagos/go-builder
🧩 Install: go install github.com/pablolagos/go-builder@latest
--init writes a starter YAML, ready to tweak.

Stop chasing glibc errors. Ship one binary, run it anywhere.
Static linking can actually be fun.


Do you ship static Go binaries? Drop your tips below — I'd love to hear how you do it!

Top comments (0)