2 12 Modules
Nimmo edited this page 2026-05-14 07:54:37 +01:00

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/.