Part of my NixOS migration series

This article continues from Setting up NixOS with Niri, Home Manager and a declarative desktop, where I set up NixOS from scratch.

Two days after installing NixOS, I looked at my configuration files and realized something was wrong. The configuration.nix had 274 lines, home.nix had 602 lines, and I was already struggling to find specific settings. If it was already like this with one machine, what would happen when I had multiple hosts?

This article documents how I refactored my NixOS configuration into a modular structure that allows managing multiple hosts, separating responsibilities, and making maintenance easier.

The problem with monolithic configurations

When you follow the basic NixOS tutorial, you usually end up with three main files:

nixos/
├── flake.nix
├── configuration.nix # 274 lines: boot, networking, nvidia, audio, etc
└── home.nix # 602 lines: alacritty, niri, git, ssh, obs, gtk, etc

This works, but:

  • Hard to navigate: Want to adjust PipeWire? Good luck finding it on line 129 of configuration.nix
  • Impossible to reuse: Each new host needs copy-paste or starting from scratch
  • No granularity: Can’t disable a feature without commenting scattered lines
  • Merge conflicts guaranteed: If you version control with Git and edit from multiple machines

After two days using this structure, it was clear I needed something better.

The modular structure

The solution is to split the configuration into modules by functional category. Inspired by librephoenix’s nixos-config, I refactored to this structure:

nixos/
├── flake.nix # Auto-discovery of hosts
├── hosts/
│ └── fritz/ # One host (my dog's name!)
│ ├── default.nix
│ ├── configuration.nix
│ └── hardware-configuration.nix
├── modules/
│ ├── system/ # NixOS modules (root-level)
│ │ ├── default.nix # Auto-imports everything
│ │ ├── boot/
│ │ ├── nvidia/
│ │ ├── audio/
│ │ ├── networking/
│ │ ├── niri/
│ │ └── ... (13 modules)
│ └── user/ # home-manager modules
│ ├── default.nix # Auto-imports everything
│ ├── terminal/
│ ├── niri/
│ ├── browser/
│ └── ... (11 modules)
└── scripts/

Each module has a single responsibility and can be enabled/disabled easily.

Step-by-step implementation

Creating the directory structure

# Hosts
mkdir -p hosts/fritz
 
# System modules
mkdir -p modules/system/{boot,nvidia,niri,networking,locale,audio,virtualization,security,nix,users,programs,xdg}
 
# User modules
mkdir -p modules/user/{terminal,browser,niri,noctalia,vicinae,shell,media,gtk,home,development,communication}

System modules

Start by creating modules/system/default.nix that auto-imports all modules:

modules/system/default.nix
{ ... }:
{
  imports = [
    ./boot
    ./nvidia
    ./niri
    ./networking
    ./locale
    ./audio
    ./virtualization
    ./security
    ./nix
    ./users
    ./programs
    ./xdg
  ];
}

Then, extract each category from configuration.nix into its module. For example, modules/system/audio/default.nix:

modules/system/audio/default.nix
{ ... }:
{
  # Enable rtkit for audio
  security.rtkit.enable = true;
 
  # PipeWire audio configuration
  services.pipewire = {
    enable = true;
    alsa.enable = true;
    alsa.support32Bit = true;
    pulse.enable = true;
    wireplumber.enable = true;
  };
}

And modules/system/nvidia/default.nix:

modules/system/nvidia/default.nix
{ config, ... }:
{
  # Enable graphics support
  hardware.graphics = {
    enable = true;
    enable32Bit = true;
  };
 
  # NVIDIA drivers configuration
  services.xserver.videoDrivers = [ "nvidia" ];
  hardware.nvidia = {
    modesetting.enable = true;
    open = false;
    nvidiaSettings = true;
    package = config.boot.kernelPackages.nvidiaPackages.stable;
    forceFullCompositionPipeline = true;
  };
}

System packages

System packages go into their related module. For example, niri, wayland, and xwayland live in modules/system/niri/default.nix, not scattered in environment.systemPackages.

User modules

Same logic for home-manager. Create modules/user/default.nix:

modules/user/default.nix
{ ... }:
{
  imports = [
    ./home
    ./terminal
    ./browser
    ./niri
    ./noctalia
    ./vicinae
    ./shell
    ./media
    ./gtk
    ./development
    ./communication
  ];
}

Each category becomes a module. For example, modules/user/terminal/ contains:

terminal/
├── default.nix      # Imports alacritty and zsh
├── alacritty.nix    # Alacritty configuration
└── zsh.nix          # Zsh configuration

The modules/user/terminal/default.nix:

modules/user/terminal/default.nix
{ ... }:
{
  imports = [
    ./alacritty.nix
    ./zsh.nix
  ];
}

And modules/user/terminal/zsh.nix:

modules/user/terminal/zsh.nix
{ ... }:
{
  programs.zsh = {
    enable = true;
    autosuggestion.enable = true;
    syntaxHighlighting.enable = true;
 
    oh-my-zsh = {
      enable = true;
      theme = "robbyrussell";
      plugins = [ "git" "z" ];
    };
 
    shellAliases = {
      d = "docker compose";
      nrs = "sudo nixos-rebuild switch --flake ~/nixos#fritz";
      nru = "sudo nix flake update ~/nixos && sudo nixos-rebuild switch --flake~/nixos#fritz";
    };
  };
}

Relative paths

If you reference files (like scripts), watch out for relative paths. From modules/user/home/default.nix to the root is three levels up: ../../../scripts/file.sh.

Host configuration

Move hardware-configuration.nix into the host:

mv hardware-configuration.nix hosts/fritz/

Create hosts/fritz/configuration.nix with host-specific settings:

