Go Development Environments

Updated

September 12, 2026

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 gopls

which 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 flake
direnv allow
go version

Commit .envrc. Gitignore .direnv/.

Case 3: Tests as you type

nix develop --command go test ./...
nix develop --command golangci-lint run

CI 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 go in mkShell and buildGoModule.
  • gopls / golangci-lint / delve from nixpkgs, not @latest.
  • GOTOOLCHAIN=local. CGO_ENABLED matches the package.
  • direnv use flake. No Home Manager pkgs.go.
  • GOPRIVATE in the shell; tokens not in Nix.

Try this

  1. nix develop --command go env GOTOOLCHAIN GOVERSION CGO_ENABLED.
  2. which go inside vs outside direnv.
  3. Add golangci-lint run as a checks. derivation.
  4. Set GOTOOLCHAIN=local and a go.mod that asks for a newer go — read the error; bump nixpkgs go instead of letting Go download.