Options and modules

A NixOS or Home-Manager system is described by options you set and modules that group those options together. Once you see how the two fit, a generated Ternix config stops looking like magic and becomes a set of values you can read and change with confidence. The claims on this page follow the official NixOS manual writing modules chapter and the nix.dev module system tutorial.

What an option is

An option is a named, typed setting that some module declares and you give a value to. Two distinct acts are involved and keeping them apart is the single most useful idea on this page. One module declares an option, which means it states the name, the type, a default, and what the option does. You then set it, which means you assign a value in your own configuration.

You almost always only do the setting. Here you set one option that the upstream nginx module already declared for you.

{ config, pkgs, ... }:
{
  services.nginx.enable = true;
}

The declaration lives inside nixpkgs. It looks roughly like the block below, and you rarely write this yourself until you build your own module.

{ lib, ... }:
{
  options.services.nginx.enable = lib.mkEnableOption "the nginx web server";
}

lib.mkEnableOption is a helper that declares a boolean option defaulting to false with a generated description. Setting services.nginx.enable = true flips that one value, and the nginx module reacts by adding the service, its user, and its systemd unit.

Option sets

Options are not a flat list. They are organised into nested attribute sets, so related settings share a common path. Everything under services.nginx is one such set, and services.nginx.enable is a single leaf inside it.

{
  services.nginx.enable = true;
  services.nginx.virtualHosts."example.com".root = "/var/www/example";
  services.nginx.recommendedGzipSettings = true;
}

The three lines above all live under the services.nginx branch. enable and recommendedGzipSettings are leaves that hold a value directly. virtualHosts is itself a set of sub-options, which is why you can address one host by name and then reach root underneath it. Because these are ordinary attribute paths, the dotted form and the nested form mean the same thing.

{
  services.openssh = {
    enable = true;
    settings.PasswordAuthentication = false;
  };
}

Reading a config is mostly a matter of following these paths down to the leaf you care about.

The shape of a module

A module is a function from an attribute set of arguments to an attribute set with up to three keys. The full form spells out all three.

{ config, pkgs, lib, ... }:
{
  imports = [
    ./hardware-configuration.nix
  ];
 
  options = {
    # option declarations go here
  };
 
  config = {
    services.openssh.enable = true;
    environment.systemPackages = [ pkgs.git ];
  };
}

The arguments are passed in by the module system. config is the final merged configuration of the whole system, pkgs is the package set, and lib is the nixpkgs library of helper functions. The trailing ... lets the system pass extra arguments your module does not name without causing an error.

imports pulls in other module files so they evaluate as part of the same system. options declares new options. config sets values.

Most files you will ever touch, including the configuration.nix that Ternix generates, skip the options and config split entirely.

{ config, pkgs, ... }:
{
  imports = [ ./hardware-configuration.nix ];
 
  networking.hostName = "nixos-host";
  services.openssh.enable = true;
  environment.systemPackages = [ pkgs.git pkgs.vim ];
 
  system.stateVersion = "24.11";
}

This is shorthand. When a module has no options key, the module system treats the whole body as config, so the file above is identical to one that wrapped those four assignments in a config = { ... } block. You only need the explicit split once you declare your own options, covered in the Advanced section below.

Option types

Every declared option has a type drawn from lib.types. The type checks the values you assign and decides how several modules setting the same option are merged. These are the ones you meet most often.

lib.types.bool                       # true or false
lib.types.int                        # an integer
lib.types.str                        # a string
lib.types.enum [ "yes" "no" ]        # one of a fixed list of values
lib.types.listOf lib.types.str       # a list whose items are strings
lib.types.attrsOf lib.types.int      # an attribute set whose values are ints
lib.types.nullOr lib.types.str       # a string, or null
lib.types.package                    # a derivation such as pkgs.git
lib.types.path                       # a filesystem path
lib.types.submodule { options = { /* nested options */ }; }

submodule is how an option holds a structured value with its own options inside it, which is exactly what powers services.nginx.virtualHosts.<name>. You declare an option with lib.mkOption, passing the type along with a default, a description, and an example.

{ lib, ... }:
{
  options.services.myapp.port = lib.mkOption {
    type = lib.types.port;
    default = 8080;
    example = 3000;
    description = "TCP port the app listens on.";
  };
}

The default is used when nobody sets the option. The description and example show up in the manual and in option search , which is why well-documented modules are pleasant to use.

How values merge

A single option can be set by more than one module, and the module system combines those settings rather than letting the last one silently win. The merge rule depends on the type. Lists concatenate, attribute sets join key by key, and plain scalars must agree or you get a conflict error.

# module A
{ environment.systemPackages = [ pkgs.git ]; }
 
# module B
{ environment.systemPackages = [ pkgs.vim ]; }
 
# merged result
# environment.systemPackages = [ pkgs.git pkgs.vim ];

