Go Modules with buildGoModule

Updated

September 12, 2026

Go Modules with buildGoModule

Go is how the desk API ships. The boring default is: pkgs.buildGoModule with a real vendorHash, committed go.mod / go.sum, CGO_ENABLED=0 unless you need C, and the Go from nixpkgs 26.05 — not go get in the sandbox.

This is the Go analogue of buildRustPackage: one FOD for modules, one offline compile.

Mental model

go.mod + go.sum
    │  vendorHash (FOD)
    ▼
/nix/store/…-desk-api-go-modules
    │  go build -mod=vendor  (offline)
    ▼
$out/bin/desk-api
Field Role
vendorHash Hash of the vendored module tree. Probe with lib.fakeHash.
vendorHash = null Only when there are zero module deps (stdlib only).
proxyVendor Let the FOD use the module proxy (usual).
modRoot If go.mod is not at src root.
subPackages ["cmd/desk-api"] so you do not build every ./... main.
env.CGO_ENABLED "0" for static-ish binaries; "1" needs gcc in nativeBuildInputs.

go test runs in checkPhase when doCheck = true (the default for buildGoModule). Tests must be hermetic.

Worked examples

Case 1: Stdlib-only binary

Save as desk_service_go.nix:

# desk_service_go.nix
{ lib, buildGoModule }:

buildGoModule {
  pname = "desk-service";
  version = "1.0.0";
  src = ./.;
  vendorHash = null;
  meta = {
    description = "Desk Go backend";
    license = lib.licenses.mit;
  };
}

With go.mod + main.go in . (no require lines):

nix-build -E 'with import <nixpkgs> {}; callPackage ./desk_service_go.nix {}'
./result/bin/desk-service

Output:

Desk Go backend operational

(Your fmt.Println text.)

Case 2: Dependencies — probe vendorHash

# desk_api.nix
{ buildGoModule, lib }:

buildGoModule {
  pname = "desk-api";
  version = "1.0.0";
  src = lib.fileset.toSource {
    root = ./.;
    fileset = lib.fileset.unions [ ./go.mod ./go.sum ./main.go ./internal ];
  };
  vendorHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
  subPackages = [ "." ];
}
nix-build

Mismatch prints got: sha256-…. Paste it. Changing go.mod without bumping the hash is a stop, not a network call in buildPhase.

Case 3: Pin the toolchain

# go-pin.nix
{ pkgs ? import <nixpkgs> { } }:

(pkgs.buildGoModule.override { go = pkgs.go_1_24; }) {
  pname = "desk-api";
  version = "1.0.0";
  src = ./.;
  vendorHash = null;
}

pkgs.go on 26.05 is whatever that channel ships. When the desk must match Boring-Go’s 1.27 (or a security pin), override the builder, not PATH in a wrapper. Confirm with:

nix-build
./result/bin/desk-api
# or: strings result/bin/desk-api | grep '^go1\.'

Case 4: CGO_ENABLED=0

buildGoModule {
  pname = "desk-api";
  version = "1.0.0";
  src = ./.;
  vendorHash = null;
  env.CGO_ENABLED = "0";
  ldflags = [ "-s" "-w" ];
}

No gcc. Cross to GOOS=linux is then just the Go toolchain. If you import net with cgo DNS, you want this anyway for boring deploys. SQLite with mattn/go-sqlite3 needs CGO on and nativeBuildInputs = [ pkgs.pkg-config ]; plus the C library — then you left the boring path.

Case 5: Tests in the sandbox

buildGoModule {
  pname = "desk-api";
  version = "1.0.0";
  src = ./.;
  vendorHash = "sha256-…";
  doCheck = true;
  # default checkPhase is go test
}

A test that dials https://api.github.com fails. Fixtures in testdata/. vendorHash must include test-only modules too (go.sum lines).

# go-api.nix fragment
{
  env.GOTOOLCHAIN = "local";
  env.CGO_ENABLED = "0";
  ldflags = [
    "-s" "-w"
    "-X main.version=${version}"
  ];
  tags = [ "netgo" ];
  proxyVendor = true; # FOD may use proxy.golang.org; buildPhase stays offline
}

GOTOOLCHAIN=local stops Go 1.21+ from downloading a newer toolchain because go.mod says go 1.25. The Nix go is the compiler. proxyVendor = true is the usual FOD; false expects an in-tree vendor/. Do not set both vendorHash = null and a populated go.sum.

The trap

The trap is vendorHash = null with a require in go.mod. Eval may succeed; the build tries the network and dies. null is not “figure it out.”

The other trap is go get / GOPROXY=https://proxy.golang.org in buildPhase. The sandbox has no network. The FOD already fetched.

The boring rule

  • buildGoModule + vendorHash (or null only for stdlib-only).
  • Commit go.mod and go.sum. Filter src to them + packages.
  • subPackages for the mains you ship.
  • CGO_ENABLED=0 unless a C library is the point. GOTOOLCHAIN=local on the derivation.
  • Pin go via buildGoModule.override { go = …; } when the channel default is not the desk compiler.
  • ldflags / subPackages / tags on the builder, not a buildPhase that calls go build by hand.

Try this

  1. Stdlib main.go; vendorHash = null; nix-build; run the binary.
  2. go get github.com/google/uuid in a throwaway module; set fakeHash; paste got.
  3. Flip one character of vendorHash; read the mismatch.
  4. env.CGO_ENABLED = "0"; file result/bin/desk-api — dynamically linked to musl/glibc still possible via Go’s net; the point is no gcc.