Disclaimer: This post doesn’t claim to be the most idiomatic approach or superior to others. Ultimately, the best approach is the one that works for you. I simply want to share my experience, and I’d be very happy if this helps anyone facing similar challenges. I’d greatly appreciate feedback and recommendations from the community.
I started exploring the Nix ecosystem about a year ago, continuously improving (hopefully) and refactoring my configuration. Initially, I used a fairly straightforward config structure:
├── system/ # Common NixOS modules
│ ├── nixosModule1.nix
│ └── ...
├── home/ # Common Home Manager modules
│ ├── homeModule1.nix
│ └── ...
├── hosts/ # Host-specific configurations
│ ├── host1/
│ │ ├── default.nix # Imports needed home/nixos modules
│ │ └── ... # Host-specific files
│ └── ...
└── flake.nix
However, after some time I noticed a significant drawback to this approach. Configuring certain features requires two separate files—one Home Manager module and one NixOS module—and they’re imported in different places. Often it’s just a single option or tweak needed for a feature, but it still requires creating a separate module and importing it, which feels unnecessarily verbose and tedious.
The most obvious solution was to use the dendrite approach (also known as the “dendritic” or modular flake structure). Here are two good articles describing it: [link1, link2]. However, after studying this approach more deeply and rewriting parts of my config with it, I realized that it made my configuration more complex, with complexity growing disproportionately to my actual needs. Additionally, the two extra indentation levels in every file were visually jarring. Furthermore, while importing by module attributes seems more architecturally correct, for me personally (someone who navigates by fuzzy-finding file names), this adds yet another layer of abstraction that forces me to fuzzy-find the module name itself rather than the file, which is less efficient for my workflow. In my opinion, the most common current implementation of the dendrite approach using flake-parts brings more benefits to larger, more complex projects.
Based on all this, through several iterations, I believe I’ve arrived at a happy medium for my dotfiles, taking the best from both approaches (full dendrite with flake-parts and the classical host-centric NixOS configuration).
Now my dotfiles have the following structure:
├── modules/ # Common nixos/home/mixed modules
│ ├── nixosModule.nix
│ ├── homeModule.nix
│ ├── mixedModule.nix
│ └── ...
├── hosts/ # Host-specific configurations
│ ├── host1/
│ │ ├── default.nix # Imports needed home/nixos/mixed modules
│ │ └── ... # Host-specific files
│ └── ...
└── flake.nix
The key innovation: I abandoned separate directories for Home Manager modules and switched entirely to pure NixOS modules that use a special alias for Home Manager module options. I think the code will explain this better than I can describe it.
Module that bridges Home Manager and NixOS:
# options.nix
{
options = {
home = lib.mkOption {
type = lib.types.deferredModule;
default = { };
description = "Alias for home-manager.users.<username>";
};
};
config = lib.mkMerge [
{
_module.args = {
home = config.home;
};
}
(lib.optionalAttrs (options ? home-manager) {
# Only apply when home-manager is imported
home-manager.users."<username>" = config.home;
})
];
}
How it works: This module creates a home option that acts as an alias for home-manager.users.<username>. When Home Manager is available in the configuration, it automatically forwards any options set under home to the appropriate Home Manager user configuration. The lib.types.deferredModule type allows you to define module options without immediately evaluating them, which is perfect for this use case.
Practical example - Stylix module:
This allows me to create modules that contain both NixOS-specific code and Home Manager configuration in a single file. For example, here’s my Stylix module where I configure NixOS settings while also disabling certain Home Manager targets:
{
imports = [ inputs.stylix.nixosModules.stylix ]; # NixOS module import
stylix = { # NixOS options
enable = true;
... # other stylix settings
};
home.stylix.targets = { # Home Manager options
bat.enable = false;
... # other targets
};
}
The benefit: To access Home Manager settings in a module, I simply wrap them in a home = { } block, and I can import files with these settings as regular NixOS modules. Everything related to a single feature stays in one file, making it much easier to maintain.
Conclusion: This approach has worked excellently for me across my 4 machines. I think for larger-scale projects with many machines, shells, etc., the full dendrite approach with flake-parts would be the better solution. But for now, this strikes the right balance for my needs.
I’d love to hear if anyone else has tried something similar or has suggestions for improvement!