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:
{ ... }:
{
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:
{ ... }:
{
# 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:
{ 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 inenvironment.systemPackages.
User modules
Same logic for home-manager. Create 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:
{ ... }:
{
imports = [
./alacritty.nix
./zsh.nix
];
}And 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.nixto 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:
{ config, pkgs, ... }:
{
imports = [ ./hardware-configuration.nix ];
# Host-specific configurations
system.stateVersion = "25.11";
}And hosts/fritz/default.nix:
{ ... }:
{
imports = [ ./configuration.nix ];
}Updating flake.nix
The magic happens here. Use a mkHost function for auto-discovery of hosts:
{
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 checkIf it passes, do the build:
sudo nixos-rebuild build --flake .#fritzAnd finally apply:
sudo nixos-rebuild switch --flake .#fritzFlakes and Git
Nix flakes only see files tracked by Git. You need to at least
git addnew 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:
- Create
hosts/laptop/:
mkdir -p hosts/laptop
nixos-generate-config --root /mnt --show-hardware-config >
hosts/laptop/hardware-configuration.nix- Create
hosts/laptop/configuration.nix:
{ config, pkgs, ... }:
{
imports = [ ./hardware-configuration.nix ];
# Laptop-specific configurations
services.tlp.enable = true; # Battery management
system.stateVersion = "25.11";
}- Create
hosts/laptop/default.nix:
{ ... }:
{
imports = [ ./configuration.nix ];
}- Add one line to
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:
{ 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:
{
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/audioormodules/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 bootloadernvidia/- NVIDIA driversniri/- Niri packages for the systemnetworking/- NetworkManagerlocale/- Timezone, locale, keymapaudio/- PipeWire and rtkitvirtualization/- Dockersecurity/- mkcert certificatesnix/- Nix settings, garbage collection, substitutersusers/- User definitionprograms/- Steam, dconf, gvfs, greetdxdg/- XDG portals and environment variables
User (modules/user/):
home/- Base home-manager configurationterminal/- Alacritty, Zshbrowser/- Google Chromeniri/- Niri configuration (keybinds, workspaces, etc)noctalia/- Noctalia shellvicinae/- Vicinae launchershell/- Git, SSHmedia/- OBS, GIMP, VLCgtk/- GTK themesdevelopment/- IDEs (PHPStorm, Zed), Claude Codecommunication/- Slack
Resources
- librephoenix/nixos-config - Modular structure that inspired this setup
- Flakes - NixOS Wiki - Official flakes documentation
- Home Manager Manual - Complete home-manager guide