raccoon's zone!

This web is still being woven. Please don't be surprised if it changes from time to time...
Contents

last updated 2024-01-06 for nix 2.13 - 2.18, nixos 23.05 - 23.11

NixOS Modules

Continuing from the introduction, this is a guide to NixOS and how it builds on the concepts introduced by Nix. In particular we'll talk about how to use its configuration module system to customize NixOS systems as well as Home Manager user environments.

If you have any questions, complaints, or suggestions, contact us!

What is NixOS?

NixOS is a linux distribution that's built with Nix and configured entirely in the Nix language with a declarative configuration library in Nixpkgs called the module system.

Additionally, unlike a normal distribution, all system applications and libraries are managed entirely in the Nix store, too. The usual global locations look like this on NixOS!

$ cd /
$ tree bin usr sbin lib lib64
bin
└── sh -> /nix/store/...
usr
└── bin
    └── env -> /nix/store/...
sbin  [error opening dir]
lib  [error opening dir]
lib64  [error opening dir]    

Since Nix isn't allowed to affect the filesystem outside the store directly, NixOS functions as a declarative "system generator", producing a script inside the store that, when run, idempotently transmutes the currently running system configuration into the new one.

nixos-rebuild

The way NixOS "escapes the store" to modify system state is through the script nixos-rebuild. You can read the code to see what it does, but essentially, if you run nixos-rebuild switch, this is what happens:

# build the system described by the configuration
#
# <nixpkgs/nixos> evaluates the configuration at $NIXOS_CONFIG, or
# at <nixos-config>, which is by default `/etc/nixos/configuration.nix`
system=`nix build --impure --no-link --print-out-paths \
  --expr '(import <nixpkgs/nixos> {}).config.system.build.toplevel'`

# update the system's profile with a new generation that points to the new system object
nix build --profile /nix/var/nix/profiles/system $system

# pass off execution to a script inside the new system object, allowing it to
# 1. update the bootloader
# 2. stop/start/reload/restart systemd units
# 3. mount/unmount/remount filesystems
# 4. run $system/activate, which is a series of idempotent "activation scripts"
#    that make sure the running system conforms to the new configuration
$system/bin/switch-to-configuration switch

That last step is important: this is the only way a NixOS system mutates system state outside the store! Once in $system/bin/switch-to-configuration for every configuration switch, and once in $system/activate for every boot.

The other subcommands of nixos-rebuild do small variations on the same thing:

# nixos-rebuild...
# `
# | subcommand   | builds what?       | updates profile? | switch-to-configuration? |
# +--------------+--------------------+------------------+--------------------------+
# | switch       | toplevel           | yes              | yes                      |
# | boot         | toplevel           | yes              | yes                      |
# | test         | toplevel           | no               | yes                      |
# | dry-activate | toplevel           | no               | yes                      |
# | build        | toplevel           | no               | no                       |
# | dry-build    | toplevel --dry-run | no               | no                       |
# | build-vm     | vm                 | no               | no                       |
# | build-vm-wi..| vmWithBootloader   | no               | no                       |
#   th-bootloader
# `
# switch-to-configuration...
# `
# | subcommand   | installs bootloader? | activate?        |
# +--------------+----------------------+------------------+
# | switch       | yes                  | yes              |
# | boot         | yes                  | no               |
# | test         | no                   | yes              |
# | dry-activate | no                   | no, dry-activate |
# `

Switching to a new configuration is roughly equivalent to installing the system for the first time. Every deviation from the "default" NixOS system is recorded in your declarative configuration. Normal distributions can suffer from technical debt from the weight of forgotten ad hoc modifications to system configuration over long periods of time, but NixOS's declarative system allows you to largely avoid this.

However, keep in mind NixOS is not a totally immutable system. Most of the root filesystem can exist on a tmpfs or get wiped on reboot and NixOS will function normally, but you should take care to understand what kind of important state is persisted across system generations:

# for booting and activating the system
# nixos can boot if you delete *everything but these*, it just won't remember any state!
/boot/
/nix/

# for identifying a machine across reboots
/etc/machine-id
/etc/ssh/ssh_host_*

# for persisting ad hoc users/groups, unless all are declarative
/etc/{passwd,shadow,group,subuid,subgid}

# user state
/root/
/home/

# application state
/var/{db,log,lib,spool}/

The Module System

So, to modify the behavior of $system/bin/switch-to-configuration and enter a new system state, we need to know how to modify the configuration that <nixpkgs/nixos> evaluates.

A NixOS configuration is merged together from a collection of modules. Modules are specially structured attribute sets that can

They look like this:

{ 
  config, # the final configuration after merging all option definitions
  options, # the set of all declared options
  lib, # the nixpkgs standard library
  pkgs, # the nixpkgs package set
  modulesPath, # <nixpkgs/nixos/modules>
  ...
}: {
  imports = [
    # paths to other modules
  ];

  options = {
    # option declarations
  };

  config = {
    # option definitions
  };
}

or, with no option declarations, like this:

