How do you structure your NixOS configs?

Hey everyone! I’ve been on NixOS for a few months now and I’m trying to figure out the best way to organize my config. I want something that’s simple to understand but can grow with my setup.

So far I’ve landed on this structure:

├── flake.nix
├── core/                    # System-wide common
│   ├── boot.nix
│   ├── fonts.nix
│   ├── packages.nix
│   └── ...
├── home/                    # Home Manager common
│   ├── hyprland/
│   ├── scripts/
│   ├── firefox.nix
│   ├── nvf.nix
│   └── ...
└── hosts/
    ├── default/
    │   ├── default.nix
    │   ├── users.nix
    │   ├── hardware.nix
    │   └── ...
    └── ...

My flake.nix is pretty straightforward:

{
  outputs = { nixpkgs, ... }@inputs:
  let
    system = "x86_64-linux";
    host = "alex";
    username = "alex";
  in {
    nixosConfigurations = {
      ${host} = nixpkgs.lib.nixosSystem {
        inherit system;
        specialArgs = { inherit inputs username host; };
        modules = [ ./hosts/${host} ];
      };
    };
  };
}

Then each host pulls in the core system modules and sets up Home Manager:

# hosts/default/default.nix
{
  imports = [
    ./users.nix
    ./drivers.nix  
    ./hardware.nix
    ./host-packages.nix
  ];
}

# hosts/default/users.nix  
{
  imports = [
    ./../../core              # All system modules
    inputs.home-manager.nixosModules.home-manager
  ];
  
  home-manager = {
    useUserPackages = true;
    useGlobalPkgs = true;
    users.${username} = {
      imports = [ ./../../home ];  # All HM configs
      home.stateVersion = "24.11";
    };
  };
  
  users.users.${username} = {
    isNormalUser = true;
    extraGroups = [ "wheel" "networkmanager" /* ... */ ];
  };
}

The idea is that core/ has all the system-level common stuff, home/ has all common home-mgr configs, and each host just have this plus any host-specific tweaks.

I’m pretty happy with how clean this feels, but I’m still relatively new to NixOS so I’m curious:

  • How do you organize your configs?
  • Any obvious pitfalls with this approach?
  • Is splitting core modules this granularly worth it, or overkill?

Would love to hear how others structure their setups!
Full config if anyone curious

1 Like

This is how I handle mine:

The Flake defines the inputs (nixpkgs and others) and the configuration.nix and home.nix are imported as modules into the flake.

I followed this pretty much:

1 Like

This article is awesome. Now i’m sure that if i will need to tweak some shared config for specific host - value priorities will do the trick without need to adjust configs for other hosts.

I just use the host method to set the hostname per systems and certain applications. For example I install the keyboard-configurator from system76 for my system76 laptop and desktop but I just need steam on the desktop as I don’t game on the laptop.

I generally keep most things the same between everything in the Flake and Config nix files including the Home nix as well.

Like that: Refactoring My Infrastructure As Code Configurations | Not a Number

It’s using the Dendritic pattern!

3 Likes

I’m also using an Dendritic setup on my vix repo. I’ve added some words on it about how everything is wired in order to help other people move to dendritic pattern. @drupol’s blog post is very good explanation of the dendritic advantages. I’m also extracting generic enough parts from my configs to dennix, community-shared dendritic configurations.

I spent some time to understand the Dendritic patten but I feel lost. I don’t get it. Would be great with an ELI5, both how it works but also what its advantages are.

There are different “Dendritic” implementations (at least four that I know of) because the Dendritic pattern is not about having a rigid directory/file structure that mandates where to place your files - unlike other configuration libs/frameworks. That’s because Dendritic is more a pattern - the dendritic repo does not even have nix code, it just tries to explain things and maybe it not still that clear, I’ll try to expand here and you tell me if this helps we can add it to the documentation-.

And the pattern is this: Each ./modules/**/*.nix file is a flake-parts module and each file can contribute to different nix module classes at the same time by using flake-parts-modules , eg:

# modules/terminal.nix -- the important thing here is flake-parts' modules:
{
  flake.modules.nixos.myhost = {pkgs, ...}: {
     environment.systemPackages = [ pkgs.ghostty ];
  }; 

  flake.modules.darwin.myhost = {pkgs, ...}: {
     environment.systemPackages = [ pkgs.iterm2 ];
  };
}

The important thing here is, instead of having top-level files be an nixos-module or an nix-darwin-module or an home-manager-module, etc. you have a single feature flake-parts module like the one shown above that itself adds configurations to your flake modules.

