Renato Garcia

A simple Nix dendritic config

I run NixOS on both my desktop and laptop. Until a few weeks ago, all my Nix code lived in a single configuration.nix file that accepted the machine hostname as an argument. Over time, entropy did its work, and that file turned into a spaghetti mess.

After seeing the Nix Dendritic Pattern mentioned in several places, I wondered whether it would make sense for my rather simple setup, or if it would be overkill. Spoiler: even for my simple setup, it helped me better organize my system configuration.

There are great guides, posts, and examples in the wild to learn about the dendritic pattern and its components. Nevertheless, I had a hard time applying the abstract concepts to my concrete problem. I often had to dive into implementation code to fully understand some parts.

In this post, I explain how I used the dendritic pattern to solve my particular problem: NixOS + Home Manager on both a desktop and a laptop, with two users. I trimmed my configuration to the minimum while keeping all features. The goal is a complete but simple example that we can walk through and explain in full. I hope that after grasping this example, you should be able to create a solution tailored to your own requirements with the help of other sources.

I assume familiarity with Nix, NixOS, and flakes.

All the code implementing the systems configuration is available here:

Get source

/etc/nixos
├── flake.nix
└── modules
    ├── base
    │   ├── configuration.nix
    │   ├── overlays.nix
    │   └── packages.nix
    ├── flake-parts.nix
    ├── hosts
    │   ├── home-desktop
    │   │   ├── default.nix
    │   │   └── filesystem.nix
    │   └── renato-notebook
    │       ├── default.nix
    │       └── filesystem.nix
    └── users
        ├── helena.nix
        └── renatofg
            ├── default.nix
            └── _dotfiles
                └── starship.toml

We’ll cover the key concepts first, then walk through the code.

Contents

Key concepts

In short: we organize our configuration as a collection of modules that get merged together.

But to understand how this works, we first need to cover a few core concepts, the building blocks we use to structure our system configuration following the dendritic pattern.

Flake

We use all those files to structure our NixOS system configuration. At the end of the day, all that code gets evaluated into a flake output set. Specifically, the system configuration lives under the nixosConfigurations.<hostname> attribute.

outputs setflakemkFlakeNix filesDendritic
All code structured following the dendritic pattern will be transformed to a flake output set.

We write our configuration as Flake Parts modules spread across multiple .nix files following the dendritic pattern. These files are imported and passed to the mkFlake function, which evaluates them into the final flake outputs.

Module

The module is the fundamental unit upon which everything else is built. We break our configuration into “module files,” where each file configures a specific feature. How broad or narrow each feature is, and how many you create, is up to you. We’ll discuss this further in the following sections.

At its core, a module is a function receiving an attribute set as input and evaluating to another attribute set. Optionally, instead of being a function, a module can be just a plain attribute set (like Module C). In this case, a module { A } will behave the same as the constant function args: { A }.

Once we split our configuration into multiple modules, we can run nixos-rebuild to reconfigure our NixOS machine. The module system combines all those modules into one. When evaluated, it produces the final merged attribute set.

A ∪ B ∪ ... ∪ nMergenBA
During evaluation, multiple modules are merged into one, producing an attribute set that is the union of all individual attributes.

When merging modules, nested sets merge recursively (attribute by attribute), and lists are concatenated. However, if two modules define the same scalar value, it raises an error.

If we have these three modules:

{ pkgs, ... }:
{
  networking.wireless.enable = true;

  environment.systemPackages = [
    pkgs.nixd
    pkgs.emacs
    pkgs.git
  ];
}

Nix

Module A
{ pkgs, ... }:
{
  networking.wireless = {
    dbusControlled = true;
    fallbackToWPA2 = true;
  };

  environment.systemPackages = [
    pkgs.pandoc
  ];
}

Nix

Module B
{
  networking.hostName = "home-desktop";
}

Nix

Module C

After merging them, the result will be:

{ pkgs, ... }:
{
  networking = {
    wireless = {
      enable = true;
      dbusControlled = true;
      fallbackToWPA2 = true;
    };
    hostName = "home-desktop";
  };

  environment.systemPackages = [
    pkgs.nixd
    pkgs.emacs
    pkgs.git
    pkgs.pandoc
  ];
}

Nix

