Go Development Environments
Go Development Environments
A Go service that builds in CI and not in $HOME/sdk is the point of Nix. The boring default is: mkShell with go, gopls, gotools, and golangci-lint from the same 26.05 pin as buildGoModule — direnv loads it; Home Manager does not install Go.
Mental model
flake.nix
packages.desk-api = buildGoModule { … }
devShells.default = mkShell { packages = [ go gopls … ]; }
Same go attr in both. direnv + use flake. CGO_ENABLED=0 in the shell if the package sets it.
Private modules: GOPRIVATE=git.desk.internal in the shell. Tokens stay in the environment / netrc, not in the flake.
GOTOOLCHAIN=local stops Go 1.21+ from downloading a different toolchain from go.mod’s go line. The Nix go is the compiler.
Worked examples
Case 1: Shell that matches the package
Save as flake.nix:
# flake.nix
{
description = "desk-api shell";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
go = pkgs.go;
in
{
packages.${system}.desk-api = pkgs.buildGoModule {
pname = "desk-api";
version = "1.0.0";
src = ./.;
vendorHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
};
devShells.${system}.default = pkgs.mkShell {
packages = [
go
pkgs.gopls
pkgs.gotools
pkgs.golangci-lint
pkgs.delve
];
env.CGO_ENABLED = "0";
env.GOTOOLCHAIN = "local";
};
};
}nix develop --command go version
nix develop --command which goplswhich go outside the shell must not be this store path (or a Homebrew go you should not use).
Case 2: direnv
Save as .envrc:
# .envrc
use flakedirenv allow
go versionCommit .envrc. Gitignore .direnv/.
Case 3: Tests as you type
nix develop --command go test ./...
nix develop --command golangci-lint runCI runs the same via nix develop --command or buildGoModule doCheck. Do not go test with a Homebrew Go.
A flake check:
# in outputs
{
checks.x86_64-linux.lint = pkgs.runCommand "desk-lint" {
nativeBuildInputs = [ pkgs.golangci-lint go ];
src = ./.;
} ''
cd $src
golangci-lint run
touch $out
'';
}If lint needs the network, vendor fixtures; checkPhase is hermetic.
Case 4: CGO shell when you mean it
# cgo-shell.nix fragment
{
packages = [ pkgs.go pkgs.pkg-config pkgs.sqlite ];
env.CGO_ENABLED = "1";
}Now mattn/go-sqlite3 can compile. The package derivation needs the same nativeBuildInputs and CGO_ENABLED, or CI will not match.
Case 5: Private GOPRIVATE
# private.nix fragment
{
packages = [ pkgs.go pkgs.git pkgs.openssh ];
env.GOPRIVATE = "git.desk.internal";
env.GOPROXY = "https://proxy.golang.org,direct";
}SSH agent / ~/.netrc is user state (sops / HM). The flake only names the host pattern.
The trap
The trap is go install golang.org/x/tools/gopls@latest inside the shell. That ignores the pin and writes to GOPATH. pkgs.gopls from 26.05.
The other trap is GOTOOLCHAIN=auto (Go default) downloading go1.23.x on first go build. GOTOOLCHAIN=local. Bump nixpkgs go (or overlay) if go.mod asks for newer.
The boring rule
- Same
goinmkShellandbuildGoModule. gopls/golangci-lint/delvefrom nixpkgs, not@latest.GOTOOLCHAIN=local.CGO_ENABLEDmatches the package.- direnv
use flake. No Home Managerpkgs.go. - GOPRIVATE in the shell; tokens not in Nix.
Try this
nix develop --command go env GOTOOLCHAIN GOVERSION CGO_ENABLED.which goinside vs outside direnv.- Add
golangci-lint runas achecks.derivation. - Set
GOTOOLCHAIN=localand ago.modthat asks for a newer go — read the error; bump nixpkgsgoinstead of letting Go download.