The Nix Way

· Yifei Sun

How to use Nix the "right" way to manage system configurations

This is a guide to my personal dotfiles repo (for NixOS and Darwin systems), use it as a reference to create your own.

# Installation

I suggest anyone that wants to try out Nix not to use the official installer, but to use the Nix Installer by Determinate Systems. It installs Nix, while providing a way to easily remove it from your system.

1curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install --no-confirm

# Flakes?

If you are following online guides, a lot of those will probably tell you to run some commands start with nix-env or nix-channel, don't use it! Those nix- commands are channel based, while "reproducible", but hard to maintain. Instead, use Nix Flakes, think of it as a way to pin channels to a specific commit, so you can get the same result every time if the same flake inputs are given.

While Nix Flakes are still marked as "experimental", but it does not mean it's unstable. Members of the Nix community are already using flakes at scale, I don't really see any way of flakes being removed from Nix.

If you installed Nix using the DetSys Nix Installer, you should already have flakes enabled. If not, prefix all nix commands with nix --extra-experimental-features "nix-command flakes". If you see something like echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf in a guide, don't do it either. Instead, in your flake configurations, enable experimental features like this (in the module format):

1{
2    nix.settings.experimental-features = [
3        "flakes"
4        "nix-command"
5    ];
6}

Read more about flakes:

# Don't want to use Flakes?

If you don't want to use flakes, there are still ways to pin your dependencies to a specific commit (the use of channels is not recommended). You can use niv or npins to pin your dependencies.

While niv and npin are good tools, IMO they are not as good as flakes. You can achieve similar results with niv and npin, but you'll have to install more tools and (potentially) write more code.

Similar effects can also be achieved with flake-compat, but you are still using flakes, just in a different way.

# Basics

Update (2024-02-01):

Before diving into system configurations, you should understand the following concepts:

I prepared a short slide with examples to help you understand these concepts, you can find it here and more resources at the end of this guide.

# Modules

In the example above, flakes and nix-command are enabled in the format of a module. Modules can be imported into system configurations, and they are usually considered as the most basic building blocks of nix-based configurations. They can be attrsets, or a function that returns an attrset.

Generally, modules are imported directly from flake outputs inside a system configuration:

 1{
 2    inputs = {
 3        nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
 4        nix-darwin = {
 5            url = "github:lnl7/nix-darwin";
 6            inputs.nixpkgs.follows = "nixpkgs";
 7        };
 8    };
 9
10    outputs = { self, nixpkgs, nix-darwin } @ inputs:
11    let
12        inherit (self) outputs;
13        lib = nixpkgs.lib // nix-darwin.lib;
14    in
15    {
16        nixosConfigurations."nixos-machine-name" = lib.nixosSystem {
17            specialArgs = { inherit inputs outputs; };
18            modules = [
19                ... # put modules here
20            ];
21        };
22        darwinConfigurations."darwin-machine-name" = lib.darwinSystem {
23            specialArgs = { inherit inputs outputs; };
24            modules = [
25                ... # put modules here
26            ];
27        };
28    };
29}

The specialArgs is used to pass arguments to modules, we added inputs and outputs to it so we can use them in modules like this:

1{ inputs, outputs, ... }:
2
3{
4    ... # inputs and outputs are available here
5    # example: inputs.nixpkgs or inputs.nix-darwin
6    # example: outputs.nixosConfigurations."nixos-machine-name"
7    # be very careful when using outputs, it can cause infinite recursion
8}

Standard arguments like config, pkgs, modulePath, ... are passed to modules automatically.

# Getting Started on Configurations

Depending on whether you have a NixOS or Darwin system (or both), you should decide on these couple of things:

Let's continue with the assumption that you have multiple NixOS and Darwin machines, and you want to use home-manager integrated into your system configuration. The first step would be adding flake inputs:

 1{
 2    inputs = {
 3        nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
 4        nix-darwin = {
 5            url = "github:lnl7/nix-darwin";
 6            inputs.nixpkgs.follows = "nixpkgs";
 7        };
 8        home-manager = {
 9            url = "github:nix-community/home-manager";
10            inputs.nixpkgs.follows = "nixpkgs";
11        };
12    };
13
14    outputs = { self, nixpkgs, nix-darwin, home-manager, ... } @ inputs:
15    let
16        inherit (self) outputs;
17        lib = nixpkgs.lib // nix-darwin.lib // home-manager.lib; # merge all libs together so we don't need to use them separately
18    in
19    {
20        nixosConfigurations."nixos-machine-name" = lib.nixosSystem {
21            specialArgs = { inherit inputs outputs; };
22            modules = [
23                ... # put modules here
24            ];
25        };
26        darwinConfigurations."darwin-machine-name" = lib.darwinSystem {
27            specialArgs = { inherit inputs outputs; };
28            modules = [
29                ... # put modules here
30            ];
31        };
32    };
33}