Everything discussed so far applies to all modules. However, there are different types of modules, distinguished by their input and output sets. Below we cover the three module types we’ll use: NixOS modules, Home Manager modules, and Flake Parts modules.

NixOS modules

NixOS modules configure the system. They are functions that receive an attribute set { config, options, pkgs, lib, modulesPath, ... } (the official wiki describes each argument) and evaluates to a set with NixOS options like services.*, users.*, networking.*, boot.*, etc. (See Appendix A for a full list.)

Here’s an example:

{ pkgs, ... }:
{
  environment.systemPackages = [
    pkgs.nixd
    pkgs.git
    pkgs.emacs-with-packages
  ];

  services.openssh.enable = true;

  users.users.renatofg = {
    isNormalUser = true;
    extraGroups = [ "wheel" "networkmanager" ];
  };
}

Nix

This module sets three options: environment.systemPackages (system-wide packages), services.openssh.enable (enable SSH daemon), and users.users.renatofg (user account configuration).

A classic example of a NixOS module is /etc/nixos/configuration.nix, created during a fresh install.

Home Manager modules

Home Manager modules configure the user environment (dotfiles, programs, user services). Like NixOS modules, they are functions that receive an attribute set { pkgs, lib, config, options, modulesPath, osConfig, ... } (see the official documentation for details) and evaluates to a set with options like home.*, programs.*, services.* (user-level), xdg.*, etc. (See Appendix A for a full list.)

Here’s an example:

{ pkgs, osConfig, ... }:
{
  home.stateVersion = "24.05";

  programs.git = {
    enable = true;
    userName = "Renato Garcia";
    userEmail = "foo@example.com";
  };

  programs.starship = {
    enable = true;
    settings = builtins.fromTOML (builtins.readFile ./_dotfiles/starship.toml);
  };

  # Access NixOS config when integrated
  home.packages = lib.optionals osConfig.services.xserver.enable [
    pkgs.alacritty
  ];
}

Nix

Home Manager has its own module evaluation. It can run standalone or integrated with NixOS. When integrated via home-manager.users.<name>, each user’s configuration is evaluated separately from the system configuration. The osConfig argument provides read-only access to the NixOS configuration, letting you make Home Manager settings conditional on system settings.

Flake Parts modules

Flake Parts modules differ from NixOS and Home Manager modules. Instead of configuring systems or users, they structure the flake itself, using the module system to organize flake outputs.

Like other modules, they are functions that receive an attribute set { self, config, options, lib, inputs, ... } and evaluate to another attribute set. (See the documentation for input arguments and output options.)

The Flake Parts library has broad applications, but for our purposes we only need three options: flake.modules.<class>.<aspect>, flake.nixosConfigurations.<hostname>, and perSystem.overlayAttrs.

flake.modules.<class>.<aspect>

This option collects our NixOS modules and Home Manager modules. The <class> attribute designates the module type, either nixos or homeManager (other classes exist but we won’t use them here). The <aspect> attribute designates the feature being configured.

For example: common configuration shared by all hosts goes in the base aspect (example), user-specific configuration in the helena aspect (example), and a complete host configuration in the home-desktop aspect (example).

You could also create an aspect for a specific service like ssh:

{ inputs, config, ... }:
{
  flake.modules.nixos.ssh = {
    # A NixOS module: setup OpenSSH server, firewall-ports, etc.
  };

  flake.modules.homeManager.ssh = {
    # A Home Manager module: setup ~/.ssh/config, authorized_keys,
    # private keys secrets, etc.
  };
}

Nix

The <class> name must match a configuration class: nixos, homeManager, etc. This determines what module type to assign. For a nixos class, assign NixOS modules; for homeManager, assign Home Manager modules.

flake-partshomeManager.base = Bnixos.base = A ∪ CMergeflake-partsnixos.base = Cflake-partshomeManager.base = Bnixos.base = A
All flake-parts modules will be merged, and each `flake.modules.<class>.<aspect>` attribute will be merged within it.

The <aspect> name is arbitrary, use as many as you need to organize your configuration. As with all modules, you can define multiple flake.modules.nixos.base attributes and they’ll be merged together. Access the merged result from any Flake Parts module via self.modules.nixos.base.

flake.nixosConfigurations.<hostname>

This option maps to the flake’s outputs.nixosConfigurations.<hostname> attribute. The <hostname> matches what you specify in nixos-rebuild switch --flake /etc/nixos#<hostname>.

