gonixdevopstooling

Reproducible Go Projects With Nix Flakes

Nix flakes give you a reproducible dev environment that follows the repo. Here's how I set up a Go project so any contributor gets the exact same toolchain without any manual steps.

The classic “works on my machine” problem is most annoying when it’s your past self that set up the machine. Six months later you clone a repo on a new laptop and spend an hour figuring out which Go version you were using.

Nix flakes solve this at the repo level. The flake.nix in the root pins every tool — Go, golangci-lint, air for live reload, whatever you need — and nix develop drops you into a shell with exactly those versions. No version managers, no Docker, no setup docs to maintain.

What You Need

  • Nix installed with flakes enabled
  • A Go project (or a fresh directory)

If you’re on macOS, the Determinate Systems installer handles flake config automatically.

The Flake

{
  description = "My Go project";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = nixpkgs.legacyPackages.${system};
      in {
        devShells.default = pkgs.mkShell {
          buildInputs = with pkgs; [
            go_1_22
            golangci-lint
            air          # live reload
            gotools      # goimports, etc.
            delve        # debugger
          ];

          shellHook = ''
            echo "Go $(go version | awk '{print $3}')"
          '';
        };
      });
}

Drop this in the root of your project alongside go.mod and commit it. Now anyone with Nix can run nix develop and get an identical shell.

direnv Integration

Typing nix develop every time is annoying. With direnv and nix-direnv, the shell activates automatically when you cd into the directory:

# .envrc
use flake
direnv allow

After this, entering the project directory loads the environment, exiting unloads it. Your global Go installation is untouched.

Pinning Specific Versions

The flake above pulls from nixos-unstable, which tracks the latest nixpkgs. If you need a specific Go version that’s not in unstable, you can pin nixpkgs to a commit:

inputs = {
  nixpkgs.url = "github:NixOS/nixpkgs/a3a3dda3bacf61e8a39258a0b9e6f6ca94c97c5c";
};

Or use nixpkgs-24.05 for a stable channel. The flake.lock file records the exact commit, so the environment is reproducible down to the commit hash.

Adding Build Targets

You can expose the project binary as a Nix package too:

packages.default = pkgs.buildGoModule {
  pname = "myapp";
  version = "0.1.0";
  src = ./.;
  vendorHash = "sha256-..."; # nix will tell you the hash
};

Then nix build produces a binary in ./result/bin/myapp and nix run runs it directly.

The Payoff

The flake.lock file makes CI trivial — your GitHub Actions or Woodpecker pipeline installs Nix and calls nix develop --command go test ./.... Same toolchain as local. No “pin Go version in ci.yml” maintenance.

It also makes onboarding cleaner. The README can say “install Nix, run nix develop” and that’s genuinely the full setup.

The learning curve on Nix is real, but for Go projects the flake template above covers 90% of what you need. Once it’s in place you rarely touch it.