Setting up a development environment with Nix and Home Manager

geekery nix

Remember when I played about with installing NixOS on an old MacBook Air? Recently, I decided that I would have a go at using Nix as a package manager on an ordinary macOS machine, in much the same way as using homebrew to install software, but more declarative and (hopefully) more reproducible. You may also recall that I tried this before with — shall we say — mixed results. Here’s how things went this time.

What I started with

What I started with was a ‘dotfiles’ repository in which I stored all my dotfiles and deployed using Stow, and a homebrew installation to install various commandline applications. You can use a Brewfile to list what you want to install and then brew bundle to install it, but in practice I had some issues with keeping two computers up to date with the same applications and the same configuration. You used to be able to specify what versions of applications you wanted to install, but as far as I can tell, that seems to be difficult to do now. More than once I have ended up in dependency hell because I updated app A which silently updated app B which it depended on, which broke app C which depended on the older version of app B. Unfortunately, it is also very difficult to put things back the way they were before as brew now also cleans up (by default) the old versions you have installed.

What I wanted to achieve

I knew (since I had been here before) that using Nix was likely to make things more complicated. However, I hoped that it might fix a few things that I have previously struggled with using my current system. This is what I wanted to acheive:

  1. It should be declarative. I should be able to describe both the apps to install and their configuration in one set of *.nix files and deploy that easily to another computer. After initial installation, updating the config files, pushing those to a remote repository and pulling on another machine before deploying should make it easy to replicate an updated configuration on another machine.
  2. It should be reproducible. Broadly, the same set of configuration files should result in the same development environment on different machines.
  3. I should be able to roll back to a previously working state if something goes wrong when changing the configuration.
  4. It should make it easy for me to maintain a kind of basic system-wide development environment for a particular language (say, Python), while allowing me to specify a particular version of Python and its packages for each project, without affecting (or being affected by) my global environment. This latter one is tricky because I often find that I want to install some kind of globally useful application (like a linter) but I don’t want to install it for each project or cause dependency issues by installing it globally. Many programming languages have their own ways of dealing with this but they are all different, and if you are like me and an inveterate dabbler in many programming languages, it can get difficult to remember how to set things up with each language.
  5. Finally, it would be nice to re-use packages from the same store even if you install them in multiple projects, and to be able to easily clean up orphaned packages without worrying about breaking things.

That’s quite a challenging list of requirements!

Does Nix meet these requirements?

Since I last used Nix they have introduced Flakes. The series of blog posts starting with this post on Tweag does a much better job than I can of explaining the problem that Flakes solve. Essentially, you create a flake.nix file describing the inputs and outputs of what you want to build, where the inputs can be pinned to specific repository commits or tags. Either way, when you build it, a flake.lock file is created which describes how to reproduce what you have just built (Requirements 1 and 2). The series of articles by Xe Iaso also does a fantastic job of going through everything step by step which I found really helpful.

The beauty of Flakes is that they can be used for many different purposes. You can use it as a kind of wrapper file to set up a home-manager configuration. You can use it to define a disposable shell environment to try something out: write the configuration in your flake file then enter a shell with that environment set up using nix develop. Once you exit the shell, it is as if your new development environment never existed, but you can pop straight back in again with nix develop. If you install direnv, you can even set it up so that a cd into a project directory containing a flake loads it and leaving the project directory unloads it. In this way, you get the project-specific configuration that rbenv or pyenv provides, but you only have to learn one way to do it using Nix, and this can also install other tools unrelated to Python or Ruby but necessary for your project (Requirement 4). Better still, since everything Nix installs ends up somewhere in /nix/store/.. then gets symlinked where your system is looking for it, even if you install a particular version of Python and the same packages in multiple projects, Nix re-uses the binaries you installed the first time, saving disc space and reducing build time (Requirement 5).

Finally, when using Home Manager, if there’s an error with building your configuration, it doesn’t switch your configuration over, so things work as they did before. If it builds successfully but there’s some issue with something you’ve installed, you can roll back easily to a previous ‘generation’ (Requirement 3).

How did it turn all out?

Pretty well I think, but there is still work to do! Nix has a steep learning curve, and because you can use it to build and install things in an ad hoc way, configure your dotfiles, build your whole operating system, and now do the same things using flakes, it can be very difficult to find examples to learn from that fit your use case. In addition, the Darwin architectures (i.e. macOS Intel and Apple Silicon) are not the priority for fixes, so you have to be prepared for some things to be broken. Currently, Nix is using an older version of the macOS SDK, so I could not build hugo or RStudio, for example. So I’m not sure that it’s the easiest (or even the best) route to go down if you just want to install stuff and get on with your life. However, I am happy to fix things as I go and use homebrew for the things that are currently broken in Nix for now. Despite that, it was downright magical to install stuff on my personal Mac mini then build exactly the same setup on my new work MacBook Pro with an M1 Pro chip. It all just worked.

