Skip to main content

Home Manager the Hard Way

·3674 words·18 mins
Unrefined Nix Kepler
Author
Jonas A. Hultén
Opinionated engineer who rambles semi-coherently about DevOps, type systems, math, music, keyboard layouts, security, and misc.

In the last episode of “sldr learns Nix for Great Good”, I managed to mostly successfully break apart a NixOS configuration into modules, then bash those modules back together into a functioning system. Granted, I got tired along the way, and dumped way more config than is reasonable into the legacy module, just to get the machine out of the provisioning stage and into the “able to be used” stage.

Since then, I have made exactly zero progress on continuing the configuration — I discovered that World of Warcraft works out of the box under Wine these days, so that’s what I’ve been doing. 10/10 productivity.

What we’ll be looking at in this post is how to not define everything systemwide — using a Flakes-based configuration lets us use a seemingly very popular tool called home-manager to manage per-user applications and configuration, which seems quite useful. But first…

An editor that won’t make me cry
#

The downside of retiring my old laptop to server duty is that I lost all the nice IDEs I had on it. Specifically, my installation of VSCodium (open source Visual Studio Code) which I had painstakingly set up in the system configuration.nix file. Now, of course, I could copy all that code over to mercury and call it a day, but we’re trying to learn to be better here.

The boffins over in the Nix Community have set up a flake that lets you install VS Code/Codium with any extensions you might want — they’re spidering the entire VS Code Marketplace, as well as a few other sources, daily and baking the extensions into the flake. It’s really rather clever stuff, and a far better solution than me having to manually define over half of the extensions in my previous build. So, that’s what we want to do.

Now, there is a second issue: how do we define this? Defining a NixOS module which adds vscodium to environment.systemPackages is not okay, since I’ll primarily not want to install this system-wide. But at the same time, until home-manager is in place, how do I do it?

The answer turns out to be an ad-hoc module, defined straight in the root flake. But before we get to that, let’s look at how we define the VSCodium flake to begin with.

# nix/home/pkgs/vscodium/flake.nix

{
  inputs = {
    nix-vscode-extensions.url = "github:nix-community/nix-vscode-extensions";
    flake-utils.follows = "nix-vscode-extensions/flake-utils";
    nixpkgs.follows = "nix-vscode-extensions/nixpkgs";
  };

  outputs =
    inputs:
    inputs.flake-utils.lib.eachDefaultSystem (
      system:
      let
        pkgs = inputs.nixpkgs.legacyPackages.${system};
        extensions = inputs.nix-vscode-extensions.extensions.${system};

        # Extension list
        exts = (with extensions; [ open-vsx.editorconfig.editorconfig ]);

        inherit (pkgs) vscode-with-extensions vscodium;

        packages.default = vscode-with-extensions.override {
          vscode = vscodium;
          vscodeExtensions = exts;
        };

        devShells.default = pkgs.mkShell {
          buildInputs = [ packages.default ];
          shellHook = ''
            printf "VSCodium with extensions:\n"
            codium --list-extensions
          '';
        };
      in
      {
        inherit packages devShells;
      }
    );
}

This looks like a lot of work, but this is almost identical to the sample flake you get through nix flake init <name> -t github:nix-community/nix-vscode-extensions. My contribution is adding the exts list, and plugging that into the vscodeExtensions argument a little further down. This way, I can define my own list of extensions relatively cleanly, and it’ll just work.

Plugging this into the root flake was a little more of an exercise, but this is how it eventually turned out:

# flake.nix

