Advice on Multi-System flake configuration

Hello, I’m new to NixOS and have been trying it out on an old laptop before I make the switch on my main laptop.

I have three devices I want to have similar configurations on:

  • Old laptop (craptop, in my config, linked below for reference)
  • Main laptop
  • A PC

The old laptop and main laptop I’m going to configure alike (other than hardware and disk), but my PC has some very different requirements (mostly drivers).

I have separated my configuration into modules so I can choose which modules to include in their respective nixos/hosts/<host>/configuration.nix.

However, I’m not sure how to structure my flake.nix so I don’t just have a load of repeated code for each host, and I also specify the hostname in each hosts configuration.nix, this is a lot of repetition of the same stuff and my programming instincts tell me it is wrong and there must be a better way.

{
  description = "NixOS Config";

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

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

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

  outputs = {
    self,
    nixpkgs,
    nixpkgs-unstable,
    disko,
    home-manager,
    ...
  } @ inputs: let
    system = "x86_64-linux";
    pkgs-unstable = nixpkgs-unstable.legacyPackages.${system};
  in {
    nixosConfigurations.craptop = nixpkgs.lib.nixosSystem {
      inherit system;
      specialArgs = {
        inherit inputs;
        inherit pkgs-unstable;
      };
      modules = [
        disko.nixosModules.disko
        ./hosts/craptop/configuration.nix
        ./hosts/craptop/disk-config.nix
      ];
    };

    homeConfigurations.pete = home-manager.lib.homeManagerConfiguration {
      pkgs = nixpkgs.legacyPackages.${system};
      modules = [../home-manager/home.nix];
    };
  };
}

Additionally, in my home-manager config, I set things up to use my dotfiles, but depending on the system they shouldn’t be configured. For example, on my PC with proprietary NVidia drivers, there’s no point in configuring sway when I can’t even use it. Obviously this isn’t that big of a deal because those configurations are always there, but if anyone does have a better way that combines the home-manager config with the rest of my config nicely, that would be superb.

To summarise (sorry it is a relatively long post for a relatively silly question):

  • How can I configure multiple systems with the same structure but without repeating code or definitions like the hostname?
  • How can I set up my home-manager so that it isn’t completely separate from my system configuration such that it only configures software it needs to?

Any help appreciated (even if you spot something in my config that isn’t related but is objectively bad) :slight_smile:

I would recommend moving any universally required configurations out of ./hosts/<...> to separate directories, and importing them as modules.

How you structure your configuration around that idea is really just a matter of personal preference (or more realistically pedantry), but the approach I took would result in something like this:

  # ...
  nixosConfigurations = {
    "craptop" = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        home-manager.nixosModules.home-manager
        # Hardware-specific configuration.
        ./hosts/craptop
        # Base system packages.
        ./common/nixos/system/base
        # Additional system packages (in bundles categorized by purposes).
        ./common/nixos/system/media
        ./common/nixos/system/documents
        # Users available for a given config (see snippet below).
        ./users/foo
      ];
    };
    "powefulcomputer" = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        home-manager.nixosModules.home-manager
        ./hosts/powefulcomputer
        # These two imports are shared between both configurations.
        ./common/nixos/system/base
        ./common/nixos/system/media
        # Configure heavier applications on hosts that can handle them.
        ./common/nixos/system/video_editing
        # In case you want the same user on both hosts.
        ./users/foo
      ];
    };
  };
  # ...

When it comes to avoiding repeating values (like hostnames), I wouldn’t obsess too much over it, but if you’d like to, you could make good use of let ins and inherits:

# ./users/foo/default.nix
let 
  userInfo = {
    accountName = "user";
    accountGroups = [
      "users"
      "wheel"
    ];
  }
