Hey there! I'm writing this blog since I found that it's nearly impossible to find proper resources on NixOS and how to structure your configuration properly. Instead of forcing you to figure everything out yourself, here's an easy way to modularize your NixOS configuration and make it cleaner overall.
0. The Dendritic Paradigm
What the hell is a Dendritic? Well, in short, it's a paradigm that flips the configuration matrix from device-centric to feature-centric. I layman's terms, it's a way of writing Nix code that makes your configuration stay clean as it scales, and avoid the headache of imports and weird, nested folder structures.
1. From configuration.nix to a Dendritic-ish Flake
I'll start from a clean NixOS install, though please don't skip this part even if you already have your config in a flake. After installing, my /etc/nixos/configuration.nix file looks as follows:
{ config, pkgs, ... }:{ imports = [ ./hardware-configuration.nix ];
boot.loader.grub = true; boot.loader.device = "/dev/vda"; boot.loader.useOSProber = true; networking.networkmanager.enable = true;
time.timeZone = "Europe/Berlin";
i18n.defaultLocale = "en_US.UTF-8"; i18n.extraLocaleSettings = { LC_ADDRESS = "de_DE.UTF-8"; LC_IDENTIFICATION = "de_DE.UTF-8"; LC_MEASUREMENT = "de_DE.UTF-8"; LC_MONETARY = "de_DE.UTF-8"; LC_NAME = "de_DE.UTF-8"; LC_NUMERIC = "de_DE.UTF-8"; LC_PAPER = "de_DE.UTF-8"; LC_TELEPHONE = "de_DE.UTF-8"; LC_TIME = "de_DE.UTF-8"; };
services.xserver.enable = true;
services.xserver.displayManager.gdm.enable = true; services.xserver.desktopManager.gnome.enable = true;
services.xserver = { layout = "us"; xkbVariant = ""; };
hardware.pulseaudio.enable = true; security.rtkit.enable = true; services.pipewire = { enable = true; alsa.enable = true; alsa.support32Bit = true; pulse.enable = true; };
users.users.parrot = { isNormalUser = true; description = "Parrot"; extraGroups = [ "networkmanager" "wheel" ]; packages = with pkgs; [ ]; };
programs.firefox.enable = true;
nixpkgs.config.allowUnfree = true;
environment.systemPackages = with pkgs; [ ];
system.stateVersion = "24.05";}Yours should be similar enough to this... If you don't know what an option does, feel free to look it up by running man configuration.nix in another shell. The first thing we want to do here is to turn this from a configuration.nix into a flake.
Create a new file called flake.nix, and copy the following code into it:
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
import-tree.url = "github:vic/import-tree";
flake-parts.url = "github:hercules-ci/flake-parts";
systems.url = "github:nix-systems/default";
};
outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } {
imports = [
inputs.flake-parts.flakeModules.modules
./hosts.nix
# (inputs.import-tree ./modules) # keep this commented for now
];
systems = import inputs.systems;
};
}Let's break down the inputs:
- a pinned version of Nixpkgs
- a library called Flake Parts, that will later help us define modules
- a library called Import Tree, that will kill the hydra that is bad folder structuring
- a library called Systems, which is unfortunately part of the boilerplate
Alas! The boilerplate introduced by this paradigm will be much smaller than the boilerplate that you'd be writing otherwise.
For the outputs, you're basically defining a Flake Parts output... feel free to read up on the Flake Parts wiki for more details, since I don't really understand it myself. In the imports, after importing some Flake Parts helper functions that you can ignore, two sets of files are imported. First, ./hosts.nix is imported, where we will go on to define any and all devices we want to make a configuration for. Second, (inputs.import-tree ./modules) imports every file within the modules folder, where we will be migrating the bulk of our configuration to (you can keep this line commented for now).
Also, let's already define the hosts.nix file as follows:
{ inputs, self, config, ... }:{ flake.nixosConfigurations.default = inputs.nixpkgs.lib.nixosSystem {
specialArgs = { inherit inputs; };
modules = [
self.modules.nixos.default
];
};
flake.modules.nixos.default.imports = with self.modules.nixos; [ ./configuration.nix
];
}Now run sudo nixos-rebuild --flake <folder-directory>#default switch command, where you can replace <folder-directory> placeholder with your actual folder name (this means you can feel free to move your folder into the home directory now, so you don't need to run sudo for everything).
If you copied everything down correctly, it should compile! If it doesn't, please double check your code or write me a comment. Phew! Feel free to take a break here, you should be able to keep using your configuration.nix as usual.
2. Defining aspects, modularizing your code
Now it's finally time to break your code up into modules! Let's start by uncommenting that line in the flake.nix that we left commented earlier:
# -- snip --
imports = [
inputs.flake-parts.flakeModules.modules
./hosts.nix
(inputs.import-tree ./modules) # <--- uncomment this line
];
# -- snip --We can now create the modules directory, and create a file called module1.nix (at ./modules/module1.nix.) Now, it's finally time to learn about ASPECTS! There's another wonderful guide that explains all of this is way too much detail, but to keep it simple, each "aspect" is basically a little unit of code that you can easily plug into your host definitions later. Let's plug this code into module1.nix:
{ self, ... }:{ flake.modules.nixos.gnome-aspect-stuff = { services.xserver.displayManager.gdm.enable = true; services.xserver.desktopManager.gnome.enable = true; };}What is happening here?
- In this new file, we are defining an aspect called
gnome-aspect-stuff. - Formally, we have to write it out as
flake.modules.nixos.gnome-aspect-stuffbecause, unfortunately, we'll have to deal with that tiny bit of boilerplate to signal to Nix that we are defining a Flake Parts module. - Within our module, we are defining some gnome-related options. By loading in this module, we are essentially sectioning our the code we previously had in our monolithic
configuration.nixand putting it into it's own little self-contained unit.
Now, let's remove the option we just ported into module1.nix from configuration.nix:
# -- snip -- services.xserver.enable = true;
# ↓↓↓ COMMENT THESE THREE FOLLOWING LINES OUT OR DELETE THEM ↓↓↓ # services.xserver.displayManager.gdm.enable = true; # services.xserver.desktopManager.gnome.enable = true;
services.xserver = {# -- snip --Finally, we need to add our newly defined gnome-aspect-stuff aspect to hosts.nix like this:
{ inputs, self, config, ... }:{ flake.nixosConfigurations.default = inputs.nixpkgs.lib.nixosSystem {
specialArgs = { inherit inputs; };
modules = [
self.modules.nixos.default
];
};
flake.modules.nixos.default.imports = with self.modules.nixos; [ ./configuration.nix gnome-aspect-stuff # <--- add this line here ];
}IT'S THAT SIMPLE!! Now, you can rebuild switch and everything should work just dandy. :) Notice how we never even had to import the file. gnome-aspect-stuff in the code example above is automatically expanded into flake.modules.nixos.gnome-aspect-stuff, and then automagically imported as expected.
CONGRATS! You've officially ported your first aspect! However, there's still a few more things you should know about aspects that originally gave me a huge headache because nobody was properly explaining them to me.
3. Just a few more aspect caveats
Let's define one more aspect, this time where we define the user and some packages they want to have installed. Here, we'll port our user into it's own option and install some packages for them. Feel free to do it once more in the module1.nix file, or in any other file as long as it's somewhere within modules or it's sub-directories.
{ self, ... }:{ flake.modules.nixos.gnome-aspect-stuff = { services.xserver.displayManager.gdm.enable = true; services.xserver.desktopManager.gnome.enable = true; };
# I'm declaring this in the same file for the sake of convenience. # # doesn't have to be in this one specifically though, # since all aspects defined in `.nix` files # within the `modules` are auto-imported.
flake.modules.nixos.new-user-aspect = { users.users.parrot = { isNormalUser = true; description = "Parrot"; extraGroups = [ "networkmanager" "wheel" ]; packages = [ pkgs.hello pkgs.prismlauncher pkgs.godot ]; }; };}Now, follow the steps from Part 2 of this tutorial by removing the original code from configuration.nix and activating it in hosts.nix. However, you might find that this does not actually compile. Instead, nix spews some ugly error messages looking something like the one I got when trying this example:
error: undefined variable 'pkgs'at /nix/store/.../modules/<filename>.nix:...: 13| packages = [ 14| pkgs.hello | ^ 15| pkgs.prismlauncherThis error message... will not do. This is the part I spent weeks debugging when I first learnt dendritic, is simply importing pkgs on line 1 will not actually import it into the aspect. Modules are imported on a per-aspect basis, not on a per-file basis. Here's the fix:
{ self, ... }: # <------- notice how we never import pkgs here{ # I recommend you look into `...` syntax in Nix # -- snip --
# pkgs needs to be defined before the module # ↓↓↓↓ flake.modules.nixos.new-user-aspect = { pkgs, ... }: { users.users.parrot = { isNormalUser = true; description = "Parrot"; extraGroups = [ "networkmanager" "wheel" ]; packages = [ pkgs.hello pkgs.prismlauncher pkgs.godot ]; }; };}Now it should compile just fine, and install the hello, prismlauncher, and godot packages for the user.
Great! Now, in theory, you could repeat the previous two steps over and over until your whole config is modularized! Once finished, you can delete configuration.nix altogether. Then, you'll have officially mastered dendritic and flipped the configuration matrix... Wait, what was that again?
4. Several Hosts, i.e. Flipping the Configuration Matrix
Now, imagine you have several computers whose NixOS configuration is very similar, but not identical. I, for one, have a gaming laptop for home use and a crusty old laptop that I use for work. On my home computer I'd like to have some work apps installed just in case, but of course I don't want to clog up my work computer with gigabytes of programs that I'll never use in that context.
TL;DR: Our problem is that we only want some shared behavior between computers.
Here's an example hosts.nix that highlights how dendritic helps you achieve that without having to maintain multiple different, yet similar, nixos configurations in parallel:
{ inputs, self, config, ... }:{ flake.nixosConfigurations = { home-n-chill-laptop = inputs.nixpkgs.lib.nixosSystem {
specialArgs = { inherit inputs; };
modules = [
self.modules.nixos.home-n-chill-laptop ]; }; work-laptop = inputs.nixpkgs.lib.nixosSystem {
specialArgs = { inherit inputs; };
modules = [
self.modules.nixos.work-laptop ];
};
};
flake.modules.nixos.home-n-chill-laptop.imports = with self.modules.nixos; [ gnome-desktop-and-basic-os-stuff aspect-for-work-stuff research-tools
gaming-and-discord video-editing
];
flake.modules.nixos.work-laptop.imports = with self.modules.nixos; [ gnome-desktop-and-basic-os-stuff aspect-for-work-stuff research-tools
strange-tools-forced-on-me-by-work ];}In the example above, we are defining two separate computers, named home-n-chill-laptop and work-laptop respectively. Then, we import some aspects that we deem important for each computer, defining the functions it has.
I hope you can see the benefits dendritic brings by now. It allows us to broadly lump options into categories, and then import them willy-nilly to very quickly and cleanly define new devices we want to use our NixOS config on. It gets even more complex, as you can import fine-grained aspects into broader groups I call "bundles", which I explore in my own config as of f295ed4370.
Fin
Please let me know if you want me to explain more NixOS concepts simply! I really hope this guide has helped out :3
If you need a frame of reference, make sure to check out my own config. I might consider making a second entry to show how this can be expanded with Home Manager, though feel free to take inspiration from my repo if you're too impatient. ;)
P.S. Make sure to check the comments for some more details / QnA!
Comments
Displaying 5 of 5 comments ( View all | Add Comment )
sireoja
I use Nix package manager on Ubuntu Touch, I never knew that there's a whole distro built around it.
Smartboy2K12
I'm a Windows user, but personally, this is an interesting article
namesareforfriends
Honestly quite amazing
apollo
made me install nixos..
SUKO555
gem