My first NixOS flake was a 500-line monster. Everything in one file—hardware config, user packages, SSH settings, that one weird GTK theme I liked. It worked. I was proud of it. Then I bought a laptop and tried to reuse my "setup." Copy-paste, change a hostname, rebuild...
...And spent the next three hours untangling why my desktop's NVIDIA config was trying to load on a laptop with integrated graphics. That's when I realized "just split it into files" doesn't actually solve the problem.
The Mystery Meat Problem
Most modular NixOS setups I see follow a pattern like this:
imports = [
./hardware
./programs
./services
./users
];Looks clean, right? Except ./programs secretly imports ./programs/development.nix which sneaks in services.docker because "you probably want it." And six months later, you're hunting through five directories trying to figure out where that virtualisation.docker.enable = true is actually coming from.
I wanted something different. I wanted discoverable modules—where each piece explicitly says "I provide this" and hosts explicitly say "I want this." No mystery meat. No accidental cross-contamination between machines.
The Pattern: Flake-Parts + Auto-Discovery
I settled on a pattern using flake-parts that treats modules like a catalog rather than a tree. Here's the core of my flake.nix:
{
outputs = inputs@{ flake-parts, ... }:
let
inherit (inputs.nixpkgs.lib.fileset) toList fileFilter;
import-tree = path:
toList (fileFilter
(file: file.hasExt "nix" && !(inputs.nixpkgs.lib.hasPrefix "_" file.name))
path);
in
flake-parts.lib.mkFlake { inherit inputs; } {
imports = import-tree ./modules;
};
}The import-tree function recursively finds every .nix file in modules/ (ignoring files starting with _—those are helpers). This means adding a new module is literally just creating the file. No editing imports lists. No forgetting to wire things up.
How Modules Register Themselves
Each module in my setup registers itself into a flake-wide namespace. Here's a simplified version of my Neovim module:
{ inputs, ... }:
{
perSystem = { pkgs, ... }: {
# Build my custom Neovim package here
packages.neovim-nvf = inputs.nvf.lib.neovimConfiguration {
pkgs = customPkgs;
modules = [ ./neovim-config.nix ];
}.neovim;
};
flake.modules.nixos.programs_neovim = { config, pkgs, ... }: {
# NixOS system-level config
environment.systemPackages = [
config.packages.neovim-nvf
];
# Persistence settings for impermanence
custom.persist.home.directories = [ ".local/share/nvim" ];
};
}Notice the flake.modules.nixos.programs_neovim line. This module doesn't do anything by default—it just makes itself available as programs_neovim in my module catalog. It contains both the package definition (perSystem) and the system integration (flake.modules.nixos).
Hosts Pick What They Need
My host configurations read like a menu. Here's my desktop (xenomorph):
{ inputs, ... }@top:
{
flake.modules.nixos.host_xenomorph = { config, ... }: {
imports = with top.config.flake.modules.nixos; [
# Core
gui
wm
# Hardware
hardware_nvidiagpu
hardware_qmk
# Programs I want on this machine
programs_neovim
programs_steam
programs_vesktop
programs_spicetify
# Services
services_docker
services_flatpak
];
# Host-specific settings
custom.hardware.monitors = [
{ name = "DP-1"; width = 3440; height = 1440; }
];
};
}I can see at a glance what this machine has. I can diff it against my laptop config to see what's different. And if I want to test programs_steam on my laptop, I just add it to that host's imports—no touching the module itself.
The Helper Functions That Make This Work
The import-tree function I showed earlier handles the directory scanning:
import-tree = path:
toList (fileFilter
(file: file.hasExt "nix" && !(inputs.nixpkgs.lib.hasPrefix "_" file.name))
path);This uses fileFilter from inputs.nixpkgs.lib.fileset to find every .nix file while skipping private files (those starting with _). The toList converts the file set to a list of paths that can be imported into the flake.
Why This Actually Scales
I've got about 100 module files now. The pattern still works because:
Adding something is trivial. Create modules/gui/zed.nix with flake.modules.nixos.programs_zed = ... and it's immediately available to any host. No imports to edit.
Disabling is trivial. Remove programs_zed from the host's imports. The module file can stay—it just isn't used.
Testing is possible. Because modules are explicit, I can evaluate nixosConfigurations.laptop.config.programs.zed.enable and know exactly where that came from.
Cross-machine diffs are readable. I can literally diff two host files and see "this one has Steam, that one doesn't."
The Honest Trade-offs
This isn't free. The learning curve on flake-parts is real—I spent a weekend just understanding how perSystem interacts with flake.modules. And nix flake check is slower now because it has to evaluate the entire module tree even when you're just testing one host.
Debugging "where did this option come from?" also requires different tools. Instead of grep -r through directories, I'm using nixos-option and tracing through the flake-parts module system. It's different, not necessarily better.
Also, this pattern assumes you're all-in on Flakes. If you're still using nix-channel and configuration.nix, this approach won't work without major restructuring.
The "100+ Modules" Reality Check
Let me be honest: I don't have 100 unique modules. I have about 40 actual feature modules, plus 60+ small files for shell aliases, individual GUI programs, and helper functions. But the pattern doesn't care—whether it's 10 modules or 200, the structure scales the same way.
The real benefit isn't the number. It's that I can add a new program, configure it, set up persistence for its dotfiles, and expose it to hosts—all in one logical file. No jumping between home.nix, packages.nix, and persist.nix to add one tool.
Resources
If you want to dig deeper into my setup, here are the key files:
- My flake.nix – The
import-treepattern - flake-parts.nix – System definitions and options
- modules/shell/default.nix – Example of
perSystem+ module combo - modules/hosts/xenomorph/ – A complete host configuration
If you're just starting with NixOS, this might be overkill. But if you're staring at a 500-line flake.nix and dreading the next refactor, this pattern might save you from going crazy too.
