↓ Skip to main content

NixOS (6): Understanding Modules and Options — Merging and Overriding Configuration

·2124 words·10 mins
Jin Li
Author
Jin Li
Fate lies within the lightcone.
NixOS Series - This article is part of a series.
Part 6: This Article

Motivation
#

Once configuration is split into common modules and per-machine files (see Post 3), the same option may be set in several files. Whether definitions combine or override one another depends on the option type and their priorities.

For example, suppose the common configuration sets the timezone to UTC and the laptop configuration sets a different local timezone. These two ordinary definitions conflict during evaluation. mkDefault and mkForce adjust definition priorities, but understanding their differences helps choose the one that suits the configuration.

This post introduces NixOS modules, the rules for merging and overriding options, and a small custom module with an enable switch. The filenames and host names below are demonstration values you can try in a test configuration.

Introduction to NixOS Modules
#

A NixOS module does not have to be complicated. The configuration.nix we normally edit is a module. A separate file containing a related group of settings can be a module too.

For example, create modules/common.nix:

1
2
3
4
5
{ pkgs, ... }:
{
  time.timeZone = "UTC";
  environment.systemPackages = [ pkgs.tree pkgs.jq ];
}

This sets the timezone and installs tree and jq. The first line makes the file a function: it receives an argument set containing pkgs and returns the configuration below. pkgs.tree and pkgs.jq select those two packages from the package set. The ... allows other arguments without having to name them all.

Two other common arguments are lib and config. lib provides helpers such as mkDefault, while config lets us read the configuration resulting from all the modules. The module system supplies these arguments; normally we do not call the function ourselves.

If none of those arguments is needed, the file can simply contain an attribute set, such as { time.timeZone = "UTC"; }. These are NixOS configuration modules, separate from the kernel modules used for drivers.

Importing Modules with imports
#

Suppose the host configuration is at hosts/laptop/configuration.nix. It can import the common file like this:

1
2
3
4
5
6
7
8
9
{ pkgs, ... }:
{
  imports = [
    ./hardware-configuration.nix
    ../../modules/common.nix
  ];

  environment.systemPackages = [ pkgs.ripgrep ];
}

This is an excerpt from the host module; keep the existing boot, user and other settings. Paths are relative to the file containing imports, so here we go up two directories to find modules.

If you use the mkHost function from post 3, which already includes the common module in the flake’s modules list, you do not need to import it here as well. These are two ways to organize the files; choose whichever is easier to follow.

Placing a file in a directory does not activate it. It needs to be connected through the selected host’s module list or imports. For a local Git flake, new files also need to be added to the index with git add so Nix can see them.

The module system collects definitions from these files and merges them according to option types and priorities. The imports list does not execute configuration in sequence like a shell script, so moving a file to the end of the list does not make its settings override earlier ones.

The Nix language also has an import function for reading and evaluating Nix files. Despite the similar name, that function does not by itself perform NixOS option merging.

Merging Option Definitions
#

Merging list definitions
#

Both files above set environment.systemPackages. This option is a list, and the definitions combine. The common configuration’s tree and jq and the host configuration’s ripgrep all join the system package list. Other modules may contribute packages too.

There is no need to write this in the host configuration:

1
environment.systemPackages = config.environment.systemPackages ++ [ pkgs.ripgrep ];

This is an example of what goes wrong: config.environment.systemPackages is already the final merged value. Referring to it while defining that same value creates recursion. Just define the list this module wants to add.

Conflicting string definitions
#

Now suppose the common configuration still specifies "UTC", and we add this to the host module:

1
time.timeZone = "Europe/Paris";

The two ordinary definitions have the same priority, and the timezone cannot be two different strings at once. Evaluation reports a conflict. Changing the order of imports does not resolve it.

This is where option declarations and option definitions become useful terms. A declaration specifies the option’s name, accepted type and default value. A definition supplies a value, as in time.timeZone = "UTC";. NixOS already declares many common options, so we only need to define their values.

The option type determines how values merge. Lists usually concatenate, ordinary strings generally require equal values at the same priority, and there are also string types specifically for joining lines of text. When definitions conflict, check the option documentation for its type and merge rules.

Default Settings with mkDefault
#

To say “use UTC normally, but let each computer choose”, change modules/common.nix to:

1
2
3
4
5
{ lib, pkgs, ... }:
{
  time.timeZone = lib.mkDefault "UTC";
  environment.systemPackages = [ pkgs.tree pkgs.jq ];
}

The function now receives lib, and the timezone value is wrapped in lib.mkDefault. The host’s time.timeZone = "Europe/Paris"; stays as it is. Its ordinary definition wins. Computers without their own timezone setting continue to use UTC from the common configuration.

Here are the usual definition priorities:

FormPriority numberPurpose
default in mkOption1500Default supplied by the option declaration
lib.mkDefault value1000An easily overridden configuration value
Ordinary option = value;100Normal configuration
lib.mkForce value50Override the definitions above

A smaller number means a higher priority. The module system keeps the definitions at the highest priority, then merges them according to the option type. The official manual’s priority section describes these rules.

Two different timezone values both wrapped in mkDefault can still conflict. mkDefault has a priority of its own, lower than an ordinary definition; it does not yield unconditionally to any other definition.

Overriding definitions with mkForce
#

To override another module’s ordinary definition, use time.timeZone = lib.mkForce "Europe/Paris";. In this example, changing the common setting to mkDefault already gives the host setting precedence, so mkForce is unnecessary.

When checking AI edits, I pay attention to whether mkForce is necessary. For a conflict, first find both definitions: should the common setting be a default, or should a module be excluded from the imports? If the import structure is wrong, adjust it before deciding whether an override is needed.