# flake.nix
{
  description = "My Home Manager flake";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
    home-manager.url = "github:nix-community/home-manager";
    home-manager.inputs.nixpkgs.follows = "nixpkgs";
    nix-colors.url = "github:misterio77/nix-colors";
  };

  outputs = { home-manager, nix-colors, ... }:
    let
      username = "myusername";
      config = {
        configuration = import ./home.nix;
        inherit username;
        homeDirectory = "/Users/${username}";
        # Update the state version as needed.
        # See the changelog here:
        # https://nix-community.github.io/home-manager/release-notes.html#sec-release-21.05
        stateVersion = "22.05";

        # Optionally use extraSpecialArgs
        # to pass through arguments to home.nix
        extraSpecialArgs = {
          inherit nix-colors;
        };
      };
    in {
        homeConfigurations."${username}@host1" = home-manager.lib.homeManagerConfiguration (config // {system = "x86_64-darwin";});
        homeConfigurations."${username}@host2" = home-manager.lib.homeManagerConfiguration (config // {system = "aarch64-darwin";});
    };
}

The flake.nix file above sets up things for home manager on both the systems. My username is the same but obviously the system architecture is different on each machine. I wasn’t sure how to handle this but the user TLATER on the NixOS Discourse kindly helped me out with the code above so once home manager is installed, I can just run home-manager switch on either machine and it will build stuff using the correct architecture. You might also notice that I also import nix-colors. This is a really handy utility which enables you to theme things consistently using the base16 colour schemes. Basically, you can define which colour scheme you want to use once in your config, then set up the abstract terminal colours (i.e. specifying the colour as base00 instead of a hex code for the colour) for different shells, terminal editors or whatever. Then if you want to switch themes, you change which base16 scheme is defined as your colourscheme and everything changes. I use this to enable the same scheme in zsh and fish shells as well as nvim, fzf and so on.

It is easier to keep a tidy and consistent set of config files with home manager for a number of reasons. Many of the more common programmes and utilities have special modules in home manager so that you can specify their configuration in the same expression which enables and installs them. Not only is this neater, but it also makes it easier to clean things up if you decide not to use something anymore. For example, I have started using the fantastic cross-shell prompt utility starship. The code snippet below enables and installs it, creates starship’s own config file and also adds enabling snippets to the config files for zsh and fish shells (the enableFishIntegration line etc.):

  programs.starship = {
    enable = true;
    enableFishIntegration = true;
    enableZshIntegration = true;
    settings = {
      shell = { disabled = false; };
      rlang = { detect_files = [ ]; };
      python = { disabled = true; };
    };
  };

This means that it gets set consistently in each shell I use, but also that if I decided to stop using it, I could delete this expression, do a home-manager switch and all of the configuration snippets added to my shells would be cleaned up and removed too. In addition, you can use home.sessionPath, home.sessionVariables and home.shellAliases to globally add stuff to your $PATH, environment variables and simple shell aliases so that you can conveniently manage a base set up for all the shells you use in one place. This consistency and ease of configuration has meant that I can use fish as my main shell, with zsh as a backup if needed, and have the same environment in each.

Similarly, it is easy to configure vim or neovim with the config file and plugins set up in one place. I only use vim for very quick edits so it is great to have a capable configuration already set up without having to remember how to install and configure plugins.

You can, however, also pull in existing file-based configuration if you want, making it possible to gradually shift your configuration over, or if home manager does not have a specific module for something you use. I’ve taken that line with some of the stuff I use, particularly Doom Emacs: Nix installs Emacs for me, but I then get it to link in my existing .doom.d directory and install Doom myself. I was astounded when this all worked first time on both machines!

Conclusion

This is all still a work in progress as I get more familiar with using Nix flakes and figure out the best way to handle different situations. My actual config is also private, but I have reproduced a few of the files as examples in this gist as I found it really helpful to copy bits from other people’s configurations as I was working this all out.

In addition to the resources I have linked to above, I would heartily recommend the Nixology series of videos on YouTube produced by Burke Libbey. He doesn’t go into Flakes but he explains Nix really clearly and focuses on using it to manage your dotfiles and set up reproducible development environments. I found it incredibly helpful when I was struggling to get to grips with Nix, particularly the one titled ‘Nix: What Even Is It Though’ which basically summed up and then clarified my bafflement!