How I Organized 100+ NixOS Modules Without Going Crazy

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:

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.

Asaduzzaman Pavel

About the Author

Asaduzzaman Pavel is a Software Engineer who actually enjoys the friction of a well-architected system. He has over 15 years of experience building high-performance backends and infrastructure that can actually handle the real-world chaos of scale.

Currently looking for new opportunities to build something amazing.