Setting up a development environment with Nix and Home Manager
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:
- 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. - It should be reproducible. Broadly, the same set of configuration files should result in the same development environment on different machines.
- I should be able to roll back to a previously working state if something goes wrong when changing the configuration.
- 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.
- 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!