How to include home-manager conditionally in nixos?

I’m trying to build a common nixos configuration for two different roles of machines - one a HTPC, the other a headless server. For machines with the HTPC role I’m using home-manager to pre-configure a desktop user; for the headless server, I’m not.

I found many examples of how conditionals can be used for a configuration, but I’m not using flakes and wanted to keep my approach as simple as possible, so I landed on the below approach. (I’m very open to suggestions for improvement, especially if it’ll help me solve the home-manager issue below.)

I have a variables.nix that contains all host-specific info:

{
  role.htpc = false;
  role.headless = true;
  hostName = "hostname";
  <SNIP>
}

Then in my config files I’m using an import to reference the variables, along with mkIf for conditionals based onthe server role:

{ config, lib, pkgs, modulesPath, ... }:
let
  variables = import ./variables.nix;
in
{
  services.xserver = lib.mkIf variables.role.htpc {
    enable = true;
<SNIP>

Where this falls down is with home-manager. home-manager is only needed on htpc machines, but I can’t find the right syntax for that. I’m running into two blockers in particular.


The first is importing home-manager. I have all of my nix files imported in configuration.nix. My first attempt was to use mkMerge to conditionally import it, but I could only make that work be prefixing it with config = like the following:

  # Include sub-configuration files
  config = lib.mkMerge [
    {
      imports = [
        ./backups.nix
        ./filesystems.nix
        <SNIP>
      ];
    }
    (lib.mkIf variables.role.htpc {
      imports = [
        <home-manager/nixos>
      ];
    })
  ];

But, I get this error:

error: Module /etc/nixos/configuration.nix’ has an unsupported attribute environment'. This is caused by introducing a top-level config’ or options' attribute. Add configuration attributes immediately on the top level instead, or move all of them (namely: environment nix powerManagement security system systemd time users) into the explicit config’ attribute.`

So, it doesn’t like the ‘config’, but I get other errors without it. I tried moving the import for <home-manager/nixos> into one of the other nix files and make it conditional like this:

  imports = lib.mkIf variables.role.htpc [
    <home-manager/nixos>  # HTPC
  ];

But that fails with error: value is a set while a list was expected. mkIf seems to expect an {, but that seems to be incompatible with imports. Every variant of this I’ve tried has failed.

How can I properly import <home-manager/nixos> only when role.htpc is true?


The second issue is here:

  home-manager.users.media = { pkgs, lib, ... }: lib.mkIf variables.role.htpc {

I tried simply deleting the <home-manager/nixos> line just to see if everything else would work, but it fails here because it’s trying to invoke home-manager before the condition. I don’t know how to reverse that.

I’d really like to do something like this instead:

lib.mkIf variables.role.htpc {
  home-manager.users.media = { pkgs, lib, ... }: {

In addition to solving this issue that’d also greatly simplify my configuration if I could define that condition once and nest everything that applies inside of it, but it doesn’t seem possible to do that (or I’m just too dumb to figure it out).

I’m sure this must be solvable. Would appreciate any pointers.

Thanks.

This is a commonly experienced problem and it boils down to the fact that your list of imports cannot in any way depend on the value of config.


First issue:

For module imports, use lib.optionals instead of lib.mkIf.

The reason is that lib.mkIf produces a special attrset which is recognized during modules evaluation (hence the error: value is a set while a list was expected). lib.optionals should always return a list.


Second issue:

I usually define a dummy home-manager module for this purpose.

# dummy-home-manager.nix
{ lib, ... }: {
  options.home-manager = with lib; mkOption {
    type = types.any;
    description = "Unused";
  };
}

Then you can choose whether to import the real home-manager or a stub, depending your variables:

# configuration.nix
let
  variables = import ./variables.nix;
in {
  imports = [
    (if variables.role.htpc
     then <home-manager/nixos>  # for HTPC, etc
     else ./dummy-home-manager.nix)  # for headless
  ];
}

Finally, at some point you will probably be tempted to define NixOS options for things in ./variables.nix. This is fine and good for every purpose except conditional imports. You will get an infinite recursion error if imports depends on config.

1 Like

Just import <home-manager/nixos> unconditionally. It is inert if you don’t define any home-manager.users. Stratifying your config into pre-module variables and post-module configuration is going to cause you headaches when you refactor later.

You should be able to write:

home-manager.users = lib.mkIf (your condition here) {
  media = { ... }: {
    ...
  };
};

And that’ll count as not defining any users if your condition is false.

In this mkIf, you can get away with referencing other module options, not just things defined in a pre-module variables.nix file. Defining your own module options is often the better way to share configuration across multiple modules, because you don’t have to manually import the same file everywhere, and you can override options with mkForce or extend them with mkAfter/mkBefore to handle special cases—and you can do that overriding anywhere, not just in the place you’re using the value.

3 Likes

Thanks! Clever use of a dummy module. This got me through my roadblocks.

Follow-up question on your if/then/else usage, though - I’ve read that mkIf should be used rather than if/else because if/else can lead to infinite loops, so I’ve tried to stick with mkIf since that seems to be the general recommendation.

But… I have a couple nix file full of stuff like this:

  services.xserver = lib.mkIf variables.role.htpc {
    <SNIP>
  services.pipewire = lib.mkIf variables.role.htpc {
    <SNIP>
  services.lirc = lib.mkIf variables.role.htpc {
    <SNIP>
  etc.

This works, but I’d much prefer to have a single condition at the top. Seeing your code in action, I tried switching to this:

  config = if variables.role.htpc then {
    services.xserver = {
      <SNIP> 
    };
  } else {};

And it works! I find this simpler to work with. This wiki article is one of the sources I found recommending against using if/else:

https://nixos.wiki/wiki/NixOS:config_argument

It looks like the main issue is when you have a nested condition also dependent upon config, but if I don’t have that circular dependency, is if/else reasonably safe to use like I’m doing?

Thanks for the guidance. I had read that same “import unconditionally” suggestion elsewhere and was going down that road, but home-manager tripped me up because I couldn’t import it since my headless servers weren’t subscribed to that channel.

Thinking about it now I guess there’s no real harm in adding that channel to all of my systems, but I wanted to avoid doing that on systems that don’t need home-manager.

I definitely need to look into defining and using custom module options vs. simple variables. I can see some benefits from that, but the examples I was looking at earlier didn’t click with me right away. My main experience with declarative languages comes from ansible, so a separate vars file felt more natural. Will look into this more going forward. Again - I do appreciate the guidance.

Ah, so you aren’t yet using a deployment tool to manage your servers, but are instead running nixos-rebuild on each one? That’s not an unreasonable place to start learning, but if you take the next step to a deployment tool then this issue goes away—only the machine that does the deployment will need to be subscribed to channels, and all the target machines won’t need channels or even configuration files locally.

Ah. Yeah, I didn’t even know those tools exist. One more thing to research now. :slight_smile:

If picking up, e.g., Colmena seems too daunting, nixos-rebuild itself can be used as a simple deployment tool, for machines that already exist and are running NixOS. The wiki has tips; look for the --target-host option.

1 Like

This is a subtle issue. I have spent a lot of time scratching my head trying to debug infinite loops.

Stick with mkIf is a good recommendation. Since your variables.role.htpc value is completely outside of config, you are free to use it in any context. if/else - lib.mkIf. Fish - mammal, whatever.

Correct. That wiki page has a good explanation.

I think perhaps the issue which tripped you is that imports and config are evaluated separately and in very different ways. I’m sure there was a doc somewhere about it, but can’t find a link.