16-Dec

The Cloud

Declarative management of dotfiles with Nix and Home Manager

Nix is a declarative package manager with rising popularity. One of the best ways to actually learn it is to use it, and what better way to learn it than using it every day to manage your dotfiles?

10 min read

·

By Ole Kristian Pedersen

·

December 16, 2021

I've spent quite some time configuring my development environment to make the tools I use daily work well together. I have multiple computers and want similar configurations on each computer. In the beginning, copying files between computers worked okay-ish, but keeping the computers in sync was hard. So I started looking for alternatives.

I started using a "dotfiles" repository as the single source of truth to keep track of my configuration files. I used different shell scripts, symbolic links and package managers to keep the configuration the same. The process was still fragile and required a lot of work to be sure configuration files and programs stayed in sync on all my computers. Small differences in per-system configuration could wreak havoc when I tried to move it to a new computer.

Late 2020 was the first time I was introduced to Nix. I found some time over Christmas holidays to get started with some simple tutorials and navigating the ecosystem. I then slowly migrated my dotfiles from Homebrew to Nix (using Home Manager, I'll get back to that) whenever I had time.

What's the benefit of using Nix for dotfiles? Moving to Nix gave me a declarative, reproducible setup, with built-in automation and flexibility. I also did not have to deal with dead symlinks and long bash scripts. The setup on a new computer was also faster and more reliable than previous approaches.

In the rest of this article I'll explain how I started replacing my configuration, and walk you through the first steps to learning Nix by using it on your everyday computer.

Getting started

This blog post won't do a deep dive into the benefits of Nix, since the excellent blog post Deterministic systems with Nix published a couple of days ago already introduces the key concepts. If you haven't read that blog post, now is a good time to do so and read the rest of this article afterwards.

Installing Nix 2.4

$ sh <(curl -L https://nixos.org/nix/install)

Since Nix 2.4 was released recently, we'll use the new feature called "Flakes", which helps us with a better way to handle dependencies. I'll not go into details about Nix Flakes and how it works, because this 3-part series, by the creator of Nix himself, is the best resource to get an introduction to Nix Flakes. We'll need to create the file ~/.config/nix/nix.conf, with the following line to enable it:

experimental-features = nix-command flakes

Installing Home Manager

Finally, we'll use the tool Home Manager. It's created to to manage the user environment (e.g., dotfiles) in a declarative way using Nix. We'll start out by adding the minimal configuration needed to get Home Manager up and running.

Start by creating a dotfiles directory if you don't have one already (the name of the directory does not matter). Use git init (or your favorite GUI) to initialize a git repository in the folder. Add the two following files:

flake.nix, follow the TODO comments in the file to make it work on your system:

{
  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";
  };

  outputs = inputs: {
    defaultPackage.x86_64-linux = home-manager.defaultPackage.x86_64-linux;
    defaultPackage.x86_64-darwin = home-manager.defaultPackage.x86_64-darwin;
 
    homeConfigurations = {
	    # TODO: Modify "your.username" below to match your username
      "your.username" = inputs.home-manager.lib.homeManagerConfiguration {
        system = "x86_64-darwin"; # TODO: replace with x86_64-linux on Linux
        homeDirectory = "/home/your.username"; # TODO: make this match your home directory
        username = "your.username"; # TODO: Change to your username
        configuration.imports = [ ./home.nix ];
      };
    };
  };
}

home.nix (we'll edit this more later):

{ ... }: {
  programs.home-manager.enable = true;
}

Use git add (or your favorite GUI) to stage these files. No need to commit, but in order to use Nix Flakes, all files referred to (directly or indirectly) by the flake must be tracked by git (i.e., at least staged).

Finally, running nix run . switch installs home-manager in your environment. We'll get back to exactly why this command works at the end of the blog post. Confirm that Home Manager is installed and available by running home-manager help.

The first run will also create a lock file, flake.lock, which locks flake dependencies to a given commit in the flake repository. I recommend adding this file to version control to make sure your configuration is reproducible across systems.

Using Home Manager to install packages

Okay, let's install cowsay using Home Manager. We'll edit the home.nix file to contain the following:

{ pkgs, ... }: {
   programs.home-manager.enable = true;

   home.packages = [
    pkgs.cowsay
  ];
}

Then, run home-manager switch. This command downloads whatever is required, does some magic with symbolic links, and you should now be able to run cowsay:

$ cowsay "Hello bekk.christmas!"
 _______________________
< Hello bekk.christmas! >
 -----------------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

Okay, so how do you uninstall it? Easy. Just remove the program from your home.nix, and then run home-manager switch again. home.nix should now look something like this:

{ pkgs, ... }: {
  programs.home-manager.enable = true;

  home.packages = [
  ];
}

Okay, so right now we've seen than Nix and Home Manager the same features as Homebrew: we can install and uninstall packages.

But wait, there's more! We can manage program configurations using Home Manager too.

Using Home Manager to manage program configuration

What does "manage program configuration" mean, really? Okay, so dotfiles are not just programs, but configuration files too. For instance, you might have a .bashrc file for your shell, or .gitconfig to manage your git preferences. We can create and/or manage these files with Home Manager. For instance, you can add your git configuration to home.nix like this:

{ pkgs, ... }: {
  programs.home-manager.enable = true;
  
  home.packages = [
  ];
  
  programs.git = {
    enable = true;
  };
}

Running home-manager switch will install git, and create a (currently empty) configuration file at ~/.config/git/config. Also note the similarity between the way we install Home Manager, and the way we installed git - they are equivalent.

NB! Home Manager will try not to overwrite files and symlinks not managed by Nix, so if you already have a configuration file in the same location, home-manager will exit with an error:

Existing file '/home/your.username/.config/git/config' is in the way of '/nix/store/m42x1cc0g10cis1w58mbgdkkyn8pv1w1-home-manager-files/.config/git/config'
Please move the above files and try again or use 'home-manager switch -b backup' to back up existing files automatically.

Following the help text in the error message and running, e.g., home-manager switch -b bk, will copy the pre-existing configuration file to ~/.config/git/config.bk.

We can use Home Manager to add some basic configuration to the configuration file like this:

  programs.git = {
    enable = true;
	  includes = [
      { path = "~/.gitlocalconfig"; }
    ];
    aliases = {
      ap = "add -p";
    };
    extraConfig = {
      pull.ff = "only";
    };
  };

Running home-manager switch again, you should now get the following content in ~/.config/git/config:

[alias]
        ap = "add -p"

[pull]
        ff = "only"

[include]
        path = "~/.gitlocalconfig"

One important thing to note: you're not allowed to edit the newly created configuration file! Why? Because the file is supposed to be immutable, and can only be changed by updating your dotfiles and running home-manager switch again. The benefit is that you don't get any configuration drift, but on the flip side, the new workflow can be hard to get used to.

The Home Manager wrapper around program configuration is not perfect. It doesn't have all the options for all programs, and might not be that flexible compared to the original configuration format. Usually, an option named extraConfig or similar is available to use configurations options that are not supported by the wrapper. Additionally, not all programs are wrapped by Home Manager. Check out Appendix A of the Home Manager manual to see all the available configuration options for wrapped programs.

Another possibility is to install the program directly as a package, and use home.file.<name>.source to use files directly. As an example, let's use the Git configuration example above. Store the same configuration in a file called gitconfig in your dotfiles directory (remember to stage it with git add too!):

[alias]
        ap = "add -p"

[pull]
        ff = "only"

[include]
        path = "~/.gitlocalconfig"

Then, modify home.nix to install git as a package, and source the new gitconfig file:

{ pkgs, ... }: {
  programs.home-manager.enable = true;
  
  home.packages = [
    pkgs.git  # Install git as a pacakge
  ];
  
  # Install the gitconfig file, as .gitconfig in the home directory
  home.file.".gitconfig".source = ./gitconfig;
}

After running home-manager switch, you should have the file ~/.gitconfig and the previous config file (~/.config/git/config) should be removed.

Note that the new file is still not editable. You still have to use the same workflow of editing it in your dotfiles repository, followed by home-manager switch. This works just like the previous method (which is good!), but you can keep configuration files on their original format.

Okay, so we've now learned a couple of ways to install software and manage configuration files using Nix and Home Manager. You know more or less everything you need to move most of your installed tools away from Homebrew or any other package manager you're using. How do you use your new knowledge in practice?

Moving from Homebrew to Nix and Home Manager

Start simple, and learn as you go. I started out moving tools with no dependencies and little to no configurations, such as ripgrep, curl, fzf, git, etc. Use the home.file.<name>.source approach for configuration files to get them quickly managed with Nix and Home Manager. Remember to uninstall programs from your other package manager. Some programs might not be supported by Nix on your platform, just skip those for now.

You can use this page to find the correct name of the packages you want to install. Some packages do not have the same name in all package managers. This page is easier to use than the alternatives and shows packages available on NixOS, but not all of them is available on e.g., macOS. You can use nix search nixpkgs <name> to search for packages available on your platform.

From here you can go multiple ways to improve your configuration. Trying to move configurations from files into Home Manager (using the programs.<program-name> approach) is a good way to get more acquainted the Nix syntax and Home Manager. Appendix A of the Home Manager manual contains all the available configuration options for each program.

Other ways to move forward include trying to port programs not available on your platform using overlays or trying to use your configuration on another computer. You could also try installing GUI programs like VS Code, a terminal or other programs you're using.

As you go, you might want to split your configuration in multiple files to easier manage it. You can create new Nix files and import them like in the example below:

imports = [
  ./my-new-file.nix
  ./my-second-file.nix
]

This is all you need to get started. Happy learning! Just to help you out a bit more, I've tried to answer most of the common issues I encountered below:

How do I update my installed programs?

You can update all you programs by navigating to your dotfiles folder and running nix flake lock, which updates the flake.lock file. Then run home-manager switch.

How do I install programs not supported on my system?

Overlays is a cool feature where you can override a package to make it work for a new system, assuming it exists for a system already. Or you can create your own derivation. Overlays and derivations can quickly get complicated when unfamiliar with Nix, so you might want to first check out out the Nix User Repository (NUR) which might contain overlays and packages for your system (or give you some good examples to start out from).

How do I get GUI programs into Spotlight search on macOS?

Spotlight only indexes programs located in /Applications/ or ~/Applications/. There are discussions to figure out how to best solve this, but luckily workaround exists. I use this one (use at your own risk!):

{ lib, pkgs, config, ... }:
{
  # Copy GUI apps to "~/Applications/Home Manager Apps"
  # Based on this comment: https://github.com/nix-community/home-manager/issues/1341#issuecomment-778820334
  home.activation.darwinApps =
    if pkgs.stdenv.isDarwin then
      let
        apps = pkgs.buildEnv {
          name = "home-manager-applications";
          paths = config.home.packages;
          pathsToLink = "/Applications";
        };
      in
      lib.hm.dag.entryAfter [ "writeBoundary" ] ''
        # Install MacOS applications to the user environment.
        HM_APPS="$HOME/Applications/Home Manager Apps"
        # Reset current state
        [ -e "$HM_APPS" ] && $DRY_RUN_CMD rm -r "$HM_APPS"
        $DRY_RUN_CMD mkdir -p "$HM_APPS"
        # .app dirs need to be actual directories for Finder to detect them as Apps.
        # In the env of Apps we build, the .apps are symlinks. We pass all of them as
        # arguments to cp and make it dereference those using -H
        $DRY_RUN_CMD cp --archive -H --dereference ${apps}/Applications/* "$HM_APPS"
        $DRY_RUN_CMD chmod +w -R "$HM_APPS"
      ''
    else
      "";
}

This is based on this issue comment and a couple of related comments in the same thread.

Save it in a file called darwin-application-activation.nix, and import it by using the following snippet in home.nix:

imports = [
  ./darwin-application-activation.nix
];

How does the flake.nix file we're using actually work?

You've only made slight modifications to the file after importing it, so let's go through the most important parts. The first part is just a human-readable description of the Flake:

description = "My Home Manager flake";

Next up is the inputs. There are two inputs: nixpkgs and home-manager, both of which point to a GitHub repository containing a flake.nix file. Additionally, we make the nixpkgs input of the home-manager flake point to the same version of nixpkgs that we're using in this flake.

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

Finally, the outputs. Let's split them up a little bit. The first part declares outputs as a function that takes one argument inputs:

outputs = inputs: {

Then we declare a couple of defaultPackages. These point to the default packages of the home-manager flake (i.e., the home-manager program). These are defined for convenience, so when we invoke nix run . switch on our first run (when home-manager is not yet available) it is equivalent to running home-manager switch:

defaultPackage.x86_64-linux = home-manager.defaultPackage.x86_64-linux;
defaultPackage.x86_64-darwin = home-manager.defaultPackage.x86_64-darwin;


Last, we define the homeConfigurations, which is the output read when we invoke home-manager. home-manager looks up the user name of the current user, and find the entry in homeConfigurations that corresponds to the user name. It also needs some information about your system, and a list of files to import.

homeConfigurations = {
  "your.username" = inputs.home-manager.lib.homeManagerConfiguration {
    system = "x86_64-darwin";
    homeDirectory = "/home/your.username";
    username = "your.username";
    configuration.imports = [ ./home.nix ];
  };
};

If you want to use your configuration on another system where the username is different, you will have to add another entry in homeConfigurations.

16-Dec

Join in the holiday cheer and count down to Christmas with us!

I would like updates:
Your email will only be used for these advent calendar updates during the holiday season. They will never be sold or shared with third parties.