in
{
  # NixOS user config.
  users.users = {
    "${userInfo.accountName}" = {
      description = userInfo.fullName;
      extraGroups = userInfo.accountGroups;
    };
  };
  # Home Manager user config.
  home-manager = {
    users = {
      # The entry point to this user's Home Manager configurations.
      # Each set written there will be assigned to `home-manager.users.<username>`, 
      # making it local to that user.
      # You may also pass `userInfo` as an argument to use it there too!
      "${userInfo.accountName}" = import ./home.nix { inherit userInfo; };
    };
    useGlobalPkgs = true;
    useUserPackages = true;
  };
  # ...
}

You can see my full configuration here, although do be mindful that I haven’t gotten around to writing too much documentation there yet. If there is something unclear or bad there, I’d love to read your feedback via issue tickets or replies.

2 Likes

Ooh I like the putting modules into the flake.nix itself, a little more central and separates hardware from software config nicely.

I’ll have to look at the way you manage users and home-manager because I’m not entirely happy with what I currently do. Thanks for your help :slight_smile:

It does also result in a lot of overhead in a file whose intent is to primarily be metadata. Personally I would use the per-host entrypoints to import the other modules, e.g.:

# ./hosts/craptop/configuration.nix
{
  imports = [
    ../../generic.nix
    ./disk-config.nix
    ./hardware-configuration.nix
  ];
}
# ./generic.nix
{ pkgs, inputs, ... }: {
  imports = [
    inputs.disko.nixosModules.disko
  ];

  _module.args.pkgs-unstable = inputs.nixpkgs-unstable.legacyPackages.${pkgs.stdenv.hostPlatform.system};
}
# flake.nix
{
  outputs = {
    self,
    nixpkgs,
    ...
  } @ inputs: {
    nixosConfigurations.craptop = nixpkgs.lib.nixosSystem {
      specialArgs = {
        inherit inputs;
      };
      modules = [
        ./hosts/craptop/configuration.nix
      ];
    };
  };
}

If you’d like, this then also lets you DRY out the rest of the boilerplate completely, and replace it with a directory walk:

# flake.nix
{
  outputs = {
    self,
    nixpkgs,
    ...
  } @ inputs: {
    nixosConfigurations = let
      hostNames = nixpkgs.lib.pipe (builtins.readDir ./hosts) [
        (nixpkgs.lib.filterAttrs (_: type: type == "directory")
        nixpkgs.lib.attrNames
      ];
    in
      nixpkgs.lib.listToAttrs (map (name: {
        inherit name;
        value = nixpkgs.lib.nixosSystem {
          specialArgs = { inherit inputs; };
          modules = [ (./hosts + "./${name}/configuration.nix") ];
        };
      }) hostNames);
  };
}

Et voila, we’ve replaced all our entrypoints with a bit of boilerplatey function calling. Stick it in a separate project and call it a framework, if that pleases you. YMMV, all that code was written on my phone with no nix binary in sight to check even just syntax.

It does come down to personal preference which route you take. The other approach has the benefit of telling you at a glance in flake.nix what each system consists of, if your file naming is up to scratch, while this approach revolves more around the per-host entrypoints telling you what the hosts are about.

I think @username-generic 's approach is nicer for cattle-style deployments, mine more for the type of varied pet systems I deploy. The difference is mostly that with the former you end up coding in a deeply-nested structure, which is fine for less divergent hosts, but a royal PITA if you have a lot of modules.

My approach is also more resistant to upcoming changes to flakes, since all the actual meaningful code remains in the module system; it effectively just uses flakes for input management (which IMO is the best way to use them for NixOS). Makes you less hopelessly bought-in in case you want to switch to npins or something one day.


Some additional notes that are less high-level, I’ve already put them into action up there:

  • The system arg is deprecated, don’t specify it here, use hardware-configuration.nix

    • To hand a system to your pkgs-unstable, do that inside your modules through pkgs.stdenv.hostPlatform.system instead of hard-coding it on the flake level. This way you don’t need to repeat that, either.
  • You can also put your disko.nixosModules.disko inside a shared configuration.nix’s imports; there’s no reason to repeat that with every lib.nixosSystem.

  • Personally, I’d avoid using let ... in as @username-generic suggests, because this makes those values bound opaquely, and inaccessible to the module system. You can use config. references instead.

    I concur however that you shouldn’t take DRY too far; its benefits are mainly preventing mistake-by-copy-paste stuff and drift over time; weigh the need for that by likekihood of such issues. Likely very minimal for a personal config with one contributor, and more complex projects (in terms of contribution complexity) almost certainly won’t have a need for the type of deduplication you’d employ.

3 Likes

How can I configure multiple systems with the same structure but without repeating code or definitions like the hostname?

You can make your own lib function that returns a nixosSystem exactly the way you want it. That’s what I did in my own modules system. You can, for example, make it so that the hostname is just a function argument specified once then the return value is an attribute set that maps that given hostname to the create nixosSystem value. Anything really.

How can I set up my home-manager so that it isn’t completely separate from my system configuration such that it only configures software it needs to?

What worked for me was to make an additional ‘profile’ abstraction (naming things are hard :confused:). But it’s basically just a collection of pre-set module options, which by itself is also a module.

Once you’ve captured all your home-manager config as a module, then it’s just a matter of embedding this in a NixOS config (so you’ve to run home-manager as a NixOS module). Another reason I do it this way, is so that I can still use home-manager standalone on external systems (e.g. a remote server).

That said, the question of whether packages should be managed by home-manager or NixOS does not always have a clean answer. It’s up to you to decide how strong this separation should be.

2 Likes

If you check out my actual config, you’ll notice I separate nixosConfigurations to a separate outputs.nix file. I didn’t mention it here since it wasn’t relevant to the question, but I’m also unsure if this kind of separation would topple your “resistance” concern, so feel free to elaborate.

As per the “cattle-style deployments”, firstly: how dare you? But being serious, I think my approach can work just fine, especially given that I try to add all.nix bundle files wherever appropriate, so that you can easily import all the basics you will probably want to use, with the option to cherry pick some specific configs (for those “varied pets”) by making alternate bundles (and by “bundles” I mean Nix files that contain nothing but imports = [ ... ] of configs in sub-directories). It can get a little nested, but I think I’ve done a decent job at not making it ridiculous (or so I hope).

Good notes on the system arg, and with that let ... in advice, though!

I don’t disagree at all; your approach can work just fine, it’s just better with cattle (you are familiar with that terminology, right? If anything “pets” is the derogatory term). In fact, my current config looks similar, though I intend to fix that sometime.

The ability to spot differences between deplyoment targets from the metadata file is a nice benefit for cattle deployments, and since the hosts are unlikely to diverge much the file doesn’t get unwieldy in the process.

For pet-style deployments the benefit is much diminished (since the hosts totally diverge anyway), and the metadata file gets unwieldy if there is too much inside it. Sure, you can make bigger individual modules, but that only works to a point, and results in more awkard code at the module level.

Take your repo for example; with pets, it’s anyway obvious if a host is a desktop or a server, and all the other modules are just copy-pasted boilerplate between both and serve no expressive purpose. You gain nothing from bubbling up the module imports into outputs.nix, except ~20 lines of code that make the outputs.nix harder to view on one editor page.

That’s not to say it is bad, I just think it makes less sense for pets than for cattle. If I had servers deployed to different AWS regions that import 3 modules at most, but I need to remind myself which is which before deployment from time to time, this would be way more sensible than hiding everything behind configuration.nix.

At the end of the day, either approach will work and be quite clean. A hybrid works too, though it can’t be DRY’d out quite as much. It’s a matter of taste, I just think there are these subtle trade-offs.

This in turn I quite strongly disagree with. I don’t think you understood the limitations of flake.nix at all - they do not apply to what goes into outputs.

Splitting out an outputs.nix is completely pointless IMO, if not actively harmful since it makes the metadata file contain none of the metadata it should.

It has no impact on that “resistance” point at all, since you’re still encoding everything in flake.nix (though you’re doing so with some indirection) in a way that would be incompatible with a toml-based flake, or anything else that would change how nixosConfigurations is defined. At small scales, that point hardly matters at all anyway, though.

I didn’t know it was an actual term, I had a rough idea of what you meant by it, but regardless, I just found it funny. No offense taken, don’t worry.

You’ve compared the two approaches quite clearly, I must say.

So what you mean by this is I should move nixosConfigurations back into flake.nix, and make it import singular entry points (that would end up being what I earlier defined as a “bundles”) for each configuration? Sounds reasonable.

This example would vary a little from your earlier snippet, but it would fit my preferred structure style:

flake.nix # Defines inputs, and imports compositions as entry points.
|-- hardware # Contains only hardware-specific configurations.
|   |-- hosts
|       |-- <hostname>
|-- compositions # Contains bundles of hardware and system configurations.
|   |-- <hostname>
# ...

Also, is there a reason for this concatenation from your last snippet?

          modules = [ (./hosts + "./${name}/configuration.nix") ];

That’s reasonable if your “bundles” are very large, but I’d personally just put everything directly in flake.nix. You only have two, pretty small nixosConfigurations, no real need to break that out.

That said, if you do want to break that out, why not commit to having a single configuration.nix for each host, which in turn uses imports to import all the other modules, i.e. use my approach? You don’t gain much from having an intermediate between the module system and flake entrypoint at that point.

Yes, builtins.readFile gives you the filenames in ./hosts, not the paths. Concatenating a string to a path type in turn gives you a path type and resolves this correctly.

Theoretically you could use just a string (i.e. "./hosts/${name}/configuration.nix"), but using paths gives slightly nicer error messages for unreadable files and such, since nix processes them rather than the module system.

1 Like

I think we are on the same page, but perhaps I could’ve worded myself better.

Since you can have multiple hostnames on a single machine (by dualbooting, for example), I wanted to have hardware/hosts/... contain only hardware-specific configuration, so no stuff like users, software, etc (I could probably rename the directory to hardware/machines/... to make its purpose more clear).

The directory compositions/... would contain the same bundles of modules which are currently declared in my config’s outputs.nix file. An example composition would look like this:

# compositions/craptop/default.nix
{ inputs, ...}:
{
  imports = [
    inputs.home-manager.nixosModules.home-manager
    ../hosts/machines/
    ../common/nixos/system/base
    ../users/foo
  ];
  networking.hostName = "craptop";
}

And flake.nix would look just like yours, but with the modules containing:

(./compositions + "./${name}")

I guess it would also make sense to pass the name via specialArgs, so that the composition could access it when defining networking.hostName (I’m unsure if it would be possible to read the name from config, at that point).

Thank you for all the advice, this bit seems especially nice, only had to make small edits to get it working.

Oh, cool. I was wondering why I had to specify this having watched tutorials. It made more sense to have it in hardware-configuration.nix.

Is that what you’ve done with the generic.nix thing here?

This is neat, I haven’t done any functional programming in about two years and that was Haskell, so I don’t really know all the lib functions and haven’t seen the rec syntax either. I don’t know if this is how I’m going to end up doing stuff on my systems but it might come in handy.

I’ll have to have a look, your config is pretty big, do you have a minimal like example? Doesn’t need to be 100% fully fleshed, just want to know how that sort of works.

I’m also not fully committed to NixOS yet, can you use home-manager or parts of a config on a non-NixOS system? Might also be handy if I ever can’t use NixOS for whatever reason (work) but still want all my stuff configured.

All I have to figure out now is how I’m going to actually do my home-manager stuff in a sensible way for me. I think I might just need to wrap my head around the syntax and semantics of Nix so I know what is possible and what isn’t.

I just remembered I also use a system string for setting up home-manager:

    homeConfigurations.pete = home-manager.lib.homeManagerConfiguration {
      pkgs = nixpkgs.legacyPackages."x86_64-linux"; # TODO: fix
      modules = [../home-manager/home.nix];
    };

I’m probably going to refactor my whole home-manager set up because it is far from ideal, but how can I avoid this?

If you’re using home-manager standalone, you cannot. The architecture of the system you’re deploying to must be defined somewhere, and home-manager doesn’t currently have a way to defer that to the module system.

That said, you seem to be working on a NixOS configuration. IMO using home-manager standalone on NixOS is usually wrong.

Instead of specifying homeConfigurations in flake.nix, I’d recommend using home-manager’s NixOS module (though the sample config there is misleading).

In your case, that would mean adding to generic.nix:

# ./generic.nix
{ pkgs, inputs, ... }: {
  imports = [
    inputs.disko.nixosModules.disko
    inputs.home-manager.nixosModules.home-manager
  ];

  _module.args.pkgs-unstable = inputs.nixpkgs-unstable.legacyPackages.${pkgs.stdenv.hostPlatform.system};

  home-manager = {
    extraSpecialArgs = { inherit inputs; };
    useGlobalPkgs = true;
    useUserPackages = true;
    users.pete = ../home-manager/home.nix;
  };
}

This way you both get to install your home-manager config and NixOS config with one command instead of two, while also no longer having to specify the system (since home-manager inherits the correct system from NixOS).

You’ll want to remove the home.homeDirectory and home.username settings from your home.nix, though.

Of course, if you ever end up wanting to deploy your home-manager config to a non-NixOS system in the future, you’ll still have to specify the system, but at that point it won’t be redundant anymore.

1 Like

Thank you, though, for whatever reason, when I make this change disko spits out a load of evaluation warnings and I don’t think they apply?

> Building NixOS configuration
warning: Git tree '/home/pete/dotfiles' is dirty
evaluation warning: The legacy table is outdated and should not be used. We recommend using the gpt type instead.
                    Please note that certain features, such as the test framework, may not function properly with the legacy table type.
                    If you encounter errors similar to:
                    "error: The option `disko.devices.disk.disk1.content.partitions."[definition 1-entry 1]".content._config` is read-only, but it's set multiple times,"
                    this is likely due to the use of the legacy table type.
                    for a migration you can follow the guide at https://github.com/nix-community/disko/blob/00395d188e3594a1507f214a2f15d4ce5c07cb28/docs/table-to-gpt.md
evaluation warning: the create output is deprecated and will be removed, please open an issue if you're using it!
evaluation warning: The legacy table is outdated and should not be used. We recommend using the gpt type instead.
                    Please note that certain features, such as the test framework, may not function properly with the legacy table type.
                    If you encounter errors similar to:
                    "error: The option `disko.devices.disk.disk1.content.partitions."[definition 1-entry 1]".content._config` is read-only, but it's set multiple times,"
                    this is likely due to the use of the legacy table type.
                    for a migration you can follow the guide at https://github.com/nix-community/disko/blob/00395d188e3594a1507f214a2f15d4ce5c07cb28/docs/table-to-gpt.md
evaluation warning: the mount output is deprecated and will be removed, please open an issue if you're using it!
evaluation warning: The legacy table is outdated and should not be used. We recommend using the gpt type instead.
                    Please note that certain features, such as the test framework, may not function properly with the legacy table type.
                    If you encounter errors similar to:
                    "error: The option `disko.devices.disk.disk1.content.partitions."[definition 1-entry 1]".content._config` is read-only, but it's set multiple times,"
                    this is likely due to the use of the legacy table type.
                    for a migration you can follow the guide at https://github.com/nix-community/disko/blob/00395d188e3594a1507f214a2f15d4ce5c07cb28/docs/table-to-gpt.md

I checked the docs it links to and it doesn’t apply. I didn’t get these warnings before, and this is all I’ve changed:

diff --git a/home-manager/home.nix b/home-manager/home.nix
index 2db46f0..26ec4db 100644
--- a/home-manager/home.nix
+++ b/home-manager/home.nix
@@ -9,8 +9,6 @@
   };
 
   home = {
-    username = "pete";
-    homeDirectory = "/home/pete";
     stateVersion = "25.11";
 
     file = {
diff --git a/nixos/flake.nix b/nixos/flake.nix
index 8a27e71..1e4ee9f 100644
--- a/nixos/flake.nix
+++ b/nixos/flake.nix
@@ -35,10 +35,5 @@
           };
         })
         hostNames);