Be careful with lists too. When an ordinary definition and a mkDefault list coexist, the default list is discarded entirely rather than concatenated first. Using mkForce on the whole environment.systemPackages option also discards its other definitions with lower priority, rather than replacing just one package. Two different mkForce definitions at the same priority can still conflict.

Ordering with mkBefore and mkAfter
#

Ordering is another easy thing to mix up. Suppose we want the host’s extra package to appear after ordinary list definitions. We can change its package setting to:

1
2
3
4
{ lib, pkgs, ... }:
{
  environment.systemPackages = lib.mkAfter [ pkgs.ripgrep ];
}

Again, this shows only the relevant part of the module. mkBefore and mkAfter order the list definitions that survive priority filtering. They do not change override priority, so other ordinary list definitions can still participate in the merge.

The package example makes the order easy to observe, but packages rarely need deliberate sorting. mkBefore and mkAfter are more useful for script fragments that run in sequence. They do not resolve file collisions between packages.

Custom Modules and Options
#

Splitting ordinary configuration into files already solves many problems. Every file does not need its own new options. But when several settings are often enabled together, with a few values that differ between computers, an option can make them easier to use.

We will reuse the previous post’s idea of creating an /etc file. Enabling the module creates a demonstration file whose text comes from an option. Create modules/demo-notice.nix:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{ config, lib, ... }:
let
  cfg = config.demo.notice;
in
{
  options.demo.notice = {
    enable = lib.mkEnableOption "the demonstration notice file";

    message = lib.mkOption {
      type = lib.types.str;
      default = "Hello from NixOS";
      description = "Text written to the demonstration notice file.";
    };
  };

  config = lib.mkIf cfg.enable {
    environment.etc."nixos-module-demo".text = cfg.message + "\n";
  };
}

demo.notice is a name chosen for this example, not a built-in NixOS feature. The options section declares two options: enable is a boolean switch that defaults to off, and message is a string with a default value. cfg is just a local abbreviation for config.demo.notice.

The config section describes what happens when the feature is enabled: the existing environment.etc option creates the file. Our module translates the newly declared options into settings NixOS already understands.

Once we use the full options and config structure, system settings must go inside config; we cannot also place environment.etc at the outer level. The earlier short modules, which only supplied settings, could omit that config wrapper.

Next, add ../../modules/demo-notice.nix to the host module’s existing imports list, and add these host settings:

1
2
3
4
demo.notice = {
  enable = true;
  message = "Hello from this computer";
};

Do not create a second imports = ...; in the same attribute set. Defining an attribute twice within one Nix attribute set is itself an error. The merging discussed above applies to definitions supplied by different modules.

Importing the module without enabling it does not create the demonstration file. Enabling it without setting message uses Hello from NixOS. Setting message to a number produces a type error when that option is evaluated, which is one benefit of declaring a type.

Conditional configuration with mkIf
#

cfg.enable comes from the final configuration, and this module helps produce that configuration. Writing config = if cfg.enable then { ... } else { }; can require the module system to know the configuration before it has determined which definitions exist, causing infinite recursion.

lib.mkIf lets the module system carry the condition into individual option definitions and process it there. Similarly, do not decide whether this module appears in imports based on config.demo.notice.enable. Import the module first, then use its switch to control the settings it contributes. mkIf cannot resolve circular dependencies: a switch depending on its own opposite still has no reasonable result.

Configuration Checks and Troubleshooting
#

We can observe these merging rules through evaluation without running switch each time. Assuming the current repository already provides a laptop host output, first add the new module to Git’s index:

1
2
git add modules/demo-notice.nix
nix eval --raw .#nixosConfigurations.laptop.config.demo.notice.message --no-update-lock-file

With the settings above, the result should be Hello from this computer. --raw outputs the string directly, without JSON quotes or an extra newline.

We can also inspect the text intended for the file:

1
nix eval --raw '.#nixosConfigurations.laptop.config.environment.etc."nixos-module-demo".text' --no-update-lock-file

This outputs the same text followed by a newline. It only evaluates the configuration; it does not create /etc/nixos-module-demo on the running computer.

The timezone can be queried too:

1
nix eval --raw .#nixosConfigurations.laptop.config.time.timeZone --no-update-lock-file

If the common module uses mkDefault "UTC" and the host uses the ordinary definition "Europe/Paris", the result should be Europe/Paris.

For an unexpected result, search your definitions and read the files named in the error:

1
rg -n 'timeZone|demo\.notice|mk(Default|Force|Before|After)' . --glob '*.nix'

To inspect the definitions contributing to the final timezone, you can also run:

1
2
3
nix eval --json .#nixosConfigurations.laptop.options.time.timeZone.definitionsWithLocations \
  --apply 'defs: map (d: { inherit (d) file value; }) defs' \
  --no-update-lock-file

This queries information under options, listing definition sources and their values. It is not a complete history of overridden settings: definitions discarded because of lower priority may be absent. Use it alongside source searches. The output can contain local paths, so review it before sharing an error report.

If an option does not exist at all, check its spelling, whether the module was imported, and which Nixpkgs version the example applies to. NixOS option search provides types, defaults and declaration locations; the selected version should correspond to your locked input.

Finally, check and build the target system using the previous post’s workflow:

1
2
3
git diff --check
nix flake check --no-build --show-trace --no-update-lock-file
nixos-rebuild build --flake .#laptop --no-update-lock-file

Querying one option evaluates only what it needs, so it does not replace checks of the whole system. After reviewing the build, decide whether to apply it. Only after activation should you use cat /etc/nixos-module-demo to inspect the actual file.

When checking AI-generated configuration, use these methods to assess the location of settings, defaults and overrides. The next post covers software installation and environment management through system configuration, Home Manager, temporary shells and project development environments.

NixOS Series - This article is part of a series.
Part 6: This Article