# modules/vic.nix -- configures os-level vic user
let
  userName = "vic"; # a binding just to show another dendritic advantage: 
  # you use let bindings directly across different module classes, instead of having
  # to polute nixos config specialArgs to propagate values accross module classes.
in
{
  flake.modules.nixos.myhost = {
     users.users.${userName} = { isNormalUser = true; extraGroups = [ "wheel" ]; };
  };

  flake.modules.darwin.myhost = {
     system.primaryUser = userName;
  };

  flake.modules.homeManager.${userName} =
    { pkgs, lib, ... }:
    {
      home.username = lib.mkDefault userName;
      home.homeDirectory = lib.mkDefault (if pkgs.stdenvNoCC.isDarwin then "/Users/${userName}" else "/home/${userName}");
      home.stateVersion = lib.mkDefault "25.05";
    };
}

Notice how configuring the os-level user is different if you are on nixos or nix-darwin, but this single vic.nix file is a cross-cutting concern (the concern of configuring the vic user) that contributes to both and also to an home-manager module. You will also have other files (eg, modules/vic/secrets.nix) that also contributes to flake.modules.homeManager.vic but it appends to that module another concern!

That’s the basic idea behind the pattern. How you organize your files is free to you, but since each file is mostly safe contained you can move it around and refactor easily large systems configurations. File location does not matter since they are all loaded by import-tree (or similar).

So each file contributes to some flake.modules.<class>.<name> of your liking, how you split and organize modules is up to you.

Then you finally import those flake.modules.<class>.<name> on their respective nixos/darwin configurations (how you wire things up is not defined by the pattern), I for example have a osConfigurations.nix that is itself a flake-module, and it exposes my flake hosts and loads root modules on them.

1 Like

Also looked at setups and article about Dendritic pattern and feels too complex for me.

BTW, as I understand it, when adding a new host that will be little different from the previous ones, you need to add it in each feature module?

1 Like

no, I used flake.modules.nixos.myhost as an example (mainly to make a point about the different nix config classes, modules.nixos and modules.darwin). However, people actually define modules like: modules.nixos.laptop, modules.nixos.ai and then mix them up as needed on a single modules.nixos.myhost so, adding more hosts just touches one file (where you define that host and you include reusable concerns from other modules). Again, there’s no rule imposed on how you name your modules and what they represent to you. Dendritic is only about cross-cutting concerns across different nix config classes

example from one of my 11 hosts

This image from @drupol’s blog post illustrates this, different “feature” modules get mixed into each single host module:

2 Likes

This was a very good explanation, it feels more clear what it is now. Thank you!

1 Like

I am a big fan of GitHub - numtide/blueprint: Standard folder structure for Nix projects

1 Like

I also concur. You should upstream this into the dendritic repo explanation

It’s like any cross cutting pattern (aspects from Java!). You get focused code but also sometimes having it all in a single file is best.

I haven’t tried it yet but it does seem like you have to then toggle it on for each individual host rather than relying on common imports to just work it all out ?

thusly:

configuration.nix
flake.nix
hardware-configuration.nix

10k lines for all occasions in one file baby, oh yeah, i just dont care :stuck_out_tongue: pls dont judge am noob :yum:

3 Likes

I’m seeing that is a common misconception. I’ll try to clarify how it all depends on you designing your modules to compose effectively for you.

Imagine I have many hosts (I do have a home-lab of 11 hosts). If I want a common feature (say my ssh-keys setup) across all of them, then I’d create a generic ssh feature - on nixos it would add openssh, and some firewall rules. on darwin it would enable macos ssh server. on homeManager it would setup .ssh/config or keys -.

This ssh feature is just an example of something I’d like to be present across all my home-lab hosts.

Then I would add another more global feature: home-lab

{inputs, ...}:
{
  # this base module will be included in each host of mine, having a single point
  # where to include common configs across all my 11 hosts.
  flake.modules.nixos.home-lab.imports = with inputs.self.modules.nixos [
     ssh # our example feature
     # ... other modules that will always be part of any host
     home-lab-fw # firewall rules to participate on home-lab network
     vic-user # include my user on all of them
     etc-etc
  ];
}

And you are right that this single nixos.home-lab module should be included in all hosts. How you do that, is not mandated by the pattern, I do it by having a single abstraction that instantiates my nixos hosts.

  mkNixos =
    system: cls: name:
    inputs.nixpkgs.lib.nixosSystem {
      inherit system;
      modules = [
        inputs.self.modules.nixos.${cls}
        inputs.self.modules.nixos.${name}
        inputs.self.modules.nixos.${system}
        {
          networking.hostName = lib.mkDefault name;
          nixpkgs.hostPlatform = lib.mkDefault system;
          system.stateVersion = "25.05";
        }
      ];
    };