This is rather cumbersome, let's extract the common section out:

 1{
 2    inputs = { ... }; # same as above
 3
 4    outputs = { self, nixpkgs, nix-darwin, home-manager, ... } @ inputs:
 5    let
 6        inherit (self) outputs;
 7        lib = nixpkgs.lib // nix-darwin.lib // home-manager.lib; # merge all libs together so we don't need to use them separately
 8        mkSystem = { type, platform, config, username, stateVersion, hmstateVersion, extraModules, extraHMModules }: lib."${type}System" {
 9            specialArgs = { inherit inputs outputs; };
10            modules = [
11                config # path to system config, hardware configs are usually imported inside the config
12                { nixpkgs.hostPlatform = lib.mkDefault platform; } # set host platform
13                { system.stateVersion = stateVersion; }                                  # state version
14                { home-manager.users."${username}".home.stateVersion = hmstateVersion; } # home-manager state version
15
16                # system user
17                (../. + "/users/${username}") # or change it based on your directory structure
18
19                # integrated home-manager
20                home-manager."${type}Modules".home-manager
21                {
22                  home-manager.extraSpecialArgs = { inherit inputs outputs; };
23                  home-manager.useGlobalPkgs = true;
24                  home-manager.useUserPackages = true;
25                  home-manager.users."${username}" = {
26                    imports = [
27                      (../. + "/users/${username}/home.nix") # or change it based on your directory structure
28                    ] ++ extraHMModules;
29                  };
30                }
31            ] ++ extraModules;
32        };
33    in
34    {
35        # rec means recursive attrset, attrs inside recursive attrset can refer to other attrs inside the scope
36        nixosConfigurations."nixos-machine-name" = mkSystem rec { # we are using rec here since home-manager state version is the same as nixos state version
37            type = "nixos";
38            platform = "x86_64-linux"; # or "aarch64-linux"
39            config = ./path/to/nixos/configuration.nix;
40            username = "your-username";
41            stateVersion = "24.05";         # string type
42            hmstateVersion = stateVersion;  # string type
43            extraModules = [
44                # extra nixos modules
45            ];
46            extraHMModules = [
47                # extra home-manager modules
48            ];
49        };
50        # we don't need to use rec here since we are not referring to other attrs
51        darwinConfigurations."darwin-machine-name" = mkSystem {
52            type = "darwin";
53            platform = "x86_64-darwin"; # or "aarch64-darwin"
54            config = ./path/to/darwin/configuration.nix;
55            username = "your-username";
56            stateVersion = 4;              # integer type
57            hmstateVersion = "24.05";      # string type
58            extraModules = [
59                # extra darwin modules
60            ];
61            extraHMModules = [
62                # extra home-manager modules
63            ];
64        };
65    };
66}

mkSystem is a function that returns a system config, it assumes you have the following directory structure:

 1- flake.nix
 2- flake.lock
 3- some-other-directory-that-stores-your-system-config
 4  - ...
 5- some-other-directory-that-stores-your-modules
 6  - ...
 7- users
 8  - your-username
 9    - home.nix    # home-manager module
10    - default.nix # nixos/darwin module

You can change the directory structure, but you'll need to change the paths in mkSystem accordingly. Note that users/${username}/default.nix is a nixos/darwin module, it's content must match the definitions in nixpkgs or nix-darwin. users/${username}/home.nix is a home-manager module, it's content must match the definitions in home-manager. Similarly, the modules in extraModules must be from nixpkgs or nix-darwin, and the modules in extraHMModules must be from home-manager. Putting the mkSystem function in a separate file is also a good idea, check out Haumea to easily manage your custom libraries.

# Conflicts? System-Dependent Configs?

Some options might only be available in nixpkgs options but not in nix-darwin options, or vice versa. To address this, lib.optionalAttrs can be very useful:

 1{ config
 2, lib
 3, pkgs
 4, ...
 5}:
 6
 7{
 8  programs.zsh.enable = true;
 9
10  users.users."your-username" = {
11    shell = pkgs.zsh;
12
13    description = "Your Username";
14    home =
15      if pkgs.stdenv.isLinux
16      then lib.mkDefault "/home/your-username"
17      else if pkgs.stdenv.isDarwin
18      then lib.mkDefault "/Users/your-username"
19      else abort "Unsupported OS";
20
21    openssh.authorizedKeys.keys = [
22      "ssh-ed25519 ...";
23      "ssh-ed25519 ...";
24    ];
25  } // lib.optionalAttrs pkgs.stdenv.isLinux {
26    isNormalUser = true;
27    extraGroups = [ "wheel" "networkmanager" "input" "audio" "video" ];
28    hashedPassword = "...";
29  };
30}

In the example above, users.users.<username>.isNormalUser is only available in nixpkgs (for NixOS systems, not Darwin), so we use lib.optionalAttrs pkgs.stdenv.isLinux to make it only available on Linux systems or nix-darwin will throw an error.

Also, if you only want some packages to be installed on a specific system, lib.optionals can make attributes appear or disappear based on conditions:

 1{ config
 2, lib
 3, pkgs
 4, ...
 5}:
 6
 7{
 8  # available on all systems
 9  home.packages = with pkgs; [
10    nix-output-monitor
11    # ...
12  ]
13  # linux only and when hyprland is enabled
14  ++ (lib.optionals (pkgs.stdenv.isLinux && config.wayland.windowManager.hyprland.enable) [
15    cider
16    # ...
17  ])
18  # darwin only
19  ++ (lib.optionals pkgs.stdenv.isDarwin [
20    cocoapods
21    # ...
22  ]);
23}

# Resources

Nix's documentation is bad, my best advise is get used to reading the source code, and messing around with it using nix repl. Instead of complaining about the documentation, use online resources like official discourse and github code search (query with lang:Nix).


1# Use Glow to view this page in terminal
2$ glow https://static.ysun.co/txts/<path>.md