Table of Contents
- Chapter 12: Modules In Depth
- What Is a Module?
- Simple Configuration Modules
- Example: modules/hardware/bluetooth.nix
- Example: modules/desktop/plasma.nix
- Example: modules/boot/silent-boot.nix
- Modules with Custom Options
- The imports Mechanism
- Option Types and Merging
- List options (concatenated)
- Singleton options (must not conflict)
- Priority with mkDefault and mkForce
- Writing Your Own Module
- How to Find Available Options
Chapter 12: Modules In Depth
Modules are the building blocks of NixOS configuration. Understanding them deeply is what separates "I can copy-paste configuration" from "I can design and debug my own system."
What Is a Module?
A NixOS module is a function that takes an attrset of arguments and returns an attrset of configuration. At minimum:
{ config, pkgs, ... }:
{
# configuration goes here
}
That is it. Every .nix file in your configuration follows this pattern.
The arguments NixOS passes to every module include:
| Argument | What it is |
|---|---|
config |
The fully evaluated system configuration (all modules merged) |
pkgs |
The Nix packages collection |
lib |
The Nix standard library |
modulesPath |
Path to the NixOS modules directory |
options |
All declared options |
Any specialArgs |
Extra arguments you pass (like inputs) |
You only need to list the ones you actually use. The ... catches the rest.
Simple Configuration Modules
Most of your modules are simple: they set options without declaring any of their own.
Example: modules/hardware/bluetooth.nix
{ config, pkgs, ... }:
{
hardware.bluetooth = {
enable = true;
powerOnBoot = true;
settings = {
General = {
Experimental = true;
};
};
};
}
This module does one thing: enables and configures Bluetooth.
Any host that imports this module gets Bluetooth. Any host that does not import it gets no Bluetooth configuration.
Example: modules/desktop/plasma.nix
{ ... }:
{
services.desktopManager.plasma6.enable = true;
}
One line of configuration that sets up an entire desktop environment. This is the power of NixOS modules -- complex features are exposed as simple options.
Example: modules/boot/silent-boot.nix
{ config, pkgs, lib, ... }:
{
boot.plymouth = {
enable = true;
theme = "bgrt";
};
boot.kernelParams = [
"quiet"
"splash"
"vt.global_cursor_default=0"
"rd.systemd.show_status=false"
"rd.udev.log_level=3"
"udev.log_priority=3"
];
boot.consoleLogLevel = 0;
boot.initrd.verbose = false;
}
Notice this module sets boot.kernelParams to a list. Electra's hardware.nix also sets boot.kernelParams:
boot.kernelParams = [ "amdgpu.gtt_size=32768" ];
Do these conflict? No. Because boot.kernelParams is a list option, NixOS concatenates them. The final system gets all kernel parameters from both modules:
quiet splash vt.global_cursor_default=0 rd.systemd.show_status=false
rd.udev.log_level=3 udev.log_priority=3 amdgpu.gtt_size=32768
This is why splitting configuration into modules works so well.
Modules with Custom Options
Your modules/services/nixos-auto-update.nix is a more advanced module: it declares its own options. This is how you create reusable, configurable modules.
Let us examine it in detail:
{ config, lib, pkgs, ... }:
let
inherit (lib) mkEnableOption mkOption mkIf types;
cfg = config.services.nixos-auto-update;
updateScript = pkgs.writeShellScript "nixos-auto-update" ''
# ... script content ...
'';
in
{
options.services.nixos-auto-update = {
enable = mkEnableOption "automatic NixOS updates";
flakePath = mkOption {
type = types.str;
default = config.nixosConfig.flakeRepo; # From modules/common/default-config.nix
description = "Path to the NixOS configuration flake";
};
currentConfig = mkOption {
type = types.str;
default = config.networking.hostName;
description = "NixOS configuration name in flake (defaults to hostname)";
};
schedule = mkOption {
type = types.str;
default = "daily";
description = "Timer schedule (systemd calendar format)";
};
user = mkOption {
type = types.str;
default = config.nixosConfig.primaryUser; # From modules/common/default-config.nix
description = "User for git operations and notifications";
};
};
config = mkIf cfg.enable {
systemd.services.nixos-auto-update = {
# ... service configuration ...
};
systemd.timers.nixos-auto-update = {
# ... timer configuration ...
};
};
}
Breaking it down
The inherit (lib) at the top
inherit (lib) mkEnableOption mkOption mkIf types;
This brings specific functions from lib into scope so you can write mkEnableOption instead of lib.mkEnableOption. This is preferred over with lib; because it makes dependencies explicit and avoids polluting the scope.
The let block
let
cfg = config.services.nixos-auto-update;
updateScript = pkgs.writeShellScript "nixos-auto-update" ''...'';
in
cfg is a convenience binding. Instead of writing config.services.nixos-auto-update.enable everywhere, you write cfg.enable. This is a very common pattern.
updateScript creates a shell script as a Nix derivation. pkgs.writeShellScript takes a name and script contents and produces a script in the Nix store.
The options section
options.services.nixos-auto-update = {
enable = mkEnableOption "automatic NixOS updates";
# ...
};
This declares new options. After this module is imported, anyone can write:
services.nixos-auto-update.enable = true;
Each option has:
- A type (
types.str,types.bool,types.int,types.listOf types.str, etc.) - An optional default value
- A description for documentation
mkEnableOption is shorthand for a boolean option that defaults to false.
The config section with mkIf
config = mkIf cfg.enable {
# ...
};
This is the actual configuration, but it only applies when cfg.enable is true. If nobody sets services.nixos-auto-update.enable = true, all of this is ignored.
How it is used
In hosts/electra/default.nix:
services.nixos-auto-update.enable = true;
The module is imported (via the imports list), its options are declared, and then the host enables it. The currentConfig option defaults to the hostname (config.networking.hostName), so no explicit value is needed. The config block activates because enable is true, and the systemd service and timer are created.
Both hosts import this module and enable it in pull-only mode.
The imports Mechanism
Every module can have an imports attribute -- a list of other modules to pull in:
{
imports = [
../common
./hardware-configuration.nix
./hardware.nix
../../modules/boot/silent-boot.nix
../../modules/desktop/plasma.nix
../../modules/common/system.nix
../../users/nimmo.nix
];
# ... rest of config ...
}
When NixOS evaluates this module, it also evaluates everything in imports. Imports can import other things (e.g., hosts/common/default.nix imports modules/maintenance/garbage-collection.nix), forming a tree. Additionally, home-manager modules like home/nimmo.nix are loaded via flake.nix and manage user-level configuration separately from the NixOS module tree.
Import paths
Imports can be:
- Relative paths:
./hardware.nix,../../modules/desktop/plasma.nix - Directory paths:
../common(loads../common/default.nix) - Attribute paths from inputs:
inputs.disko.nixosModules.disko
Importing from flake inputs
Your hosts/common/default.nix imports a module from the disko flake:
imports = [
inputs.disko.nixosModules.disko
# ...
];
And hosts/lena/default.nix imports from nixos-hardware:
imports = [
inputs.nixos-hardware.nixosModules.lenovo-ideapad-16ahp9
# ...
];
These work because inputs is available via specialArgs (Chapter 13). The flake input provides a NixOS module at that attribute path.
Option Types and Merging
Understanding how options merge is crucial for avoiding errors and for knowing where to put configuration.
List options (concatenated)
Options like environment.systemPackages, boot.kernelParams, users.users.nimmo.packages are list types. When multiple modules set them, the lists are joined:
# Module A
environment.systemPackages = [ pkgs.git ];
# Module B
environment.systemPackages = [ pkgs.wget ];
# Result: [ pkgs.git pkgs.wget ]
Singleton options (must not conflict)
Options like networking.hostName, time.timeZone, boot.loader.systemd-boot.enable accept a single value. If two modules set the same singleton option to different values, NixOS raises an error:
error: The option 'networking.hostName' has conflicting definitions
Priority with mkDefault and mkForce
When you need to override a singleton option that is set elsewhere:
# Low priority (can be overridden by normal assignments)
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
# Normal priority (default)
networking.hostName = "electra";
# High priority (overrides everything)
security.pam.services.login.fprintAuth = lib.mkForce true;
Priority order (lowest to highest): mkDefault < normal < mkForce
Your hardware files use mkDefault for settings that might need overriding. Your fingerprint configuration uses mkForce because PAM defaults might otherwise conflict.
Writing Your Own Module
A simple configuration module
To add a new feature, create a file in modules/:
# modules/services/tailscale.nix
{ config, pkgs, ... }:
{
services.tailscale.enable = true;
networking.firewall.allowedUDPPorts = [ 41641 ];
environment.systemPackages = [ pkgs.tailscale ];
}
Then import it in any host that needs it:
# In hosts/electra/default.nix
imports = [
# ...
../../modules/services/tailscale.nix
];
A module with options
If you want the module to be configurable:
# modules/services/example.nix
{ config, lib, pkgs, ... }:
let
inherit (lib) mkEnableOption mkOption mkIf types;
cfg = config.services.example;
in
{
options.services.example = {
enable = mkEnableOption "example service";
port = mkOption {
type = types.port;
default = 8080;
description = "Port to listen on";
};
};
config = mkIf cfg.enable {
systemd.services.example = {
description = "Example service";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${pkgs.example}/bin/example --port ${toString cfg.port}";
};
};
};
}
Usage:
services.example = {
enable = true;
port = 9090;
};
How to Find Available Options
NixOS has thousands of options. Here is how to find them:
Search online
Browse https://search.nixos.org/options -- the official option search. Type what you are looking for (e.g., "bluetooth", "docker", "pipewire") and see every available option with its type, default, and description.
From the command line
# Search for options related to bluetooth
nixos-option hardware.bluetooth
# Show the type and default of a specific option
nixos-option hardware.bluetooth.enable
Read the source
NixOS modules are in the nixpkgs repository. You can find the module for any service by searching the NixOS/nixpkgs GitHub repository, typically under nixos/modules/services/.