The important bit there is cls, for me that is the top-level aspect (or feature) that I want included on all hosts of that class, in our example this could be home-lab since home-lab already includes all other modules. Notice my mkNixos function just includes three modules modules.nixos.${cls} (eg, our home-lab example), modules.nixos.${hostname} so that we also have a module that contributes specific features for that host, and modules.nixos.${system} currently empty but could be common configs per arch.

At the end of the day, you DO have common imports you pass into nixpkgs.lib.nixosSystem.modules, and that’s all.

The dendritic pattern does not mandate anything about how you instantiate your hosts or which modules you include or how many files you touch if adding a global feature. We (people already using the Dendritic pattern) are still exploring and finding out better ways to do things. I’m quite happy with my mkNixos function and has served well for me.

I’m currently trying to move common shareable features like my mkNixos function (and many other reusable modules) for other people to reuse on their dendritic setups.

Hope this has helped clarify the issue about having common imports. Having common imports is actually the point of using such dendritic setups. :slight_smile:

Will do :slight_smile: Maybe we should follows these Dendritic specific questions on another thread to avoid hijacking this thread.

@vic Have you tried the unify framework ? Pretty sure you’ll like it.

I looked at it yesterday (thanks to you pointing me to it again). I really like it! It has improved a lot since the last time I checked the source. (No more string tags, and it has default -nameless- modules and now multi-user support). I’d also like to explore having a branch of my vix repo using Unify, so I guess I’ll contribute darwin support to Unify and maybe other issues on design.

But yeah, I’ve been keeping an eye on it and hope to try it soon at least on my nixos hosts.

I am currently evaluating it since a couple of weeks now, I have a branch where you can see what I did to switch from the basic dendritic pattern to Unify at refactor: use `unify` framework by drupol · Pull Request #84 · drupol/infra · GitHub (the ?w=1 is to avoid showing whitespaces in the diff).

I like your current structure. simple enough but works. I had the similar structure( just added more folders for overlays/pkgs etc. ) GitHub - kaleocheng/nix-dots: the boilerplate of my Nix dotfiles

1 Like

How many users and hosts you have?
One? Overkill for sure
Two? Still overkill (I think)

This is simple.

├── flake.nix  # ugly inside, but simple outside

This is less simple but common for historical reasons, for most cases simple to understand.

├── flake.nix  # dependency managenment
├── configuration.nix  # your configuration
├── hardware.nix  # auto generated file you never touched

You will gain 10lbs - nix has this effect on people, we don’t know why - but your body isn’t adding some extra inches before you have it. Knowing that, you can buy larger clothes already, but not something like 4x larger. Wait until your current ones, doesn’t fit anymore.

That won’t mean you can’t have a rice, but have you ever wonder how to make it so light that your future self won’t hate you?

{
  outputs = inputs: {
    nixosConfigurations.alex = inputs.nixpkgs.lib.nixosSystem {
      system  = "x86_64-linux";
      modules = [ ./configuration.nix ];  # user and host is alex for sure
      specialArgs.inputs = inputs; # but ./host/alex can set specialArgs too like:
                                   # _module.args.username = "alex"; this var won't work in paths
    };
  };
}

Now you got married to Alexandra aka AlexaAntonik

{
  outputs = inputs: rec { # rec or use inputs.self.lib.os
    nixosConfigurations.alex  = lib.os ./alex;
    nixosConfigurations.alexa = lib.os ./alexa;
    lib.os = host: inputs.nixpkgs.lib.nixosSystem {
      system  = "x86_64-linux";
      modules = [ host ];
      specialArgs.inputs = inputs;
    };
  };
}

Then you adopt a cat named Alexis that for some reason is also user in both hosts

{
  outputs = inputs: rec {
    nixosConfigurations.alex  = lib.os [ ./hosts/alex  ./users/alex  ./users/alexis ];
    nixosConfigurations.alexa = lib.os [ ./hosts/alexa ./users/alexa ./users/alexis ];
    lib.os = modules: inputs.nixpkgs.lib.nixosSystem {
      system  = "x86_64-linux";
      modules = modules;
      specialArgs.inputs = inputs;
    };
  };
}

One day your cat found another house with better food and won’t come back anymore, what would you do?

2 Likes