Code walkthrough

Now let’s walk through the code.

flake.nix

Remember: the flake output set is our final result. When you run nixos-rebuild switch --flake /etc/nixos#home-desktop, it evaluates /etc/nixos/flake.nix, reads the configuration from outputs.nixosConfigurations.home-desktop, and reconfigures the system accordingly.

The function flake-parts.lib.mkFlake processes all our modules to create the host configurations:

View in source

outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } (inputs.import-tree ./modules);

Nix

Instead of manually importing each Flake Parts module, we use import-tree to recursively discover and import all .nix files under ./modules.

Flake Parts modules

All Nix files under modules/ directory are Flake Parts modules. They receive the arguments described in the Flake Parts section and output a set with one or more of the following attributes:

flake.modules.<class>.<aspect>

View in source

flake.modules.nixos.base =

Nix

Here nixos is the class and base is the aspect. The directory tree follows a convention: either the file is named after the aspect (e.g., helena.nix) or the parent directory matches the aspect name. This is not a strict requirement, just a convenient way to organize the code.

flake.nixosConfigurations.<hostname>

View in source

flake.nixosConfigurations.renato-notebook = inputs.nixpkgs.lib.nixosSystem {

Nix

Here renato-notebook is the hostname. The nixosSystem function is covered in Defining a host.

perSystem.overlayAttrs

View in source

overlayAttrs = {

Nix

Here we define the overlay applied to nixpkgs. For example, we can add an attribute pointing to a different nixpkgs version:

View in source

unstable = import inputs.nixpkgs-unstable {
  inherit system;
};

Nix

Or override a specific package:

View in source

polybar = pkgs.polybar.override {
  alsaSupport = false;
  mpdSupport = true;
  pulseSupport = true;
  iwSupport = true;
  nlSupport = true;
  i3Support = false;
};

Nix

Or create new packages:

View in source

emacs-with-packages = pkgs.emacs.pkgs.withPackages (
  epkgs:
  (with epkgs.melpaPackages; [
    doom-modeline
    evil-collection
    magit
    zenburn-theme
  ])
  ++ [
    epkgs.nix-ts-mode
    epkgs.tree-sitter-langs
    epkgs.treesit-grammars.with-all-grammars
  ]
);

Nix

These changes are visible when packages are installed on the system.

imports

View in source

imports = [
  inputs.flake-parts.flakeModules.modules
  inputs.flake-parts.flakeModules.easyOverlay
];

Nix

Here we import the Flake Parts modules that provide the features used in our configuration.

inputs.flake-parts.flakeModules.modules enables defining multiple modules as attributes in the flake.modules.<class>.<aspect> format. Without it, we would get a “flake.modules is defined multiple times while it’s expected to be unique” error.

inputs.flake-parts.flakeModules.easyOverlay enables defining overlays using the perSystem.overlayAttrs format. Without it, we would also get an error.

systems

View in source

systems = [ ];

Nix

This just needs to exist, otherwise we would get a “The option systems was accessed but has no value defined” error. It becomes relevant when using other Flake Parts features, but is not used in our current configuration.

NixOS modules

All values assigned to attributes in the flake.modules.nixos.<aspect> format (i.e., all aspects with nixos as their class) are NixOS modules. See the code here: 1, 2, 3, 4, 5, 6, 7, 8.

Within a NixOS module we can set system options, install packages, and so on. However, a special category in our dendritic configuration are modules where the aspect matches the host name, following the format flake.modules.nixos.<hostname>. Our example has both flake.modules.nixos.renato-notebook (1, 2) and flake.modules.nixos.home-desktop (1, 2). These are special because they gather all the modules used by each host, specifically in the imports attribute:

View in source

imports = with self.modules.nixos; [
  base
  renatofg
  helena
];

Nix

we list all the aspects used by that host. Here we included the merged flake.modules.nixos.base modules (1, 2), the flake.modules.nixos.renatofg module (1), and flake.modules.nixos.helena (1).

Note that when listing packages to install:

View in source

environment.systemPackages = with pkgs; [
  unstable.nushell

  # Language servers
  bash-language-server
  clang-tools
  cmake-language-server
  nixd

  alacritty
  emacs-with-packages
  git
  pandoc
  polybar
  qutebrowser
  starship
];

Nix

any changes made with the overlay are visible.

Home Manager modules

All values assigned to attributes in the flake.modules.homeManager.<aspect> format (i.e., all aspects with homeManager as their class) are Home Manager modules. See the code here.

Defining a host

To generate the configuration for a host, we call the nixpkgs.lib.nixosSystem function. This is where we combine all module pieces to build a complete NixOS system configuration. The nixosSystem function accepts several arguments, but we only use the modules list:

View in source

flake.nixosConfigurations.renato-notebook = inputs.nixpkgs.lib.nixosSystem {
  modules = [

    self.modules.nixos.renato-notebook

    inputs.home-manager.nixosModules.home-manager

    {
      home-manager = {
        useGlobalPkgs = true;
        users.renatofg = self.modules.homeManager.renatofg;
      };
    }

    {
      nixpkgs = {
        config.allowUnfree = true;
        overlays = [ self.overlays.default ];
      };
    }

  ];
};

Nix

Each element in the modules list is a NixOS Module. When merged together, they fully define the host.

The first item:

View in source

self.modules.nixos.renato-notebook

Nix

is the merged result of all flake.modules.nixos.renato-notebook modules—the NixOS module containing the complete system configuration for the renato-notebook host.

The second item:

View in source

inputs.home-manager.nixosModules.home-manager

Nix

is a module defined by the Home Manager project that enables including Home Manager configuration as part of the NixOS configuration. This allows rebuilding both together when running nixos-rebuild switch --flake /etc/nixos#renato-notebook. Without this module, assigning options to home-manager attributes would fail since they are not valid NixOS module options.

With home-manager.nixosModules.home-manager included, we can add the following NixOS module to the list:

View in source

{
  home-manager = {
    useGlobalPkgs = true;
    users.renatofg = self.modules.homeManager.renatofg;
  };
}

Nix

All valid options are documented here, but the key one is users.<username>. Here we add the Home Manager modules for all users on that host. In this example, we reference our defined flake.modules.homeManager.renatofg.

The last module in the list:

View in source

{
  nixpkgs = {
    config.allowUnfree = true;
    overlays = [ self.overlays.default ];
  };
}

Nix

configures nixpkgs for our NixOS system. All valid nixpkgs.* options are in the NixOS manual; the relevant ones here are nixpkgs.overlays and nixpkgs.config.

The nixpkgs.overlays list specifies overlays to apply to nixpkgs. In our configuration, we apply only self.overlays.default, defined via the flake-parts perSystem.overlayAttrs option here.

The nixpkgs.config attribute set configures the Nixpkgs collection. Above, we only enable unfree packages, but the same options can enable CUDA support system-wide, as done for the home-desktop host here.

Wrapping it up: Dendritic Pattern

dendrite /ˈden-ˌdrīt/
1. a branching treelike figure produced on or in a mineral by a foreign mineral
2. a crystallized arborescent form
3. any of the usually branching protoplasmic processes that conduct impulses toward the body of a neuron

Merriam-Webster

A dendritic pattern describes a structure that branches like a tree: a trunk splits into limbs, limbs into branches, and branches into twigs.

Applied to Nix configuration, the dendritic pattern is an organizational philosophy, not a library or a set of rigid, well-defined rules. A dendritic configuration organizes code as a tree of modules:

These modules subdivide when they grow too large or complex, and merge together when evaluating a host’s configuration.

This principle extends to the directory and file structure: broad concepts like hosts and users become directories, with modules further split into files and subdirectories as needed.

Each module is a self-contained unit responsible for a single aspect. Modules can import other modules, creating the branching structure. A host configuration is built by combining the relevant modules.

The key dendritic insight is separation of concerns: instead of one monolithic configuration.nix, we split configuration by what it configures (networking, users, packages) and where it applies (all hosts, specific host, specific user). This makes it easier to:

The dendritic configuration in this post is one concrete implementation among many possible variations. It is intentionally short and simple, to convey the core idea without needless complexity. However, there is no official or standardized format; the community is actively exploring alternatives and building tools for the dendritic pattern. Good references include the NixOS Discourse post where the pattern was first discussed, Vic’s Dendritic Libs for developing dendritic configurations, Doc-Steve’s guides, and the Dendrix repository for sharing dendritic configs.