As you may know, I have been using Nix/NixOS for the last few months. I finally started doing some development, after spending lots and lots and lots of time tweaking my setup (and neovim).

As part of starting to do some real development work, I am now trying to leverage devshells with Nix flakes. I like the concept of Nix devshells, I have tried using Docker dev containers in the past, but the issue I had with those was adding my tools such as shell (fish) or cli tools was not easy. Whereas Nix shells just add tools and scripts to our existing shell.

By using Nix flakes we can guarantee (or close to) that developers will be using the same versions of all the tools, provided in the devshell.

Flake Template

First, we make sure you have support for Nix flakes 1. To get started let’s use a flake template to create a new flake in our go project. First, make sure you are in the root of your project i.e. where go.mod is and then run nix flake init -t github:nix-community/gomod2nix#app 2.

Fix

At the moment the created flake is broken, on line 25 we have to fix this. Remove buildGoApplication so the line looks like inherit (gomod2nix.legacyPackages.${system}) mkGoEnv gomod2nix;.

See this PR for more information.

direnv

I would recommend enabling direnv which is a tool that allows us to run commands automatically when entering a directory there is a version available for nix. This will cache our Nix development shell and make it much faster to run after the first run. Also prevents the garbage collector from removing build dependencies we need for our nix shells.

We can enable nix-direnv in home-manager like so:

{
  programs.direnv = {
    enable = true;
    nix-direnv.enable = true;
  };
}

gomod2nix

Then after entering the development shell either via direnv or running nix develop, run the following command:

gomod2nix generate

This will populate the gomod2nix.toml file with information about our dependencies:

schema = 3

[mod]
  [mod."github.com/PuerkitoBio/goquery"]
    version = "v1.8.1"
    hash = "sha256-z2RaB8PVPEzSJdMUfkfNjT616yXWTjW2gkhNOh989ZU="
  [mod."github.com/andybalholm/cascadia"]
    version = "v1.3.1"
    hash = "sha256-M0u22DXSeXUaYtl1KoW1qWL46niFpycFkraCEQ/luYA="
  [mod."github.com/davecgh/go-spew"]
    version = "v1.1.1"
    hash = "sha256-nhzSUrE1fCkN0+RL04N4h8jWmRFPPPWbCuDc7Ss0akI="
  [mod."github.com/pmezard/go-difflib"]
    version = "v1.0.0"
    hash = "sha256-/FtmHnaGjdvEIKAJtrUfEhV7EVo5A/eYrtdnUkuxLDA="

Adding extra packages

Now how we can add extra packages to our Nix shell? Simply go to our shell.nix file and find the bit where we specify the pkgs.mkShell. Then here we can add the packages we want available, such as say golangci-lint or gotools to have goimports tool available.

pkgs.mkShell {
  hardeningDisable = [ "all" ];
  packages = [
    goEnv
    gomod2nix
    pkgs.golangci-lint
    pkgs.go_1_21
    pkgs.gotools
    pkgs.go-junit-report
    pkgs.go-task
    pkgs.delve
  ];
}

Now our nix shell will have these tools available including go version 1.21. It’d be nice to find a way to specify the go version in the go.mod file and just use that version.

flake.lock

We can check the flake.lock which makes sure that when we share this repository other developers will get the same version of the tools we did. As the flake.lock specifies a specific git revision until we do a flake update, this will include nixpkgs which are a set of nix expressions git repo.

So updating the flake will update the revision of nixpkgs, which may then include the expression to build a newer version of say golangci-lint. However again this will be the same for all developers once they have pulled in our changes and rebuilt their dev shell. Which makes our development environment far more reproducible.

pre-commit

Now that we have packages available, we can also add pre-commit hooks to our development shell. Using the popular pre-commit tool. First, we need to add a new input to our flake.

{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
inputs.flake-utils.url = "github:numtide/flake-utils";
inputs.gomod2nix.url = "github:nix-community/gomod2nix";
inputs.gomod2nix.inputs.nixpkgs.follows = "nixpkgs";
inputs.gomod2nix.inputs.flake-utils.follows = "flake-utils";
inputs.pre-commit-hooks.url = "github:cachix/pre-commit-hooks.nix";
}

Then add the pre-commits as an argument to our outputs and make sure its accessible to our devShell, this is where we will set up our pre-commit hooks.

outputs = { self, nixpkgs, flake-utils, gomod2nix, pre-commit-hooks, ... }: {
  devShells.default = callPackage ./shell.nix {
    inherit (gomod2nix.legacyPackages.${system}) mkGoEnv gomod2nix;
    inherit pre-commit-hooks;
  };
}

Then in our shell.nix file we want it to look something like this:

{ pkgs 
, mkGoEnv ? pkgs.mkGoEnv
, gomod2nix ? pkgs.gomod2nix
, pre-commit-hooks
, ...
}:

let
  pre-commit-check = pre-commit-hooks.lib.${pkgs.system}.run {
    src = ./.;
    hooks = {
      gofmt.enable = true;
      golangci-lint = {
        enable = true;
        name = "golangci-lint";
        description = "Lint my golang code";
        files = "\.go$";
        entry = "${pkgs.golangci-lint}/bin/golangci-lint run --new-from-rev HEAD --fix";
        require_serial = true;
        pass_filenames = false;
      };
      goimports = {
        enable = true;
        name = "goimports";
        description = "Format my golang code";
        files = "\.go$";
        entry =
          let
            script = pkgs.writeShellScript "precommit-goimports" ''
              set -e
              failed=false
              for file in "$@"; do
                  # redirect stderr so that violations and summaries are properly interleaved.
                  if ! ${pkgs.gotools}/bin/goimports -l -d "$file" 2>&1
                  then
                      failed=true
                  fi
              done
              if [[ $failed == "true" ]]; then
                  exit 1
              fi
            '';
          in
          builtins.toString script;
      };
    };
  };
in

pkgs.mkShell {
  inherit (pre-commit-check) shellHook;
}

When we enter our nix shell it will automatically install pre-commit hooks and the yaml file .pre-commit-config.yaml (We should add this file to a gitignore). That’s all we need to get our pre-commit.

Build go binary

To build our binary using Nix we can simply run nix run, where we can see how this works in our default.nix file. Particularly the part with buildGoApplication 3:

buildGoApplication {
  pname = "myapp";
  version = "0.1";
  pwd = ./.;
  src = ./.;
  modules = ./gomod2nix.toml;
}

That’s it! We set up a development shell using Nix flakes for our go project. Including adding pre-commits and how we can build our Go binary using nix. Leveraging the gomod2nix tool.