Reusing global configuration between home-manger as nixos module and home-manger for generic LInux

For context I think that this is nix language question(all the code I will provide is a short version of my actual files). I have flake.nix that defines nixos(and home manger as nixos module) and home-manger for generic use.

{
  description = "Multi machine flake";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05";

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

    # implicitly sets programs.command-not-found.enable = false;
    nix-index-database = {
      url = "github:Mic92/nix-index-database";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    sops-nix = {
      url = "github:Mic92/sops-nix";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    disko = {
      url = "github:nix-community/disko";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs =
    { nixpkgs, ... }@inputs:
    let
      system = "x86_64-linux";
      pkgs = import nixpkgs {
        inherit system;
        config.allowUnfree = true;
      };

      lib = nixpkgs.lib;
    in
    {
      nixosConfigurations = {
        kvm-nixos-server = lib.nixosSystem {
          inherit system;
          specialArgs = {
            inherit inputs;
            machineName = "kvm-nixos-server";
          };
          modules = [
            ./machines/kvm-nixos-server/configuration.nix
            inputs.home-manager.nixosModules.home-manager
            inputs.nix-index-database.nixosModules.nix-index
            inputs.sops-nix.nixosModules.sops
            inputs.disko.nixosModules.disko
          ];
        };

      homeConfigurations = {
        kmedrish = inputs.home-manager.lib.homeManagerConfiguration {
          inherit pkgs;
          extraSpecialArgs = {
            inherit inputs;
            machineName = "generic_linux_distro";
          };
          modules = [
            ./machines/generic_linux_distro/home.nix
            inputs.sops-nix.homeManagerModules.sops
          ];
        };
      };
    };
}

And I have a file host-specification.nix with global variables which I use all over my configuration files by importing it.

{ lib, config, ... }:

{
  options.hostSpecification = {

    syncthing = lib.mkOption {
      type = lib.types.submodule {
        options = {

          syncDir = lib.mkOption {
            default = "${config.hostSpecification.primeUserHomeDirectory}/Sync";
            type = lib.types.str;
            description = "Defines the Syncthing sync directory";
          };
        };
      };
      default = {};
      description = "Syncthing related configuration";
    };
  };
}

In case of my nixos + home-manger as nixos module I import host-specification.nix inside of my configuration.nix and then pass its value inside on my home-manger module.

{
  inputs,
  machineName,
  config,
  ...
}:
{
  home-manager = {
    useGlobalPkgs = true;

    useUserPackages = true;
    extraSpecialArgs = {
      inherit inputs;
      inherit machineName;
      hostSpecification = config.hostSpecification;
    };
    users.${config.hostSpecification.primeUsername} =
      import config.userDefinedGlobalVariables.homeMangerImportPath;


    sharedModules = [
      inputs.sops-nix.homeManagerModules.sops
    ];
  };
}

This means that inside my home-manger files I don"t call for config.hostSpecification.syncthing.syncDir I instead use hostSpecification.syncthing.syncDir like in taskwarrior.nix

{ pkgs, hostSpecification, ... }:
{
  home.packages = with pkgs; [ taskwarrior-tui ];
  programs.taskwarrior = {
    enable = true;
    package = pkgs.taskwarrior3;

    dataLocation = "${hostSpecification.syncthing.syncDir}/taskwarrior_data/task";
  };
}

The question is how do I go about this in my “home-manger for generic Linux setup” as there is no
hostSpecification = config.hostSpecification; for it meaning when I try and reuse my taskwarrior.nix it falils as there is no hostSpecification.syncthing.syncDir only config.hostSpecification.syncthing.syncDi

This is a complex question that ultimately boils down to a discussion of programming style. So, I’m going to be very opinionated here, you may or may not disagree.

My opinion hinges on this:

I believe this to be a code smell rooted in misunderstanding, and simply not using the module system correctly. Since you’re not using the module system correctly, now that you have a more complex use case you cannot use the module system to properly model this use case, and you run into a bit of a wall.

My suggestion would be to, rather than defining a module just to hold some random values which you then refer to in other modules, you use the custom module to actually define the settings which depend on your custom option.

I.e., something like this:

# syncthing-paths.nix
# Very much pseudo-code; note this is a home-manager module, not a NixOS one
{ config, ... }: {
  options = {
    syncthing-path = lib.mkOption { type = path };
  };

  config = {
    programs.taskwarrior.dataLocation = "${config.syncthing-path}/taskwarrior_data/task";
  };
}

We also create a generic home-manager entrypoint, which sets all host-independent configuration:

# home.nix
{
  imports = [
    syncthing-path.nix
  ];
}

You can then import this generic module both in your NixOS configuration’s entrypoint for home-manager, as well as in your home-manager config, and just set the per-host options alongside it:

# ./machines/kvm-nixos-server/configuration.nix
{
  home-manager.user."username" = {
    # This should probably go into your generic `configuration.nix`
    imports = [
      home.nix
    ];

    syncthing-path = "<some-path>";
  };
}
# generlic-linux.nix
{
  imports = [
    home.nix
  ];

  syncthing-path = "<some-path>";
}

Of course, any other host-specific config also goes into these modules. I would completely remove the hostSpec thing, and simply define the options directly in the host-specific entrypoints, liberally using the config module arg for DRY purposes.

Personally, I would even go one step further and use the syncthing config instead of defining my own option, but I’ll leave simplifying your config as an exercise to the reader.

Again, this ultimately all comes from one opinion I hold: Modules should not be used as a generic global variable dumping ground, but to define options which configure services. If you want to change one of these options for a host, it should be changed in configuration.nix (or home.nix) directly, not sourced from variables defined in a separate module with custom options. Doing so means that you don’t use the module system to merge your options, which leads you down silly paths.

1 Like

I am far from being a nix expert. and mostly work with c/cpp. But your take makes sense form a single responsibility perspective.

Although I can see some drawbacks. First of all you are creating some code duplication As you are importing the units twice you will need to define them twice(which is unlike what I have right now where I define everything once inside of my configuration.nix and then just pass it home-manger with extraSpecialArgs .

The second issue is you are binding option to existing modules this works fine for things like
syncthing-path but less so for things like like the system user name which I use across many modules.

{ lib, config, ... }:

{
  options.hostSpecification = {
    
    primeUsername = lib.mkOption {
      type = lib.types.nullOr lib.types.str;
      default = "kmedrish";
      description = "Name of prime user on this host";
    };
  };
}

Which I use inside of my nixos users

  users.users.${config.hostSpecification.primeUsername} = {
    isNormalUser = true;
    # The hash, as a string or as a file need to be sutiable for the chpasswd -e command
    # which means that at least at the moment argon2 will not work for now.
    # to create the hash and keep it out of the history
    #
    # read -s password && echo "$password" | mkpasswd -s
    #
    # By default Yescrypt is used for hasing.
    hashedPasswordFile = config.sops.secrets.initial_hashed_password.path;
    description = config.hostSpecification.primeUsername;
    extraGroups = [
      "networkmanager"
      "wheel"
    ];
    packages = with pkgs; [ ];
  };

  users.groups.${config.hostSpecification.primeUsername} = {
    members = [ config.hostSpecification.primeUsername ];
  };

  users.groups.${config.userDefinedGlobalVariables.mediaGroup} = {
    members = [ config.hostSpecification.primeUsername ];
  };

  users.groups.${config.userDefinedGlobalVariables.dataGroup} = {
    members = [ config.hostSpecification.primeUsername ];
  };

and in my home-manger level

    users.${config.hostSpecification.primeUsername} =
      import config.userDefinedGlobalVariables.homeMangerImportPath;

    sharedModules = [

      inputs.sops-nix.homeManagerModules.sops
    ];
  };

and in bunch of other places like

{ config, ... }:
{
  virtualisation.docker.enable = true;
  users.users.${config.hostSpecification.primeUsername} = {
    extraGroups = [ "docker" ];
  };

}

So you either repeat yourself multiple times with a prime user name or you create a module that is option only. And this is true out of the scope of the roadblock I came across.

You’ve misunderstood how it works if that’s your take-away. You don’t need to define the units twice, at most import them twice. Take e.g. this file tree:

- flake.nix
- nixos
  - configuration.nix
  - hosts
    - host1.nix
- home-manager
  - home.nix
  - hosts
    - generic-linux.nix

Your configuration could look like this:

# flake.nix
{
  outputs = {
    nixosConfigurations.host1 = nixosConfiguration {
      modules = [ ./nixos/hosts/host1.nix ];
    };

    homeConfigurations.generic-linux = nixosConfiguration {
      modules = [ ./home-manager/hosts/generic-linux.nix ];
    };
  };
}
# configuration.nix
{ systemUsername, flakeInputs, ... }: {
  # Generic NixOS settings and option definitions, including
  home."${systemUsername}" = import ../home-manager/home.nix { inherit systemUsername flakeInputs; };
}
# home.nix
{
  # Generic home-manager settings and option definitions
}
# host1.nix
{ systemUsername, ... }: {
  imports = [
    ../configuration.nix
  ];

  # Unsure if you can immediately access it with the fixed-point
  # eval, if this doesn't work use a `let` binding
  _module.args.systemUsername = "<some-user>";

  # host-specific settings, e.g.
  home-manager.user."${systemUsername}" = {
    syncthing-path = "${systemUsername}/<some-path>";
  };
}
# generic-linux.nix
{
  imports = [
    ../home.nix
  ];

  _module.args.systemUsername = "<some-user>";

  # host-specific settings, e.g.
  syncthing-path = "<some-path>;
}

There are no lines here that could be deduplicated without making them generic across either all home-manager installations (at which point they should go to home.nix) or NixOS installations (at which point they should go to configuration.nix.

Personally I would go a step further and make syncthing-path depend on something like config.home.homeDirectory, at which point you don’t have any host-specific settings left in this example, except for the username.

This also includes the resolution for your other issue:

Such values should be set using _module.args IMO, if having system-specific values is mandatory.

Personally I settle on a standard “main” username, though.

1 Like

Thanks! I was not aware that “_module.args” existed this solves the “global” variables such as user name that are used all over the place including inside of home-manager that is used as a nixos module. Will be testing with “home-manger” with generic Linux as well".

And then the pattern of binding specific options to configurations

# syncthing-paths.nix
# Very much pseudo-code; note this is a home-manager module, not a NixOS one
{ config, ... }: {
  options = {
    syncthing-path = lib.mkOption { type = path };
  };

  config = {
    programs.taskwarrior.dataLocation = "${config.syncthing-path}/taskwarrior_data/task";
  };
}

covers the rest of the configurations , And as you already mentioned you can always just set the specific options based on existing option(I sometimes create options with clear names for readability even if they all point to some common string.

Word of warning though, it’s typically best to avoid having variables like that as much as possible. Again, this bypasses the module system, which is really quite powerful if you understand overrides and use it appropriately. I appreciate it’s a different way of thinking coming from the C/C++ world, it certainly took me some getting used to, but it’s worth learning to use the module system idiomatically.

The “main” user’s username and flake inputs (or, more clearly, npins) are the only really appropriate uses of _module.args I’ve seen so far, at least IMO, but yeah, that’s a pretty good use.

Makes sense bind/encapsulate options with configurations. I have a very big meta.nix and host-spec.nix(My first attempt to refactor meta.nix) that I include as part of all of my hosts and it always bothered me that I just dump all of those “global variables” into hosts that don’t need them, like this one

      wallpaperName = lib.mkOption {
        default = "watchtower.png";
        type = lib.types.str;
        description = "The name of the wallpaper file";
      };

      wallpaperOut = lib.mkOption {
        default = "wallpaper/${config.userDefinedGlobalVariables.wallpaperName}";
        type = lib.types.str;
        description = "Defines the path where the wallpaper will be located";
      };

      wallpaperIn = lib.mkOption {
        default = ../wallpaper/${config.userDefinedGlobalVariables.wallpaperName};
        type = lib.types.path;
        description = "The relative path to the wallpaper file inside the repository";
      };

into a host that is a headless server. makes much more sense to bind it to my wallpaper module

{
  userDefinedGlobalVariables,
  ...
}:

{
  xdg.configFile."${userDefinedGlobalVariables.wallpaperOut}".source =
    userDefinedGlobalVariables.wallpaperIn;
}

and set the wallpaperName in home.nix if the default is needed.

Just wanted to update what I ended up doing(full code is available in my repository for reference).

When I created my original post I had a huge meta.nix file which had two responsibilities:

  • It had bunch of options that I used across all of my configurations(nixos and home-manger).
  • It had an if statement that used the host name(which was provided via flake.nix specialArgs) to assign values to some of the options in meta.nix.

I started by removing the if statement flow control from meta.nix and instead of assigning value to options inside of meta.nix I moved the assignment into each machine configuration(E.g home.nix, configuration.nix…).

The meta.nix had a lot of options which were used across all of the configurations(nixos and home-manager) regardless of the actual scope of the of such options.

  • Options that were only used inside of specific module and were intended to remove repetition inside of a module and not to be reassigned with value by higher configuration files which imported them were refactored into let-in scoped options.
{ config, pkgs, lib, ... }:

let
  serviceName = "bazarr";
in
{

    services.${serviceName} = {
      enable = true;
      openFirewall = true;
      user = "${serviceName}";
      group = "${config.customGlobalOptions.mediaGroup}";
      listenPort = 6767;
    };
}

  • Some options were removed completely, as exciting module options could be used instead directly such as most of the options that defined ports. E.g for postgresql has an option for port
    that could be assigned with a value using
services.postgresql.settings.port = <value>

And then the same option can be used to get the current value

config.services.postgresql.settings.port
  • I have renamed the “namespace” under which my custom options existed into several different “namespaces”(customOptions, customGlobalOptions…) to better convey their intent.

  • I have left some options in my original meta.nix(that I renamed) as putting them into flake.nix as specialArgs and extraSpecialArgs felt wrong.

  • Multiple options were moved into the scope of the module, as a way for higher configuration file to assign them value(E.g syncthing.nix).

  • As an effort to refactor my configuration into the “import everything and enable” pattern many options were wrapped inside mkEnableOption option.

{ config, lib, ... }:

let
  cfg = config.customOptions.enableModule.sshd;
in
{
  options.customOptions.enableModule.sshd = lib.mkEnableOption "Enable OpenSSH service";

  config = lib.mkIf cfg {
    # Enable the OpenSSH daemon.
    services.openssh = {
      enable = true;
      settings = {
        PasswordAuthentication = false;
        PermitRootLogin = "no";
      };
    };
  };
}