Search for best dotfiles structure: Dendritic edition

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!

Dotfiles repo

4 Likes

I think your dotfiles look great; they’re a fantastic example of native configuration.

I’ve never tried dendritic or anything like that because I found them too complex. So I developed my own framework that automatically enables modules or injects parameters, which is much more convenient. You can check out this GitHub - SaltedFishesNG/nixos

For me, I don’t like determining files based on their location; I prefer based on the code itself. I prefer automatic imports to manual additions, like an import-tree. Automatic additions allow you to write a file and immediately jump to another location for debugging without having to think about imports.

If you’re interested, you can check out my project. :eyes:

1 Like

I have been using something similar:

{ config, options, lib, ... }: let
  # cfg = config.solo;
  cfg = { username = "<username>"; enable = true; }
in {
  imports = [
    (lib.doRename {
      from = ["my"];
      to = [ "home-manager" "users" cfg.username ];
      visible = false;
      warn = false;
      use = lib.id;
      condition = options ? home-manager && cfg.enable;
    })
  ];
}

I have also used home-manager.sharedModules = [ <cfg> ];for the same purpose.

To support nixos configurations without home-manager, I have a stub module:

{ lib, ... }: {
  options.home-manager = with lib; mkOption {
    type = types.anything // {
      merge = loc: defs: {};
    };
    description = "unused value";
  };
}

The advantage of your approach with lib.types.deferredModule is that it’s less code and there’s no need to stub when home-manager isn’t imported.

What is the reason for adding home to _module.args?

Occasionally, I found it useful for my NixOS configuration to use values from my home-manager configuration. With this structure, it’s possible. For example:

{ config, ... }: {
  networking.firewall.allowedTCPPorts = [ config.my.services.mpd.port ];
}

Intrinsic problem of this structure is that it’s difficult to separate the home-manager configuration from nixos configuration. I am getting tired of slow eval times for NixOS and so would like to activate home-manager and nixos separately. It will be more work now that my home-manager config is spread out over various nixos modules.

About _module.args - I actually need it for this:

{ home, ... }: {
  networking.firewall.allowedTCPPorts = [ home.services.mpd.port ];
}

Instead of writing config.home.services.mpd.port every time. Probably an anti-pattern but I’ve gotten used to it.

Regarding separate activation - I always activate everything together so haven’t run into that issues yet, but I can see how that could become a problem.

Actually, I did hit one snag: one of my machines doesn’t use home-manager at all but still imports modules that have home.* settings. The lib.optionalAttrs (options ? home-manager) condition completely solved this - if home-manager isn’t imported, all the home options are just ignored. Made it really easy to share modules between machines with and without HM.

1 Like

Yes, after years of practice and refinement, I eventually reached a conclusion extremely similar to yours: namely, that the structure of flake packages has a greatest common denominator. I believe that if everyone followed a certain convention, it would be more convenient for everyone to share and read each other’s flake source code. So, I created the following project, which you might want to try: https://flake-fhs.lambda.lc/

1 Like

As another approach, I recently have been refactoring my personal config to the flake-aspects library, it’s pretty nice! In particular, my new host logic is nice (see modules/hosts.nix), although I kind of miss automatic deploy-rs config generation UPDATE: Automatic deploy-rs profile generation is now in modules/deploy.nix.

2 Likes

Other interesting dendritic configs I’ve been reading and find interesting:

quasigod’s, using den and previously quasigod’s own unify.
FrdrCkII’s using their falake + nixlock + flake-aspects

both @quasigod and @FrdrCkII have created amazing stuff around the dendritic and no-flakes space, worth exploring what are doing and their repos.

EDIT: adding my own vic/vix, that I moved today into using den + unflake, (no nix-experimental features nor flake-parts). Still very wip but I’m currently running it on my laptop. The repo is small enough to get an idea of what Den feels like.

2 Likes

Had 1033 error trying open website

@AlexAntonik It’s cloudflared tunnel accidentally disconnected issue. if it occurs, you can visit the docs repo directly

1 Like