-
-    homeConfigurations.pete = home-manager.lib.homeManagerConfiguration {
-      pkgs = nixpkgs.legacyPackages."x86_64-linux"; # TODO: fix
-      modules = [../home-manager/home.nix];
-    };
   };
 }
diff --git a/nixos/generic.nix b/nixos/generic.nix
index 00518fe..4e138c6 100644
--- a/nixos/generic.nix
+++ b/nixos/generic.nix
@@ -8,4 +8,11 @@
   ];
 
   _module.args.pkgs-unstable = inputs.nixpkgs-unstable.legacyPackages.${pkgs.stdenv.hostPlatform.system};
+
+  home-manager = {
+    extraSpecialArgs = {inherit inputs;};
+    useGlobalPkgs = true;
+    useUserPackages = true;
+    users.pete = ../home-manager/home.nix;
+  };
 }
diff --git a/nixos/modules/sw/fish.nix b/nixos/modules/sw/fish.nix
index bfd03c1..0ff0a0b 100644
--- a/nixos/modules/sw/fish.nix
+++ b/nixos/modules/sw/fish.nix
@@ -23,7 +23,6 @@
     '';
     shellAliases = {
       sw = "NH_OS_FLAKE=${flakePath} nh os switch";
-      hms = "NH_HOME_FLAKE=${flakePath} nh home switch";
     };
   };