Scalars are stricter. If two modules both set networking.hostName to different strings, evaluation fails with a conflict, because there is no sensible way to have two host names at once. Priorities exist to resolve exactly that case. A value can carry a priority number, and the lowest number wins. The helpers below wrap a value with a priority so you do not count numbers by hand.

lib.mkDefault value      # priority 1000, a weak default you expect to override
value                    # priority 100, a normal assignment
lib.mkForce value        # priority 50, beats normal assignments
lib.mkOverride 25 value  # any explicit priority, lower number wins

A concrete conflict and its resolution looks like this. A shared base module sets a weak default, your host overrides it normally, and a hardening module forces the final value.

# base.nix, a soft default that anything can override
{ services.openssh.settings.PasswordAuthentication = lib.mkDefault true; }
 
# host.nix, a normal assignment, wins over the mkDefault above
{ services.openssh.settings.PasswordAuthentication = false; }
 
# hardening.nix, mkForce wins over the normal assignment in host.nix
{ services.openssh.settings.PasswordAuthentication = lib.mkForce false; }

For list and string options where order matters, two more helpers control where a contribution lands rather than which value wins. lib.mkBefore pushes a module's contribution toward the front of the merged value and lib.mkAfter toward the back. They only affect ordering between several modules setting the same option, so the two snippets below have to target the same option to do anything.

# module A, its line lands at the front of the merged shellInit
{ environment.shellInit = lib.mkBefore "echo first"; }
# module B, its line lands at the back
{ environment.shellInit = lib.mkAfter "echo last"; }

How the config works end to end

Setting options would be pointless if nothing turned them into a running system. Here is the pipeline, from the flake output down to an activated generation.

The flake output nixosConfigurations.nixos-host calls nixpkgs.lib.nixosSystem, passing the system and the list of modules.

{
  outputs = { self, nixpkgs, ... }: {
    nixosConfigurations.nixos-host = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [ ./configuration.nix ];
    };
  };
}

nixosSystem loads every module in that list, follows each imports, evaluates all the options declarations and config assignments, and merges them with the priority rules from the previous section. The outcome is one large config value that holds the final answer for every option in the system.

From that merged config, the NixOS modules build config.system.build.toplevel, a single derivation that represents the entire system. It bundles the kernel, the initrd, every package, the systemd units, and the activation script that wires them together.

# build the whole system derivation without switching to it
nix build .#nixosConfigurations.nixos-host.config.system.build.toplevel

nixos-rebuild switch --flake .#nixos-host builds that same derivation and then activates it as a new generation, registering it with the bootloader and starting or restarting the affected services. Older generations stay on disk, so a rollback is just booting or switching to a previous one.

One detail makes the whole thing possible. The evaluation is a fixpoint, which means each module receives the fully merged config as an argument even while that same config is still being computed. A module can read config.networking.hostName to decide what to put in config.environment.etc, and Nix resolves the dependency graph for you as long as no option truly depends on itself. This is why let cfg = config.services.myapp; in works inside the very module that helps define config.

Inspecting options on a real system

You do not have to read nixpkgs source to learn an option. Three tools answer most questions. nixos-option prints the value and type of any option path on the current system.

nixos-option services.openssh.enable
nixos-option services.nginx.virtualHosts

For interactive exploration, open the flake in nix repl and walk the config tree by hand. :lf . loads the current flake, after which you can tab-complete into the evaluated configuration.

nix repl
nix-repl> :lf .
nix-repl> nixosConfigurations.nixos-host.config.networking.hostName
"nixos-host"
nix-repl> nixosConfigurations.nixos-host.config.services.openssh.enable
true

When you want to discover which option exists in the first place, the web option search indexes every option in nixpkgs with its type, default, and description.

Advanced, writing your own module

Putting the pieces together, here is the classic pattern for a self-contained service module. It declares an enable switch and a port, binds config to a short name, and uses lib.mkIf so the config block only takes effect when the service is enabled.

{ config, lib, pkgs, ... }:
 
let
  cfg = config.services.myapp;
in
{
  options.services.myapp = {
    enable = lib.mkEnableOption "myapp";
 
    port = lib.mkOption {
      type = lib.types.port;
      default = 8080;
      description = "Port myapp listens on.";
    };
  };
 
  config = lib.mkIf cfg.enable {
    networking.firewall.allowedTCPPorts = [ cfg.port ];
 
    systemd.services.myapp = {
      wantedBy = [ "multi-user.target" ];
      serviceConfig.ExecStart = "${pkgs.myapp}/bin/myapp --port ${toString cfg.port}";
    };
  };
}

lib.mkIf cfg.enable returns the whole attribute set when cfg.enable is true and an empty set otherwise, so a disabled service contributes nothing to the merged system. Drop this file into your imports and the option services.myapp.enable becomes settable like any built-in one.

Next

To change values in a real config, see Editing your config. For how the flake output that drives all of this is built, read Flakes. If the Nix syntax above is unfamiliar, the Nix language primer covers it. To build and activate what you have set, see Commands and scripts.