Go on NixOS & FreeBSD
Beyond Linux: NixOS & FreeBSD
Overview
Go treats cross-compilation as a first-class feature: set GOOS and GOARCH, get a binary. That matters when you target NixOS (purely functional paths, no global /lib) or FreeBSD (jails, ZFS, kqueue). Both platforms reward pure-Go static binaries and punish casual CGO.
This chapter covers cross-compilation matrices, Nix flakes for reproducible toolchains, FreeBSD notes (kqueue, jails), and production pitfalls.
Cross-Compilation Basics
From any host (macOS, Linux, Windows):
# Linux amd64 (most VPS / cloud)
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o bin/app-linux-amd64 ./cmd/server
# Linux arm64 (Graviton, Raspberry Pi OS 64-bit)
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o bin/app-linux-arm64 ./cmd/server
# FreeBSD amd64
GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go build -o bin/app-freebsd-amd64 ./cmd/server
# FreeBSD arm64
GOOS=freebsd GOARCH=arm64 CGO_ENABLED=0 go build -o bin/app-freebsd-arm64 ./cmd/server
# Windows
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o bin/app.exe ./cmd/server
# macOS Apple Silicon
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -o bin/app-darwin-arm64 ./cmd/serverList supported pairs:
go tool dist listMatrix build script
#!/usr/bin/env bash
set -euo pipefail
mkdir -p bin
for goos in linux freebsd darwin windows; do
for goarch in amd64 arm64; do
ext=""
[[ "$goos" == "windows" ]] && ext=".exe"
out="bin/app-${goos}-${goarch}${ext}"
echo "building $out"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
go build -trimpath -ldflags="-s -w" -o "$out" ./cmd/server
done
doneCGO breaks easy cross-compilation. Cross-CGO needs a matching cross-compiler and libraries. Prefer pure Go drivers (e.g. jackc/pgx pure Go, modernc.org/sqlite) when you ship multi-OS binaries.
Go on NixOS
NixOS has no FHS layout by default. Dynamically linked binaries that expect /lib64/ld-linux-x86-64.so.2 often fail unless you use steam-run, buildFHSUserEnv, or patchelf. Static pure-Go binaries just run.
Pure Go: the happy path
CGO_ENABLED=0 go build -o app ./cmd/server
./app # works on NixOS without wrappingVerify linkage (on a system with ldd):
ldd ./app
# statically linked (or "not a dynamic executable")Development shell with Flakes
Pin the exact Go toolchain for the whole team:
# flake.nix
{
description = "Go service dev shell";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
in {
devShells = forAllSystems (pkgs: {
default = pkgs.mkShell {
packages = with pkgs; [
go_1_26
gopls
gotools
golangci-lint
govulncheck
delv
];
shellHook = ''
export CGO_ENABLED=0
echo "Go $(go version)"
'';
};
});
};
}nix develop
go test ./...Packaging a Go module with Nix
# overlay-style package sketch
{ buildGoModule, fetchFromGitHub, lib }:
buildGoModule rec {
pname = "myapp";
version = "1.2.3";
src = ./.; # or fetchFromGitHub { ... }
vendorHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; # set via nix build error
ldflags = [ "-s" "-w" "-X main.version=${version}" ];
env.CGO_ENABLED = "0";
meta = {
description = "Example Go service";
mainProgram = "myapp";
};
}Run nix build and let Nix tell you the correct vendorHash on first failure, then pin it.
CGO on NixOS
If you need CGO, build inside Nix so headers and libs are on NIX_CFLAGS_COMPILE / NIX_LDFLAGS:
pkgs.mkShell {
packages = [ pkgs.go pkgs.pkg-config pkgs.sqlite ];
# CGO_ENABLED=1 only inside this shell when required
}Never assume /usr/include exists.
Go on FreeBSD
FreeBSD is a first-class Go port. The runtime uses kqueue for network and file notifications (instead of Linux epoll/inotify).
Runtime notes
| Area | FreeBSD behavior |
|---|---|
net poller |
kqueue-backed |
fsnotify and similar |
kqueue (not inotify) |
os/signal |
Works; jail signal policies may differ |
| File paths | Case-sensitive UFS/ZFS; no drive letters |
syscall / x/sys/unix |
FreeBSD-specific constants and wrappers |
Build on FreeBSD
pkg install go git
git clone https://example.com/myapp.git
cd myapp
CGO_ENABLED=0 go build -o myapp ./cmd/serverCross-build from Linux/macOS as shown above; scp into a jail or host.
Jails and networking
Run the service inside a jail with a dedicated IP or VNET:
host
└── jail: myapp
├── /usr/local/bin/myapp
├── env: DATABASE_URL=...
└── listen: 10.0.0.20:8080
Prefer binding explicitly:
addr := os.Getenv("LISTEN_ADDR")
if addr == "" {
addr = "0.0.0.0:8080"
}
log.Fatal(http.ListenAndServe(addr, mux))rc.d service sketch
#!/bin/sh
# /usr/local/etc/rc.d/myapp
# PROVIDE: myapp
# REQUIRE: NETWORKING
# KEYWORD: shutdown
. /etc/rc.subr
name="myapp"
rcvar="myapp_enable"
command="/usr/local/bin/myapp"
myapp_user="myapp"
pidfile="/var/run/${name}.pid"
command_args="&"
load_rc_config $name
run_rc_command "$1"sysrc myapp_enable=YES
service myapp startPortable Go Code Tips
package platform
import "runtime"
func DataDir() string {
switch runtime.GOOS {
case "windows":
return filepath.Join(os.Getenv("ProgramData"), "MyApp")
case "darwin":
return filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "MyApp")
default: // linux, freebsd, ...
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return filepath.Join(xdg, "myapp")
}
return filepath.Join(os.Getenv("HOME"), ".local", "share", "myapp")
}
}Use path/filepath, not string concat with /. Use runtime.GOOS only at boundaries (paths, notifications), not scattered through business logic.
Production Checklist
- Default builds use
CGO_ENABLED=0for portable artifacts - CI builds at least
linux/amd64and any real target (freebsd/amd64,linux/arm64) - Nix users have a flake/
shell.nixwith pinned Go - No hard-coded Linux-only paths (
/procscraped only behindlinuxtags) - File watching libraries verified on FreeBSD if used
- Release binaries named with
GOOS-GOARCH - Document CGO exceptions (and required C toolchains) explicitly
Common Pitfalls
- Dynamic binary on NixOS — “No such file or directory” often means the dynamic loader path is wrong, not that the file is missing.
- CGO cross-compile surprise —
go buildsilently uses CGO whenCGO_ENABLEDis unset and a C compiler exists; setCGO_ENABLED=0in CI. /procand cgroup assumptions — code that reads Linux cgroup files breaks on FreeBSD; guard with build tags.- Case-insensitive assumptions — macOS default FS is case-insensitive; FreeBSD/Linux are not. Tests that rely on case folding will fail.
- Firewall / jail — binding succeeds but traffic never arrives because the jail has no IP or pf blocks the port.
- Old FreeBSD + new Go — use a FreeBSD version supported by your Go release; check Go’s FreeBSD port notes when upgrading.
Exercises
- Cross matrix — Build the same
./cmd/serverforlinux/amd64,linux/arm64, andfreebsd/amd64. Record binary sizes. - Static proof — On Linux, run
fileandlddon the artifact; ensure it is statically linked withCGO_ENABLED=0. - Flake shell — Add a minimal
flake.nixwithgoandgopls; runnix develop -c go test ./.... - GOOS-specific file — Split a helper with
//go:build linuxand//go:build freebsdstubs;go teston your host andGOOS=freebsd go test(compile-only is fine). - Path portability — Implement
DataDir()above with tests usingt.SetenvforXDG_DATA_HOMEandHOME.
More examples
Portable data directory (XDG + HOME fallback)
mkdir -p /tmp/go-xdg && cd /tmp/go-xdg
go mod init example.com/xdgSave as main.go:
package main
import (
"fmt"
"os"
"path/filepath"
"runtime"
)
func dataDir(app string) string {
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return filepath.Join(xdg, app)
}
home, err := os.UserHomeDir()
if err != nil {
return filepath.Join(".", app)
}
// FreeBSD/Linux/macOS-friendly default under home.
return filepath.Join(home, ".local", "share", app)
}
func main() {
fmt.Println("GOOS/GOARCH:", runtime.GOOS+"/"+runtime.GOARCH)
os.Setenv("XDG_DATA_HOME", "/var/lib/custom")
fmt.Println("with XDG:", dataDir("myapp"))
os.Unsetenv("XDG_DATA_HOME")
// Force HOME for deterministic demo output.
os.Setenv("HOME", "/home/demo")
fmt.Println("fallback:", dataDir("myapp"))
}go run .Expected output (GOOS/GOARCH varies by host):
GOOS/GOARCH: darwin/arm64
with XDG: /var/lib/custom/myapp
fallback: /home/demo/.local/share/myapp
Build identity without CGO surprises
mkdir -p /tmp/go-static-id && cd /tmp/go-static-id
go mod init example.com/static-idSave as main.go:
package main
import (
"fmt"
"runtime"
"runtime/debug"
)
func main() {
fmt.Println("compiler:", runtime.Compiler)
fmt.Println("version:", runtime.Version())
fmt.Println("cgo:", cgoEnabled())
if bi, ok := debug.ReadBuildInfo(); ok {
fmt.Println("main path:", bi.Path)
fmt.Println("go version (mod):", bi.GoVersion)
}
}
func cgoEnabled() string {
// Pure-Go programs can still report whether the toolchain had CGO available
// at build time via the "CGO_ENABLED" setting in build info when present.
if bi, ok := debug.ReadBuildInfo(); ok {
for _, s := range bi.Settings {
if s.Key == "CGO_ENABLED" {
return s.Value
}
}
}
return "unknown (run: CGO_ENABLED=0 go build)"
}CGO_ENABLED=0 go run .Expected output (illustrative):
compiler: gc
version: go1.22.x
cgo: 0
main path: example.com/static-id
go version (mod): go1.22.x
Runnable example
Cross-compilation and portable paths matter more than platform-specific syscalls for most services. This program prints build identity (GOOS/GOARCH), uses XDG-style data dirs, and stays pure Go (static-friendly).
mkdir -p /tmp/go-portable && cd /tmp/go-portable
go mod init example.com/portableSave as main.go:
package main
import (
"fmt"
"os"
"path/filepath"
"runtime"
)
func dataDir(app string) string {
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return filepath.Join(xdg, app)
}
home, err := os.UserHomeDir()
if err != nil {
return filepath.Join(".", app+"-data")
}
// Portable default; FreeBSD/Linux/macOS all understand home-relative paths.
return filepath.Join(home, ".local", "share", app)
}
func main() {
fmt.Printf("goos=%s goarch=%s compiler=%s\n", runtime.GOOS, runtime.GOARCH, runtime.Compiler)
fmt.Printf("version=%s\n", runtime.Version())
fmt.Printf("num_cpu=%d\n", runtime.NumCPU())
dir := dataDir("myapp")
fmt.Println("data_dir:", dir)
if err := os.MkdirAll(dir, 0o750); err != nil {
fmt.Println("mkdir error:", err)
os.Exit(1)
}
marker := filepath.Join(dir, "hello.txt")
if err := os.WriteFile(marker, []byte("portable pure-go write\n"), 0o640); err != nil {
fmt.Println("write error:", err)
os.Exit(1)
}
b, err := os.ReadFile(marker)
if err != nil {
fmt.Println("read error:", err)
os.Exit(1)
}
fmt.Printf("read_back: %q\n", string(b))
_ = os.Remove(marker)
fmt.Println("ok: pure Go paths work without CGO")
}go run .
# Cross-compile without running foreign binaries:
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o /tmp/app-linux-amd64 .
GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go build -o /tmp/app-freebsd-amd64 .
file /tmp/app-linux-amd64 /tmp/app-freebsd-amd64 2>/dev/null || ls -la /tmp/app-*Expected output (illustrative, host-dependent):
goos=darwin goarch=arm64 compiler=gc
version=go1.22.x
num_cpu=8
data_dir: /Users/you/.local/share/myapp
read_back: "portable pure-go write\n"
ok: pure Go paths work without CGO
What to notice
runtime.GOOS/GOARCHreport the target you built for—set env vars at build time for NixOS/FreeBSD artifacts.CGO_ENABLED=0keeps binaries static so NixOS does not need an FHS loader path.- Prefer
filepath+ env (XDG_DATA_HOME) over hard-coded/varLinux paths.
Try next
- Add a
//go:build linuxfile and a stub for other OS; compile withGOOS=freebsd go build. - On Linux, run
ldd/fileon aCGO_ENABLED=0binary and confirm it is statically linked.