hosts/fritz/configuration.nix
{ config, pkgs, ... }:
{
  imports = [ ./hardware-configuration.nix ];
 
  # Host-specific configurations
  system.stateVersion = "25.11";
}

And hosts/fritz/default.nix:

hosts/fritz/default.nix
{ ... }:
{
  imports = [ ./configuration.nix ];
}

Updating flake.nix

The magic happens here. Use a mkHost function for auto-discovery of hosts:

flake.nix
{
  description = "NixOS config";
 
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    home-manager = {
      url = "github:nix-community/home-manager/master";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    niri.url = "github:sodiboo/niri-flake";
    vicinae.url = "github:vicinaehq/vicinae";
    noctalia.url = "github:noctalia-dev/noctalia-shell";
    nix-colors.url = "github:misterio77/nix-colors";
  };
 
  outputs = inputs@{ self, nixpkgs, home-manager, ... }:
    let
      # Helper for automatic host discovery
      mkHost = hostname: nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        specialArgs = { inherit inputs; };
        modules = [
          ./hosts/${hostname}
          ./modules/system
          home-manager.nixosModules.home-manager
          {
            networking.hostName = hostname;
            home-manager = {
              useGlobalPkgs = true;
              useUserPackages = true;
              users.fuhrmann = import ./modules/user;
              extraSpecialArgs = { inherit inputs; };
              sharedModules = [
                inputs.niri.homeModules.niri
                inputs.vicinae.homeManagerModules.default
                inputs.noctalia.homeModules.default
                inputs.nix-colors.homeManagerModules.default
              ];
            };
          }
        ];
      };
    in {
      nixosConfigurations = {
        fritz = mkHost "fritz";
        # Adding new hosts is trivial:
        # laptop = mkHost "laptop";
        # server = mkHost "server";
      };
    };
}

The mkHost function automatically:

  • Sets the hostname based on the argument
  • Imports hosts/${hostname}
  • Imports all system modules via modules/system/default.nix
  • Configures home-manager with all user modules

Validating before applying

Before rebuilding, validate the configuration:

nix flake check

If it passes, do the build:

sudo nixos-rebuild build --flake .#fritz

And finally apply:

sudo nixos-rebuild switch --flake .#fritz

Flakes and Git

Nix flakes only see files tracked by Git. You need to at least git add new files before running the build, even without committing.

Adding a new host

The beauty of modularization shows when you need to add a second host. Suppose you have a laptop:

  1. Create hosts/laptop/:
mkdir -p hosts/laptop
nixos-generate-config --root /mnt --show-hardware-config >
hosts/laptop/hardware-configuration.nix
  1. Create hosts/laptop/configuration.nix:
hosts/laptop/configuration.nix
{ config, pkgs, ... }:
{
  imports = [ ./hardware-configuration.nix ];
 
  # Laptop-specific configurations
  services.tlp.enable = true;  # Battery management
 
  system.stateVersion = "25.11";
}
  1. Create hosts/laptop/default.nix:
hosts/laptop/default.nix
{ ... }:
{
  imports = [ ./configuration.nix ];
}
  1. Add one line to flake.nix:
flake.nix
nixosConfigurations = {
  fritz = mkHost "fritz";
  laptop = mkHost "laptop";  # <-- that's it!
};

Done. The laptop automatically inherits all system and user modules. If you want to disable something specific (for example, NVIDIA drivers on a laptop with Intel), you can override in the laptop’s configuration.nix:

hosts/laptop/configuration.nix
{ config, pkgs, lib, ... }:
{
  imports = [ ./hardware-configuration.nix ];
 
  # Disable NVIDIA on this host
  hardware.nvidia.enable = false;
  services.xserver.videoDrivers = lib.mkForce [ ];
 
  system.stateVersion = "25.11";
}

Disabling specific modules

Want to test without Noctalia? Comment the line in modules/user/default.nix:

modules/user/default.nix
{
  imports = [
    ./home
    ./terminal
    # ./noctalia  # <-- temporarily disabled
    ./niri
    # ...
  ];
}

Rebuild and everything related to Noctalia disappears from the system.

Practical benefits

After modularizing:

  • I find settings in seconds: modules/system/audio or modules/user/niri
  • I reuse across hosts: Desktop and laptop share 90% of modules
  • I test features in isolation: Disable a module, rebuild, verify
  • I understand the system better: Each module documents a responsibility
  • Git stays readable: Changes appear in specific files, not in 600 lines

The time invested in refactoring (a few hours) already paid off the first time I needed to adjust something.

Complete structure reference

For reference, the final module structure looks like this:

System (modules/system/):

  • boot/ - systemd-boot bootloader
  • nvidia/ - NVIDIA drivers
  • niri/ - Niri packages for the system
  • networking/ - NetworkManager
  • locale/ - Timezone, locale, keymap
  • audio/ - PipeWire and rtkit
  • virtualization/ - Docker
  • security/ - mkcert certificates
  • nix/ - Nix settings, garbage collection, substituters
  • users/ - User definition
  • programs/ - Steam, dconf, gvfs, greetd
  • xdg/ - XDG portals and environment variables

User (modules/user/):

  • home/ - Base home-manager configuration
  • terminal/ - Alacritty, Zsh
  • browser/ - Google Chrome
  • niri/ - Niri configuration (keybinds, workspaces, etc)
  • noctalia/ - Noctalia shell
  • vicinae/ - Vicinae launcher
  • shell/ - Git, SSH
  • media/ - OBS, GIMP, VLC
  • gtk/ - GTK themes
  • development/ - IDEs (PHPStorm, Zed), Claude Code
  • communication/ - Slack

Resources