Are you sure, that you did not update your inputs inbetween?

Even though the warning seems to exist for about a year at least…

I have auto-updates on so it is possible, but I stashed those changes and rebuilt and the warnings went away.

here is an example GitHub - adomixaszvers/dotfiles-nix

I used this and other public repos to make my own config for my machines

Wrong, how?

My background, if that matters for the answer: I’m managing 14 (home) servers/workstations/laptops/SBCs/phone from a single /etc/nixos/flake.nix, and on these systems 15 user configurations in a single standalone ~/.config/home-manager/flake.nix

The user homedirectories are encrypted which makes the NixOS home-manager not an option for me, but I’m still curious for your insights.

I tend to write advice for newcomers.

For a newcomer, using home-manager standalone means maintaining two profiles, when they don’t really understand the concept of profiles yet.

This typically results in half-updated systems and general confusion. Explaining this is an option, of course, but you’ll run into limited attention spans; By the time someone comes to ask for help here they’re already mildly annoyed and really need help with something else, adding another yak to shave rarely helps keep them interested enough to continue.

Even if holding a newcomers’ hand through dual-profile setups works, eventually they’ll come back with complaints about having to run multiple commands (or attempt scripting a solution to that themselves), or maybe the nixpkgs instances don’t align and create lots of disk churn, or they end up wanting an osConfig that cannot exist in the standalone version, etc. etc.

Of course there are use cases where using home-manager standalone makes sense, or where it is even the only option, but adding that nuance just competes for the readers’ attention span.

But, yes, to be clear, IME it’s almost always wrong for a newcomer on a single-user machine who is just dipping their toes into NixOS. If you know what you’re doing, carry on :wink:

1 Like

That is fair. And yes your care for newcomers and others here is obvious from your many posts, and much appreciated!

1 Like