In NixOS, a module is the fundamental unit of configuration. Every piece of system configuration—from the bootloader to the most obscure kernel parameter—is defined through modules. Even your /etc/nixos/configuration.nix is a module.
A module is conceptually a function that accepts some inputs and returns a configuration description. The inputs typically include:
specialArgs in your flakeThe output is an attribute set that can contain several special attributes:
The power of the module system comes from composition. You don't write one giant configuration file. Instead, you write many small modules, each handling one concern, and NixOS combines them into a complete system.
When NixOS evaluates your configuration, it:
1. Starts with your top-level module (the file you import in your flake)
2. Recursively follows all imports, building a tree of modules
3. Evaluates each module as a function, passing the appropriate arguments
4. Collects all the outputs (options, config, imports)
5. Merges the configurations according to type-specific rules
This merging is where the magic happens. If two modules set the same option, the module system has rules for how to combine them:
The separation between declaring what can be configured and defining what the configuration is might seem odd at first, but it's crucial for composition.
Option declarations specify:
Configuration definitions provide actual values for those options.
The NixOS modules in nixpkgs declare thousands of options. Your configuration (and the modules you import) provide definitions for those options. The module system checks that your definitions match the declared types and merges them appropriately.
The config parameter passed to modules is the fully evaluated system configuration. This is important: it's not your module's configuration, it's the entire configuration after all modules have been merged.
This enables conditional configuration. Your module can say "only apply these settings if some other module enabled PostgreSQL" by checking config.services.postgresql.enable.
This is how service dependencies work. If you enable a service that needs a database, its module checks if the database is enabled and can either enable it automatically or warn you that you need to enable it.
The imports attribute in a module specifies other modules that should be included. These can be:
.nix filesdefault.nixWhen NixOS encounters an import, it recursively processes that module, including all its imports. This builds a dependency graph of modules.
There are no circular import checks at the language level—you can create circular imports if you're not careful. In practice, the module system handles this by ensuring that modules are evaluated in an order that respects dependencies.
A common pattern in modules is mkIf. This function takes a boolean condition and a configuration set. If the condition is true, the configuration is included. If false, it's ignored.
This is the primary mechanism for conditional configuration in NixOS. Service modules wrap all their configuration in mkIf cfg.enable, where cfg is their local configuration. This means all the systemd units, firewall rules, and user creation only happen if the service is actually enabled.
Without mkIf, every service module would always apply its full configuration, whether you wanted the service or not. With mkIf, services are opt-in: you set enable = true, and only then does the module do anything.
The mkIf function returns a special data structure that the module system recognizes. During the merge phase, the module system evaluates all the conditions and only includes configurations where the condition is true.
Most NixOS service modules follow a consistent structure:
1. Options declaration: Define enable option and service-specific settings
2. Local config alias: Create a shorthand for the module's configuration section
3. Conditional config block: Use mkIf to wrap all the service setup
4. Package installation: Add the service package to system packages
5. Systemd service: Define how the service runs
6. Supporting resources: Users, groups, directories, firewall rules
This pattern is so common that many modules look structurally identical—they only differ in the specific values and resources they configure.
When you see repetition in configuration, you can extract it into a function. In NixOS, this often takes the form of let-bindings at the top of a module.
For example, if you're configuring multiple virtual hosts that all need the same rate limiting and security headers, you can write a function that takes the unique parts (like the backend URL) and returns the complete configuration string.
This is configuration generation—you're writing Nix code that generates configuration text. The function is evaluated at build time, producing strings that end up in configuration files.
For complex configurations, you might create a lib/ directory with helper functions. These are pure Nix functions that take parameters (like lib for access to the standard library) and return utility functions.
This keeps your main configuration clean and allows reuse across modules. For example, you might have a library function for generating Prometheus scrape configurations that you use in multiple monitoring modules.
Understanding evaluation order helps debug configuration issues:
1. Parse phase: Nix parses all module files
2. Import resolution: The module system builds the complete import graph
3. Option declaration: All modules' option declarations are collected
4. Configuration definition: All modules' config definitions are collected
5. Conditional evaluation: mkIf conditions are evaluated
6. Type checking: Definitions are checked against option types
7. Merge: Definitions are merged according to type rules
8. Final config: The merged configuration is available as config
The config parameter is available in step 8, which means you can't use it in earlier steps. This is why you can't reference config in option declarations—config doesn't exist yet when options are being declared.
The module system enables several important properties:
Extensibility: Anyone can write a module that adds new functionality. The module system handles integrating it with the rest of the system.
Composition: Modules from different sources (nixpkgs, NUR, your own code) combine seamlessly because they all speak the same language.
Discoverability: The options system provides a searchable interface to all configurable aspects of NixOS. You can browse what options exist and their documentation.
Type Safety: Configuration definitions are checked against option types. You can't accidentally set a string where a number is expected.
Reversibility: Because the complete desired state is declared, NixOS can determine what changed between configurations and reverse changes if needed.
When configuration doesn't work as expected, several tools help:
nixos-option: Query the value of any option and see where it was defined:
nixos-option services.openssh.enable
}}}
*nix-instantiate*: Check syntax and type errors without building:
nix-instantiate --eval ./configuration.nix
}}}
nixos-rebuild test: Build configuration without activating it:
nixos-rebuild test --flake .#snek
}}}
*Option documentation*: Browse available options:
man configuration.nix
}}}