Go on NixOS & FreeBSD

Updated

July 30, 2026

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/server

List supported pairs:

go tool dist list

Matrix 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
done

CGO 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 wrapping

Verify 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/server

Cross-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 start

Portable 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.

Build tags for OS-specific files

// notify_linux.go
//go:build linux

package notify

func watch(path string) error { /* inotify path via library */ return nil }
// notify_freebsd.go
//go:build freebsd

package notify

func watch(path string) error { /* kqueue path */ return nil }
// notify_stub.go
//go:build !linux && !freebsd

package notify

func watch(path string) error { return errUnsupported }

Production Checklist

  • Default builds use CGO_ENABLED=0 for portable artifacts
  • CI builds at least linux/amd64 and any real target (freebsd/amd64, linux/arm64)
  • Nix users have a flake/shell.nix with pinned Go
  • No hard-coded Linux-only paths (/proc scraped only behind linux tags)
  • File watching libraries verified on FreeBSD if used
  • Release binaries named with GOOS-GOARCH
  • Document CGO exceptions (and required C toolchains) explicitly

Common Pitfalls

  1. Dynamic binary on NixOS — “No such file or directory” often means the dynamic loader path is wrong, not that the file is missing.
  2. CGO cross-compile surprisego build silently uses CGO when CGO_ENABLED is unset and a C compiler exists; set CGO_ENABLED=0 in CI.
  3. /proc and cgroup assumptions — code that reads Linux cgroup files breaks on FreeBSD; guard with build tags.
  4. Case-insensitive assumptions — macOS default FS is case-insensitive; FreeBSD/Linux are not. Tests that rely on case folding will fail.
  5. Firewall / jail — binding succeeds but traffic never arrives because the jail has no IP or pf blocks the port.
  6. Old FreeBSD + new Go — use a FreeBSD version supported by your Go release; check Go’s FreeBSD port notes when upgrading.

Exercises

  1. Cross matrix — Build the same ./cmd/server for linux/amd64, linux/arm64, and freebsd/amd64. Record binary sizes.
  2. Static proof — On Linux, run file and ldd on the artifact; ensure it is statically linked with CGO_ENABLED=0.
  3. Flake shell — Add a minimal flake.nix with go and gopls; run nix develop -c go test ./....
  4. GOOS-specific file — Split a helper with //go:build linux and //go:build freebsd stubs; go test on your host and GOOS=freebsd go test (compile-only is fine).
  5. Path portability — Implement DataDir() above with tests using t.Setenv for XDG_DATA_HOME and HOME.

More examples

Portable data directory (XDG + HOME fallback)

mkdir -p /tmp/go-xdg && cd /tmp/go-xdg
go mod init example.com/xdg

Save 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-id

Save 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/portable

Save 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 / GOARCH report the target you built for—set env vars at build time for NixOS/FreeBSD artifacts.
  • CGO_ENABLED=0 keeps binaries static so NixOS does not need an FHS loader path.
  • Prefer filepath + env (XDG_DATA_HOME) over hard-coded /var Linux paths.

Try next

  • Add a //go:build linux file and a stub for other OS; compile with GOOS=freebsd go build.
  • On Linux, run ldd / file on a CGO_ENABLED=0 binary and confirm it is statically linked.