{ ... }: {
  imports = [
    # paths to other modules
  ];

  # option definitions
}

or, with no imports or arguments, like this:

{
  # option definitions
}

An important property of modules is how they combine into a single final configuration set.

When two different modules define the same option to be two different values, the configuration normally fails to evaluate. But when those values are sets or lists, their values are merged together:

# assuming two declared options `someList` and `someSet`...

# imported.nix
{
  someList = [ 1 ];
  someSet.a = 1;
}

# main.nix
{
  imports = [ ./imported.nix ];

  someList = [ 2 ];
  someSet.b = 2;
}

# the resulting configuration
{
  someList = [ 1 2 ];
  someSet = {
    a = 1;
    b = 2;
  };
}

configuration.nix

Okay, so, now that we know how modules work, we can modify our configuration at /etc/nixos/configuration.nix, which you'll be delighted to know is just another module! Specifically, one which does not declare any new options, just defines existing ones. This kind of module is sometimes referred to as a profile.

To find out what options exist for you to change, you can use the search tool or take a look around the modules directory. For instance, if you wanted to use the KDE desktop environment, you might make it so that services.xserver.desktopManager.plasma5.enable = true.

Remember that these are normal Nix attribute sets, nothing special, so a.b.c = true; is the very same thing as a = { b = { c = true; }; };.

Properties

Special functions provided by lib can also be applied to option definitions in order to change their behavior during merging.

{ lib, config, ... }:
with lib;
{
  ## Overriding
  # if you want to be able to change a scalar option that's already been defined
  # in another module, you can change a definition's override priority to
  # signal which should be preferred.
  #
  # for set/list options, this will also totally replace lower-priority definitions
  # rather than merging like normal.
  
  # lower priority = more preferred
  # the default is 100
  myScalarOption = mkOverride 1 "important!!!";

  # presets are also available
  myScalarOption = mkDefault "not very important..."; # mkOverride 1000
  myScalarOption = mkForce   "kind of a big deal!!!"; # mkOverride 50

  ## Ordering
  # if you want to make sure definitions are merged in a *specific order*, you can
  # change a definition's order priority, too

  # default is 1000
  myListOption = mkOrder 1 [ "i go first!!!" ];

  # and, the presets
  myListOption = mkBefore [ "put me in early :D" ]; # mkOrder 500
  myListOption = mkAfter  [ "fashionably late ;)" ]; # mkOrder 1500

  ## Conditional and Assertion
  # you can also keep a portion of the configuration from being defined unless
  # a predicate is `true`
  myMaybeOption = mkIf config.foo.enabled "success!";

  # or, more forcefully, you can assert a precondition and throw otherwise
  myMaybeOption = mkAssert config.foo.enabled
    "you need to enable foo to use this!"
    "success!";
  
  ## Merge
  # finally, you can invoke the merge algorithm on a list of sets yourself, as if
  # each of the sets were inside a different module

  # this can be convenient in cases where you want to apply different properties to
  # different subsets of a config
  myMergedConfig = mkMerge [
    (mkDefault {
      a = 1;
      c = 3;
    })
    (mkIf config.foo.enabled {
      b = 2;
      c = 4;
    })
  ];
  # if config.foo.enabled == true:
  #   { a = 1; b = 2; c = 4; }
  # otherwise:
  #   { a = 1; c = 3; }
}

Declaring Options

Finally, if you want to make your own option-declaring modules like the ones NixOS provides, you need to know how to use another library function, mkOption:

{ lib, pkgs, ... }:
with lib;
{
  options.foo = {
    myOption = mkOption {
      # all attributes are optional, `null` if unset

      # behavior:
      default = "world", # what does this option default to if left undefined?
      type = types.str, # what type of data is this option?
      apply = x: "Hello, ${x}!", # transforms the value given to a different result
      readOnly = false, # can this only be written once?

      # documentation:
      description = ..., # description of option
      defaultText = ..., # string representation of `default`
      example = ..., # example value
      internal = false, # is this for nixos developers only?
      visible = true # should this be visible in documentation?
    };

    # option to enable a module is so common it gets a shortcut
    enable = mkEnableOption "foo";
    # equivalent to:
    enable = mkOption {
      default = false;
      example = true;
      description = "Whether to enable foo.";
      type = types.bool;
    };

    # also, a shortcut for creating a package option
    myPackage = mkPackageOption
      pkgs # package set
      "foo" # package name
      {
        # all attributes are optional

        nullable = false; # whether the package can be null
        default = "foo"; # default package, by default the name specified above
        example = ...;
        extraDescription = ...;
      };
  };

  config = mkIf config.foo.enable {
    /* ... */
  };
}

Modules outside NixOS

The module system exists in the Nixpkgs standard library, cleanly seperate from the NixOS-specific machinery. As such, now that you know how modules work in general, you can configure your dotfiles in Nix using Home Manager (even on non-NixOS systems), or manage macOS systems with nix-darwin!

You can manage all of your configurations from one single repository using flakes :)