{
...
  inputs = {
...
    # Package flakes
    codium.url = "path:./nix/home/pkgs/vscodium";
...
  };

  outputs =
    inputs@{
      self,
      nixpkgs,
      nixos-hardware,
      flake-utils,
      codium,
      sldr-meta,
      ...
    }:
...
    {
      # Create the system configurations
      nixosConfigurations = {
        mercury = nixpkgs.lib.nixosSystem rec {
          system = "x86_64-linux";
          modules =
            defaultModules
...
             ++
              # Evil hacking
              [ { environment.systemPackages = [ codium.packages.${system}.default ]; } ];
...

A couple of important notes here.

Firstly, I tried to simply declare environment.systemPackages within the nixpkgs.lib.nixosSystem call, but that was seemingly not okay, so I had to create a tiny module and glue it onto the list of modules. I guess this is also how I can add any other config via the root flake.

Secondly, note that the set given to nixpkgs.lib.nixosSystem is now recursive (via the rec keyword) — this is to allow me to reference the system variable inside my new ad-hoc module.

However, between these two additions, I now have VSCodium up and running, and I can use it — rather than Vim — to continue my configuration.

No really, look at the difference
#

I’ve extracted the two most heinous examples from my old VSCodium configuration, and want to contrast them against how I’d install the same extensions using the new flake.

    [
    ...
      {
        name = "EditorConfig";
        publisher = "EditorConfig";
        version = "0.16.4";
        sha256 = "j+P2oprpH0rzqI0VKt0JbZG19EDE7e7+kAb3MGGCRDk=";
      }
    ] ++ [
      (codium-pkgs.vscode-utils.buildVscodeExtension (rec {
        basename = "open-remote-ssh";
    name = "${basename}-${version}";
        version = "0.0.45";
        src = fetchurl {
          url = "https://open-vsx.org/api/${vscodeExtPublisher}/${basename}/${version}/file/${vscodeExtUniqueId}-${version}.vsix";
          hash = "sha256-YoeUNvxLSmy3OftZp2AnqRU+TKe3KYLt3zZ0B5XGgeE=";
          name = "${vscodeExtPublisher}-${basename}.zip";
        };
        vscodeExtPublisher = "jeanp413";
        vscodeExtName = "Open Remote - SSH";
        vscodeExtUniqueId = "${vscodeExtPublisher}.${basename}";
      }))
    ];

The second extension there — jeanp413.open-remote-ssh — took me about an hour to configure, which required perusing the internal workings of how the vscode-utils functions were pulling and installing extensions, then finagling the information I could extract about the extension into a form that would work.

Doing the exact same configuration using the flake — which you’ve already seen, so I won’t copy it all again — looks like this:

        # Extension list
        exts = (with extensions; [
          open-vsx.editorconfig.editorconfig
          open-vsx.jeanp413.open-remote-ssh
        ]);

Boy howdy that’s a lot easier.

Was that actually the whole story? No, of course not.

Since the codium config is now trapped inside a flake, I need to make sure Nix actually updates it. However, I really just want to update the codium flake, not all flakes (since that would result in a full system update).

The trick was:

nix flake lock --update-input codium

Having done that, I could rebuild my system so that codium actually contained the correct extensions.

Home Manager, then…
#

Having painstakingly ported all my VSCode extensions (and installed them system-wide; we’re getting to that), I guess it’s time to finally look into the Home Manager situation.

Ostensibly, it’s a handy-dandy way to configure each user’s home directory using Nix. However, it’s a sub-system that’s about as large as all of NixOS in scope, at least at first glance.

The official documentation helpfully explains the trivial case of how to slot the Home Manager flake into a system configuration, but it also places a lot of configuration in the root flake. I’d like to be able to hide that behind sane defaults and abstractions, but it reveals an issue in how I’m going about this…

NixOS Modules revisited: what even are they?
#

As I’ve kind of touched on previously, NixOS modules can be either simple sets (i.e. { #stuff }) or functions which return sets (i.e. { ... }: { #stuff }). The ad-hoc module I stapled into my root flake above falls into the first category.

But, of course, that isn’t the whole story. There’s a third category for modules, which declare options and config separately — options, as I understand it, are options which this module makes available to other modules, whereas config is definitions which may or may not depend on those options. The previous two categories is shorthand for just config without any options. There’s also the imports list, which seems to exist sometimes and sometimes not… whatever.

Every module we’ve defined and dealt with so far falls into one of the first two categories. The nixosModules.home-manager module provided by Home Manager falls into the third category, and now I’m scared.

A sudden flash of understanding
#

The thing about these unrefined posts is that you get to come along with me as I figure things out — I intentionally don’t edit these posts to erase mistakes, because… because. Do I need a reason? It’s my blog. I’ll do as I please.

So I was staring at the example configuration again, trying to make heads of how they were calling the Home Manager module as a function, until I realized… They’re not.

Look here.

...
  outputs = inputs@{ nixpkgs, home-manager, ... }: {
    nixosConfigurations = {
      hostname = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./configuration.nix
          home-manager.nixosModules.home-manager
          {
            home-manager.useGlobalPkgs = true;
            home-manager.useUserPackages = true;
            home-manager.users.jdoe = import ./home.nix;

            # Optionally, use home-manager.extraSpecialArgs to pass
            # arguments to home.nix
          }
...

This is the salient part of the minimal flake that’s in the documentation. Pay special attention to the modules list.

For far too long I thought home-manager.nixosModules.home-manager { ... } meant that they were invoking the module as a function, with the home-manager options in the set passed as arguments. I was staring blindly at the code for the home-manager module, trying to figure out how the 🏴‍☠️ that works.

But of course that’s not what’s happening, otherwise — by the same logic — configuration.nix would be “called” using the home-manager module. The modules list is just that: a list. That set containing the home-manager options is an ad-hoc module, just like the one I made!

This answers a couple of questions:

  • By simply adding the home-manager module to the modules list, you bring the home-manager options into scope. That makes things a lot easier if I want to abstract things away into submodules.
  • The home-manager module code serves as an example on how to define one of those Category 3 modules I was banging on about earlier — I am planning on making a module like that to handle GUI setup on a system.

Back to the home module
#

As shown above, home-manager comes with some global settings which I’d like to abstract out of the root flake. Having now finally figured out how the home-manager module behaves, this turns out to be relatively easy:

{
  description = "Home Manager wrapper flake";

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

  outputs =
    inputs@{ nixpkgs, home-manager, ... }:
    {
      nixosModules.default = {
        imports = [ home-manager.nixosModules.home-manager ];

        home-manager.useGlobalPkgs = true;
        home-manager.useUserPackages = true;
      };
    };
}

That’s my entire home flake, which wraps home-manager and sets some sane defaults. As we’ve learned, adding this module to the system’s modules list will bring all the home-manager options into scope… which makes me realize, I should probably be diligent and actually declare things as defaults.

  outputs =
    inputs@{ nixpkgs, home-manager, ... }:
    let
      lib = nixpkgs.lib;
    in
    {
      nixosModules.default = {
        imports = [ home-manager.nixosModules.home-manager ];

        home-manager.useGlobalPkgs = lib.mkDefault true;
        home-manager.useUserPackages = lib.mkDefault true;
      };
    };

There.

Now, the next trick is, of course, setting up a user’s home environment. According to the home-manager documentation, that’s usually handled via a configuration file in ~/.config/home-manager/home.nix, but that won’t fly with Kepler; that file needs to live alongside all the other config files.

Now, I’m weighing whether I should abstract out the user definitions into the root flake. On the one hand, that would preserve the idea that all “settings” live there, but it also seems like a pretty sane default that my user exists and has home-manager set up, so why should that even be an option?

Looking through how the home-manager configuration files are set up, it seems like I should stuff that under the home flake. The config file can have two modes — a “normal” module or a flake, where I obviously want a flake. So, to avoid having to deal with importing the home-manager user config flake at the root level, let’s hide that away under the home flake. Does that make sense?

The way I’m trying to think about it is — again — the demarcation between system-wide configuration and user-specific configuration. While it’s not a perfect line — there will be differences in user-specific configuration depending on which system I’m working on — it makes more sense than dragging everything up to the top/root level.

But, staring at the sample flake further, it looks a lot like the configuration is per-username, so if I want separate home setups for different machines, then I’ll need separate files. Also, the flake is just a wrapper for the home.nix file, so nothing too fun there.

What we see here is a perfect case of analysis paralysis. I spent too much time thinking about how to set this up, rather than actually doing any setup, that I got overwhelmed and stopped, spending the rest of my evening leveling my Warrior. Though, there is something to be said for the power of just putting a problem aside and doing something else for a while.

Do the thing
#

So, I installed home-manager, in the sense that I slotted in the module and set those two settings. I didn’t set up any user, so I guess I shouldn’t be surprised that nothing seems to have changed after a reboot.

My hope is that I will, in difference from the legacy flake in my previous post, be able to use the system’s hostname for enabling/disabling home modules. That way I can have a single configuration for my user, but still — hopefully easily — maintain different configurations across different machines.

Though, I’ve hit a new snag — the system variable. This corresponds to the architecture of the host system. In the sample home-manager flake, that’s set outside the user configurations, implying that all users share the same architecture, but I want to do the opposite — make a user config that can apply on any (reasonable) architecture.

Inspecting the system
#

While still being a novice in the NixOS world, the simple process of “figure out what a given option is set to” isn’t particularly easy. I found a command called nixos-option which… didn’t help one bit. This may be because I’m bad, or because the system is flake-based.

However, I did have some success in using the nix repl to review the system configuration:

# Inside the kepler directory
$ nix repl
Welcome to Nix 2.18.4. Type :? for help.

nix-repl> :lf .  # Load the root kepler flake
Added 15 variables.

nix-repl> m = outputs.nixosConfigurations.mercury  # Shorthand

# From here, I can start to dig around the config using tab completion
nix-repl> m.config.nixpkgs.hostPlatform.system
"x86_64-linux"

Bingo.

Now, here’s a discrepancy between what’s in the config files and what’s in the actual system config. Looking at Mercury’s hardware-configuration.nix file, we find the line:

nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";

However, the config.nixpkgs.hostPlatform value does not contain just that value. No, it is a massive set of different values, booleans, and who knows what else. I found the system key in the set contained the string I was looking for, but there’s clearly some behind-the-scenes processing of the config I set in the files and how it ends up stored inside NixOS.

A word of warning: Do not try to just print the whole system config to the REPL. There are a lot of options that are deprecated and, consequently, throw warnings when you try to read them. Use tab completion judiciously, and filter through the options. You’ll eventually find what you’re looking for. Probably.

Trial by syntax error
#

The time has come to stop faffing about. Let’s see if we can get this thing working.

# nix/home/flake.nix

{
  description = "Home Manager wrapper flake";

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

  outputs =
    inputs@{
      self,
      nixpkgs,
      home-manager,
      ...
    }:
    let
      lib = nixpkgs.lib;
    in
    {
      nixosModules.default = {
        imports = [ home-manager.nixosModules.home-manager ];

        home-manager.useGlobalPkgs = lib.mkDefault true;
        home-manager.useUserPackages = lib.mkDefault true;

        home-manager.users.sldr = self.homeConfigurations.sldr;
      };

      homeConfigurations.sldr = home-manager.lib.homeManagerConfiguration {
        modules = [ ./users/sldr.nix ];
      };
    };
}

Instead of setting up a user-specific flake, I decided to bake the homeManagerConfiguration into the Home Manager flake, then do a little cheeky self-reference to get it to work. The flake really just references the home.nix file anyway, so there’s no real reason for it to even be a flake.

# nix/home/users/sldr.nix

inputs@{ config, pkgs, ... }:
{
  # Preamble
  home.username = "sldr";
  home.homeDirectory = "/home/sldr";

  # Enable home-manager itself
  programs.home-manager.enable = true;

  # Set the state
  home.stateVersion = "24.05";
}

The user file is the bare minimum that seems to be required, just to see if I can get it to build. Which, of course, I can’t.

The homeManagerConfiguration function (because this time it is a function) requires pkgs to be defined. In the documentation, this is dependent on the system — as discussed above — but I’m not sure I can reference the config set in the flake. Maybe?

At this point we remind ourselves that we need to diligently update the root lock file every time we rebuild, or else we’re really just going to be seeing the same error, no matter what we do.

The solution, after so much wailing, turned out to be to cut out the middle man:

      nixosModules.default = {
        imports = [ home-manager.nixosModules.home-manager ];

        home-manager.useGlobalPkgs = lib.mkDefault true;
        home-manager.useUserPackages = lib.mkDefault true;

        home-manager.users.sldr = import ./users/sldr.nix;
      };

Doing a straight import does not require me to define pkgs, which begs the question why the Home Manager config needs it in the first place.

This does raise another slight problem — since sldr.nix isn’t a flake, I’m going to run into trouble when I want to include flake-defined applications like codium. There is the bonus layer of flake-compat which I can use, but it feels like extra chaff to be bouncing back and forth between flakes and not-flakes.

Since home-manager seems to expect the entries under the users set to be modules, I should be able to finagle such a module inside a flake, so that I can handle all my flaky applications that way. Probably.

But now, let’s see if this runs… after changing the inputs to

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

For reasons entirely beyond my understanding, home-manager was using the 24.11 version. Somehow. Despite it only being July. At least it was kind enough to raise an error that there was a version mismatch between Nix and Home Manager.

With that pegged, let’s build and reboot.

Seeing it in action
#

The system started alright and… nothing of note had changed. Perhaps not entirely surprising, but still. I checked my shell and found no home-manager utility, so maybe things hadn’t worked after all?

Being the impatient kind, I opened the configuration back up and added

# nix/home/users/sldr.nix
...
  home.packages =  with pkgs; [
    hello
    cowsay
  ];

Some stupid programs just to see if they would appear. I triggered a system rebuild — I guess that’s the disadvantage of this method, that I’ll have to rebuild the entire system to change my home configuration, but they’re also pretty tightly linked. I digress.

I triggered a rebuild and something seemed to be happening. After all was done, I noticed something in the output.

restarting the following units: home-manager-sldr.service

That sure wasn’t there before.

I try hello and, sure enough, my terminal replies Hello, world!. I switch user — though definitely not to the root user, because that would be irresponsible — and hello is no longer on the PATH.

Home Manager is up and running.

About that machine-specific configuration
#

The final hurdle, before I’m willing to call this a successful experiment, is somehow conditionally adding a package depending on the hostname of the machine.

This time, I figured I could be more savvy and dig through the REPL straight away. There was something in the documentation about a extraSpecialArgs which would be passed to the home-manager module, but I couldn’t find any reference to how. Or, for that matter, how to access it inside the module. The REPL didn’t answer the second question, but I did find the extraSpecialArgs lurking inside the home-manager configuration.

$ nix repl
Welcome to Nix 2.18.4. Type :? for help.

nix-repl> :lf .
Added 14 variables.

nix-repl> m = outputs.nixosConfigurations.mercury

nix-repl> m.config.home-manager.extraSpecialArgs
{ nixosConfig = { ... }; }

Is that, by any chance, the system NixOS configuration being passed by default?

nix-repl> m.config.home-manager.extraSpecialArgs.nixosConfig.networking.hostName
"mercury"

Why, yes it is.

[Hacker voice] I’m in

The next hurdle, as mentioned, is to work out how it’s referenced inside the module. That’s also not something I can diagnose using the REPL, so I have to be a bit more clever.

The easiest way to test my hypothesis is to conditionally install, say, fortune. Skipping past me getting a deluge of syntax errors, here’s how we do that:

# nix/home/users/sldr.nix
...
  home.packages =
    with pkgs;
    [
      hello
      cowsay
    ]
    ++ (if <hostname-somehow> == "mercury" then [ fortune ] else [ ]);

I’m sure there are more clever ways of doing this, but this seems to work. The idea is simply that we append a list containing our conditionally installed packages if the conditional passes, otherwise we append an empty list. Appending an empty list is a no-op — it doesn’t change the list, but is still a valid operation.

So, how do we get the extraSpecialArgs? The easiest scenario would be if the contents of that set were passed to this function verbatim, so let’s try that.

...
++ (if inputs.nixosConfig.networking.hostName == "mercury" then [ fortune ] else [ ]);
...

To my great surprise, that worked. Now, we can make it even more compact by bringing the nixosConfig set into scope explicitly.

inputs@{
  config,
  pkgs,
  nixosConfig,
  ...
}:
{
...
  home.packages =
    with pkgs;
    [
      hello
      cowsay
    ]
    ++ (if nixosConfig.networking.hostName == "mercury" then [ fortune ] else [ ]);

I mean, it’s not very compact, but that’s the formatter doing its thing. The important point is that it works. I tried changing the name to something that wouldn’t match, and then fortune vanished.

As a final note, I’ve learned that nixosConfig.system.name is a neater way to reference the hostname. I think. They may be different things, but both say mercury, so I’m gonna use that going forward.


Like with the previous post, I’ve gone on for quite long enough. Lessons have been learned, though.

Until next time, beware of the Nix.