Updating Home Manager configs without rebuilding the entire system


Home Manager is a great part of the Nix ecosystem, allowing you to easily manage your home directories and configurations. I use Home Manager with Nix flakes, and am quite happy, except for one very annoying aspect of it: since I use Home Manager as a NixOS module, anytime I make a change to a program’s configuration file, I need to rebuild the entire system in order for those changes to take effect. This slows down many workflows that involve updating files in home directories (mainly program configurations).

We’ll use the example of neovim. Home Manager takes my configuration options (the options I set for neovim through the home manager module, and also the path to the raw lua/vim configuration files that I point to) and builds them into a full neovim configuration (setting the correct runtime paths, putting plugins in the right places, and so on). It then puts that configuration in an entry in this nix store, and symlinks it to the appropriate home directory (~/.config/nvim in the case of neovim). All of this does give us the benefits of nix, i.e reproducibility, and having our neovim configuration in the same place as the rest of our system, neatly organized inside the flake.

The problem is that home manager is part of my system’s NixOS configuration. Let’s take a look at (a shortened version of) my flake:

{
  description = "some description";
  inputs = {
    # ... nixpkgs, other inputs...

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

  outputs = { nixpkgs, ... }: {
    nixosConfigurations = {
      HOSTNAME = nixpkgs.lib.nixosSystem {
        modules = [
          ./host/HOSTNAME
          hm.nixosModules.home-manager

          {
            home-manager = {
              # ... HM config ...
            };
          }
        ];
      };
    };

    # ... other outputs ...
  };
}

Since the HM configuration is part of the nixos configuration, any changes made need a rebuild of the entire system:

$ nixos-rebuild switch --flake path/to/flake#HOSTNAME

Although nix is smart and only rebuilds the parts that are necessary, it is nonetheless a much slower process than it needs to be.

1 The easy solution

The easiest solution is to use mkOutOfStoreSymlink:

# in a home manager module
programs.neovim = { enable = true; };

xdg.configFile."nvim" = {
  enable = true;
  recursive = true;
  source = config.lib.file.mkOutOfStoreSymlink "/absolute/path/to/nvim/config";
}

This takes in an absolute path to your config (which I don’t like very much) and makes a symlink from that (instead of copying it to the nix store and symlinking that). This, too, I don’t like very much, because it doesn’t feel like a very “nix” way of solving things. I will concede that this is the simplest solution, and should cover most cases as well.

2 Taking advantage of flakes

The next two solutions take advantage of flakes to detach packages and their configurations from the entire nixos system configuration (and, consequently, home manager as well). This does mean we won’t have access to all the niceties and options provided by home manager, but in my experience it’s not been a very big loss.

Now, if you’re as unfamiliar with flakes as I was before writing this post, you’ll want to know a bit about flakes and how they work. A lot of people describe flakes as just “a bunch of inputs and outputs”, which is true, but grossly unhelpful. I’ll try to give what is hopefully a more useful answer. Flakes indeed take just about anything as an input - source code, configuration files, or (most commonly) other flakes. It can then produce a number of different types of outputs, with some of the most common being:

Read more about flake outputs here.

Each of these outputs has a nix CLI command associated with it that allows us to consume that output. For example:

We’re interested in that last one. We’ll take the original package, somehow bundle our configuration together with it, and then use nix run <flake>#<pkg> to build just that package (along with our configuration) and run it. This cleanly avoids having to rebuild the system, while still making sure our configuration is handled by nix.

2.1 Overriding the package

We can modify nix packages by overriding the arguments passed to them or their attributes, using <pkg>.override or <pkg>.overrideAttrs respectively. A lot of packages will take extra configuration as an argument, and we can override them. Sometimes this is enough to achieve what we want. Neovim is one such package, so we’ll try it out.

Let’s start by adding an output to the flake:

# In flake.nix...
{
  # ... Inputs, description, etc...

  outputs = { nixpkgs, ... }:
  let
    system = "x86_64-linux";
    pkgs = nixpkgs.legacyPackages.${system};
  in {
    # ... Rest of the outputs...
    packages.${system}.neovim = pkgs.callPackage ./pkgs/neovim { };
  }
}

pkgs.callPackage will call the function in pkgs/neovim/default.nix, and pass all of its attributes as arguments.

Next, we create the directory pkgs/neovim which will contain a default.nix and another directory that contains our entire neovim configuration, like so:

pkgs/neovim/
├── default.nix
└── dots/
    ├── ftplugin
    ├── lua
    └── README.md

Inside default.nix, we will take in as arguments the neovim package, and vimUtils (which we will use to turn our own configuration into a “plugin”):

{ neovim, vimUtils }:
let
  # My Neovim Config, as a plugin.
  config-nvim = vimUtils.buildVimPlugin {
    name = "config-nvim";
    src = ./dots;
  };

  # Neovim package with all the plugins and config.
in neovim.override {
  withRuby = false;
  withPython3 = false;
  withNodeJs = false;
  configure = {
    # Our "customRC" will just call `init.lua`.
    customRC = ''lua require("init")'';
    packages.foo = {
      opt = [];
      start = with vimPlugins; [
        # My config.
        config-nvim

        # ... other neovim plugins, e.g lspconfig, telescope, etc...
      ];
    };
  };
}

2.2 Wrapping the program

Sometimes overriding the package is not an option.

TODO (if I ever get to it, but the idea is to use the wrapProgram function from the makeWrapper utility, see this gist).