Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter1

✔️ Click to Expand Table of Contents

gruv13

Intro

Welcome to nix-book, an introductory book about Nix. This book leans more towards using Flakes but will contrast traditional Nix where beneficial. Originally, this content started as a blog. I'm refining its flow to make it more cohesive.

In this chapter, I will touch on the different parts of the Nix ecosystem, give a quick example of each and explain how they fit together.

- ✔️: Will indicate an expandable section, click the little triangle to expand.
  • These sections are expandable!

The code blocks have an option to hide code, where I find it reasonable I will hide the outputs of the expressions. Click the eye in the right corner of the code block next to the copy clipboard.

Example click the eye to see hidden text:

{
  attrset = { a = 2; b = 4; };
  hidden_set = { a = hidden; b = set; };
}

❗ If you're new to Nix, think of it as a recipe book for software: you describe what you want (declarative), and Nix ensures it’s built the same way every time (reproducible).

Why Learn Nix?

The main reason to learn Nix is that it allows you to write declarative scripts for reproducible software builds. Rather than mutate the global state and install packages to a global location such as /usr/bin Nix stores packages in the Nix store, usually the directory /nix/store, where each package has its own unique subdirectory. This paradigm gives you some powerful features, such as:

  • Allowing multiple versions or variants of the same package at the same time. This prevents "DLL hell" from different applications having dependencies on different versions of the same package.

  • Atomic upgrades: Upgrading or uninstalling an application cannot break other applications and either succeed completely or fail completely preventing partial upgrades breaking your system. The nix store is immutable preventing package management operations from overwriting other packages. They wouldn't overwrite each other anyways because the hashing scheme ensures that new versions or repeat packages end up at different paths.

  • Nix is designed to provide hermetic builds that aren't affected by the environment, this helps you make sure that when packaging software that the dependencies are complete because they must be explicitly declared as inputs. With other package managers it is more difficult to be sure that an environment variable or something in your $PATH isn't affecting your build.

Let’s dive into the key characteristics of Nix:

ConceptDescription
PureFunctions don't cause side effects.
FunctionalFunctions can be passed as arguments and returned as results.
LazyNot evaluated until needed to complete a computation.
DeclarativeDescribing a system outcome.
ReproducibleOperations that are performed twice return same results

❗ Important: In Nix, everything is an expression, there are no statements.

❗ Important: Values in Nix are immutable.

The Nix Ecosystem

The Nix Language is the foundation of the ecosystem and is used to write Nix Expressions.

Example of a simple nix expression:

{ hello = "world"; }
# or
"foo" + "bar"

While the Nix language provides the foundation for writing expressions, it is only part of the ecosystem. These expressions become powerful when used within the Nix Package Manager, which evaluates and realizes them into tangible software builds and system configurations. This is where Nixpkgs and NixOS come into play.

The Nix Package Manager, Nixpkgs, and NixOS

At the heart of the Nix ecosystem is Nix Package Manager. This powerful engine is responsible for orchestrating the entire process: taking Nix expressions (like package definitions and configuration modules), evaluating them into precise derivations, executing their build steps (the realization phase), and meticulously managing the immutable Nix store.

A cornerstone of the Nix ecosystem is Nixpkgs. This vast collection comprises tens of thousands of Nix expressions that describe how to build a wide array of software packages from source. Nixpkgs is more than just a package repository—it also contains NixOS Modules, declarative configurations that define system behavior, ensuring a structured and reproducible environment. These modules enable users to declaratively describe a Linux system, with each module contributing to the desired state of the overall system by leveraging package definitions and derivations. This is how NixOS emerges: it is quite simply the natural consequence of applying the Nix philosophy to building an entire Linux operating system.

We will further expand our understanding of modules in Chapter 3

The following is an example of a NixOS module that is part of the nixpkgs collection:

# nixpkgs/nixos/modules/programs/zmap.nix
{
  pkgs,
  config,
  lib,
  ...
}:

let
  cfg = config.programs.zmap;
in
{
  options.programs.zmap = {
    enable = lib.mkEnableOption "ZMap, a network scanner designed for Internet-wide network surveys";
  };

  config = lib.mkIf cfg.enable {
    environment.systemPackages = [ pkgs.zmap ];

    environment.etc."zmap/blacklist.conf".source = "${pkgs.zmap}/etc/zmap/blacklist.conf";
    environment.etc."zmap/zmap.conf".source = "${pkgs.zmap}/etc/zmap.conf";
  };
}
  • This module, programs.zmap.nix, demonstrates how NixOS configurations work. It defines an enable option for the ZMap network scanner. If enabled by the user in their system configuration, the module ensures the zmap package is installed and its default configuration files are placed in /etc, allowing ZMap to be managed declaratively as part of the operating system.

    • When nixpkgs is imported (e.g., in a NixOS configuration), the configuration options and settings defined by its modules (like programs.zmap.nix) become available for use, typically accessed via dot notation (e.g., config.programs.zmap.enable). This ability to make such a huge set of modules and packages readily available without a significant performance penalty is due to Nix's lazy evaluation; only the expressions required for a particular build or configuration are actually evaluated.

    • Most of the time you'll simply search to see if the package is already included in nixpkgs and follow the instructions there to get it on your system. It is good practice to first search for the options to see what configurable settings are available, and then proceed to search for the package itself if you know it exists or if you need its specific package definition. When you look up the options for Zmap, programs.zmap.enable is all that is listed in this example.

    • Home Manager uses the same underlying Nix module system as NixOS, and when you do something like home.packages = with pkgs; you are referring to the same package derivations from nixpkgs as you would with environment.systemPackages. However, Home Manager's own configuration modules (e.g., for programs.zsh or git) are distinct and reside in the Home Manager repository, designed for user-specific configurations.

One of the main differentiating aspects of Nix, as opposed to traditional package managers, is this concept that package builds are treated as pure functions. This functional paradigm ensures consistency and reproducibility, which are core tenets of the Nix philosophy.

Nix is not

Fig. X: Conceptual diagram illustrating the distinction between Nix and NixOS. Source: xeiaso, from the blog post "Making NixOS modules for fun and (hopefully) profit", https://xeiaso.net/talks/asg-2023-nixos/.

Nix expressions permeate the ecosystem—everything in Nix is an expression, including the next key components: package definitions and derivations.

Package Definitions & Derivations

Package Definitions are specialized expressions that tell Nix how to build software.

Example of a package definition:

# hello.nix
{pkgs ? import <nixpkgs> {}}:
pkgs.stdenv.mkDerivation {
  pname = "hello";
  version = "2.12.1";

  src = pkgs.fetchurl {
    url = "https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz";
    sha256 = "086vqwk2wl8zfs47sq2xpjc9k066ilmb8z6dn0q6ymwjzlm196cd";
  };

  nativeBuildInputs = [pkgs.autoconf pkgs.automake pkgs.gcc];

  configurePhase = ''
    ./configure --prefix=$out
  '';

  buildPhase = ''
    make
  '';

  installPhase = ''
    make install
  '';
}
  1. Evaluation Phase:

Now when you run something like:

nix-instantiate hello.nix
warning: you did not specify '--add-root'; the result might be removed by the garbage collector
/nix/store/p2hbg16a9kpqgx2nzcsq39wmnyxyq4jy-hello-2.12.1.drv
  • Nix evaluates the expression and produces a .drv file (the derivation), a precise JSON-like blueprint describing how the package will be built. It does not contain the built software itself.
  1. Realization Phase:

When you run:

nix-build hello.nix
#...snip...
shrinking RPATHs of ELF executables and libraries in /nix/store/53hqyw72dijq3wb5kc0ln04g681gk6cp-hello-2.12.1
shrinking /nix/store/53hqyw72dijq3wb5kc0ln04g681gk6cp-hello-2.12.1/bin/hello
checking for references to /build/ in /nix/store/53hqyw72dijq3wb5kc0ln04g681gk6cp-hello-2.12.1...
gzipping man pages under /nix/store/53hqyw72dijq3wb5kc0ln04g681gk6cp-hello-2.12.1/share/man/
patching script interpreter paths in /nix/store/53hqyw72dijq3wb5kc0ln04g681gk6cp-hello-2.12.1
stripping (with command strip and flags -S -p) in  /nix/store/53hqyw72dijq3wb5kc0ln04g681gk6cp-hello-2.12.1/bin
/nix/store/53hqyw72dijq3wb5kc0ln04g681gk6cp-hello-2.12.1
  • Nix realizes the derivation by actually executing the build steps, fetching sources, compiling (if needed), and producing the final result (typically stored in e.g. /nix/store/53hqyw72dijq3wb5kc0ln04g681gk6cp-hello-2.12.1)

  • nix-build also creates a symlink named result in your current directory, pointing to the final build output in the Nix store.

  1. Execute the program:
./result/bin/hello
Hello, world!

result/bin/hello points to the executable inside the output of the derivation.The derivation describes how the package is built, but does not include the final binaries.

To say that another way, the derivation is not the executable. The executable is one of the derivations outputs. When Nix "realizes" a derivation, it executes those build instructions, and the result is the actual built software, which gets placed into its own unique path in the Nix store.

A single derivation can produce multiple outputs. The executable is typically part of the out output, specifically in its bin directory.

Here is a small snippet of what a .drv file could look like, I got this from building the hello derivation and running the following on the store path:

nix show-derivation /nix/store/9na8mwp5zaprikqaqw78v6cdn1rxac7i-hello-2.12.1
{
  "/nix/store/871398c9cbskmzy6bvfnynr8yrlh7nk0-hello-2.12.1.drv": {
    "args": [
      "-e",
      "/nix/store/v6x3cs394jgqfbi0a42pam708flxaphh-default-builder.sh"
    ],
    "builder": "/nix/store/1jzhbwq5rjjaqa75z88ws2b424vh7m53-bash-5.2p32/bin/bash",
    "env": {
      "__structuredAttrs": "",
      "buildInputs": "",
      "builder": "/nix/store/1jzhbwq5rjjaqa75z88ws2b424vh7m53-bash-5.2p32/bin/bash",
      "cmakeFlags": "",
      "configureFlags": "",
      "depsBuildBuild": "",
      "depsBuildBuildPropagated": "",
      "depsBuildTarget": "",
      "depsBuildTargetPropagated": "",
      "depsHostHost": "",
      "depsHostHostPropagated": "",
      "depsTargetTarget": "",
      "depsTargetTargetPropagated": "",
      "doCheck": "",
      "doInstallCheck": "",
      "mesonFlags": "",
      "name": "hello-2.12.1",
      "nativeBuildInputs": "",
      "out": "/nix/store/9na8mwp5zaprikqaqw78v6cdn1rxac7i-hello-2.12.1",
      "outputs": "out",
      "patches": "",
      "pname": "hello",
      "propagatedBuildInputs": "",
      "propagatedNativeBuildInputs": "",
      "src": "/nix/store/pa10z4ngm0g83kx9mssrqzz30s84vq7k-hello-2.12.1.tar.gz",
      "stdenv": "/nix/store/80wijs24wjp619zmrasrh805bax02xjm-stdenv-linux",
      "strictDeps": "",
      "system": "x86_64-linux",
      "version": "2.12.1"
    },
# ... snip ...

Conclusion

In this introductory chapter, we've laid the groundwork for understanding the powerful Nix ecosystem. We explored how the Nix Language forms the declarative bedrock, enabling us to define desired system states and software builds as expressions. You saw how the Nix Package Manager orchestrates this process, transforming those expressions into precise derivations during the evaluation phase, and then faithfully "realizing" them into reproducible, isolated artifacts within the immutable /nix/store.

We also introduced the vast Nixpkgs collection, which provides tens of thousands of package definitions and forms the foundation for NixOS — a fully declarative operating system built on these principles—and even user-level configurations like those managed by Home Manager. This unique functional approach, with its emphasis on immutability and lazy evaluation, is what enables Nix's promises of consistency, atomic upgrades, and truly hermetic builds, fundamentally changing how we think about software and system management.

Now that you have a foundational understanding of the Nix ecosystem and its core operational cycle, we are ready to delve deeper into the building blocks of Nix expressions. In the next chapter, Understanding Nix Functions, we will peel back the layers and explore the intricacies of function arguments, advanced patterns, scope, and how functions play a crucial role in building more sophisticated Nix expressions and derivations.

Here are some resources that are helpful for getting started:

Resources

✔️ Resources (Click to Expand)

Nix Language

✔️ Click to Expand Table of Contents

lambda1

Nix Expression Language Syntax Overview

The Nix language is designed for conveniently creating and composing derivations precise descriptions of how contents of files are used to derive new files. --Nix Reference Manual

Nix is often described as “JSON with functions.” It’s a declarative language where you define outcomes, not step-by-step instructions. Instead of writing sequential code, you create expressions that describe data structures, functions, and dependencies. These expressions are evaluated lazily, meaning Nix computes values only when needed, making it efficient for managing large systems.

You can plug most of the following into the nix repl I'm showing it in a single code block here for brevity:

# Comments Look Like This!

# Strings
"This is a string"          # String literal

''
one
two                        # multi-line String
three
''

("foo" + "bar")           # => "foobar"

"foo" != "bar"   # Inequality test  # => true

!false      # => true

("Home dir is ${builtins.getEnv "HOME"}")  # String Interpolation
# => "Home dir is /home/jr"

"3 6 ${builtins.toString 9}"
# => "3 6 9"

"goodbye ${ { d = "world";}.d}"
# => "goodbye world"

# Booleans

(false && true)    # AND         # => false

(true || false)    # OR         # => true

(if 6 < 9 then "yay" else "nay")  # => "yay"

null      # Null Value

679       # Integer

(6 + 7 + 9) # => 22   # Addition

(9 - 3  - 2) # => 4   # Subtraction

(6 / 3)  # => 2       # Division
6.79      # Floating Point

/etc/nixos      # Absolute Path

../modules/nixos/boot.nix    # relative

# Let expressions

(let a = "2"; in                   # Let expressions are a way to create variables
a + a + builtins.toString "4")
# => "224"

(let first = "firstname"; in
"lastname " first)
# => "lastname firstname"

# Lists

[ 1 2 "three" "bar" "baz" ]   # lists are whitespace separated

builtins.elemAt [ 1 2 3 4 5 ] 3
# => 4

builtins.length [ 1 2 3 4 ]
# => 4

# Attrsets

{ first = "Jim"; last = "Bo"; }.last # Attribute selection
# => "Bo"

{ a = 1; b = 3; } // { c = 4; b = 2; }   # Attribute Set merging
# => { a = 1; b = 2; c = 4; }               # Right Side takes precedence

builtins.listToAttrs [ { name = "Jr"; value = "Jr Juniorville"; } {name = "$"; value = "JR"; } { name = "jr"; value = "jr
ville"; }]
# => { "$" = "JR"; Jr = "Jr Juniorville"; jr = "jrville"; }

# Control Flow

if 2 * 2 == 4
then "yes!"
else "no!"
# => "yes!"

assert 2 * 2
== 4; "yes!"
# => "yes!"

with builtins;
head [ 5 6 7 ]
# => 5

# or

builtins.head[ 5 6 7 ]

inherit pkgs     # pkgs = pkgs;
src;             # src = src;

Understanding Laziness

Nix expressions are evaluated lazily, meaning Nix computes values only when needed. This is a powerful feature that makes Nix efficient for managing large systems, as it avoids unnecessary computations.

For example, observe how a is never evaluated in the following nix-repl session:

nix-repl> let a = builtins.div 4 0; b = 6; in b
6
  • Since a isn't used in the final result, there's no division by zero error.

Strings and String Interpolation

Strings: Strings are enclosed in double quotes (") or two single quotes ('').

nix-repl> "stringDaddy"
"stringDaddy"
nix-repl> ''
  This is a
  multi-line
  string
''
"This is a\nmulti-line\nstring.\n"

string interpolation. is a language feature where a string, path, or attribute name can contain an expressions enclosed in ${ }. This construct is called an interpolated string, and the expression inside is an interpolated expression.

Rather than writing:

let path = "/usr/local"; in "--prefix=${path}"

This evaluates to "--prefix=/usr/local". Interpolated expressions must evaluate to a string, path, or an attribute set with an outPath or __toString attribute.

Attribute Sets

Attribute sets are all over Nix code and deserve their own section, they are name-value pairs wrapped in curly braces, where the names must be unique:

{
  string = "hello";
  int = 8;
}

Attribute names usually don't need quotes.

You can access attributes using dot notation:

let person = { name = "Alice"; age = 30; }; in person.name
"Alice"

You will sometimes see attribute sets with rec prepended. This allows access to attributes within the set:

rec {
  x = y;
  y = 123;
}.x

Output: 123

or

rec {
  one = 1;
  two = one + 1;
  three = two + 1;
}

Output:

 {
  one = 1;
  three = 3;
  two = 2;
 }
# This would fail:
{
  one = 1;
  two = one + 1;  # Error: undefined variable 'one'
  three = two + 1;
}

Recursive sets introduce the danger of infinite recursion For example:

rec {
  x = y;
  y = x;
}.x

Will crash with an infinite recursion encountered error message.

The attribute set update operator merges two attribute sets.

Example:

{ a = 1; b = 2; } // { b = 3; c = 4; }

Output:

{ a = 1; b = 3; c = 4; }

However, names on the right take precedence, and updates are shallow.

Example:

{ a = { b = 1; }; } // { a = { c = 3; }; }

Output:

{ a = { c = 3; }; }

Above, key b was completely removed, because the whole a value was replaced.

Inheriting Attributes

  • Click to see Output:
let x = 123; in
{
  inherit x;
  y = 456;
}

is equivalent to

let x = 123; in
{
  x = x;
  y = 456;
}

which are both equivalent to

{
  x = 123;
  y = 456;
}

❗: This works because x is added to the lexical scope by the let construct.

Now that we understand attribute sets lets move on to functions, a powerful feature of the Nix language that gives you the ability to reuse and share logical pieces of code.

Functions(lambdas):

Functions in Nix help you build reusable components and are the the building blocks of Nix. In the next chapter we'll go even further into Nix functions and how to use them but I will touch on them here.

Nix functions have this form:

pattern: body

The following is a function that expects an integer and returns it increased by 1:

x: x + 1   # lambda function, not bound to a variable

The pattern tells us what the argument of the function has to look like, and binds variables in the body to (parts of) the argument.

(x: x + 5) 200
205

They are all lambdas (i.e. anonymous functions without names) until we assign them to a variable like the following example.

Functions are defined using this syntax, where x and y are attributes passed into the function:

{
  my_function = x: y: x + y;
}

The code below calls a function called my_function with the parameters 2 and 3, and assigns its output to the my_value field:

{
  my_value = my_function 2 3;
}
my_value
5

The body of the function automatically returns the result of the function. Functions are called by spaces between it and its parameters. No commas are needed to separate parameters.

The following is a function that expects an attribute set with required attributes a and b and concatenates them:

{ a, b }: a + b

Default Values in Functions:

Functions in Nix can define default values for their arguments. This allows for more flexible function calls where some arguments are optional.

{ x, y ? "foo", z ? "bar" }: z + y + x
  • Specifies a function that only requires an attribute named x, but optionally accepts y and z.

@-patterns in functions:

An @-pattern provides a means of referring to the whole value being matched by the function's argument pattern, in addition to destructuring it. This is especially useful when you want to access attributes that are not explicitly destructured in the pattern:

args@{ x, y, z, ... }: z + y + x + args.a
# or
{ x, y, z, ... } @ args: z + y + x + args.a
  • Here, args is bound to the argument as passed, which is further matched against the pattern { x, y, z, ... }. The @-pattern makes mainly sense with an ellipsis(...) as you can access attribute names as a, using args.a, which was given as an additional attribute to the function.

  • We will expand on Functions in This Chapter

If, Let, and With Expressions

Nix is a pure expression language, meaning every construct evaluates to a value — there are no statements. Because of this, if expressions in Nix work differently than in imperative languages, where conditional logic often relies on statements (if, elsif, etc.).

If expressions in Nix:

Since everything in Nix is an expression, an if expression must always produce a value:

nix-repl> a = 6
nix-repl> b = 10
nix-repl> if a > b then "yes" else "no"
"no"

Here, "no" is the result because a(6) is not greater than b(10). Notice that there's no separate conditional statement -- the entire construct evaluates to a value.

Another example, integrating built-in functions:

{
  key = if builtins.pathExists ./path then "YES" else "NO!";
}

If ./path exists it will evaluate to the value "YES" or else it will evaluate to "NO!".

Thus, the final result of the expression would be:

{ key = "YES"; }
# or
{ key = "NO!"; }

Since Nix does not have statements, Nix's if statements behave more like ternary operators (condition ? value_if_true : value_if_false) in other languages.

Let expressions:

Let expressions in Nix is primarily a mechanism for local variable binding and scoping. It allows you to define named values that are only accessible within the in block of the let expression. This is useful for keeping code clean and avoiding repitition.

For example:

let
  a = "foo";
  b = "fighter";
in a + b
"foofighter"

Here, a and b are defined inside the let block, and their values are used in the in expression. Since everything in Nix is an expression, a + b evaluates to "foofighter"

Using Let Expressions Inside Attribute Sets

Let expressions are commonly used when defining attribute sets (Click for output):

let
  appName = "nix-app";
  version = "1.0";
in {
  name = appName;
  fullName = appName + "-" + version;
}
{
  name = "nix-app";
  fullName = "nix-app-1.0";
}

This allows you to reuse values within an attribute set, making the code more modular and preventing duplication.

Let Expressions in Function Arguments

You can also use let expressions within function arguments to define intermediate values before returning an output:

{ pkgs, lib }:
let
  someVar = "hello";
  otherVar = "world";
in
{ inherit pkgs lib someVar otherVar; }

Result:

{
  pkgs = <value>;
  lib = <value>;
  someVar = "hello";
  otherVar = "world";
}

Here, inherit brings pkgs and lib into the resulting attribute set, alongside the locally defined variables someVar and otherVar.

Key Takeaways:

  • Let expressions allow local variable bindings that are only visible inside the in block. They also help avoid repitition and improve readability.

  • Commonly used inside attribute sets or function arguments.

  • Their scope is limited to the expression in which they are declared.

With expressions:

A with expression in Nix is primarily used to simplify access to attributes within an attribute set. Instead of repeatedly referring to a long attribute path, with temporarily brings the attributes into scope, allowing direct access without prefixing them.

Basic Example: Reducing Attribute Path Usage

Consider the following expressions:

nix-repl> longName = { a = 3; b = 4; }
nix-repl> longName.a + longName.b
7

Here, we must explicitly reference longName.a and longName.b. Using a with expression simplifies this:

nix-repl> with longName; a + b
7

Now, within the scope of the with expression, a and b are accessible without prefixing them with longName.

Practical Use Case: Working with pkgs

One of the most common uses of with that you'll see is when dealing with packages from nixpkgs is writing the following:

{ pkgs }:
with pkgs; {
  myPackages = [ vim git neofetch ];
}

Instead of writing this:

{ pkgs }:
{
  myPackages = [ pkgs.vim pkgs.git pkgs.neofetch ];
}

Tip: Overusing with lib; or with pkgs; can reduce clarity, it may be fine for smaller modules where the scope is limited. For larger configurations, explicit references (pkgs.something) often make dependencies clearer and prevent ambiguity.

Nix Language Quirks

  1. with gets less priority than let. This can be confusing, especially if you like to write with pkgs;:
nix-repl> pkgs = { x = 2; }

nix-repl> with pkgs; x
2

nix-repl> with pkgs; let x = 4; in x
4

This shows us that the let binding overrides the with binding.

let x = 4; in with pkgs; x
4

Still returns 4, but the reasoning is different. The with expression doesn't define new bindings; it simply makes attributes from pkgs available as unqualified names. However, because let x = 4 is outside the with, it already extablished x = 4, so when with pkgs; x is evaluated inside, x still refers to the outer let binding, not the one from pkgs.

  1. Default values aren't bound in @-patterns

In the following example, calling a function that binds a default value "baz" to the argument's attribute b with an empty attribute set as argument will produce an empty attribute set inputs rather than the desired { b = "baz"; }:

(inputs@(b ? "baz"): inputs) {}

Output:

{}
  1. Destructuring function arguments:
nix-repl> f = { x ? 2, y ? 4 }: x + y

nix-repl> f { }
6

The function f takes an attribute set with default values (x = 2, y = 4)

When called with {} (an empty set), it falls back to the default values (2 + 4 -> 6)

Using @args to capture the entire input set:

The @args syntax allows us to retain access to the full attribute set, even after destructuring:

nix-repl> f = { x ? 1, y ? 2, ... }@args: with args; x + y + z

nix-repl> f { z = 3; }
6

The { x ? 1, y ? 2, ... } syntax means x and y have defaults, while ... allows additional attributes.

@args binds the entire attribute set (args) so that we can access z, which wouldn't be destructured by default.

When calling f { z = 3; }, we pass an extra attribute (z = 3), making x + y + z1 + 2 + 3 = 6.

  1. Imports and namespaces

There is a keyword import, but it's equivalent in other languages is eval. It can be used for namespacing too:

let
  pkgs = import <nixpkgs> {};
  lib = import <nixpkgs/lib>;
in
  pkgs.runCommand (lib.strings.removePrefix "....

consider using import here as using qualified import ... in Haskell or import ... in Python.

Another way of importing is with import ...;, which corresponds to Python from ... import *.

But because of not very great IDE support in Nix, with import ...; is discouraged. Rather use inherit, especially if you are targeting source code for Nix newcomers:

let
  lib = import <nixpkgs/lib>;
  inherit (lib.strings)
    removePrefix removeSuffix
  ;
  inherit (lib.lists)
    isList init drop
  ;
in
  removePrefix ...

inherit has higher priority than with, and conflicts with let

nix-repl> let pkgs = { x = 1; }; x = 2; x = 3; inherit (pkgs) x; in x
error: attribute ‘x’ at (string):1:31 already defined at (string):1:24

This makes it a sane citizen of Nix lanugage... except it has a twin, called { inherit ...; }. They DON'T do the same - let inherit ... adds let-bindings, and { inherit ...; } adds attributes to a record. --https://nixos.wiki/wiki/Nix_Language_Quirks

  1. Only attribute names can be interpolated, not Nix code:
nix-repl> let ${"y"} = 4; in y
4

nix-repl> with { ${"y"} = 4; }; y
4

let y = 1; x = ${y}; in x
error: syntax error, unexpected DOLLAR_CURLY

Conclusion

  • let bindings introduce new local values and override anything from with.

  • with doesn't create bindings - it only makes attributes available within its scope.

  • The order matters: If let x = 4 is outside with, then x = 4 already exists before with runs, so with pkgs; x resolves to 4, not the value from pkgs.

Resources

✔️ Resources (Click to Expand)

A few resources to help get you started with the Nix Language, I have actually grown to love the language. I find it fairly simple but powerful!

Nix Package Manager

Click to Expand Table of Contents

nix99

Nix Package Manager

Nix is a purely functional package manager. This means that it treats packages like values in purely functional programming languages -- they are built by functions that don't have side-effects, and they never change after they have been built.

Nix stores packages in the Nix store, usually the directory /nix/store, where each package has its own unique subdirectory such as:

/nix/store/y53c0lamag5wpx7vsiv7wmnjdgq97yd6-yazi-25.5.14pre20250526_74a8ea9

You can use the Nix on most Linux distributions and Mac OS also has good support for Nix. It should work on most platforms that support POSIX threads and have a C++11 compiler.

When I install Nix on a distro like Arch Linux I usually use the Zero to Nix installer as it automates several steps, such as enabling flakes by default:

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

If you have concerns about the "curl to Bash" approach you could examine the installation script here then download and run it:

curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix > nix-installer.sh
chmod +x nix-installer.sh
./nix-installer.sh install

I got the above commands from zero-to-nix

The main difference between using the nix package manager on another distribution and NixOS is that NixOS uses Nix not just for package management but also to manage the system configuration (e.g., to build config files in /etc).

Home Manager is a Nix-powered tool for reproducible management of the contents of the users' home directories. This includes programs, configuration files, environment variables, and arbitrary files. Home manager uses the same module system as NixOS.

Now that we've discussed some of the basics of the Nix package manager, lets see how it is used to build and manage software in NixOS.

Channels

Nix packages are distributed through Nix channels; mechanisms for distributing Nix expressions and the associated binary caches for them. Channels are what determine which versions your packages have. (i.e. stable or unstable). A channel is a name for the latest "verified" git commits in Nixpkgs. Each channel represents a different policy for what "verified" means. Whenever a new commit in Nixpkgs passes the verification process, the respective channel is updated to point to that new commit.

While channels provide a convenient way to get the latest stable or unstable packages, they introduce a challenge for strict reproducibility. Because a channel like nixos-unstable is constantly updated, fetching packages from it today might give you a different set of package versions than fetching from it tomorrow, even if your configuration remains unchanged. This "rolling release" nature at a global level can make it harder to share and reproduce exact development environments or system configurations across different machines or at different points in time.

Channels vs. Flakes Enhancing Reproducibility

Before the introduction of Nix Flakes, channels were the primary mechanism for sourcing Nixpkgs. While functional, they posed a challenge for exact reproducibility because they point to a moving target (the latest commit on a branch). This meant that a nix-build command run yesterday might produce a different result than one run today, simply because the channel updated.

Nix Flakes were introduced to address this. Flakes bring a built-in, standardized way to define the exact inputs to a Nix build, including the precise Git revision of Nixpkgs or any other dependency.

Here's a quick comparison:

FeatureNix Channels (traditional)Nix Flakes (modern approach)
Input SourceGlobal system configuration (nix-channel --update)Explicitly defined in flake.nix (e.g., github:NixOS/nixpkgs/nixos-23.11)
Reproducibility"Rolling release"; less reproducible across time/machinesHighly reproducible due to locked inputs (flake.lock)
Dependency Mgmt.Implicitly managed by global channelExplicitly declared and version-locked within flake.nix
SharingRelies on users having same channel versionSelf-contained; flake.lock ensures everyone gets same inputs
Learning CurveSimpler initial setup, but tricky reproducibility debuggingHigher initial learning curve, but simplifies reproducibility

The ability of Flakes to "lock" the exact version of all dependencies in a flake.lock file is a game-changer for collaboration and long-term reproducibility, ensuring that your Nix configuration builds the same way, every time, everywhere.

Nixpkgs

Nixpkgs is the largest repository of Nix packages and NixOS modules.

For NixOS users, nixos-unstable channel branch is the rolling release, where the packages are tested and must pass integration tests.

For standalone Nix users, nixpkgs-unstable channel branch is the rolling release, where packages pass only basic build tests and are upgraded often.

For Flakes, as mentioned above they don't use channels so nixpkgs will be listed as an input to your flake. (e.g., inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";) When using flakes you can actually disable channels and actually recommended to avoid conflicts between traditional channel-based workflows and the flake system.

Updates

The mechanism for updating your Nix environment differs fundamentally between channels and flakes, directly impacting reproducibility and control.

Updating with Channels (Traditional Approach)

With channels, updates are a global operation that pulls the latest state of a specific branch.

How it works: You typically use nix-channel --update to fetch the latest commit from the channels you've subscribed to. For instance, sudo nix-channel --update nixos (for NixOS) or nix-channel --update nixpkgs (for nix-env on other Linux distributions).

Implication: This command updates your local system's understanding of what "nixos" or "nixpkgs-unstable" means. From that point on, any nixos-rebuild switch, nix-env -iA, or nix-build commands that implicitly or explicitly refer to nixpkgs will use this newly updated version.

Reproducibility Challenge: The update itself is not recorded in your configuration files. If you share your configuration.nix with someone, they might run nix-channel --update on a different day and get a different set of package versions because the channel has moved. This makes it challenging to guarantee that two users building the "same" configuration will get identical results. You're effectively relying on the implicit, globally managed state of your channels.

Updating with Flakes (Modern Approach)

Flakes, by contrast, use a more explicit and localized update mechanism tied to your flake.lock file.

How it works: When you define a flake.nix, you specify the exact URL (e.g., a Git repository with a specific branch or tag) for each input. When you first use a flake, Nix resolves these URLs to a precise Git commit hash and records this hash, along with a content hash, in a flake.lock file.

To update your flake inputs, you run nix flake update.

Implication: This command goes to each input's specified URL (e.g., github:NixOS/nixpkgs/nixos-unstable) and fetches the latest commit for that input. It then updates your flake.lock file with the new, precise Git commit hash and content hash for that input. Your flake.nix itself doesn't change, but the flake.lock file now points to newer versions of your dependencies.

Reproducibility Advantage: The flake.lock file acts as a manifest of your exact dependency versions.

Sharing: When you share your flake (the flake.nix and flake.lock files), anyone using it will fetch precisely the same Git commit hashes recorded in the flake.lock, guaranteeing identical inputs and thus, identical builds (assuming the same system architecture).

Updating Selectively: You can update individual inputs within your flake by specifying them: nix flake update nixpkgs. This provides fine-grained control over which parts of your dependency graph you want to advance.

Rolling Back: Because the flake.lock explicitly records the versions, you can easily revert to a previous state by checking out an older flake.lock from your version control system.

In essence: Channels involve a global "pull" of the latest branch state, making reproducibility harder to guarantee across time and machines. Flakes, however, explicitly pin all inputs in flake.lock, and updates involve explicitly refreshing these pins, providing strong reproducibility and version control out of the box.

Managing software with Nix

Derivation Overview

In Nix, the process of managing software starts with package definitions. These are files written in the Nix language that describe how a particular piece of software should be built. These package definitions, when processed by Nix, are translated into derivations.

At its core, a derivation in Nix is a blueprint or a recipe that describes how to build a specific software package or any other kind of file or directory. It's a declarative specification of:

  • Inputs: What existing files or other derivations are needed as dependencies.

  • Build Steps: The commands that need to be executed to produce the desired output.

  • Environment: The specific environment (e.g., build tools, environment variables) required for the build process.

  • Outputs: The resulting files or directories that the derivation produces.

Think of a package definition as the initial instructions, and the derivation as the detailed, low-level plan that Nix uses to actually perform the build.

Again, a derivation is like a blueprint that describes how to build a specific software package or any other kind of file or directory.

Key Characteristics of Derivations:

  • Declarative: You describe the desired outcome and the inputs, not the exact sequence of imperative steps. Nix figures out the necessary steps based on the builder and args.

  • Reproducible: Given the same inputs and build instructions, a derivation will always produce the same output. This is a cornerstone of Nix's reproducibility.

  • Tracked by Nix: Nix keeps track of all derivations and their outputs in the Nix store. This allows for efficient management of dependencies and ensures that different packages don't interfere with each other.

  • Content-Addressed: The output of a derivation is stored in the Nix store under a unique path that is derived from the hash of all its inputs and build instructions. This means that if anything changes in the derivation, the output will have a different path.

Here's a simple Nix derivation that creates a file named hello in the Nix store containing the text "Hello, World!":

✔️ Hello World Derivation Example (Click to expand):
{pkgs ? import <nixpkgs> {}}:
pkgs.stdenv.mkDerivation {
  name = "hello-world";

  dontUnpack = true;

  # No need for src = null; when dontUnpack = true;
  # src = null;

  buildPhase = ''
     # Create a shell script that prints "Hello, World!"
    echo '#!${pkgs.bash}/bin/bash' > hello-output-file # Shebang line
    echo 'echo "Hello, World!"' >> hello-output-file # The command to execute
    chmod +x hello-output-file # Make it executable
  '';

  installPhase = ''
    mkdir -p $out/bin
    cp hello-output-file $out/bin/hello # Copy the file from build directory to $out/bin
  '';

  meta = {
    description = "A simple Hello World program built with Nix";
    homepage = null;
    license = pkgs.lib.licenses.unfree; # licenses.mit is often used as well
    maintainers = [];
  };
}

And a default.nix with the following contents:

{ pkgs ? import <nixpkgs> {} }:

import ./hello.nix { pkgs = pkgs; }
  • { pkgs ? import <nixpkgs> {} }: This is a function that takes an optional argument pkgs. We need Nixpkgs to access standard build environments like stdenv.

  • pkgs.stdenv.mkDerivation { ... }: This calls the mkDerivation function from the standard environment (stdenv). mkDerivation is the most common way to define software packages in Nix.

  • name = "hello-world";: Human-readable name of the derivation

  • The rest are the build phases and package metadata.

To use the above derivation, save it as a .nix file (e.g. hello.nix). Then build the derivation using,:

nix-build
this derivation will be built:
  /nix/store/9mc855ijjdy3r6rdvrbs90cg2gf2q160-hello-world.drv
building '/nix/store/9mc855ijjdy3r6rdvrbs90cg2gf2q160-hello-world.drv'...
Running phase: patchPhase
Running phase: updateAutotoolsGnuConfigScriptsPhase
Running phase: configurePhase
no configure script, doing nothing
Running phase: buildPhase
Running phase: installPhase
Running phase: fixupPhase
shrinking RPATHs of ELF executables and libraries in /nix/store/2ydxh5pd9a6djv7npaqi9rm6gmz2f73b-hello-world
checking for references to /build/ in /nix/store/2ydxh5pd9a6djv7npaqi9rm6gmz2f73b-hello-world...
patching script interpreter paths in /nix/store/2ydxh5pd9a6djv7npaqi9rm6gmz2f73b-hello-world
stripping (with command strip and flags -S -p) in  /nix/store/2ydxh5pd9a6djv7npaqi9rm6gmz2f73b-hello-world/bin
/nix/store/2ydxh5pd9a6djv7npaqi9rm6gmz2f73b-hello-world
  • Nix will execute the buildPhase and installPhase

  • After a successful build, the output will be in the Nix store. You can find the exact path by looking at the output of the nix build command (it will be something like /nix/store/your-hash-hello-world).

Run the "installed" program:

./result/bin/hello
  • This will execute the hello file from the Nix store and print "Hello, World!".

Cachix and the devour-flake

Using devour-flake to Cache All Your Flake Outputs to Cachix

When working with Nix flakes, it’s common to have many outputs—packages, apps, dev shells, NixOS or Darwin configurations, and more. Efficiently building and caching all these outputs can be challenging, especially in CI or when collaborating. This is where devour-flake and Cachix shine. Why Use devour-flake?

By default, building all outputs of a flake with nix build .#a .#b ... .#z can be slow and inefficient, as Nix will evaluate the flake multiple times—once for each output. devour-flake solves this by generating a "consumer" flake that depends on all outputs, allowing you to build everything in one go with a single evaluation

Installation

There quite a few ways to do this, choose a method of installation from the devour-flake repository and then continue with step 1.

You can even build it without installing with the following command:

nix build github:srid/devour-flake \
  -L --no-link --print-out-paths \
  --override-input flake path/to/flake | cachix push <name>
nix-shell -p cachix

This will push all flake outputs to cachix if you have a valid authentication token and have created a cache already.

How to Use devour-flake with Cachix

  1. Prerequisites
  • A Cachix cache: Create one on Cachix and generate a "Write + Read" auth token. You'll click the cache you just created and select Settings, in the settings you'll find Auth Tokens. When in the Auth Tokens section give your token a Description, Expiration date, and finally click Generate.

(Optional) Configure your token locally, copy your auth token for the following command:

cachix authtoken <YOUR_TOKEN>
# Use cachix cli for the following
cachix use <your-cache-name>
  • cachix use adds your substitutors and trusted-public-keys to your ~/.config/nix/nix.conf and creates one if it doesn't exist.

Push All Flake Inputs to Cachix

Replace <mycache> with the name of the cache you just created.

nix flake archive --json \
  | jq -r '.path,(.inputs|to_entries[].value.path)' \
  | cachix push <mycache>

You should see output similar to the following:

Pushing 637 paths (2702 are already present) using zstd to cache sayls8 ⏳

✓ /nix/store/0aqvmjvhkar3j2f7zag2wjl4073apnvk-vimplugin-crates.nvim-2025-05-30 (734.65 KiB)
✓ /nix/store/02wm10zck7rb836kr0h3afjxl80866dp-X-Restart-Triggers-keyd (184.00 B)
✓ /nix/store/0asdaaax0lf1wa6m6lqqdvc8kp6qn3f6-dconf-cleanup (1008.00 B)
✓ /nix/store/09ki2jlh6sqbn01yw6n15h8d55ihxygf-helix-tree-sitter-mojo-3d7c53b8038f9ebbb57cd2e61296180aa5c1cf64 (601.37 KiB)
✓ /nix/store/0i2c29nldqvb9pnypvp3ika4i7fhc0ck-devour-output (312.00 B)
✓ /nix/store/0c0mwfb78xm862a7g4h9fhgzn55zppj6-helix-term (29.88 MiB)
✓ /nix/store/0fhdpb2qck1kbngq1dlc8lyqqadj2pb1-hyprcursor-0.1.12+date=2025-06-05_45fcc10-lib (487.30 KiB)
✓ /nix/store/0mfpi51bswgd91l8clqcz6mxy5k5zcd4-vimplugin-auto-pairs-2019-02-27 (40.60 KiB)
✓ /nix/store/0k2zq8y78vrhhkf658j6i45vz3y89v11-helix-tree-sitter-tcl-56ad1fa6a34ba800e5495d1025a9b0fda338d5b8 (110.25 KiB)
✓ /nix/store/0qxmahrw935136dbxkmvrg14fgnzi6bb-vimplugin-obsidian.nvim-2025-07-01 (493.02 KiB)
✓ /nix/store/0wjppqzcbnlf9srhr6k27pz403j3mg2j-hm-session-vars.sh (1.86 KiB)
✓ /nix/store/0z41071z33zg1zqyasccc3cfhxj389k0-helix-tree-sitter-swift-57c1c6d6ffa1c44b330182d41717e6fe37430704 (2.77 MiB)
✓ /nix/store/0n5f1x8lpc93zm81bxrfh6yccyngvrdl-unit-plymouth-read-write.service (1.19 KiB)
✓ /nix/store/0z8ac35n89lv2knzaj6kkp0cfxr6pmgc-hm_face.png (300.60 KiB)
✓ /nix/store/0zp5846pry5rknnvzz81zlvj4ghnkxp5-hyprutils-0.8.1+date=2025-07-07_a822973 (421.64 KiB)
✓ /nix/store/118ihgwjw6kp0528igns3pnvzbszljmg-unit-dbus.service (1.34 KiB)
✓ /nix/store/0pajdq9mfgkcdwbqp38j7d4clc9h9iik-hm_.mozillafirefoxdefault.keep (112.00 B)
✓ /nix/store/0nlvffvpx6s8mpd2rpnqb1bl5idd16yk-hm-dconf.ini (224.00 B)
✓ /nix/store/1fiqgqvi574rdckav0ikdh8brwdhvh69-vimplugin-alpha-nvim-2025-05-26 (69.38 KiB)
✓ /nix/store/1fqxw31p1llag0g7wg7izq22x5msz47r-vimplugin-persistence.nvim-2025-02-25 (37.74

❗ NOTE: The effectiveness of pushing the rest to cachix depend on your network speed. I actually noticed a slow down after pushing the nix/store. Pushing the nix/store is rarely necessary and can be very slow and bandwidth-intensive. Most users will only need to push relevent outputs.

Push the Entire /nix/store

nix path-info --all | cachix push <mycache>

Pushing shell environment

nix develop --profile dev-profile -c true
# then run
cachix push <mycache> dev-profile
  • For the Flake way of doing things you would create something like the following:
{
  config,
  lib,
  pkgs,
  ...
}: let
  cfg = config.custom.cachix;
in {
  options = {
    custom.cachix.enable = lib.mkEnableOption "Enable custom cachix configuration";
  };

  config = lib.mkIf cfg.enable {
    environment.systemPackages = with pkgs; [cachix];

    # to prevent garbage collection of outputs immediately after building
    nix.extraOptions = "gc-keep-outputs = true";
    nix.settings = {
      substituters = [
        "https://nix-community.cachix.org"
        "https://hyprland.cachix.org"
        "https://ghostty.cachix.org"
        "https://neovim-nightly.cachix.org"
        "https://yazi.cachix.org"
        "https://helix.cachix.org"
        "https://nushell-nightly.cachix.org"
        "https://wezterm.cachix.org"
        "https://sayls88.cachix.org"
        # "https://nixpkgs-wayland.cachix.org"
      ];
      trusted-public-keys = [
        "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
        "hyprland.cachix.org-1:a7pgxzMz7+chwVL3/pzj6jIBMioiJM7ypFP8PwtkuGc="
        "ghostty.cachix.org-1:QB389yTa6gTyneehvqG58y0WnHjQOqgnA+wBnpWWxns="
        "neovim-nightly.cachix.org-1:feIoInHRevVEplgdZvQDjhp11kYASYCE2NGY9hNrwxY="
        "yazi.cachix.org-1:Dcdz63NZKfvUCbDGngQDAZq6kOroIrFoyO064uvLh8k="
        "helix.cachix.org-1:ejp9KQpR1FBI2onstMQ34yogDm4OgU2ru6lIwPvuCVs="
        "nushell-nightly.cachix.org-1:nLwXJzwwVmQ+fLKD6aH6rWDoTC73ry1ahMX9lU87nrc="
        "wezterm.cachix.org-1:kAbhjYUC9qvblTE+s7S+kl5XM1zVa4skO+E/1IDWdH0="
        "sayls88.cachix.org-1:LT8JnboX8mKhabC3Mj/ONHb5tyrjlnsdauQkD8Lu0us="
        # "nixpkgs-wayland.cachix.org-1:3lwxaILxMRkVhehr5StQprHdEo4IrE8sRho9R9HOLYA="
      ];
    };
  };
}
  • The sayls88 entries are my custom cache. To find your trusted key go to the cachix website, click on your cache and it is listed near the top.

  • I enable this with custom.cachix.enable = true; in my configuration.nix or equivalent.

  • Another option is to use the top-level nixConfig attribute for adding your substitutors and trusted-public-keys. You only need to choose 1 method FYI:

{
  description = "NixOS & Flake Config";

# the nixConfig here only affects the flake itself, not the system configuration!
  nixConfig = {
    experimental-features = [ "nix-command" "flakes" ];
    trusted-users = [ "ryan" ];

    substituters = [
      # replace official cache with a mirror located in China
      "https://mirrors.ustc.edu.cn/nix-channels/store"
      "https://cache.nixos.org"
    ];

    # nix community's cache server
    extra-substituters = [
      "https://nix-community.cachix.org"
      "https://nixpkgs-wayland.cachix.org"
    ];
    extra-trusted-public-keys = [
      "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
      "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
      "nixpkgs-wayland.cachix.org-1:3lwxaILxMRkVhehr5StQprHdEo4IrE8sRho9R9HOLYA="
    ];
  };
# ... snip
  1. Building and Caching All Outputs

You can build and push all outputs of your flake to Cachix using the following command when in your flake directory:

nix build github:srid/devour-flake \
 -L --no-link --print-out-paths \
 --override-input flake . \
 | cachix push <your-cache-name>
  • Replace with your actual Cachix cache name.

    This command will:

  • Use devour-flake to enumerate and build all outputs of your flake (including packages, devShells, NixOS configs, etc.)

  • Pipe the resulting store paths to cachix push, uploading them to your binary cache.

  1. Example

Suppose your cache is named my-flake-cache:

nix build github:srid/devour-flake \
 -L --no-link --print-out-paths \
 --override-input flake . \
 | cachix push my-flake-cache
  1. Integration in CI

This approach is particularly useful in CI pipelines, where you want to ensure all outputs are built and cached for collaborators and future builds. You can add the above command to your CI workflow, ensuring the Cachix auth token is provided as a secret

  1. Advanced: Using as a Nix App

You can add devour-flake as an input to your flake for local development:

{
  inputs = {
    devour-flake.url = "github:srid/devour-flake";
    devour-flake.flake = false;
  };
}

And in your flake's outputs, add an overlay that makes devour-flake available in your package set:

outputs = { self, nixpkgs, devour-flake, ... }@inputs: {
  overlays.default = final: prev: {
    devour-flake = import devour-flake { inherit (prev) pkgs; };
  };

  # Example: Add devour-flake to your devShell
  devShells.x86_64-linux.default = let
    pkgs = import nixpkgs {
      system = "x86_64-linux";
      overlays = [ self.overlays.default ];
    };
  in pkgs.mkShell {
    buildInputs = [ pkgs.devour-flake ];
  };
};

Use devour-flake in your devShell:

nix develop

You'll have the devour-flake command available for local use, so you can quickly build and push all outputs as needed.

TIP: Alternatively, use devour-flake as an app:

apps.x86_64-linux.devour-flake = {
 type = "app";
 program = "${self.packages.x86_64-linux.devour-flake}/bin/devour-flake";
};

What Gets Built and Cached?

devour-flake detects and builds all standard outputs of a flake, including:

  • packages

  • apps

  • checks

  • devShells

  • nixosConfigurations.*

  • darwinConfigurations.*

  • home-manager configurations

This ensures that everything your flake produces is available in your Cachix cache for fast, reproducible builds.

References:

devour-flake documentation

Discourse Cachix for Flakes

Cachix docs: Flakes

Nix Paths

✔️ Click to Expand Table of Contents

The following examples are done with a local nixpkgs clone located at ~/src/nixpkgs

Paths in Nix always need a / in them and always expand to absolute paths relative to your current directory.

nix repl
nix-repl> ./.
/home/jr/src/nixpkgs
nix-repl> ./. + "/lib"
/home/jr/src/nixpkgs/lib

Nix does path normalization every time you append strings, so if you just add a slash / its not actually there:

nix-repl> ./.
/home/jr/src/nixpkgs
nix-repl> ./. + "/"
/home/jr/src/nixpkgs
nix-repl> ./. + "/" + "lib"
/home/jr/src/nixpkgslib
nix-repl> "${./.}/lib"
# using ${./.} causes a store copy
copying '/homr/jr/src/nixpkgs' to the store
"/nix/store/3z9fzx8z03wslxvri5syv3jnnhn5fkbd-nixpkgs/lib"
nix-repl> "${toString ./.}/lib"
# using toString avoids making a store copy
"/home/jr/src/nixpkgs/lib"
nix-repl> ./lib/..             # nix removes all `..` to avoid redundant path resolutions
/home/jr/src/nixpkgs
nix-repl> :q
realpath ./lib/..
/home/jr/src/nixpkgs
ln -s pkgs/applications lib-symlink
realpath ./lib-symlink/..
/home/jr/src/nixpkgs/pkgs
nix repl
nix-repl> ./lib-symlink/..   # Nix doesn't read this file at all like realpath did
/home/jr/src/nixpkgs
nix-repl> builtins.readDir ./. # listing of all entries in current dir and their types
{
  ".devcontainer" = "directory";
  ".editorconfig" = "regular";
  ".git" = "directory";
  ".git-blame-ignore-revs" = "regular";
  ".gitattributes" = "regular";
  ".github" = "directory";
  ".gitignore" = "regular";
  ".mailmap" = "regular";
  ".mergify.yml" = "regular";
  ".version" = "symlink";
  "CONTRIBUTING.md" = "regular";
  COPYING = "regular";
  "README.md" = "regular";
  ci = "directory";
  "default.nix" = "regular";
  doc = "directory";
  "flake.nix" = "regular";
  lib = "directory";
  maintainers = "directory";
  nixos = "directory";
  pkgs = "directory";
  "shell.nix" = "regular";
}
nix-repl> builtins.readFile ./default.nix
"let\n  requiredVersion = import ./lib/minver.nix;\nin\n\nif !builtins ? nixVersion
 || builtins.compareVersions requiredVersion builtins.nixVersion == 1 then\n\n  abort
 ''\n\n    This version of Nixpkgs requires Nix >= \${requiredVersion}, please
 upgrade:\n\n    - If you are running NixOS, `nixos-rebuild' can be used to upgrade
 your system.\n\n    - Alternatively, with Nix > 2.0 `nix upgrade-nix' can be used
 to imperatively\n      upgrade Nix. You may use `nix-env --version' to check which
 version you have.\n\n    - If you installed Nix using the install script (https://nixos.org/nix/install),\n
  it is safe to upgrade by running it again:\n\n          curl -L https://nixos.org/nix/install | sh\n\n
For more information, please see the NixOS release notes at\n    https://nixos.org/nixos/manual
 or locally at\n    \${toString ./nixos/doc/manual/release-notes}.\n\n    If you need further help,
 see https://nixos.org/nixos/support.html\n  ''\n\nelse\n\n  import ./pkgs/top-level/impure.nix\n"
nix-repl> :l <nixpkgs/lib>
nix-repl> importJSON ./pkgs/development/python-modules/notebook/missing-hashes.json # Return the nix value for JSON
{
  "@nx/nx-darwin-arm64@npm:16.10.0" = "aabcc8499602b98c9fc3b768fe46dfd4e1b818caa84b740bd4f73a2e4528c719b979ecb1c10a0d793a1fead83073a08bc86417588046aa3e587e80af880bffd3";
  "@nx/nx-darwin-x64@npm:16.10.0" = "9dd20f45f646d05306f23f5abb7ade69dcb962e23a013101e93365847722079656d30a19c735fdcfa5c4e0fdf08691f9d621073c58aef2861c26741ff4638375";
  "@nx/nx-freebsd-x64@npm:16.10.0" = "35b93aabe3b3274d53157a6fc10fec7e341e75e6818e96cfbc89c3d5b955d225ca80a173630b6aa43c448c6b53b23f06a2699a25c0c8bc71396ee20a023d035f";
  "@nx/nx-linux-arm-gnueabihf@npm:16.10.0" = "697b9fa4c70f84d3ea8fe32d47635864f2e40b0ceeb1484126598c61851a2ec34b56bb3eeb9654c37d9b14e81ce85a36ac38946b4b90ca403c57fe448be51ccb";
  "@nx/nx-linux-arm64-gnu@npm:16.10.0" = "001e71fedfc763a4dedd6c5901b66a4a790d388673fb74675235e19bb8fe031ff3755568ed867513dd003f873901fabda31a7d5628b39095535cb9f6d1dc7191";
  "@nx/nx-linux-arm64-musl@npm:16.10.0" = "58e3b71571bdadd2b0ddd24ea6e30cd795e706ada69f685403412c518fba1a2011ac8c2ac46145eab14649aa5a78e0cedcdb4d327ccb3b6ec12e055171f3840b";
  "@nx/nx-linux-x64-gnu@npm:16.10.0" = "97729a7efb27301a67ebf34739784114528ddb54047e63ca110a985eaa0763c5b1ea7c623ead1a2266d07107951be81e82ffa0a30e6e4d97506659303f2c8c78";
  "@nx/nx-linux-x64-musl@npm:16.10.0" = "442bdbd5e61324a850e4e7bd6f54204108580299d3c7c4ebcec324da9a63e23f48d797a87593400fc32af78a3a03a3c104bfb360f107fe732e6a6c289863853a";
  "@nx/nx-win32-arm64-msvc@npm:16.10.0" = "b5c74184ebfc70294e85f8e309f81c3d40b5cf99068891e613f3bef5ddb946bef7c9942d9e6c7688e22006d45d786342359af3b4fc87aadf369afcda55c73187";
  "@nx/nx-win32-x64-msvc@npm:16.10.0" = "c5b174ebd7a5916246088e17d3761804b88f010b6b3f930034fa49af00da33b6d1352728c733024f736e4c2287def75bafdc3d60d8738bd24b67e9a4f11763f8";
}
nix-repl> builtins.toJSON  # serialize
«primop toJSON»
nix-repl> builtins.fromTOML
«primop fromTOML»
nix-repl> builtins.toXML

For more serialization formats see nixpkgs/lib/generators.nix as well as in nixpkgs/pkgs/pkgs-lib/formats/ we can see them with the nix repl as follows:

cd ~/src/nixpkgs
nix repl
nix-repl> :l .
nix-repl> lib.generators.toYAML {} { a = 10; }
"{\"a\":10}"
nix-repl> lib.generators.toYAML {} { a.b.c = 10; }
"{\"a\":{\"b\":{\"c\":10}}}"
nix-repl> builtins.trace (lib.generators.toYAML {} { a.b.c = 10; }) null
trace: {"a":{"b":{"c":10}}}
null
nix-repl> yamlFormat = pkgs.formats.yaml {}

nix-repl> yamlFormat
{
  generate = «lambda generate @ /home/jr/src/nixpkgs/pkgs/pkgs-lib/formats.nix:111:9»;
  type = { ... };
}
  • We can see that it provides a generate function that we can use. generate doesn't just generate a string anymore because if we want to lift the restriction at evaluation time we can't return the formatted form at evaluation time anymore. We need a name to return a derivation continued below:
yamlFormat.generate "name" { a.b.c = 10; }
«derivation /nix/store/xakajb2rzbmqqkjbh08bxwqdf0xqvjly-name.drv»
nix-repl> :b yamlFormat.generate "name" { a.b.c = 10; }
This derivation produced the following outputs:
out -> /nix/store/y4c5029k6w3l0qmdw7cq396zrdy5x8yj-name
nix-repl> :q

Let's cat the result to see if it's formatted correctly as YAML:

cat /nix/store/y4c5029k6w3l0qmdw7cq396zrdy5x8yj-name
───────┬───────────────────────────────────────────────────────────────
       │ File: /nix/store/y4c5029k6w3l0qmdw7cq396zrdy5x8yj-name
───────┼──────────────────────────────────────────────────────────────
   1   │ a:
   2   │   b:
   3   │     c: 10
───────┴───────────────────────────────────────────────────────────

Looks good. There is also a type:

nix repl
nix-repl> :l .
nix-repl> yamlFormat = pkgs.format.yaml {}
nix-repl> yamlFormat.type
{
  _type = "option-type";
  check = «lambda check @ /home/jr/src/nixpkgs/lib/types.nix:1029:19»;
  deprecationMessage = null;
  description = "YAML value";
  descriptionClass = "conjunction";
  emptyValue = { ... };
  functor = { ... };
  getSubModules = null;
  getSubOptions = «lambda @ /home/jr/src/nixpkgs/lib/types.nix:214:25»;
  merge = «lambda merge @ /home/jr/src/nixpkgs/lib/types.nix:1031:13»;
  name = "nullOr";
  nestedTypes = { ... };
  substSubModules = «lambda substSubModules @ /home/jr/src/nixpkgs/lib/types.nix:1046:29»;
  typeMerge = «lambda defaultTypeMerge @ /home/jr/src/nixpkgs/lib/types.nix:115:10»;
}
nix-repl> lib.modules.mergeDefinitions [] yamlFormat.type [ { value = null; } ]
{
  defsFinal = [ ... ];
  defsFinal' = { ... };
  isDefined = true;
  mergedValue = null;
  optionalValue = { ... };
}
nix-repl> (lib.modules.mergeDefinitions [] yamlFormat.type [ { value = null; } ]).mergedValue
null
nix-repl> :p (lib.modules.mergeDefinitions [] yamlFormat.type [ { value = { a.b.c = 10; }; } ]).mergedValue
{
  a = {
    b = { c = 10; };
  };
}
nix-repl> :p (lib.modules.mergeDefinitions [] yamlFormat.type [ { value = { a.b.c = 10; }; } { value = { a.b.d = 20; }; } ]).mergedValue
{
  a = {
    b = {
      c = 10;
      d = 20;
    };
  };
}
  • lib can't access any packages, it is entirely at evaluation time. It can't access any formatters or things like that. If we lift that restriction as is done in pkgs.formats we can make it look much nicer.
cd ~/src/nixpkgs
nix-build -A hello
warning: Nix search path entry '/nix/var/nix/profiles/per-user/root/channels' does not exist, ignoring
this path will be fetched (0.06 MiB download, 0.26 MiB unpacked):
  /nix/store/29mhfr5g4dsv07d80b7n4bgs45syk3wl-hello-2.12.2
copying path '/nix/store/29mhfr5g4dsv07d80b7n4bgs45syk3wl-hello-2.12.2' from 'https://cache.nixos.org'...
/nix/store/29mhfr5g4dsv07d80b7n4bgs45syk3wl-hello-2.12.2

Say we rely on this store path in a derivation:

nix-repl> thePath = "/nix/store/29mhfr5g4dsv07d80b7n4bgs45syk3wl-hello-2.12.2"
nix-repl> thePath + "/bin/hello"
"/nix/store/29mhfr5g4dsv07d80b7n4bgs45syk3wl-hello-2.12.2/bin/hello"
hx ~/src/nixpkgs/test2.nix
# test2.nix
with import ./. {};

runCommand "test" {
    nativeBuildInputs = [
        hello
    ];
}''
  hello > $out
''

Try building it:

nix-build test2.nix && cat result
warning: Nix search path entry '/nix/var/nix/profiles/per-user/root/channels' does not exist, ignoring
/nix/store/m55p4vpb8s7s28s20vs89i467kxbrdac-test
Hello, world!

Now if we try it with the store path:

# test2.nix
with import ./. {};

runCommand "test" {
}''
  /nix/store/29mhfr5g4dsv07d80b7n4bgs45syk3wl-hello-2.12.2/bin/hello > $out
''

This doesn't work

nix-build test2.nix
last 1 log lines:
> /build/.attr-0l2nkwhif96f51f4amnlf414lhl4rv9vh8iffyp431v6s28gsr90: line 1: /nix/store/29mhfr5g4dsv07d80b7n4bgs45syk3wl-hello-2.12.2/bin/hello: No such file or directory
For full logs, run:
nix log /nix/store/58zcp9xwgf1sirmzf9sh61j8gz9lkw34-test.drv
nix-instantiate test2.nix
/nix/store/58zcp9xwgf1sirmzf9sh61j8gz9lkw34-test.drv
nix derivation show /nix/store/58zcp9xwgf1sirmzf9sh61j8gz9lkw34-test.drv | jq
{
  "/nix/store/58zcp9xwgf1sirmzf9sh61j8gz9lkw34-test.drv": {
    "args": [
      "-e",
      "/nix/store/vj1c3wf9c11a0qs6p3ymfvrnsdgsdcbq-source-stdenv.sh",
      "/nix/store/shkw4qm9qcw5sc5n1k5jznc83ny02r39-default-builder.sh"
    ],
    "builder": "/nix/store/xy4jjgw87sbgwylm5kn047d9gkbhsr9x-bash-5.2p37/bin/bash",
    "env": {
      "__structuredAttrs": "",
      "buildCommand": "/nix/store/29mhfr5g4dsv07d80b7n4bgs45syk3wl-hello-2.12.2/bin/hello > $out\n",
      "buildInputs": "",
      "builder": "/nix/store/xy4jjgw87sbgwylm5kn047d9gkbhsr9x-bash-5.2p37/bin/bash",
      "cmakeFlags": "",
      "configureFlags": "",
      "depsBuildBuild": "",
      "depsBuildBuildPropagated": "",
      "depsBuildTarget": "",
      "depsBuildTargetPropagated": "",
      "depsHostHost": "",
      "depsHostHostPropagated": "",
      "depsTargetTarget": "",
      "depsTargetTargetPropagated": "",
      "doCheck": "",
      "doInstallCheck": "",
      "enableParallelBuilding": "1",
      "enableParallelChecking": "1",
      "enableParallelInstalling": "1",
      "mesonFlags": "",
      "name": "test",
      "nativeBuildInputs": "",
      "out": "/nix/store/ljrkx5midby3j7p4g96d74jrq8f9rpya-test",
      "outputs": "out",
      "passAsFile": "buildCommand",
      "patches": "",
      "propagatedBuildInputs": "",
      "propagatedNativeBuildInputs": "",
      "stdenv": "/nix/store/aq801xbgs98nxx3lckrym06qfvl8mfsf-stdenv-linux",
      "strictDeps": "",
      "system": "x86_64-linux"
    },
    "inputDrvs": {
      "/nix/store/bmncp7arkdhrl6nkyg0g420935x792gl-stdenv-linux.drv": {
        "dynamicOutputs": {},
        "outputs": [
          "out"
        ]
      },
      "/nix/store/rfkzz952hz2d58d90mscxvk87v5wa5bz-bash-5.2p37.drv": {
        "dynamicOutputs": {},
        "outputs": [
          "out"
        ]
      }
    },
    "inputSrcs": [
      "/nix/store/shkw4qm9qcw5sc5n1k5jznc83ny02r39-default-builder.sh",
      "/nix/store/vj1c3wf9c11a0qs6p3ymfvrnsdgsdcbq-source-stdenv.sh"
    ],
    "name": "test",
    "outputs": {
      "out": {
        "path": "/nix/store/ljrkx5midby3j7p4g96d74jrq8f9rpya-test"
      }
    },
    "system": "x86_64-linux"
  }
}

You see the "inputDrvs", they are the derivations that we depend on and it doesn't know about the hello.drv. In Nix for the builder sandbox it creates a sandbox that only contains the derivations that you depend on which ensures that you can't depend on any derivation that you haven't explicitly decalred.

Nix does have builtins.storePath that allows you to do this, otherwise it's kind of an anti pattern.

# test2.nix
# test2.nix
# test2.nix
with import ./. {};
  runCommand "test" {
  } ''
    ${builtins.storePath "/nix/store/29mhfr5g4dsv07d80b7n4bgs45syk3wl-hello-2.12.2"}/bin/hello > $out
  ''

builtins.storePath: Turns a store path into the thing that it represents in the store.

nix-build test2.nix && cat result
/nix/store/x48741w0k9hgqywzv6wc7rk90r1y75js-test
Hello, world!

To demonstrate what builtins.storePath does:

nix-repl> builtins.storePath "/nix/store/29mhfr5g4dsv07d80b7n4bgs45syk3wl-hello-2.12.2/bin/hello"
"/nix/store/29mhfr5g4dsv07d80b7n4bgs45syk3wl-hello-2.12.2/bin/hello"
nix-repl> builtins.getContext "/nix/store/29mhfr5g4dsv07d80b7n4bgs45syk3wl-hello-2.12.2"
{ }
nix-repl> builtins.getContext (builtins.storePath "/nix/store/29mhfr5g4dsv07d80b7n4bgs45syk3wl-hello-2.12.2")
{
  "/nix/store/29mhfr5g4dsv07d80b7n4bgs45syk3wl-hello-2.12.2" = { ... };
}
nix-repl> :p builtins.getContext (builtins.storePath "/nix/store/29mhfr5g4dsv07d80b7n4bgs45syk3wl-hello-2.12.2")
{
  "/nix/store/29mhfr5g4dsv07d80b7n4bgs45syk3wl-hello-2.12.2" = { path = true; };
}
nix-repl> :l .
warning: Nix search path entry '/nix/var/nix/profiles/per-user/root/channels' does not exist, ignoring
Added 24878 variables.

nix-repl> hello.outPath
# this is the output path of the hello derivation
"/nix/store/29mhfr5g4dsv07d80b7n4bgs45syk3wl-hello-2.12.2"
nix-repl> :p builtins.getContext hello.outPath
# we see that this is a `.drv`, this is because derivations can have multiple outputs
{
  "/nix/store/ljxsxdy1syy03b9kfnnh8x7zsk21fdcq-hello-2.12.2.drv" = {
    outputs = [ "out" ];
  };
}
# for example
nix-repl> openssl.outputs
[
  "bin"
  "dev"
  "out"
  "man"
  "doc"
  "debug"
]
nix-repl> openssl.all
# a list of all the derivations
[
  «derivation /nix/store/rw3y8k94ib37dc86n0wivr551wyzxgsk-openssl-3.4.1.drv»
  «derivation /nix/store/rw3y8k94ib37dc86n0wivr551wyzxgsk-openssl-3.4.1.drv»
  «derivation /nix/store/rw3y8k94ib37dc86n0wivr551wyzxgsk-openssl-3.4.1.drv»
  «derivation /nix/store/rw3y8k94ib37dc86n0wivr551wyzxgsk-openssl-3.4.1.drv»
  «derivation /nix/store/rw3y8k94ib37dc86n0wivr551wyzxgsk-openssl-3.4.1.drv»
  «derivation /nix/store/rw3y8k94ib37dc86n0wivr551wyzxgsk-openssl-3.4.1.drv»
]
nix-repl> lib.concatStringsSep " " openssl.all
"/nix/store/rjzx8v679rwd6dsb6s08iy3j2rrax72s-openssl-3.4.1-bin /nix/store/kcgqglb4iax0zh5jlrxmjdik93wlgsrq-openssl-3.4.1-dev /nix/store/8pviily4fgsl02ijm65binz236717wfs-openssl-3.4.1 /nix/store/1l5b31cnswnbcdcac9rzs9xixnc2n9r5-openssl-3.4.1-man /nix/store/9fz5qmj0z70cbzy7mapml0sbi8z6ap0a-openssl-3.4.1-doc /nix/store/yk2g2gfcj2fy1ffyi1g91q7jmp4h8pxa-openssl-3.4.1-debug"
nix-repl> :p builtins.getContext (builtins.unsafeDiscardOutputDependency (lib.concatStringsSep " " openssl.all))
{
  "/nix/store/rw3y8k94ib37dc86n0wivr551wyzxgsk-openssl-3.4.1.drv" = {
    outputs = [
      "bin"
      "debug"
      "dev"
      "doc"
      "man"
      "out"
    ];
  };
}
nix-repl> :p builtins.getContext openssl.drvPath
{
  "/nix/store/rw3y8k94ib37dc86n0wivr551wyzxgsk-openssl-3.4.1.drv" = { allOutputs = true; };
}
# useful if you need to create a derivation that copies this derivation to another machine
# remote builders usually take care of this but you may need it occasionally
nix-repl> :p builtins.getContext (builtins.unsafeDiscardOutputDependency openssl.drvPath)
{
  "/nix/store/rw3y8k94ib37dc86n0wivr551wyzxgsk-openssl-3.4.1.drv" = { path = true; };
}

Relying on paths outside of the nix store is generally not recommended because of garbage collection and it's considered unsafe.

Version Control with Git

✔️ Click to Expand Table of Contents

Git Logo

⚠️ Important: Never commit secrets (passwords, API keys, tokens, etc.) in plain text to your Git repository. If you plan to publish your NixOS configuration, always use a secrets management tool like sops-nix or agenix to keep sensitive data safe. See the Sops-Nix Guide for details.

It's also important to understand that all files in the /nix/store are world-readable by default This has important security implications for anyone managing sensitive data on a NixOS system.

What Does "World-Readable" Mean?

  • All files in /nix/store are readable by any user on the sytem.

  • This is by design, the nix store is intended to be shared, immutable store of all packages and configuration files.

  • Permissions are typically set to r-xr-xr-x(read and execute for everyone)

Security Implications

  • Never store secrets or sensitive data in plane text in the Nix store.

  • If you include secrets directly in your configuration, they will end up in the /nix/store and be accessible to any user or process on the system.

  • This applies to files, environment variables, and any data embedded in derivations.

Best Practices

  • Always use a secrets management tool (like sops-nix or agenix) that decrypts secrets at activation time and stores them outside the Nix store, with restricted permissions.

  • Do not embed secrets directly in Nix expressions or configuration files that will be build into the store.

  • Even hashed passwords can be vulnerable when stored in a public repository, be conscious of what you store where.

  • If you’re unsure about what’s safe to share, start with a private repository. This gives you time to learn about secrets management and review your configuration before making anything public.

First, I'll breefly explain some of the limitations of NixOS Rollbacks and then I'll go into how Git compliments them.

Limitations of NixOS Rollbacks

NixOS is famous for its ability to roll back to previous system generations, either from the boot menu or with commands like nixos-rebuild --rollback.

When you perform rollbacks in NixOS, whether from the boot menu or using commands like nixos-rebuild --rollback only the contents and symlinks managed by the Nix store are affected. The rollback works by switching which system generation is active, atomically updating symlinks to point to the previous version of all packages, systemd units and services stored in /nix/store.

However, it’s important to understand what these rollbacks actually do and what they don’t do. What NixOS Rollbacks Cover

  • System generations: When you rebuild your system, NixOS creates a new “generation” that you can boot into or roll back to. This includes all packages, services, and system configuration managed by Nix.

  • Quick recovery: If an upgrade breaks your system, you can easily select an older generation at boot and get back to a working state

Key Limitations:

  • Configuration files are not reverted: Rolling back only changes which system generation is active, it does not revert your actual configuration files (like configuration.nix or your flake files)

  • User data and service data are not rolled back: Only files managed by Nix are affected. Databases, user files, and other persistent data remain unchanged, which can cause problems if, for example, a service migrates its database schema during an upgrade

  • Manual changes persist: Any manual edits to configuration files or system state outside of Nix are not reverted by a rollback

How Git Helps

Git Logo 2

  • The gh-cli, simplifies quite a few things for working with GitHub from the command line.

  • Tracks every configuration change: By version-controlling your NixOS configs with Git, you can easily see what changed, when, and why.

  • True config rollback: If a configuration change causes issues, you can use git checkout or git revert to restore your config files to a previous good state, then rebuild your system

  • Safer experimentation: You can confidently try new settings or upgrades, knowing you can roll back both your system state (with NixOS generations) and your config files (with Git).

  • Collaboration and backup: Git lets you share your setup, collaborate with others, and restore your configuration if your machine is lost or damaged.

In summary: NixOS rollbacks are powerful for system state, but they don’t manage your configuration file history. Git fills this gap, giving you full control and traceability over your NixOS configs making your system both robust and truly reproducible. Version control is a fundamental tool for anyone working with NixOS, whether you’re customizing your desktop, managing servers, or sharing your configuration with others. Git is the most popular version control system and is used by the NixOS community to track, share, and back up system configurations.

Why use Git with NixOS?

  • Track every change: Git lets you record every modification to your configuration files, so you can always see what changed, when, and why.

  • Experiment safely: Try new settings or packages without fear—if something breaks, you can easily roll back to a previous working state.

  • Sync across machines: With Git, you can keep your NixOS setups in sync between your laptop, desktop, or servers, and collaborate with others.

  • Disaster recovery: Accidentally delete your config? With Git, you can restore it from your repository in minutes.

Installing Git on NixOS

You can install Git by adding it to your system packages in your configuration.nix or via Home Manager:

Git Tips

Octocat

If you develop good git practices on your own repositories it will make it easier to contribute with others as well as get help from others.

Atomic Commits

Atomic commits are a best practice in Git where each commit represents a single, focused, and complete change to the codebase. The main chacacteristics of atomic commits are:

  • One purpose: Each commit should address only one logical change or task.

  • Complete: The commit should leave the codebase in a working state.

  • Descriptive: The commit message should be able to clearly summarize the change in a single sentence.

Why Atomic Commits Matter

  • Easier debugging: You can use tools like git bisect to quickly find which commit introduced a bug, since each commit is isolated.

  • Simpler reverts: You can revert without affecting unrelated changes.

  • Better collaboration: Code reviews and merges are more manageable when changes are small and focused.

When you lump together a bunch of changes into a single commit it can lead to quite a few undesirable consequences. They make it harder to track down bugs, it's more difficult to revert undesired changes without reverting desired ones, make larger tickets harder to manage.

Every time a logical component is completed, commit it. Smaller commits make it easier for other devs and yourself to understand the changes and roll them back if necessary. This also makes it easier to share your code with others to get help when needed and makes merge conflicts less frequent and complex.

Finish the component, then commit it: There's really no reason to commit unfinished work, use git stash for unfinished work and git commit for when the logical component is complete. Use common sense and break complex components into logical chunks that can be finished quickly to allow yourself to commit more often.

Write Good Commit Messages: Begin with a summary of your changes, add a line of whitespace between the summary and the body of your message. Make it clear why this change was necessary. Use consistent language with generated messages from commands like git merge which is imperative and present tense (<<change>>, not <<changed>> or <<changes>>).

Tips for Keeping Commits Atomic with a Linear History

Squashing limits the benefits of atomic commits as it combines them all into a single commit as if you didn't take the time to write them all out atomically.

🧠 Why Rebasing Wins for Linear History

  • No Merge Bubbles: Rebasing avoids those extra merge commits that clutter git log --graph. You get a clean, readable timeline.

  • Atomic Commit Integrity: Each commit stands alone and tells a story. Rebasing preserves that narrative without diluting it with merge noise.

  • Better Blame & Bisect: Tools like git blame and git bisect work best when history is linear and logical.

  • Time-Travel Simplicity: Cherry-picking or reverting is easier when commits aren’t tangled in merge commits.

By default, when you run git pull git merges the commits into your local repo. To change this to a rebase you can set the following:

git config --global pull.rebase true
git config --global rebase.autoStash true
git config --global fetch.prune true  # auto delets remote-tracking branches that no longer exist
git config --global pull.ff only          # blocks merge pulls

Note: With pull.ff only pulls will fail if they would have had to merge. This could happen if your local branch has diverged from the remote (e.g., someone pushed new commits and you also committed locally) git pull will throw an error like:

fatal: Not possible to fast-forward, aborting.

How to fix it

You basically do what Git won't auto-do:

git fetch origin
git rebase origin/main

This rewinds your local commits, applies remote commits, and replays yours on top, keeping the history linear.

If you don't care about your local changes and want to discard them you can use the following command:

git reset --hard origin/main

This just makes your branch identical to the remote, no rebase required. This prevents rogue merge commits, preserving atomic commits and linear logs.

You could set an alias for this with:

git config --global alias.grs '!git fetch origin && git rebase origin/main'

To check whether a setting is active or now you can use:

git config --get rebase.autoStash
true

To set these options with home-manager:

# ... snip ...
    extraConfig = lib.mkOption {
      type = lib.types.attrs;
      default = {
        commit.gpgsign = true;
        gpg.format = "ssh";
        user.signingkey = "/etc/ssh/ssh_host_ed25519_key.pub";
        extraConfig = {
          pull = {
            rebase = true;
            ff = "only";
        };
        };
        rebase = {
          autoStash = true; # Auto stashes and unstashes local changes during rebase
        };
        fetch = {
          prune = true; # Automatically deletes remote-tracking branches that no longer exist
        };
# ... snip ...

Time Travel in Git

✔️ Click to Expand Time Travel Section

View an old commit:

git checkout <commit_hash>

This puts you in a "detached HEAD" state, letting you explore code as it was at that commit. To return, checkout your branch again.

Go back and keep history (revert):

git revert <commit_hash>

Go back and rewrite history (reset):

  • Soft reset (keep changes staged):
git reset --soft <commit_hash>
  • Mixed reset (keep changes in working directory):
git reset <commit_hash>
  • Hard reset (discard all changes after the commit):
git reset --hard <commit_hash>

Use the above command with caution, it can delete commits from history.

  • Relative time travel:
git reset --hard HEAD@{5.minutes.ago}

or

git reset --hard HEAD@{yesterday}

Create a branch from the past:

git checkout -b <new-brach> <commit_hash>

This starts a new branch from any previous commit, preserving current changes.

Some repositories have guidelines, such as Nixpkgs:

✔️ Click to Expand Nixpkgs Commit Conventions

Commit conventions

  • Create a commit for each logical unit.

  • Check for unnecessary whitespace with git diff --check before committing.

  • If you have commits pkg-name: oh, forgot to insert whitespace: squash commits in this case. Use git rebase -i. See Squashing Commits for additional information.

  • For consistency, there should not be a period at the end of the commit message's summary line (the first line of the commit message).

  • When adding yourself as maintainer in the same pull request, make a separate commit with the message maintainers: add . Add the commit before those making changes to the package or module. See Nixpkgs Maintainers for details.

    Make sure you read about any commit conventions specific to the area you're touching. See: Commit conventions for changes to pkgs. Commit conventions for changes to lib. Commit conventions for changes to nixos. Commit conventions for changes to doc, the Nixpkgs manual.

Writing good commit messages

In addition to writing properly formatted commit messages, it's important to include relevant information so other developers can later understand why a change was made. While this information usually can be found by digging code, mailing list/Discourse archives, pull request discussions or upstream changes, it may require a lot of work.

Package version upgrades usually allow for simpler commit messages, including attribute name, old and new version, as well as a reference to the relevant release notes/changelog. Every once in a while a package upgrade requires more extensive changes, and that subsequently warrants a more verbose message.

Pull requests should not be squash merged in order to keep complete commit messages and GPG signatures intact and must not be when the change doesn't make sense as a single commit.

A Git workflow is a recipe or recommendation for how to use Git to accomplish work in a consistent and productive manner. Having a defined workflow lets you leverage Git effectively and consistently. This is especially important when working on a team.

Origin is the default name (alias) for the remote repository that your local repository is connected to, usually the one you cloned from.

Remote Repositories are versions of your project that are hosted on the internet or network somewhere.

  • When you run git push origin main, you're telling Git to push your changes to the remote repo called origin.

  • You can see which URL origin points to with git remote -v.

  • You can have multiple remotes (like origin, upstream, etc.) each pointing to a different remote repo. Each of which is generally either read-only or read/write for you. Collaborating involves managing these remotes and pushing and pulling data to and from them when you need to share work.

❗ You can have a remote repo on your local machine. The word "remote" doesn't imply that the repository is somewhere else, only that it's elsewhere.

  • The name origin is just a convention, it's not special. It is automatically set when you clone a repo.

git local remote

Local is your local copy of the repository, git tracks the differences between local and remote which is a repo hosted elsewhere (e.g., GitHub GitLab etc.)

The Upstream in Git typically refers to the original repository from which your local repository or fork was derived. The Upstream is the remote repo that serves as the main source of truth, often the original project you forked from. You typically fetch changes from upstream to update your local repo with the latest updates from the original project, but you don't push to upstream unless you have write access.

A Basic Git Workflow

Git logo 3

  1. Initialize your Repository:

If you haven't already created a Git repo in your NixOS config directory (for example, in your flake or /etc/nixos):

cd ~/flake
git init
git add .
git commit -m "Initial commit: NixOS Configuration"

Taking this initial snapshot with Git is a best practice—it captures the exact state of your working configuration before you make any changes.

  • The command git add . stages all files in the directory (and its subdirectories) for commit, meaning Git will keep track of them in your project history.

  • The command git commit -m "message" then saves a snapshot of these staged files, along with your descriptive message, into the repository.

    • Think of a commit as a "save point" in your project. You can always go back to this point if you need to, making it easy to experiment or recover from mistakes. This two-step process, staging with git add and saving with git commit is at the heart of how Git tracks and manages changes over time.

git commit add

  1. Make and Track Changes:

Now that you've saved a snapshot of your working configuration, you're free to experiment and try new things, even if they might break your setup.

Suppose you you want to try a new desktop environment, like Xfce. You edit your configuration.nix to add:

services.xserver.desktopManager.xfce.enable = true;

You run:

sudo nixos-rebuild switch # if configuration.nix is in /etc/nixos/

but something goes wrong: the system boots, but your desktop is broken or won't start. You decide to roll back using the boot menu or:

sudo nixos-rebuild switch --rollback

What happens?

  • Your system reverts to the previous working generation in /nix/store

  • But: Your configuration.nix file is still changed, it still has the line enabling Xfce. If you rebuild again, you'll get the same broken system, because your config itself wasn't rolled back.

How does Git Help on Failure?

Git gives you quite a few options and ways to inspect what has been done.

  • Use git status to see what's changed, and git checkout -- <file> to restore any file to its last committed state.

  • Review your changes with git diff to see exactly what you modified before deciding whether to keep or revert those changes.

  • Reset everything with git reset --hard HEAD, this will discard all local changes and return to your last commit.

With Git you can simply run:

git checkout HEAD~1 configuration.nix
# or, if you committed before the change:
git revert <commit-hash>

Show the full hash of the latest commit:

git rev-parse HEAD
f53fef375d89496c0174e70ce94993d43335098e

Short hash:

git log --pretty=format:'%h' -n 1
f53fef3
git revert f53fef3

Show a list of Recent commits:

git log
# a list of all commits, with hashes, author, date, and message
git log --oneline
git log --oneline
f53fef3 (HEAD -> main) thunar
b34ea22 thunar
801cbcf thunar
5e72ba5 sops
8b67c59 sops
1a353cb sops

You can copy the commit hash from any of these and use it in commands like git checkout <hash> or git revert <hash>.

Commit successful experiments

  • If your changes work, stage, and commit them:
git add .
# or more specifically the file you changed or created
git add configuration.nix
git commit -m "Describe the new feature or fix"

Basic Branching

With Git you're always on a branch and the default branch is master. Many change it to main because of the suggestion Git gives you I think people are too easily offended these days, just keep this in mind that main and master refer to the main development branch.

You can get a listing of your current branches with:

git branch
* (no branch)
  main

The * is next to the current branch and is where the HEAD is currently pointing. It says (no branch) because I'm currently in detached HEAD where HEAD points to no branch. The reason for this is because I've been trying out Jujutsu VCS and that's JJ's default setting, a detached HEAD.

Git actually gives you a warning about working in a detached HEAD:

You are in 'detached HEAD' state. You can make experimental
changes and commit them, and you can discard any commits you make
in this state without impacting any branch by switching back.

If you want to create a new branch to retain commits you create,
you can do so now (using 'git switch -c <new-branch-name>') or
later (using 'git branch <new-branch-name> <commit-id>').

See 'git help switch' for details.

To attach the HEAD (i.e., have the pointer pointing to a branch), use the git checkout command

git checkout main
Switched to branch 'main'
git branch
* main
# Ensure that you have the latest "tip" from the remote repository `origin`
git fetch origin main
From github.com:sayls8/flake
 * branch            main       -> FETCH_HEAD

Although we're working on our own repo and there is basically no chance of our local branch diverging from our remote, it's still good to get in the practice of getting everything in sync before merging or rebasing etc.

git fetch doesn't update main, it just updates your references. To update main you would use git pull origin/main or git rebase origin/main

You can inspect your upstream branches with the following command:

git remote show origin
* remote origin
  Fetch URL: git@github.com:saylesss88/flake.git
  Push  URL: git@github.com:saylesss88/flake.git
  HEAD branch: main
  Remote branch:
    main tracked
  Local ref configured for 'git push':
    main pushes to main (fast-forwardable)

* branch main -> FETCH_HEAD: This line signifies that the main branch from the remote repository (likely origin) was successfully fetched, and the commit ID of its current tip (its latest commit) is now stored in your local FETCH_HEAD reference.

Now that we know our local main is up to date with our remote origin/main we can safely create a new feature branch:

git checkout -b feature/prose_wrap
Switched to a new branch 'feature/prose_wrap'

Right now the branch feature/prose_wrap is exactly the same as main and we can safely make changes without affecting main. We can try crazy or even "dangerous" things and always be able to revert to a working state with git checkout main.

If our crazy idea works out, we can then merge our feature branch into main.

Ok the feature works, I've added and committed the change. Now it's time to point the HEAD to main and then either merge or rebase the feature branch into main:

git checkout main
git fetch origin main
git merge feature/prose_wrap
Updating c8bd54c..b281f79
Fast-forward
 home/editors/helix/default.nix | 69 +++++++++++++++++++++++++++++++--------------------------------------
 1 file changed, 31 insertions(+), 38 deletions(-)
  • "fast-forward" means that our feature/prose_wrap branch was directly ahead of the last commit on main. When you merge one commit with another commit that can be reached by following the first commits history, remember the feature branch is exactly the same as main until I made another commit. If the branches diverged more and the history can't be followed, Git will perform a 3-way merge where it creates a new "merge commit" that combines the 2 changes.

If you have a bunch of branches and forget which have been merged yet use:

git branch --merged
feature/prose_wrap
* main
# OR to see branches that haven't been merged use:
git branch --no-merged

It's now safe to delete the feature branch:

git branch -d feature/prose_wrap
Deleted branch feature/prose_wrap (was b281f79)

❗ TIP: If your feature branch has a lot of sloppy commits that won't be of much benefit to anyone, squash them first then merge. The workflow would look something like this:

 # Make sure you're on the main branch
 git checkout main

 # Merge the feature branch with squash
 git merge --squash feature/prose_wrap
  • This combines all the commits in your branch and adds them to your main staging area, it doesn't move HEAD or create a merge commit for you. To apply the changes into one big commit, finalize it with:
 git commit -m "Add prose wrapping feature"

This is often referred to as the "squash commit".

Branching means to diverge from the main line of development and continue to do work without risking messing up your main branch. There are a few commits on your main branch so to visualize this it would look something like this, image is from Pro Git:

Git Branch 1

Nix flake update example with branches

Let's say you haven't ran nix flake update in a while and you don't want to introduce errors to your working configuration. To do so we can first, make sure we don't lose any changes on our main branch:

git add .
git commit -m "Staging changes before switching branches"
# I always like to make sure the configuration will build before pushing to git
sudo nixos-rebuild switch --flake .
# If everything builds and looks correct
git push origin main

OR, if you have incomplete changes that you don't want to commit yet you can stash them with git stash:

git status
On branch main
Your branch is ahead of 'origin/main' by 1 commit.
  (use "git push" to publish your local commits)

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   home/git.nix

no changes added to commit (use "git add" and/or "git commit -a")

Now we want to switch branches, without committing the incomplete changes to git.nix:

git stash
Saved working directory and index state WIP on main: 0e46d6b git: lol alias

git status
On branch main
Your branch is ahead of 'origin/main' by 1 commit.
  (use "git push" to publish your local commits)

nothing to commit, working tree clean

git stash is equivalent to git stash push

To see which stashes you have stored, use git sash list:

git stash list
stash@{0}: WIP on main: 0e46d6b git: lol alias

To apply the most recent stash:

git stash apply
git add home/git.nix
On branch main
Your branch is ahead of 'origin/main' by 1 commit.
  (use "git push" to publish your local commits)

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   home/git.nix

# or for multiple stashes
git stash apply stash@{2}

Running git stash apply applies the changes that were in your stash but doesn't automatically restage them, to apply the changes and stage them in one command:

git stash apply --index

Now let's create our branch so we can safely update:

git checkout -b update-test
Switched to a new branch 'update-test'

-b is to switch to the branch that was just created

Some may prefer a more descriptive branch name such as: update/flake-inputs, I kept it short for the example. Or if your company uses an issue tracker, including the ticket number in the branch name can be helpful: update/123-flake-inputs

The above command is equivalent to:

git branch update-test
git checkout update-test

Now our branches would look something like this, note how both branches currently point to the same commit:

Git Branch 2

Now, lets run our update:

nix flake update
sudo nixos-rebuild test --flake .
# If everything looks ok let's try applying the changes
sudo nixos-rebuild switch --flake .
# And if everything looks ok:
git add .
git commit -m "feat: Updated all flake inputs"
git push origin update-test

❗ This is the same workflow for commiting a PR. After you first fork and clone the repo you want to work on, you then create a new feature branch and push to that branch on your fork. This allows you to create a PR comparing your changes to their existing configuration.

At this point our graph would look similar to the following:

Git Branch 3

If we are satisfied, we can switch back to our main branch and merge update-test into it:

git checkout main
git merge origin/update-test
git branch -D update-test
sudo nixos-rebuild test --flake .
sudo nixos-rebuild switch --flake .

It's good practice to delete a branch after you've merged and are done with it.

Rebasing Branches

To combine two seperate branches into one unified history you typically use git merge or git rebase.

git merge takes two commit pointers and finds a common base commit between them, it then creates a "merge commit" that combines the changes.

git rebase is used to move a sequence of commits to a new base commit.

Git rebase

Configure Git Declaratively

The following example is the git.nix from the hydenix project it shows some custom options and a way to manage everything from a single location:

# git.nix from hydenix: declarative Git configuration for Home Manager
{ lib, config, ... }:

let
  cfg = config.hydenix.hm.git;
in
{

  options.hydenix.hm.git = {
    enable = lib.mkOption {
      type = lib.types.bool;
      default = config.hydenix.hm.enable;
      description = "Enable git module";
    };

    name = lib.mkOption {
      type = lib.types.nullOr lib.types.str;
      default = null;
      description = "Git user name";
    };

    email = lib.mkOption {
      type = lib.types.nullOr lib.types.str;
      default = null;
      description = "Git user email";
    };
  };

  config = lib.mkIf cfg.enable {

    programs.git = {
      enable = true;
      userName = cfg.name;
      userEmail = cfg.email;
      extraConfig = {
        init.defaultBranch = "main";
        pull.rebase = false;
      };
    };
  };
}

❗ You can easily change the name of the option, everything after config. is custom. So you could change it to for example, config.custom.git and you would enable it with custom.git.enable = true; in your home.nix or equivalent.

Then he has a hm/default.nix with the following to enable it.

#...snip...

 # hydenix home-manager options go here
  hydenix.hm = {
    #! Important options
    enable = true;
      git = {
        enable = true; # enable git module
        name = null; # git user name eg "John Doe"
        email = null; # git user email eg "john.doe@example.com"
      };
    }

    # ... snip ...

You can enable git, and set your git username as well as git email right here.

Resources

Version Control with JJ

✔️ Click to Expand Table of Contents

JJ Logo

⚠️ Security Reminder: Never commit secrets (passwords, API keys, tokens, etc.) in plain text to your Git repository. If you plan to publish your NixOS configuration, always use a secrets management tool like sops-nix or agenix to keep sensitive data safe. See the Sops-Nix Guide for details.

Getting Started

Jujutsu (jj) is a modern, Git-compatible version control system designed to simplify and improve the developer experience. It offers a new approach to distributed version control, focusing on a more intuitive workflow, powerful undo capabilities, and a branchless model that reduces common pitfalls of Git.

Recommended resources:

  • Steve's Jujutsu Tutorial (most up to date). Steve does an excellent job explaining the ins and outs of Jujutsu.

  • Official:

jj help -k tutorial
  • Command help:
jj <command> --help
jj git init --help
jj git push --help

🔑 Key Concepts

✔️ Click to Expand Key Concepts
  1. Working Copy as Commit
  • In JJ your working copy is always a real commit. Any changes you make are automatically recorded in this working commit. The working copy is always (@) and the Parent commit is always (@-) keep this in mind.

  • There is no staging area (index) as in Git. You do not need to run git add or git commit for every change. Modifications are always tracked in the current commit.

  1. Branchless Workflow and Bookmarks
  • JJ does not have the concept of a "current branch." Instead, use bookmarks, which are named pointers to specific commits.

  • Bookmarks do not move automatically. Commands like jj new and jj commit move the working copy, but the bookmark stays were it was. Use jj bookmark move to move bookmarks. (e.g., jj bookmark move main).

  • Only commits referenced by bookmarks are pushed to remotes, preventing accidental sharing of unfinished work.

  1. Automatic Tracking and Simpler Workflow
  • Adding or removing files is automatically tracked, no need for explicit add or rm commands.

  • The working copy acts as a live snapshot of your workspace. Commands first sync filesystem changes into this commit, then perform the requested operation, and finally update the working copy if needed.

  1. Operation Log and Undo
  • JJ records every operation (commits, merges, rebases, etc.) in an operation log. Inspect it with: jj op log

  • You can view and undo any previous operation, not just the most recent one, making it easy to recover from mistakes, a feature not present in Git’s core CLI.

  1. First-Class Conflict Handling

Conflicts happen when JJ can't figure out how to merge different changes made to the same file.

  • Conflicts are stored inside commits, not just in the working directory. You can resolve them at any time, not just during a merge or rebase.

  • Conflict markers are inserted directly into files, and JJ can reconstruct the conflict state from these markers. You can resolve conflicts by editing the files or using jj resolve.

  1. Revsets and Filesets
  • Revsets: JJ's powerful query language for selecting sets of commits, inspired by Mercurial. For example, jj log -r "author(alice) & file(*.py)" lists all commits by Alice that touch Python files.

  • Filesets:JJ supports a functional language for selecting sets of files, allowing advanced file-based queries and operations.

FeatureGitJujutsu (jj)
Staging AreaYes (git add/index)No, working copy is always a commit
Commit WorkflowStage → CommitAll changes auto-recorded in working commit
BranchesCentral to workflowOptional, bookmarks used for sharing
Undo/RedoLimited, complexEasy, operation log for undo
Conflict HandlingManual, can be confusingConflicts tracked in commits, easier to fix
Integration with GitNativeFully compatible, can switch back anytime
  1. Anonymous branches: In Git a branch is a pointer to a commit that needs a name.

If you haven't taken the time to deep dive Git, it may be a good time to learn about a new way of doing Version Control that is actually less complex and easier to mentally map out in my opinion.

Jujutsu is a new front-end to Git, and it's a new design for distributed version control. --jj init

You can use jujutsu (jj) with existing Git repositories with one command. jj git init --colocate or jj git init --git-repo /path/to/git_repository. The native repository format for jj is still a work in progress so people typically use a git repository for backend.

Unlike git, jj has no index "staging area". It treats the working copy as an actual commit. When you make changes to files, these changes are automatically recorded to the working commit. There's no need to explicitly stage changes because they are already part of the commit that represents your current working state.

Simplified Workflow

Check where you're at, JJ doesn't care about commits without descriptions but Git and GitHub do:

jj st
Working copy  (@) now at: zuknrzrx 8a20bfa7 (empty) (no description set)
Parent commit (@-)      : yzppulzo bdd64e8d main | (empty) "Enable Rofi and update nu func for jj"

We can see that the Working copy is (empty) and has (no description set), lets give it a description:

jj desc -m "My feature"
# ...edit files...
# Check where we're at again
jj st
Working copy changes:
M home/jj.nix
Working copy  (@) : zuknrzrx bcd3d965 My feature
Parent commit (@-): yzppulzo bdd64e8d main | (empty) "Enable Rofi and update nu func for jj"

# Tell JJ which branch we're interested in
jj bookmark set main
# Push this change to main
jj git push
Changes to push to origin:
  Move forward bookmark main from bdd64e8d6ea5 to bcd3d96567f8
remote: Resolving deltas: 100% (3/3), completed with 3 local objects.
Warning: The working-copy commit in workspace 'default' became immutable, so a new commit has been created on top of it.
Working copy  (@) now at: ktlywzlx 8e88ddbe (empty) (no description set)
Parent commit (@-)      : zuknrzrx bcd3d965 main | My feature

What is the Jujutsu Working Copy

✔️ Click To Expand Working Copy Description

SourceTree image

The working copy in Jujutsu is an actual commit that represents the current state of the files you're working on. Unlike Git, where the working copy is separate from commits and changes must be explicitly staged and committed, in JJ the working copy is a live commit that automatically records changes as you modify files.

Adding or removing files in the working copy implicitly tracks or untracks them without needing explicit commands like git add

The working copy commit acts as a snapshot of your current workspace. When you run commands, Jujutsu first syncs the filesystem changes into this commit, then performs the requested operation, and finally updates the working copy if needed

To finalize your current changes and start a new set of changes, you use the jj new command, which creates a new working-copy commit on top of the current one. This replaces the traditional Git workflow of staging and committing changes separately.

Conflicts in the working copy are represented by inserting conflict markers directly into the files. Jujutsu tracks the conflicting parts and can reconstruct the conflict state from these markers. You resolve conflicts by editing these markers and then committing the resolution in the working copy

  • This means that you don't need to worry about making a change, running git add ., running git commit -m "commit message" because it's already done for you. This is handy with flakes by preventing a "dirty working tree" and can instantly be rebuilt after making a change.

Example JJ Module

✔️ Click to Expand JJ home-manager module example
  • For lazygit fans, Nixpkgs has lazyjj. I've seen that it's recommended to use jj with meld. I'll share my jj.nix here for an example:
{
  lib,
  config,
  pkgs,
  # userVars ? {},
  ...
}: let
  cfg = config.custom.jj;
in {
  options.custom.jj = {
    enable = lib.mkOption {
      type = lib.types.bool;
      default = true;
      description = "Enable the Jujutsu (jj) module";
    };

    userName = lib.mkOption {
      type = lib.types.nullOr lib.types.str;
      default = "sayls8";
      description = "Jujutsu user name";
    };

    userEmail = lib.mkOption {
      type = lib.types.nullOr lib.types.str;
      default = "sayls8@proton.me";
      description = "Jujutsu user email";
    };

    packages = lib.mkOption {
      type = lib.types.listOf lib.types.package;
      default = with pkgs; [lazyjj meld];
      description = "Additional Jujutsu-related packages to install";
    };

    settings = lib.mkOption {
      type = lib.types.attrs;
      default = {
        ui = {
          default-command = ["status"];
          diff-editor = ["nvim" "-c" "DiffEditor" "$left" "$right" "$output"];
          merge-editor = ":builtin";
        };
        git = {
          auto-local-bookmark = true;
        };
        aliases = {
          tug = ["bookmark" "move" "--from" "heads(::@- & bookmarks())" "--to" "@-"];
          upmain = ["bookmark" "set" "main"];
          squash-desc = ["squash" "::@" "-d" "@"];
          rebase-main = ["rebase" "-d" "main"];
          amend = ["describe" "-m"];
          pushall = ["git" "push" "--all"];
          dmain = ["diff" "-r" "main"];
          l = ["log" "-T" "builtin_log_compact"];
          lf = ["log" "-r" "all()"];
        };
      };
      description = "Jujutsu configuration settings";
    };
  };

  config = lib.mkIf cfg.enable {
    home.packages = cfg.packages;

    programs.jujutsu = {
      enable = true;
      settings = lib.mergeAttrs cfg.settings {
        user = {
          name = cfg.userName;
          email = cfg.userEmail;
        };
      };
    };
  };
}

In my home.nix I have this to enable it:

custom = {
    jj = {
        enable = true;
        userName = "sayls88";
        userEmail = "sayls88@proton.me";
    };
};

The custom.jj module allows me to override the username, email, and whether jj is enabled from a single, centralized place within my Nix configuration. So only if jj is enabled, lazyjj and meld will be installed.

Issues I've Noticed

jj tree

I have run into a few issues, such as every flake command reloading every single input every time. What I mean by this is what you see when you run a flake command for the first time, it adds all of your flakes inputs. I believe the fix for this is deleting and regenerating your flake.lock. The same thing can happen when you move your flake from one location to another.

That said, I recommend doing just that after running something like jj git init --colocate. Delete your flake.lock and run nix flake update, nix flake lock --recreate-lock-file still works but is being depreciated.

Sometimes the auto staging doesn't pick up the changes in your configuration so rebuilding changes nothing, this has been more rare but happens occasionally.

One of the most fundamental differences between Jujutsu and Git is how pushing works. If you’re coming from Git, it’s important to understand this shift so you don’t get tripped up by “nothing happened” warnings or missing changes on your remote.

  • In Git, you're always "on" a branch (e.g., main).

  • When you make a commit, the branch pointer automatically moves forward.

  • git push pushes the current branch's new commits to the remote.

  • If you forget to switch branches, you might accidentally push to the wrong place, but you rarely have to think about "moving" the branch pointer yourself.

The JJ Push Model

  • JJ has no concept of a "currrent branch"

  • Bookmarks do not move automatically. When you make a new commit, the bookmark (e.g., main) stays where it was. You must explicitly move it to your new commit with jj bookmark set main (or create a new one).

  • JJ only pushes commits that are referenced by bookmarks. If your latest work isn't pointed to by a bookmark, jj git push will do nothing and warn you. This is to prevent accidental pushes and gives you more control over what gets shared.

Typical JJ Push Workflow

  1. Check out where your working copy and Parent commit are, you will notice that jj highlights the minimal amount of characters needed to reference this change:
jj st
Working copy changes:
M README.md
Working copy  (@) : mnkrokmt 7f0558f8 say hello and goodbye
Parent commit (@-): ywyvxrts 986d16f5 main | test3
  • We can see that ywy is the main branch so lets create our change on top of that. We can also see that it's (@-), and this is actually what main will always be. Once I understood this everything came together.
jj new @-
Working copy  (@) now at: kxwrsmmu bc7e8144 (empty) (no description set)
Parent commit (@-)      : ywyvxrts 986d16f5 main | test3
Added 0 files, modified 1 files, removed 0 files
jj desc @ -m "Add a devShell"
Working copy  (@) now at: kxwrsmmu eacafd73 (empty) Add a devShell
Parent commit (@-)      : ywyvxrts 986d16f5 main | test3

Being more explicit about your commands ensures both you and jj know where everything should go. (i.e. jj desc @ -m explicitly describes @, the working copy.) This will save you some headaches.

Our new change, the Working copy is now built off of main. The working copy will always be (@).

Make some changes

jj st
Working copy changes:
A dev/flake.lock
A dev/flake.nix
Working copy  (@) : kxwrsmmu 42b011cd Add a devShell
Parent commit (@-): ywyvxrts 986d16f5 main | test3

Now I'm done, and since we built this change on top of main the following command will tell jj we know what we want to push:

jj bookmark set main
jj git push

If you forget to move a bookmark, JJ will warn you and nothing will be pushed. This is a safety feature, not a bug. That's what the upmain alias does, moves the bookmark to main.

# home-manager alias
upmain = ["bookmark" "set" "main"];

If you really have problems, jj git push --change @ explicitly pushes the working copy.

This is a bit different than Git and takes some getting used to but you don't need to move the bookmark after every commit, just when you want to push. I know I've made the mistake of pushing to the wrong branch before this should prevent that.

Here's an example of using JJ in an existing Git repo

Say I have my configuration flake in the ~/flakes/ directory that is an existing Git repository. To use JJ as the front-end I could do something like:

cd ~/flakes
jj git init --colocate
Done importing changes from the underlying Git repo.
Setting the revset alias `trunk()` to `main@origin`
Initialized repo in "."
  • By default, JJ defines trunk() as the main development branch of your remote repository. This is usually set to main@origin, but could be named something else. This means that whenever you use trunk() in JJ commands, it will resolve to the latest commit on main@origin. This makes it easier to refer to the main branch in scripts and commands without hardcoding the branch name.

Bookmarks in jj are named pointers to specific revisions, similar to branches in Git. When you first run jj git init --colocate in a git repo, you will likely get a Hint saying "Run the following command to keep local bookmarks updated on future pulls".:

jj bookmark list
track main@origin
jj st
The working copy has no changes.
Working copy  (@) : qzxomtxq 925eca75 (empty) (no description set)
Parent commit (@-): qnpnrklz bf291074 main | notes

This shows that running jj git init --colocate automatically started tracking main in this case. If it doesn't, use jj bookmark track main@origin.

I'll create a simple change in the README.md:

jj st
Working copy changes:
M README.md
Working copy  (@) : qzxomtxq b963dff0 (no description set)
Parent commit (@-): qnpnrklz bf291074 main | notes

We can see that the working copy now contains a modified file M README.md and has no description set. Lets give it a description before pushing to github.

jj desc @ -m "Added to README"
jj bookmark set main -r @
Moved 1 bookmarks to pxwnopqo 1e6e08a2 main* | Added to README

jj bookmark set main -r @ moves the main bookmark to the current revision (the working copy), which is the explicit, recommended way to update bookmarks in JJ. Without this step, your bookmark will continue to point at the old commit, not your latest work. This is a major difference from Git.

And finally push to GitHub:

jj git push
Changes to push to origin:
  Move forward bookmark main from bf291074125e to e2a75e45237b
remote: Resolving deltas: 100% (1/1), completed with 1 local object.
Warning: The working-copy commit in workspace 'default' became immutable, so a new commit has been created on top of it.
Working copy  (@) now at: pxwnopqo 8311444b (empty) (no description set)
Parent commit (@-)      : qzxomtxq e2a75e45 main | Added to README

Create a Repo without an existing Git Repo

Or to do this in a directory that isn't already a git repo you can do something like:

cargo new hello-world --vcs=none
cd hello-world
jj git init
Initialized repo in "."

JJ and Git Side by Side

Or for example, with Git if you wanted to move to a different branch before running nix flake update to see if it introduced errors before merging with your main branch, you could do something like:

git checkout -b update-test

nix flake update

sudo nixos-rebuild test --flake .

If you're satisfied you can merge:

git checkout main
git add . # Stage the change
git commit -m "update"
git merge update-test
git branch -D update-test
sudo nixos-rebuild switch --flake .

With JJ a similar workflow could be:

  1. Run jj st to see what you have:
jj st
The working copy has no changes.
Working copy  (@) : ttkstzzn 3f55c42c (empty) (no description set)
Parent commit (@-): wppknozq e3558ef5 main@origin | jj diff

If you don't have a description set for the working copy set it now.

jj desc @ -m "enable vim"
jj st
The working copy has no changes.
Working copy  (@) : ttkstzzn 63fda123 (empty) enable vim
Parent commit (@-): wppknozq e3558ef5 main@origin | jj diff
  1. Start from the working copy (which is mutable). The working copy in JJ is itself a commit that you can edit and squash changes into. Since main is immutable, you can create your new change by working on top of the working copy commit.

Create a new change off of the working copy:

jj new @
  1. Make your edits:
jj st
Working copy changes:
M home/editors/vim.nix
Working copy  (@) : qrsxltmt 494b5f18 (no description set)
Parent commit (@-): wytnnnto a07e775c (empty) enable vim
  1. Squash your changes into the new change:
jj squash
The working copy has no changes.
Working copy  (@) : tmlwppnu ba06bb99 (empty) (no description set)
Parent commit (@-): wytnnnto 52928ed9 enable vim

This moves your working copy changes into the new commit you just created.

  1. Describe the new change, this might feel weird but the jj squash command created a new commit that you have to describe again:
jj desc @ -m "Enabled Vim"
Working copy  (@) : tmlwppnu 5c1569c3 (empty) Enabled Vim
Parent commit (@-): wytnnnto 52928ed9 enable vim
  1. Set the bookmark to the Parent commit that was squashed into:
jj bookmark set wyt
  1. Finally Push to the remote repository:
jj git push --allow-new
Changes to push to origin:
  Add bookmark wyt to 5c1569c35b22
remote: Resolving deltas: 100% (4/4), completed with 4 local objects.
remote:
remote: Create a pull request for 'wyt' on GitHub by visiting:
remote:      https://github.com/sayls8/flake/pull/new/wyt
remote:

This command does the following:

  • Uploads your bookmark and the associated commit to the remote repository (e.g., GitHub).

  • If the bookmark is new (not present on the remote), --allow-new tells JJ it’s okay to create it remotely.

  • After pushing, GitHub (or your code host) will usually suggest creating a pull request for your new branch/bookmark, allowing you or your collaborators to review and merge the change into main.

Zsh function to squash and push in 1 command

✔️ Click to Expand Zsh Function

Another option is to use a shell function to do a few steps for you. The following is a zsh function to squash and push at the same time:

# ... snip ...
 initContent = ''

            jj-squash-push() {
               echo "Enter commit message:"
               read -r msg
               jj squash -r @
               jj describe -m "$msg"
               jj bookmark set main
               jj git push
             }
'';
# ... snip ...
  • Once you're edits are comple and you're ready to run jj squash, run jj-squash-push instead. It will prompt you for a squashed commit message, then push for you. You can easily remove the last jj git push step to have a jj-squash func also.

Merging your Change into main

Option 1. Go to the URL suggested in the output, visit in this case:

https://github.com/sayls8/flake/pull/new/wyt
  • Click Create PR

  • Click Merge PR if it shows it can merge cleanly.

Option 2.

  1. Switch to main (if not already there):
jj bookmark set main
  1. Create a new change that combines the new change with main:
jj new tml wyt -m "Merge: enable vim"

This creates a new commit with both tml and wyt as parents, which is how JJ handles merges (since jj merge depreciated). JJ merges are additive and history-preserving by design especially for folks used to Git's fast-forward and squash options.


Summary

  • With jj you're creating a new commit rather than a new branch.

  • Amending vs. Squashing: Git's git commit --amend updates the last commit. jj squash combines the current commit with its parent, effectively doing the same thing in terms of history.

  • Merging: Git's merge command is explicit. In jj, the concept is similar, but since there's no branch, you're "merging" by moving your working commit to include these changes.

  • No need to delete branches: Since there are no branches in jj, there's no equivalent to git branch -D to clean up. Instead commits that are no longer needed can be "abandoned" with jj abandon if you want to clean up your commit graph.

  • jj describe without a flag just opens $EDITOR where you can write your commit message save and exit.

  • In git, we finish a set of changes to our code by committing, but in jj we start new work by creating a change, and then make changes to our code. It's more useful to write an initial description of your intended changes, and then refine it as you work, than it is creating a commit message after the fact.

  • I have heard that jj can struggle with big repositories such as Nixpkgs and have noticed some issues here and there when using with NixOS. I'm hoping that as the project matures, it gets better on this front.


The 2 main JJ Workflows

The Squash Workflow

This workflow is the most similar to Git and Git's index.

The workflow:

  1. Describe the work we want to do with jj desc -m "message"

  2. We create a new empty change on top of that one with jj new

  3. When we are done with a feature, we run jj squash to move the changes from @ into the change we described in step 1. @ is where your working copy is positioned currently.

For example, let's say we just ran jj git init --colocate in our configuration Flake directory making it a jj repo as well using git for backend.

cd flake
jj git init --colocate
jj log
@  lnmmxwko sayls8@proton.me 2025-06-27 10:14:57 1eac6aa0
│  (empty) (no description set)
○  qnknltto sayls8@proton.me 2025-06-27 09:04:08 git_head() 5358483a
│  (empty) jj

The above log output shows that running jj git init creates an empty working commit (@) on top of the git_head()

jj desc -m "Switch from nixVim to NVF"
jj new  # Create a new empty change
jj log
@  nmnmznmm sayls8@proton.me 2025-06-27 10:16:30 52dd7ee0
│  (empty) (no description set)
○  lnmmxwko sayls8@proton.me 2025-06-27 10:16:24 git_head() 3e8f9f3a
│  (empty) Switch from nixVim to NVF
○  qnknltto sayls8@proton.me 2025-06-27 09:04:08 5358483a
│  (empty) jj

The above log shows that running jj desc changes the current (@) commits description, and then jj new creates a new empty commit on top of it, moving (@) to this new empty commit.

The "Switch from nixVim to NVF" commit is now the parent of (@).

Now, we'd make the necessary changes and to add them to the commit we just described in the previous steps.

The changes are automatically "staged" so theres no need to git add them, so we just make the changes and squash them.

jj squash  # Squash the commit into its parent commit (i.e., our named commit)
jj log
@  zsxsolsq sayls8@proton.me 2025-06-27 10:18:01 2c35d83f
│  (empty) (no description set)
○  lnmmxwko sayls8@proton.me 2025-06-27 10:18:01 git_head() 485eaee9
│  (empty) Switch from nixVim to NVF

This shows jj squashes effect, it merges the changes from the current (@) commit into its parent. The (@) then moves to this modified parent, and a new empty commit is created on top, ready for the next set of changes.

sudo nixos-rebuild switch --flake .

We're still in the nameless commit and can either continue working or run jj desc -m "" again describing our new change, then jj new and jj squash it's pretty simple. The nameless commit is used as an adhoc staging area.

When you are ready to push, it's important to know where your working copy currently is and if it's attached to a bookmark. It's common for jj new to detach the head, all you have to do is tell JJ which branch to attach to, then push:

jj st
Working copy changes:
M hosts/magic/configuration.nix
M hosts/magic/container.nix
Working copy  (@) : youptvvn 988e6fc9 (no description set)
Parent commit (@-): qlwqromx 4bb754fa mdbook container

The above output means that the working copy has modifications (M) in two files. And these changes are not yet committed.

jj bookmark set main
jj git push

The Edit Workflow

This workflow adds a few new commands jj edit, and jj next.

Here's the workflow:

  1. Create a new change to work on the new feature with jj new

  2. If everything works exactly as planned, we're done.

  3. If we realize we want to break this big change up into multiple smaller ones, we do it by making a new change before the current one, swapping to it, and making the necessary change.

  4. Lastly, we go back to the main change.

The squash workflow leaves @ at an empty undescribed change, with this workflow, @ will often be on the existing change.

If @ wasn't at an empty change, we would start this workflow with:

jj new -m "Switch from NVF to nixVim"

since our @ is already at an empty change, we'll just describe it and get started:

For this example, lets say we want to revert back to nixVim:

jj desc -m "Switch from NVF to nixVim"
jj log
@  zsxsolsq sayls8@proton.me 2025-06-27 10:18:47 606abaa7
│  (empty) Switch from NVF to nixVim
○  lnmmxwko sayls8@proton.me 2025-06-27 10:18:01 git_head() 485eaee9
│  (empty) Switch from nixVim to NVF
○  qnknltto sayls8@proton.me 2025-06-27 09:04:08 5358483a
│  (empty) jj

Again, this shows jj desc renaming the current empty @ commit.

We make the changes, and it's pretty straightforward so we're done, every change is automatically staged so we can just run sudo nixos-rebuild switch --flake . now to apply the changes.

If we wanted to make more changes that aren't described we can use jj new -B which is similar to git add -a.

jj new -B @ -m "Adding LSP to nixVim"
Rebased 1 descendant commits
Working copy  (@) now at: lpnxxxpo bf929946 (empty) Adding LSP to nixVim
Parent commit (@-)      : lnmmxwko 485eaee9 (empty) Switch from nixVim to NVF

The -B tells jj to create the new change before the current one and it creates a rebase. We created a change before the one we're on, it automatically rebased our original change. This operation will always succeed with jj, we will have our working copy at the commit we've just inserted.

You can see below that @ moved down one commit:

jj log
○  zsxsolsq sayls8@proton.me 2025-06-27 10:22:03 ad0713b6
│  (empty) Switch from NVF to nixVim
@  lpnxxxpo sayls8@proton.me 2025-06-27 10:22:03 bf929946
│  (empty) Adding LSP to nixVim
○  lnmmxwko sayls8@proton.me 2025-06-27 10:18:01 git_head() 485eaee9
│  (empty) Switch from nixVim to NVF
○  qnknltto sayls8@proton.me 2025-06-27 09:04:08 5358483a
│  (empty) jj
○  qnknltto sayls8@proton.me 2025-06-27 09:04:08 git_head()

The "Adding LSP to nixVim" commit is directly above "Switch from nixVim to NVF" (the old git_head())

The "Switch from NVF to nixVim" commit (which was your @ before jj new -B) is now above "Adding LSP to nixVim" in the log output, meaning "Adding LSP to nixVim" is its new parent.

@ has moved to "Adding LSP to nixVim"

jj log example output


Operation Log and Undo

JJ records every operation (commits, merges, rebases, etc.) in an operation log. You can view and undo previous operations, making it easy to recover from mistakes, a feature not present in Git’s core CLI

jj op log
@  fbf6e626df22 jr@magic 15 minutes ago, lasted 9 milliseconds
│  new empty commit
│  args: jj new -B @ -m 'Adding LSP to nixVim'
○  bde40b7c17cf jr@magic 19 minutes ago, lasted 8 milliseconds
│  describe commit 2c35d83f75031dc582bf28b64d4af1c218177f90
│  args: jj desc -m 'Switch from NVF to nixVim'
○  3a2bfe1c0b0a jr@magic 19 minutes ago, lasted 8 milliseconds
│  squash commits into 3e8f9f3a6a58fef86906e16e9b4375afb43e73e3
│  args: jj squash
○  80abcb58dcb6 jr@magic 21 minutes ago, lasted 8 milliseconds
│  new empty commit
│  args: jj new
○  8c80314cbcd7 jr@magic 21 minutes ago, lasted 8 milliseconds
│  describe commit 1eac6aa0b88ba014785ee9c1c2ad6e2abc6206e9
│  args: jj desc -m 'Switch from nixVim to NVF'
○  44b5789cb4d1 jr@magic 22 minutes ago, lasted 6 milliseconds
│  track remote bookmark main@origin
│  args: jj bookmark track main@origin
○  dbefee04aa85 jr@magic 23 minutes ago, lasted 4 milliseconds
│  import git head
│  args: jj git init --git-repo .
jj op undo <operation-id>
# or
jj op restore <operation-id>

Conflict Resolution

In JJ, conflicts live inside commits and can be resolved at any time, not just during a merge. This makes rebasing and history editing safer and more flexible

JJ treats conflicts as first-class citizens: conflicts can exist inside commits, not just in the working directory. This means if a merge or rebase introduces a conflict, the conflicted state is saved in the commit itself, and you can resolve it at any time there’s no need to resolve conflicts immediately or use “--continue” commands as in Git

Here's how it works:

When you check out or create a commit with conflicts, JJ materializes the conflicts as markers in your files (similar to Git's conflict markers)

You can resolve conflicts by editing the files to remove the markers, or by using:

jj resolve

Revsets

Jujutsu Revsets

JJ includes a powerful query language for selecting commits. For example:

jj log -r "author(alice) & file(*.py)"

This command lists all commits by Alice that touch Python files.

Filesets

Jujutsu Filesets

Jujutsu supports a functional language for selecting a set of files. Expressions in this language are called "filesets" (the idea comes from Mercurial). The language consists of file patterns, operators, and functions. --JJ Docs

Summary

Jujutsu (jj) offers a streamlined, branchless, and undo-friendly approach to version control, fully compatible with Git but designed to be easier to use and reason about. Its workflows, operation log, and conflict handling provide a safer and more flexible environment for managing code changes, making it a compelling alternative for both new and experienced developers.


Resources

Installation Guides

This section provides detailed guides for installing NixOS. You'll choose between an unencrypted or encrypted base setup. After your core installation, you can explore adding optional features like sops for encrypted secrets, lanzaboote for Secure Boot, or impermanence for a stateless system.


1. Unencrypted Disko Btrfs Subvol Installation


2. Encrypted Disko Btrfs Subvol Installation

  • Encrypted Install Guide: Encrypted Install

  • Encrypted Impermanence

  • Important Considerations:

    • Secure Boot with Lanzaboote For the full benefit of Secure Boot (with Lanzaboote), it's highly recommended to have a second stage of protection, such as an encrypted disk.

    • Adding Sops You can easily add sops (for managing encrypted secrets) to your configuration after the initial encrypted installation and reboot. This can simplify the initial setup process. However, always remember the core goal of using encrypted secrets: never commit unencrypted or even hashed sensitive data directly into your Git repository. With modern equipment brute force attacks are a real threat.


3. Post-Installation Security & Features

Once your base NixOS system is installed, consider these powerful additions:

  • sops-nix: For managing encrypted secrets directly within your NixOS configuration, ensuring sensitive data is never stored in plain text.

  • lanzaboote: For enabling Secure Boot, verifying the integrity of your boot chain (requires UEFI and custom keys).

  • impermanence: For setting up a stateless NixOS system, where the root filesystem reverts to a clean state on every reboot.

Unencrypted Setups

Minimal BTRFS-Subvol Install with Disko and Flakes

✔️ Click to Expand Table of Contents

Figure 1: BTRFS Logo: Image of the BTRFS logo. Sourced from the BTRFS repo BTRFS logo

Why I Chose BTRFS

I chose BTRFS because I was already familiar with it from using it with Arch Linux and I found it to be very easy to use. From what I've read, there are licensing issues between the Linux Kernel and ZFS which means that ZFS is not part of the Linux Kernel; it's maintained by the OpenZFS project and available as a separate kernel module. This can cause issues and make you think more about your filesystem than I personally want to at this point.

✔️ Click for BTRFS Subvolume Overview

A Btrfs subvolume is essentially a distinct section within a Btrfs filesystem that maintains its own set of files and directories, along with a separate inode numbering system. Unlike block-level partitions (such as LVM logical volumes), Btrfs subvolumes operate at the file level and are based on file extents.

Extents in Btrfs are contiguous blocks of data on disk that store the actual contents of files. When files are created or modified, Btrfs manages these extents efficiently, allowing features like deduplication and snapshots. Multiple subvolumes can reference the same extents, meaning that identical data is not duplicated on disk, which saves space and improves performance.

A snapshot in Btrfs is a special kind of subvolume that starts with the same content as another subvolume at the time the snapshot is taken. Snapshots are typically writable by default, so you can make changes in the snapshot without affecting the original subvolume. This is possible because Btrfs tracks changes at the extent level, only creating new extents when files are modified (a technique called copy-on-write).

Subvolumes in Btrfs behave much like regular directories from a user’s perspective, but they support additional operations such as renaming, moving, and nesting (placing subvolumes within other subvolumes). There are no restrictions on nesting, though it can affect how snapshots are created and managed. Each subvolume is assigned a unique and unchangeable numeric ID (subvolid or rootid).

You can access a Btrfs subvolume in two main ways:

  • As a normal directory within the filesystem.

  • By mounting it directly as if it were a separate filesystem, using the subvol or subvolid mount options. When mounted this way, you only see the contents of that subvolume, similar to how a bind mount works.

When a new Btrfs filesystem is created, it starts with a "top-level" subvolume (with an internal ID of 5). This subvolume is always present and cannot be deleted or replaced, and it is the default mount point unless changed with btrfs subvolume set-default.

Subvolumes can also have storage quotas set using Btrfs’s quota groups , but otherwise, they all draw from the same underlying storage pool. Thanks to features like deduplication and snapshots, subvolumes can share data efficiently at the extent level.While ZFS is a solid choice and offers some benefits over BTRFS, I recommend looking into it before making your own decision.

If you have a ton of RAM you could most likely skip the minimal install and just set your system up as needed or just use tmpfs as root

Getting Started with Disko

Disko allows you to declaratively partition and format your disks, and then mount them to your system. I recommend checking out the README as it is a disk destroyer if used incorrectly.

We will mainly be following the disko quickstart guide

Figure 2: Disko Logo: Image of the logo for Disko, the NixOS declarative disk partitioning tool. Sourced from the Disko project disko logo

  1. Get the Nixos Minimal ISO Get it on a usb stick, I use Ventoy with Ventoy2Disk.sh. The following is the link to the Ventoy TarBall download, untar it with tar -xzf ventoy-1.1.05-linux.tar.gz, and make it executable with chmod +x Ventoy2Disk.sh, and finally execute it with sudo ./Ventoy2Disk.sh Follow the prompts to finish the install.

You'll have to run it on for the USB drive you're trying to use, you can do that by unplugging the USB stick and running lsblk, then plug it in again and run:

lsblk -f
NAME          FSTYPE      FSVER LABEL   UUID                                 FSAVAIL FSUSE% MOUNTPOINTS
sda
└─sda1        vfat        FAT32 MYUSB   46E8-9304
sdb           vfat        FAT12         F054-697D                               1.4M     0% /run/media/jr/F054-697D
nvme0n1
├─nvme0n1p1   vfat        FAT32         BCD8-8C51                               1.8G    12% /boot
  • sdb is a USB plugin for a mouse. sda is the USB stick that I want to target here:
sudo ./Ventoy2Disk.sh -i /dev/sda
# Or to force overwrite an existing Ventoy entry
sudo ./Ventoy2Disk.sh -I /dev/sda
  1. The minimal installer uses wpa_supplicant instead of NetworkManager, to enable networking run the following:
sudo systemctl start wpa_supplicant
wpa_cli
> add_network
0

> set_network 0 ssid "myhomenetwork"
OK

> set_network 0 psk "mypassword"
OK

> enable_network 0
OK

To exit type quit, then check your connection with ping google.com.

Another option is to do the following, so either the above method or the below method after starting wpa_supplicant:

# Alternative for quick setup (less interactive, but often faster)
sudo wpa_passphrase "myhomenetwork" "mypassword" >> /etc/wpa_supplicant/wpa_supplicant-wlan0.conf
sudo systemctl restart wpa_supplicant@wlan0.service
  1. Get your Disk Name with lsblk

The output should be something like:

NAME        MAJ:MIN RM   SIZE RO TYPE MOUNTPOINTS
nvme0n1     259:0    0   1,8T  0 disk
  1. Copy the disk configuration to your machine. You can choose one from the examples directory.
  • Option A: (Simpler for new users) I also created a starter repo containing much of what's needed. If you choose this option follow the README.md included with the repo.
cd ~
git clone https://github.com/saylesss88/my-flake.git

Make sure to change line 7 in disk-config.nix to what you got from step 3 device = "/dev/nvme0n1";

  • Option B: (More flexible, more manual steps) Skip cloning the repo above and for the btrfs-subvolume default layout, run the following:
cd /tmp
curl https://raw.githubusercontent.com/nix-community/disko/refs/heads/master/example/btrfs-subvolumes.nix -o /tmp/disk-config.nix
  1. Make Necessary changes, I set mine up for impermanence with the following:
nano /tmp/disk-config.nix
{
  disko.devices = {
    disk = {
      main = {
        type = "disk";
        device = "/dev/nvme0n1";
        content = {
          type = "gpt";
          partitions = {
            ESP = {
              priority = 1;
              name = "ESP";
              start = "1M";
              end = "512M";
              type = "EF00";
              content = {
                type = "filesystem";
                format = "vfat";
                mountpoint = "/boot";
                mountOptions = ["umask=0077"];
              };
            };
            root = {
              size = "100%";
              content = {
                type = "btrfs";
                extraArgs = ["-f"]; # Override existing partition
                # Subvolumes must set a mountpoint in order to be mounted,
                # unless their parent is mounted
                subvolumes = {
                  # Subvolume name is different from mountpoint
                  "/root" = {
                    mountpoint = "/";
                    mountOptions = ["subvol=root" "compress=zstd" "noatime"];
                  };
                  # Subvolume name is the same as the mountpoint
                  "/home" = {
                    mountOptions = ["subvol=home" "compress=zstd" "noatime"];
                    mountpoint = "/home";
                  };
                  # Sub(sub)volume doesn't need a mountpoint as its parent is mounted
                  "/home/user" = {};
                  # Parent is not mounted so the mountpoint must be set
                  "/nix" = {
                    mountOptions = [
                      "subvol=nix"
                      "compress=zstd"
                      "noatime"
                    ];
                    mountpoint = "/nix";
                  };
                  "/nix/persist" = {
                    mountpoint = "/nix/persist";
                    mountOptions = ["subvol=persist" "compress=zstd" "noatime"];
                  };
                  "/log" = {
                    mountpoint = "/var/log";
                    mountOptions = ["subvol=log" "compress=zstd" "noatime"];
                  };
                  "/lib" = {
                    mountpoint = "/var/lib";
                    mountOptions = ["subvol=lib" "compress=zstd" "noatime"];
                  };
                  # This subvolume will be created but not mounted
                  "/test" = {};
                };
              };
            };
          };
        };
      };
    };
  };
  fileSystems."/nix/persist".neededForBoot = true;
  fileSystems."/var/log".neededForBoot = true;
  fileSystems."/var/lib".neededForBoot = true;
}
  • For /tmp on RAM use something like the following. I've found that having disko manage swaps causes unnecessary issues. Using zram follows the ephemeral route:
{
  lib,
  config,
  ...
}: let
  cfg = config.custom.zram;
in {
  options.custom.zram = {
    enable = lib.mkEnableOption "Enable utils module";
  };

  config = lib.mkIf cfg.enable {
    zramSwap = {
      enable = true;
      # one of "lzo", "lz4", "zstd"
      algorithm = "zstd";
       priority = 5;
       memoryPercent = 50;
    };
  };
}

And in your configuration.nix you would add:

# configuration.nix
custom = {
    zram.enable = true;
};

After adding the above module, you can see it with:

swapon --show
NAME       TYPE      SIZE USED PRIO
/dev/zram0 partition 7.5G   0B    5
  1. Run disko to partition, format and mount your disks. Warning this will wipe EVERYTHING on your disk. Disko doesn't work with dual boot.
sudo nix --experimental-features "nix-command flakes" run github:nix-community/disko/latest -- --mode destroy,format,mount /tmp/disk-config.nix

Check it with the following:

mount | grep /mnt

The output for an nvme0n1 disk would be similar to the following:

#... snip ...
/dev/nvme0n1p2 on /mnt type btrfs (rw,noatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=285,subvol=/root)
/dev/nvme0n1p2 on /mnt/persist type btrfs (rw,noatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=261,subvol=/persist)
/dev/nvme0n1p2 on /mnt/etc type btrfs (rw,noatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=261,subvol=/persist)
/dev/nvme0n1p2 on /mnt/nix type btrfs (rw,noatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=260,subvol=/nix)
/dev/nvme0n1p2 on /mnt/var/lib type btrfs (rw,noatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=258,subvol=/lib)
/dev/nvme0n1p2 on /mnt/var/log type btrfs (rw,noatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=259,subvol=/log)
/dev/nvme0n1p2 on /mnt/nix/store type btrfs (ro,noatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=260,subvol=/nix)
# ... snip ...
  1. Generate necessary files, here we use --no-filesystems because disko handles the fileSystems attribute for us.
nixos-generate-config --no-filesystems --root /mnt

It may be helpful to add a couple things to your configuration.nix now, rebuild and then move on. Such as, your hostname, git, an editor of your choice. After your additions run sudo nixos-rebuild switch to apply the changes. If you do this, you can skip the nix-shell -p command coming up.

sudo mv /tmp/disk-config.nix /mnt/etc/nixos

Setting a Flake for your minimal Install

  1. Create the flake in your home directory, then move it to /mnt/etc/nixos. This avoids needing to use sudo for every command while in the /mnt/etc/nixos directory.
cd ~
mkdir flake && cd flake
nix-shell -p git yazi helix
export NIX_CONFIG='experimental-features = nix-command flakes'
export EDITOR='hx'
hx flake.nix

You'll change hostname = nixpkgs.lib.nixosSystem to your chosen hostname, (e.g. magic = nixpkgs.lib.nixosSystem). This will be the same as your networking.hostName = "magic"; in your configuration.nix that we will set up shortly.

# flake.nix
{
  description = "NixOS configuration";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    disko.url = "github:nix-community/disko/latest";
    disko.inputs.nixpkgs.follows = "nixpkgs";
    # impermanence.url = "github:nix-community/impermanence";
  };

  outputs = inputs@{ nixpkgs, ... }: {
    nixosConfigurations = {
      hostname = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./configuration.nix
          inputs.disko.nixosModules.disko
          # inputs.impermanence.nixosModules.impermanence
        ];
      };
    };
  };
}

Move all the files into your flake:

cd /mnt/etc/nixos/
sudo mv disk-config.nix hardware-configuration.nix configuration.nix ~/flake
  1. Edit configuration.nix with what is required, the following is required, I clone my original flake repo and move the pieces into place but it's fairly easy to just type it all out:
  • Bootloader, (e.g., boot.loader.systemd-boot.enable = true;)

  • User, the example uses username change this to your chosen username. If you don't set your hostname it will be nixos.

  • Networking, networking.networkmanager.enable = true;

  • hardware-configuration.nix & disk-config.nix for this setup

  • initialHashedPassword: Run mkpasswd -m SHA-512 -s, then enter your desired password. Example output,

Password: your_secret_password
Retype password: your_secret_password
$6$random_salt$your_hashed_password_string_here_this_is_very_long_and_complex

copy the hashed password and use it for the value of your initialHashedPassword

# configuration.nix
{
  config,
  lib,
  pkgs,
  inputs,
  ...
}: {
  imports = [
    # Include the results of the hardware scan.
    ./hardware-configuration.nix
    ./disk-config.nix
  ];

  networking.hostName = "magic"; # This will match the `hostname` of your flake

  networking.networkmanager.enable = true;

  boot.loader.systemd-boot.enable = true; # (for UEFI systems only)
  # List packages installed in system profile.
  # You can use https://search.nixos.org/ to find more packages (and options).
  environment.systemPackages = with pkgs; [
    vim # Do not forget to add an editor to edit configuration.nix! The Nano editor is also installed by default.
    #   wget
    git
  ];

  time.timeZone = "America/New_York";

  users.users.nixos = {
    isNormalUser = true;
    extraGroups = [ "wheel" "networkmanager" ]; # Add "wheel" for sudo access
    initialHashedPassword = "COPY_YOUR_MKPASSWD_OUTPUT_HERE"; # <-- This is where it goes!
    # home = "/home/nixos"; # Optional: Disko typically handles home subvolumes
  };

  console.keyMap = "us";

  nixpkgs.config.allowUnfree = true;

  system.stateVersion = "25.05";
}
  1. Move the flake to /mnt/etc/nixos and run nixos-install:
sudo mv ~/flake /mnt/etc/nixos/
sudo nixos-install --flake /mnt/etc/nixos/flake .#hostname
# if the above command doesn't work try this:
sudo nixos-install --flake /mnt/etc/nixos/flake#hostname
  • You will be prompted to enter a new password if everything succeeds.

  • If everything checks out, reboot the system and you should be prompted to enter your user and password to login to a shell to get started.

  • The flake will be placed at /etc/nixos/flake, I choose to move it to my home directory. Since the file was first in /etc you'll need to adjust the permissions with something like sudo chown username:users ~/flake(username will be your username) and then you can work on it without privilege escalation.

  • You can check the layout of your btrfs system with:

sudo btrfs subvolume list /
  • You may notice some old_roots in the output, which are snapshots, which are likely created before system upgrades or reboots for rollback purposes. They can be deleted or rolled back as needed.

  • BTRFS Subvolumes

  • To continue following along and set up impermanence Click Here

Unencrypted BTRFS Impermanence with Flakes

✔️ Click to Expand Table of Contents

Figure 1: Impermanence Logo: Image of the Impermanence logo. Sourced from the

Impermanence repo

Impermanence Logo

This guide is for an unencrypted setup, there are a few links at the end for encrypted setups. This guide follows the previous minimal install guide but you should be able to adjust it carefully to meet your needs.

This section details how to set up impermanence on your NixOS system using BTRFS subvolumes. With impermanence, your operating system's root filesystem will reset to a pristine state on each reboot, while designated directories and files remain persistent. This provides a highly reliable and rollback-friendly system.

In NixOS, "state" is any data or condition of the system that isn't defined in your declarative configuration. The impermanence approach aims to make this state temporary (ephemeral) or easily resettable, so your system always matches your configuration and can recover from unwanted changes or corruption.

Impermanence: The Concept and Its BTRFS Implementation

In a traditional Linux system, most of this state is stored on the disk and persists indefinitely unless manually deleted or modified. However, this can lead to configuration drift, where the system accumulates changes (e.g., log files, temporary files, or unintended configuration tweaks) that make it harder to reproduce or maintain.

Impermanence, in the context of operating systems, refers to a setup where the majority of the system's root filesystem (/) is reset to a pristine state on every reboot. This means any changes made to the system (e.g., installing new packages, modifying system files outside of configuration management, creating temporary files) are discarded upon shutdown or reboot.

What Does Impermanence Do?

Impermanence is a NixOS approach that makes the system stateless (or nearly stateless) by wiping the root filesystem (/) on each boot, ensuring a clean, predictable starting point. Only explicitly designated data (persistent state) is preserved across reboots, typically stored in specific locations like the /nix/persist subvolume. This is possible because NixOS can boot with only the /boot, and /nix directories. This achieves:

  1. Clean Root Filesystem:
  • The root subvolume is deleted and recreated on each boot, erasing transient state (e.g., temporary files, runtime data).

  • This ensures the system starts fresh, reducing clutter and making it behave closer to a declarative system defined by your NixOS configuration.

  1. Selective Persistence:
  • Critical state (e.g., user files, logs, system configuration) is preserved in designated persistent subvolumes (e.g., /nix/persist, /var/log, /var/lib) or files.

  • You control exactly what state persists by configuring environment.persistence."/nix/persist" or other mechanisms.

  • ❗ The understanding around persisting /var/lib/nixos seems to be evolving. See,The importance of persisting /var/lib/nixos See also necessary system state

  1. Reproducibility and Security:
  • By wiping transient state, impermanence prevents unintended changes from accumulating, making the system more reproducible.

  • It enhances security by ensuring sensitive temporary data (e.g., /tmp, runtime credentials) is erased on reboot.

Getting Started

  1. Add impermanence to your flake.nix. You will change the hostname in the flake to match your networking.hostName.
# flake.nix
{
  description = "NixOS configuration";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    disko.url = "github:nix-community/disko/latest";
    disko.inputs.nixpkgs.follows = "nixpkgs";
    impermanence.url = "github:nix-community/impermanence";
  };

  outputs = inputs@{ nixpkgs, ... }: {
    nixosConfigurations = {
      hostname = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./configuration.nix
          inputs.disko.nixosModules.disko
          inputs.impermanence.nixosModules.impermanence
        ];
      };
    };
  };
}
  1. Discover where your root subvolume is located with findmnt:

Before configuring impermanence, it's crucial to know the device path and subvolume path of your main BTRFS partition where the root filesystem (/) is located. This information is needed for the mount command within the impermanence script.

findmnt /
TARGET   SOURCE         FSTYPE OPTIONS
/        /dev/disk/by-partlabel/disk-main-root[/root]
                        btrfs  rw,noatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=275,sub

From the SOURCE column, note the full path, including the device (e.g., /dev/disk/by-partlabel/disk-main-root) and the subvolume in brackets (e.g., [/root]). You will use the device path in the next step

/dev/disk/by-partlabel/disk-main-root is a symlink to the actual device path (e.g. /dev/nvme0n1p2), but using the partlabel is generally more robust for scripts.

  1. Create an impermanence.nix:

Now, create a new file named impermanence.nix in your configuration directory (i.e. your flake directory). This file will contain all the specific settings for your impermanent setup, including BTRFS subvolume management and persistent data locations. Since this file is right next to your configuration.nix, you'll just add an imports = [ ./impermanence.nix ] to your configuration.nix apply it to your configuration.

{lib, ...}: {
  #  Reset root subvolume on boot
  boot.initrd.postResumeCommands = lib.mkAfter ''
    mkdir /btrfs_tmp
      mount /dev/disk/by-partlabel/disk-main-root /btrfs_tmp # CONFIRM THIS IS CORRECT FROM findmnt
      if [[ -e /btrfs_tmp/root ]]; then
        mkdir -p /btrfs_tmp/old_roots
        timestamp=$(date --date="@$(stat -c %Y /btrfs_tmp/root)" "+%Y-%m-%-d_%H:%M:%S")
        mv /btrfs_tmp/root "/btrfs_tmp/old_roots/$timestamp"
      fi

      delete_subvolume_recursively() {
        IFS=$'\n'
        for i in $(btrfs subvolume list -o "$1" | cut -f 9- -d ' '); do
          delete_subvolume_recursively "/btrfs_tmp/$i"
        done
        btrfs subvolume delete "$1"
      }

      for i in $(find /btrfs_tmp/old_roots/ -maxdepth 1 -mtime +30); do
        delete_subvolume_recursively "$i"
      done

      btrfs subvolume create /btrfs_tmp/root
      umount /btrfs_tmp
  '';

  # Use /persist as the persistence root, matching Disko's mountpoint
  environment.persistence."/nix/persist" = {
    hideMounts = true;
    directories = [
      "/etc" # System configuration (Keep this here for persistence via bind-mount)
      "/var/spool" # Mail queues, cron jobs
      "/srv" # Web server data, etc.
      "/root"
    ];
    files = [
    ];
  };
}

With btrfs subvolumes since each directory is its own subvolume, when the root is wiped on reboot the subvolumes are untouched.

Applying Your Impermanence Configuration

Once you have completed all the steps and created or modified the necessary files (flake.nix, impermanence.nix), you need to apply these changes to your NixOS system.

  1. Navigate to your NixOS configuration directory (where your flake.nix is located).
cd /path/to/your/flake
  1. Rebuild and Switch: Execute the nixos-rebuild switch command. This command will:
  • Evaluate your flake.nix and the modules it imports (including your new impermanence.nix).

  • Build a new NixOS system closure based on your updated configuration.

  • Activate the new system configuration, making it the current running system.

NOTE: On the first rebuild after setting up impermanence, you may find that you're not in the password database or cannot log in/sudo. This occurs because the initial state of your new ephemeral root filesystem, including /etc (where user passwords are stored), is fresh. It has to do with the timing of when environment.persistence takes effect during the first boot.

To avoid this password issue, before your first nixos-rebuild switch for impermanence, run:

sudo mkdir -p /nix/persist/etc # Ensure the target directory exists
sudo cp -a /etc/* /nix/persist/etc
  • This copies your current /etc directory contents (including existing user passwords) into your persistent storage.
  • Crucially: You must also ensure that /etc is explicitly included in your environment.persistence."/nix/persist".directories list in your impermanence.nix like we did above, (or main configuration). This configures NixOS to persistently bind-mount /nix/persist/etc over /etc on every subsequent boot.

Once these steps are done and you reboot, your user passwords should function correctly, and future rebuilds will not present this problem.

sudo nixos-rebuild switch --flake .#hostname # Replace 'hostname' with your actual system hostname
  1. Perform an Impermanence Test (Before Reboot):
  • Before you reboot, create a temporary directory and file in a non-persistent location. Since you haven't explicitly added /imperm_test to your environment.persistence."/nix/persist" directories, this file should not survive a reboot.
mkdir /imperm_test
echo "This should be Gone after Reboot" | sudo tee /imperm_test/testfile
ls -l /imperm_test/testfile # Verify the file exists
cat /imperm_test/testfile # Verify content
  1. Reboot Your System: For the impermanence setup to take full effect and for your root filesystem to be reset for the first time, you must reboot your machine.
sudo reboot
  1. Verify Impermanence (After Reboot):
  • After the system has rebooted, check if the test directory and file still exist:
ls -l /imperm_test/testfile

You should see an output like ls: cannot access '/imperm_test/testfile': No such file or directory. This confirms that the /imperm_test directory and its contents were indeed ephemeral and were removed during the reboot process, indicating your impermanence setup is working correctly!

Your system should now come up with a fresh root filesystem, and only the data specified in your environment.persistence."/nix/persist" configuration will be persistent.

Recovery with nixos-enter and chroot

This is if you followed the minimal_install guide, it will need to be changed for a different disk layout.

Chroot is an operation that changes the apparent root directory for the current running process and their children. A program that is run in such a modified environment cannot access files and commands outside that environmental directory tree. This modified environment is called a chroot jail. --NixOS wiki

nixos-enter allows you to access a NixOS installation from a NixOS rescue system. To use, setup /mnt as described in the installation manual

🛠️ Recovery: Chroot into Your NixOS Btrfs+Impermanence System

Take note of your layout from commands like:

sudo fdisk -l
lsblk
sudo btrfs subvol list /

Also inspect your disk-config.nix to ensure you refer to the correct subvol= names.

If you need to repair your system (e.g., forgot root password, fix a broken config, etc.), follow these steps to chroot into your NixOS install:

  1. Boot a Live ISO

    Boot from a NixOS (or any recent Linux) live USB.

    Open a terminal and become root:

sudo -i
  1. Identify Your Devices

Your main disk is /dev/nvme0n1

  • EFI partition: /dev/nvme0n1p1 (mounted at /boot)

  • Root partition: /dev/nvme0n1p2 (Btrfs, with subvolumes)

  1. Mount the Btrfs Root Subvolume

First, mount the Btrfs partition somewhere temporary (not as / yet):

mount -o subvol=root,compress=zstd,noatime /dev/nvme0n1p2 /mnt
  1. Mount Other Subvolumes

Now mount your other subvolumes as defined in your disko.nix:

# Mount Other Subvolumes
# (Ensure /mnt directories are created for each *mountpoint*)

# Home
mkdir -p /mnt/home
mount -o subvol=home,compress=zstd,noatime /dev/nvme0n1p2 /mnt/home

# IMPORTANT: No separate mount for /mnt/home/user, as it's a nested subvolume
# and handled by the /home mount.

# Nix store
mkdir -p /mnt/nix
mount -o subvol=nix,compress=zstd,noatime /dev/nvme0n1p2 /mnt/nix

# Nix persist
mkdir -p /mnt/nix/persist
# CRITICAL: Based our disko.nix, the subvolume name is 'persist', not 'nix/persist'
mount -o subvol=persist,compress=zstd,noatime /dev/nvme0n1p2 /mnt/nix/persist

# /var/log
mkdir -p /mnt/var/log
mount -o subvol=log,compress=zstd,noatime /dev/nvme0n1p2 /mnt/var/log

# /var/lib
mkdir -p /mnt/var/lib
# Confirmed: The subvolume named 'lib' is mounted to /var/lib
mount -o subvol=lib,compress=zstd,noatime /dev/nvme0n1p2 /mnt/var/lib

Note: If you get "subvolume not found," check the subvolume names with btrfs subvol list /mnt.

  1. Mount the EFI Partition
mkdir -p /mnt/boot mount /dev/nvme0n1p1 /mnt/boot
  1. (Optional) Mount Virtual Filesystems
mount --bind /dev /mnt/dev mount --bind /proc /mnt/proc mount --bind /sys
/mnt/sys mount --bind /run /mnt/run
  1. Chroot
chroot /mnt /run/current-system/sw/bin/bash

or, if using a non-NixOS live system:

nixos-enter

(You may need to install nixos-enter with nix-shell -p nixos-enter.) 8. You’re In!

You can now run nixos-rebuild, reset passwords, or fix configs as needed. 🔎

📓 Notes

  • Adjust compress=zstd,noatime if your config uses different mount options.

  • For impermanence, make sure to mount all persistent subvolumes you need.

  • If you use swap, you may want to enable it too (e.g., swapon /dev/zram0 if relevant).

You can now recover, repair, or maintain your NixOS system as needed!

Encrypted Setups

✔️ Click to Expand Table of Contents

NixOS supports file systems that are encrypted using LUKS (Linux Unified Key Setup). This guide walks you through an encrypted NixOS installation using Disko for disk management and Btrfs for subvolumes. It is designed for users who want full disk encryption and a modern filesystem layout. If you prefer an unencrypted setup, you can skip the LUKS and encryption steps, but this guide focuses on security and flexibility.

If you choose to set up impermanence, ensure it matches your install. Encrypted Setup with Encrypted Impermanence and Unencrypted Setup with Unencrypted Impermanence.

What does LUKS Encryption Protect?

It's important to understand what disk encryption protects and what it doesn't protect so you don't have any misconceptions about how safe your data is.

What LUKS Protects:

  • Data Confidentiality at Rest: LUKS encrypts entire block devices (such as disk partitions or whole drives), ensuring that all data stored on the encrypted device is unreadable without the correct decryption key or passphrase. This protects sensitive information from unauthorized access if the device is lost, stolen, or physically accessed by an attacker.

  • Physical Security: If someone gains physical possession of your device (for example, by stealing your laptop or removing a hard drive), LUKS ensures the data remains inaccessible and appears as random, meaningless bytes without the correct credentials.

  • Protection Against Offline Attacks: LUKS defends against attackers who attempt to bypass the operating system by booting from another device or removing the drive and mounting it elsewhere. Without the decryption key, the data remains protected.

What LUKS Does Not Protect:

  • Data in Use: Once the system is booted and the encrypted device is unlocked, the data becomes accessible to the operating system and any user or process with the necessary permissions. LUKS does not protect against attacks on a running system, such as malware, remote exploits, or unauthorized users with access to an unlocked session.

  • File-Level Access Control: LUKS encrypts entire partitions or disks, not individual files or directories. It does not provide granular file-level encryption or access control within the operating system.

  • Network Attacks: LUKS only protects data stored on disk. It does not encrypt data transmitted over networks or protect against network-based attacks.

  • Bootloader and EFI Partitions: The initial bootloader or EFI system partition cannot be encrypted with LUKS, so some parts of the boot process may remain exposed unless additional measures are taken. (i.e., Secure Boot, additional passwords, TPM2)

To Sum it Up: LUKS encryption protects the confidentiality of all data stored on an encrypted block device by making it unreadable without the correct passphrase or key. This ensures that, if your device is lost or stolen, your data remains secure and inaccessible to unauthorized users. However, LUKS does not protect data once the system is unlocked and running, nor does it provide file-level encryption or protect against malware and network attacks. For comprehensive security, LUKS should be combined with strong access controls and other security best practices.

The Install

  1. Get the Nixos Minimal ISO Get it on a usb stick, I use Ventoy with Ventoy2Disk.sh. The following is the link to the Ventoy TarBall download, untar it with tar -xzf ventoy-1.1.05-linux.tar.gz, and make it executable with chmod +x Ventoy2Disk.sh, and finally execute it with sudo bash Ventoy2Disk.sh Follow the prompts to finish the install.

  2. Configuring Networking

The minimal installer uses wpa_supplicant instead of NetworkManager. Choose one of the following methods to enable networking:

sudo systemctl start wpa_supplicant
wpa_cli

Option A: Interactive wpa_cli

> add_network
0

> set_network 0 ssid "myhomenetwork"
OK

> set_network 0 psk "mypassword"
OK

> enable_network 0
OK

To exit type quit, then check your connection with ping google.com.

Option B: Non-Interactive wpa_passphrase

This method is quicker for known networks and persists the configuration for the live environment.

First, identify your wireless interface name (e.g., wlan0) using ip a.

sudo systemctl start wpa_supplicant # Ensure wpa_supplicant is running
# This command generates the config and appends it to a file specific to wlan0
sudo wpa_passphrase "myhomenetwork" "mypassword" | sudo tee /etc/wpa_supplicant/wpa_supplicant-wlan0.conf
sudo systemctl restart wpa_supplicant@wlan0.service

After either method, exit wpa_cli with quit. Then test your connection:

ping google.com
  1. Get your Disk Name with lsblk

The output should be something like:

NAME        MAJ:MIN RM   SIZE RO TYPE MOUNTPOINTS
nvme0n1     259:0    0   1,8T  0 disk
  1. Copy the disk configuration to your machine. You can choose one from the examples directory.

There is still a starter repo that can save you some typing, make sure to carefully review if you decide to use it:

export NIX_CONFIG='experimental-features = nix-command flakes'
export EDITOR='hx' # or 'vi'
nix-shell -p git yazi helix mkpasswd
git config --global user.name "gitUsername"
git config --global user.email "gitEmail"
# starter repo containing disk-config set up for impermanence
git clone https://github.com/saylesss88/my-flake.git

I prefer helix here as it's defaults are great. (i.e., auto closing brackets and much more)

If you choose to use the starter repo you won't need to run the next command as it is already populated in the repo.

If you click on the layout you want then click the Raw button near the top, then copy the url and use it in the following command:

cd /tmp
curl https://raw.githubusercontent.com/nix-community/disko/refs/heads/master/example/luks-btrfs-subvolumes.nix -o /tmp/disk-config.nix

The above curl command is to the luks-btrfs-subvolumes.nix layout.

  1. Make Necessary changes, I prepared mine for impermanence with the following:
hx /tmp/disk-config.nix

Make sure you identify your system disk name with lsblk and change the device attribute below to match your disk.

lsblk
nvme0n1       259:0    0 476.9G  0 disk
├─nvme0n1p1   259:1    0   512M  0 part  /boot
└─nvme0n1p2   259:2    0 476.4G  0 part

My disk is nvme0n1, change below to match yours:

{
  disko.devices = {
    disk = {
      nvme0n1 = {
        type = "disk";
        # Make sure this is correct with `lsblk`
        device = "/dev/nvme0n1";
        content = {
          type = "gpt";
          partitions = {
            ESP = {
              label = "boot";
              name = "ESP";
              size = "1G";
              type = "EF00";
              content = {
                type = "filesystem";
                format = "vfat";
                mountpoint = "/boot";
                mountOptions = [
                  "defaults"
                ];
              };
            };
            luks = {
              size = "100%";
              label = "luks";
              content = {
                type = "luks";
                name = "cryptroot";
                content = {
                  type = "btrfs";
                  extraArgs = ["-L" "nixos" "-f"];
                  subvolumes = {
                    "/root" = {
                      mountpoint = "/";
                      mountOptions = ["subvol=root" "compress=zstd" "noatime"];
                    };
                    "/root-blank" = {
                      mountOptions = ["subvol=root-blank" "nodatacow" "noatime"];
                    };
                    "/home" = {
                      mountpoint = "/home";
                      mountOptions = ["subvol=home" "compress=zstd" "noatime"];
                    };
                    "/nix" = {
                      mountpoint = "/nix";
                      mountOptions = ["subvol=nix" "compress=zstd" "noatime"];
                    };
                    "/persist" = {
                      mountpoint = "/persist";
                      mountOptions = ["subvol=persist" "compress=zstd" "noatime"];
                    };
                    "/log" = {
                      mountpoint = "/var/log";
                      mountOptions = ["subvol=log" "compress=zstd" "noatime"];
                    };
                    "/lib" = {
                      mountpoint = "/var/lib";
                      mountOptions = ["subvol=lib" "compress=zstd" "noatime"];
                    };
                    "/persist/swap" = {
                      mountpoint = "/persist/swap";
                      mountOptions = ["subvol=swap" "noatime" "nodatacow" "compress=no"];
                      swap.swapfile.size = "18G";
                    };
                  };
                };
              };
            };
          };
        };
      };
    };
  };

  fileSystems."/persist".neededForBoot = true;
  fileSystems."/var/log".neededForBoot = true;
  fileSystems."/var/lib".neededForBoot = true;
}

I have 16G of RAM so to be safe for hibernation I chose to give it some extra space. The boot partition is 1G, this extra space is for specialisations and lanzaboote.

or for a swapfile:

swapDevices = [
  {
    device = "/persist/swap/swapfile";
    size = 18 * 1024; # Size in MB (18GB)
    # or
    # size = 16384; # Size in MB (16G);
  }
];

Create a Blank Snapshot of /root

This is essential if you plan on using impermanence with this encrypted setup. We take a snapshot of /root while it's a clean slate, right after we run disko to format the disk.

To access all of the subvolumes, we have to mount the Btrfs partitions top-level.

  1. Unlock the LUKS device, if not already unlocked as it should be from running disko:
sudo cryptsetup open /dev/disk/by-partlabel/luks cryptroot
  1. Mount the Btrfs top-level (subvolid=5):
sudo mount -o subvolid=5 /dev/mapper/cryptroot /mnt
  1. List the contents:
ls /mnt
# you should see something like
root   home  nix  persist  log  lib  ...
  1. Now we can take a snapshot of the root subvolume:
sudo btrfs subvolume snapshot -r /mnt/root /mnt/root-blank
  1. Verify Your Blank Snapshot:

Before continuing, make sure your blank snapshot exists. This is crucial for impermanence to work properly.

sudo btrfs subvolume list /mnt

You should see output containing both root and root-blank subvolumes:

ID 256 gen ... path root
ID 257 gen ... path root-blank

Check that the snapshot is read only, this ensures that our snapshot will remain the same as the day we took it. It was set ro in disko but lets check anyways:

sudo btrfs property get -ts /mnt/root-blank
# output should be
ro=true
  1. Make sure to unmount:
sudo umount /mnt

Setting up zram and /tmp on RAM

While /tmp is handled by tmpfs (as shown the below configuration.nix), you can further enhance memory efficiency with zram for compressed swap, as shown below.

{
  lib,
  config,
  ...
}: let
  cfg = config.custom.zram;
in {
  options.custom.zram = {
    enable = lib.mkEnableOption "Enable utils module";
  };

  config = lib.mkIf cfg.enable {
    zramSwap = {
      enable = true;
      # one of "lzo", "lz4", "zstd"
      algorithm = "zstd";
       priority = 5;
       memoryPercent = 50;
    };
  };
}

And in your configuration.nix you would add:

# configuration.nix
custom = {
    zram.enable = true;
};

After adding the above module and rebuilding, you can see it with:

swapon --show
NAME       TYPE      SIZE USED PRIO
/dev/zram0 partition 7.5G   0B    5
  1. Run disko to partition, format and mount your disks. Warning this will wipe EVERYTHING on your disk. Disko doesn't work with dual boot.
sudo nix --experimental-features "nix-command flakes" run github:nix-community/disko/latest -- --mode destroy,format,mount /tmp/disk-config.nix

Check it with the following:

mount | grep /mnt

The output for an nvme0n1 disk would be similar to the following:

#... snip ...
/dev/nvme0n1p2 on /mnt type btrfs (rw,noatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=285,subvol=/root)
/dev/nvme0n1p2 on /mnt/persist type btrfs (rw,noatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=261,subvol=/persist)
/dev/nvme0n1p2 on /mnt/etc type btrfs (rw,noatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=261,subvol=/persist)
/dev/nvme0n1p2 on /mnt/nix type btrfs (rw,noatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=260,subvol=/nix)
/dev/nvme0n1p2 on /mnt/var/lib type btrfs (rw,noatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=258,subvol=/lib)
/dev/nvme0n1p2 on /mnt/var/log type btrfs (rw,noatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=259,subvol=/log)
/dev/nvme0n1p2 on /mnt/nix/store type btrfs (ro,noatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=260,subvol=/nix)
# ... snip ...
  1. Generate necessary files, here we use --no-filesystems because disko handles the fileSystems attribute for us.
nixos-generate-config --no-filesystems --root /mnt
  • The above command will place a configuration.nix and hardware-configuration.nix in /mnt/etc/nixos/

It may be helpful to add a couple things to your configuration.nix now, while it's in it's default location. You can just add what you want and rebuild once with sudo nixos-rebuild switch and move on. (i.e. git, an editor, etc.).

Setting a Flake for your minimal Install

  1. Create the flake in your home directory to avoid needing to use sudo for every command:
cd   # Move to home directory
mkdir flake
cd /mnt/etc/nixos/
sudo mv hardware-configuration.nix configuration.nix ~/flake/
sudo mv /tmp/disk-config.nix ~/flake/
cd flake
hx flake.nix

You'll change hostName = nixpkgs.lib.nixosSystem to your chosen hostname, (e.g. magic = nixpkgs.lib.nixosSystem). This will be the same as your networking.hostName = "magic"; in your configuration.nix that we will set up shortly.

# flake.nix
{
  description = "NixOS configuration";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    disko.url = "github:nix-community/disko/latest";
    disko.inputs.nixpkgs.follows = "nixpkgs";
    # impermanence.url = "github:nix-community/impermanence";
  };

  outputs = inputs@{ nixpkgs, ... }: {
    nixosConfigurations = {
      # Change `hostName` to your chosen host name
      nixos = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./configuration.nix
          inputs.disko.nixosModules.disko
          # inputs.impermanence.nixosModules.impermanence
        ];
      };
    };
  };
}
  1. Edit configuration.nix with what is required, the following are required, I clone my original flake repo and move the pieces into place but it's fairly easy to just type it all out:
  • Bootloader, (e.g., boot.loader.systemd-boot.enable = true;)

  • User, the example uses username change this to your chosen username. If you don't set your hostname it will be nixos.

  • Networking, networking.networkmanager.enable = true;

  • hardware-configuration.nix & disk-config.nix for this setup

  • If you type this out by hand and mess up a single character, you will have to start over completely. A fairly safe way to do this is with vim or hx and redirect the hashed pass to a /tmp/pass.txt, you can then read it into your users.nix:

mkpasswd -m SHA-512 -s > /tmp/pass.txt
# Enter your chosen password

And then when inside configuration.nix, move to the line where you want the hashed password and type :r /tmp/pass.txt to read the hash into your current file.

# configuration.nix
{
  config,
  lib,
  pkgs,
  inputs,
  ...
}: {
  imports = [
    # Include the results of the hardware scan.
    ./hardware-configuration.nix
    ./disk-config.nix
  ];

  # systemd Stage 1: if enabled, it handles unlocking of LUKS-encrypted volumes during boot.
    boot.initrd.luks.devices = {
    cryptroot = {
      device = "/dev/disk/by-partlabel/luks";
      allowDiscards = true;
    };
  };

  # This complements using zram, putting /tmp on RAM
    boot = {
    tmp = {
      useTmpfs = true;
      tmpfsSize = "50%";
    };
  };

  # Enable autoScrub for btrfs
    services.btrfs.autoScrub = {
    enable = true;
    interval = "weekly";
    fileSystems = ["/"];
  };


  # Change me!
  networking.hostName = "nixos"; # This will match the `hostname` of your flake

  networking.networkmanager.enable = true;

  boot.loader.systemd-boot.enable = true; # (for UEFI systems only)
  # List packages installed in system profile.
  # You can use https://search.nixos.org/ to find more packages (and options).
  environment.systemPackages = with pkgs; [
    vim # Do not forget to add an editor to edit configuration.nix! The Nano editor is also installed by default.
    #   wget
    git
  ];

  time.timeZone = "America/New_York";

# Change me to your chosen username (i.e. change nixosUser to your username)
  users.users.nixosUser = {
    isNormalUser = true;
    extraGroups = [ "wheel" "networkmanager" ]; # Add "wheel" for sudo access
    initialHashedPassword = "READ_MKPASSWD_OUTPUT_HERE"; # <-- This is where it goes!
    # home = "/home/nixos"; # Optional: Disko typically handles home subvolumes
  };

  console.keyMap = "us";

  nixpkgs.config.allowUnfree = true;

  system.stateVersion = "25.05";
}

Although, just adding the disk-config.nix works for prompting you for your encryption passphrase adding the following is a more robust way of ensuring Nix is aware of this:

    boot.initrd.luks.devices = {
    cryptroot = {
      device = "/dev/disk/by-partlabel/luks";
      allowDiscards = true;
    };
  };
  1. Move the flake to /mnt/etc/nixos and run nixos-install:
sudo mv ~/flake /mnt/etc/nixos/
  • Give everything a quick once over, insuring your host is set in both your flake.nix, and configuration.nix. Ensure you changed the username in the configuration.nix from nixos to your chosen name, this is the name you'll use to login after you enter your encryption passphrase.

The below command uses #nixos because that's what the defaults are, you'll change it to your chosen hostname.

sudo nixos-install --flake /mnt/etc/nixos/flake#nixos
  • You will be prompted to enter a new password if everything succeeds.

  • If everything checks out, reboot the system and you should be prompted to enter your user and password to login to a shell to get started.

  • The flake will be placed at /etc/nixos/flake after the install and reboot, I choose to move it to my home directory. Since the file was first in /etc you'll need to adjust the permissions with something like sudo chown -R $USER:users ~/flake and then you can work on it without privilege escalation.

  • You can check the layout of your btrfs system with:

sudo btrfs subvolume list /

Persisting Critical System State

The following is a one time operation, we're just getting it out of the way now. This moves all of the important system state to a persistant location, further preparing for impermanence.

It's essential that you have first run the nixos-install command to populate these directories before copying them over.

sudo mkdir -p /mnt/persist/etc
sudo mkdir -p /mnt/persist/var/lib
sudo mkdir -p /mnt/persist/var/log
sudo mkdir -p /mnt/persist/home
sudo mkdir -p /mnt/persist/root
sudo cp -a /mnt/etc/. /mnt/persist/etc/
sudo cp -a /mnt/var/lib/. /mnt/persist/var/lib
sudo cp -a /mnt/var/log/. /mnt/persist/var/log
sudo cp -a /mnt/home/. /mnt/persist/home/
sudo cp -a /mnt/root/. /mnt/persist/root/

Since we are in a live environment, after the install and reboot the /mnt prefix will be removed.

Reboot

Now that everything is done, we can safely reboot and ensure that our LUKS password/passphrase is accepted as well as our userlevel password and username.

After reboot, you can continue to setup Sops Encrypted Secrets and Lanzaboote Secure Boot

Encrypted Impermanence

✔️ Click to Expand Table of Contents

❗ Important Note: This guide details a setup involving encrypted partitions and impermanent NixOS. While powerful, such configurations require careful attention to detail. Incorrect steps, especially concerning encryption keys or persistent data paths, can lead to permanent data loss. Please read all instructions thoroughly before proceeding and consider backing up any critical data beforehand. This has only been tested with the disk layout described in Encrypted Setups

As a system operates, it gradually accumulates state on its root partition. This state is stored in various directories such as /etc and /var, capturing all the configuration changes, logs, and other modifications—whether they’re well-documented or the result of ad-hoc adjustments made while setting up and running services.

Impermanence,in the context of operating systems, refers to a setup where the majority of the system's root filesystem (/) is reset to a pristine state on every reboot. This means any changes made to the system (e.g., installing new packages, modifying system files outside of configuration management, creating temporary files) are discarded upon shutdown or reboot.

Having an impermanent root and /tmp has some security benefits as well. By reducing your persistent footprint you reduce your chance of leaving behind sensitive activity or data. Since Nix can boot with only /nix and /boot, experienced users familiar with "stateless" systems can take advantage of this smaller attack surface.

Although this setup does not use /tmp as the root filesystem, the root itself is restored to its original state upon each reboot, as it was at installation. However, by configuring /tmp to reside in RAM, you ensure that temporary files including sensitive data like passwords are stored only in volatile memory and are automatically cleared on shutdown or reboot. This significantly enhances the security of temporary data by preventing it from ever being written to disk.

Getting Started

  1. Add impermanence to your flake.nix. You will change the hostname in the flake to match your networking.hostName.
# flake.nix
{
  description = "NixOS configuration";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    disko.url = "github:nix-community/disko/latest";
    disko.inputs.nixpkgs.follows = "nixpkgs";
    impermanence.url = "github:nix-community/impermanence";
  };

  outputs = inputs@{ nixpkgs, ... }: {
    nixosConfigurations = {
      hostname = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./configuration.nix
          inputs.disko.nixosModules.disko
          inputs.impermanence.nixosModules.impermanence
        ];
      };
    };
  };
}
  1. Discover where your root subvolume is located with findmnt:

If you followed the Encrypted Setups guide, your encrypted subvolume should be located at: /dev/mapper/cryptroot /mnt

  • Your encrypted Btrfs partition, once unlocked by LUKS, will be available at /dev/mapper/cryptroot as configured here in the disk-config.nix:
# disk-config2.nix
# ... snip ...
            luks = {
              size = "100%";
              label = "luks";
              content = {
                type = "luks";
                name = "cryptroot";
                content = {
# ... snip ...

Double check that the paths exist:

cd /dev/mapper/crypt<TAB>  # autocomplete should fill out /dev/mapper/cryptroot
  1. Create an impermanence.nix:

Now, create a new file named impermanence.nix in your configuration directory (i.e. your flake directory). This file will contain all the specific settings for your impermanent setup, including BTRFS subvolume management and persistent data locations. Since this file is right next to your configuration.nix, you'll just add an imports = [ ./impermanence.nix ] to your configuration.nix apply it to your configuration.

{
  config,
  lib,
  ...
}: {
  boot.initrd.postDeviceCommands = lib.mkAfter ''
    echo "Rollback running" > /mnt/rollback.log
     mkdir -p /mnt
     mount -t btrfs /dev/mapper/cryptroot /mnt

     # Recursively delete all nested subvolumes inside /mnt/root
     btrfs subvolume list -o /mnt/root | cut -f9 -d' ' | while read subvolume; do
       echo "Deleting /$subvolume subvolume..." >> /mnt/rollback.log
       btrfs subvolume delete "/mnt/$subvolume"
     done

     echo "Deleting /root subvolume..." >> /mnt/rollback.log
     btrfs subvolume delete /mnt/root

     echo "Restoring blank /root subvolume..." >> /mnt/rollback.log
     btrfs subvolume snapshot /mnt/root-blank /mnt/root

     umount /mnt
  '';

  environment.persistence."/persist" = {
    directories = [
      "/etc"
      "/var/spool"
      "/root"
      "/srv"
      "/etc/NetworkManager/system-connections"
      "/var/lib/bluetooth"
    ];
    files = [
      # "/etc/machine-id"
      # Add more files you want to persist
    ];
  };

# optional quality of life setting
  security.sudo.extraConfig = ''
    Defaults lecture = never
  '';
}
  • /mnt/rollback.log: this log will be available during the boot process for debugging if the rollback fails, but won't persist.

With the above impermanence script, the btrfs subvolumes are deleted recursively and replaced with the root-blank snapshot we took during the install.

I have commented out "/etc/machine-id" because we already copied over all of the files to their persistent location and the above setting would work once and then cause a conflict.

configuration.nix changes

# configuration.nix
  boot.initrd.luks.devices = {
    cryptroot = {
      device = "/dev/disk/by-partlabel/luks";
      allowDiscards = true;
      preLVM = true;
    };
  };
  • This defines how your system's initial ramdisk (initrd) should handle a specific encrypted disk during the boot process. It helps with timing and is a more robust way of telling Nix that we are using an encrypted disk.

The following is optional to enable autoScrub for btrfs, the wiki shows interval = "monthly"; FYI.

# configuration.nix
  services.btrfs.autoScrub = {
    enable = true;
    interval = "weekly";
    fileSystems = ["/"];
  };
  • Remember to ensure that your hostname in your configuration.nix matches the hostname in your flake.nix.

Applying Your Impermanence Configuration

Once you have completed all the steps and created or modified the necessary files (flake.nix, impermanence.nix), you need to apply these changes to your NixOS system.

  1. Navigate to your NixOS configuration directory (where your flake.nix is located).
cd /path/to/your/flake
  1. Rebuild and Switch: Execute the nixos-rebuild switch command. This command will:
  • Evaluate your flake.nix and the modules it imports (including your new impermanence.nix).

  • Build a new NixOS system closure based on your updated configuration.

  • Activate the new system configuration, making it the current running system.

sudo nixos-rebuild switch --flake .#hostname # Replace 'hostname' with your actual system hostname
  1. Perform an Impermanence Test (Before Reboot):
  • Before you reboot, create a temporary directory and file in a non-persistent location. Since you haven't explicitly added /imperm_test to your environment.persistence."/persist" directories, this file should not survive a reboot.
mkdir /imperm_test
echo "This should be Gone after Reboot" | sudo tee /imperm_test/testfile
ls -l /imperm_test/testfile # Verify the file exists
cat /imperm_test/testfile # Verify content
  1. Reboot Your System: For the impermanence setup to take full effect and for your root filesystem to be reset for the first time, you must reboot your machine.
sudo reboot
  1. Verify Impermanence (After Reboot):
  • After the system has rebooted, check if the test directory and file still exist:
ls -l /imperm_test/testfile

You should see an output like ls: cannot access '/imperm_test/testfile': No such file or directory. This confirms that the /imperm_test directory and its contents were indeed ephemeral and were removed during the reboot process, indicating your impermanence setup is working correctly!

Your system should now come up with a fresh root filesystem, and only the data specified in your environment.persistence."/persist" configuration will be persistent.

USB Stick Keyfile

✔️ Click to Expand Table of Contents

This allows you to use a USB stick for your keyfile, with a backup in case you want or need it. There is a setting fallbackToPassword that protects you in case something fails with the USB key.

First, I'll show how to set up a dedicated USB stick for a keyfile. (i.e., one that is only used for this). After that I will show the process of adding the keyfile to a USB stick with existing data on it that you don't want to lose.

Generate the keyfile

sudo dd if=/dev/urandom of=/root/usb-luks.key bs=4096 count=1

Keyfile Enrollment Methods

This is for a dedicated USB stick that we will wipe first then add the key.

Disko defaults to LUKS2, FYI.

# cryptsetup works for both LUKS1 and LUKS2 formats but doesn't work for
# TPM2, FIDO2, and smartcards
sudo cryptsetup luksAddKey /dev/disk/by-partlabel/luks /root/usb-luks.key

❗ NOTE: I discovered that you can only enroll your key with cryptenroll if you are using version 258 of systemd. You can check your version with:

systemctl --version
 systemd 257 (257.6)
  • Unfortunately nixos-unstable isn't there yet so cryptsetup is required.

Description

  • /dev/disk/by-partlabel/luks refers to your encrypted partition by its partition label, which is stable and less likely to change than /dev/nvme0n1p2

  • /root/usb-luks.key is the keyfile we generated.

  • You'll be prompted to enter your existing LUKS passphrase to authorize adding the new key.

  • Now our LUKS volume will accept both our existing passphrase and the new keyfile (from the USB stick) for unlocking.

  1. Clear Data on USB stick and replace with 0's
lsblk
NAME        MAJ:MIN RM   SIZE RO TYPE MOUNTPOINTS
sda           8:0    1   239M  0 disk
sdb           8:16   1   1.4M  0 disk  /run/media/jr/7CD1-149A # Example USB mount
zram0       253:0    0   7.5G  0 disk  [SWAP]
nvme0n1     259:0    0 476.9G  0 disk
├─nvme0n1p1 259:1    0   512M  0 part  /boot
└─nvme0n1p2 259:2    0 476.4G  0 part
  └─cryptroot 254:0  0 476.4G  0 crypt /persist  # Main Btrfs mount
                                               # (other subvolumes are within /persist and bind-mounted by impermanence)
# unplug the device and run lsblk again so your sure
  1. Before wiping you must unmount any mounted partitions:
sudo umount /dev/sda1
# Overwrite with Zeros (fast, sufficient for most uses)
sudo dd if=/dev/zero of=/dev/sda bs=4M status=progress
# Or overwrite with Random Data (More Secure, Slower)
sudo dd if=/dev/urandom of=/dev/sda bs=4M status=progress
# Or for the most secure way run multiple passes of
sudo shred -v -n 3 /dev/sda
  1. Create a New Partition and Format (Optional)
sudo fdisk /dev/sda
  1. Press o to create a new empty DOS partition table (if you are creating partitions on a fresh disk or want to wipe existing partitions and start over). Be very careful with this step as it will erase all existing partition information on the disk.

  2. Press n to create a new partition.

  • You will then be prompted for the partition type:

    • p for a primary partition (you can have up to 4 primary partitions)

    • e for an extended partition (which can contain logical partitions)

  • Next, you'll be asked for the partition number (e.g., 1, 2, 3, 4).

  • Then, you'll be asked for the first sector (press Enter to accept the default, which is usually the first available sector after the previous partition or the beginning of the disk).

  • Finally, you'll be asked for the last sector or size (you can specify a size like +10G for 10 Gigabytes, +512M for 512 Megabytes, or press Enter to use the rest of the available space).

  1. Press w to write the changes to the partition table and exit fdisk.

After pressing w, the kernel needs to be aware of the new partition table. Sometimes this happens automatically, but if you encounter issues, a reboot or a command like partprobe (if available and needed) can help.

Formats as FAT32:

sudo mkfs.vfat /dev/sda1
# or as ext4
sudo mkfs.ext4 /dev/sda1

I chose vfat so I ran sudo mkfs.vfat /dev/sda1. In my case this changed the device path to /run/media/jr/7CD1-149A so it's important to find your own UUID with the following command:

sudo blkid /dev/sda1
/dev/sda1: SEC_TYPE="msdos" UUID="B7B4-863B" BLOCK_SIZE="512" TYPE="vfat" PARTUUID="7d1f9d7f-01"
  • As you can see the above UUID is "B7B4-863B"

  • Remove and re-insert the USB stick, this ensures the system recognizes the new partition and filesystem.

  1. Copy the keyfile to your USB Stick
sudo cp /root/usb-luks.key /run/media/jr/B7B4-863B/
sync
  1. Update your NixOS Configuration

Note the output of blkid /dev/sda1 and if you have a backup device list that also:

The following is from the wiki edited for my setup, it was created by Tzanko Matev:

let
  PRIMARYUSBID = "B7B4-863B";
  BACKUPUSBID = "Ventoy";
in {

  boot.initrd.kernelModules = [
    "uas"
    "usbcore"
    "usb_storage"
    "vfat"
    "nls_cp437"
    "nls_iso8859_1"
  ];

  boot.initrd.postDeviceCommands = lib.mkBefore ''
    mkdir -p /key
    sleep 2
    mount -n -t vfat -o ro $(findfs UUID=${PRIMARYUSBID}) /key || \
    mount -n -t vfat -o ro $(findfs UUID=${BACKUPUSBID}) /key || echo "No USB key found"
  '';

  boot.initrd.luks.devices.cryptroot = {
    device = "/dev/disk/by-partlabel/luks";
    keyFile = "/key/usb-luks.key";
    fallbackToPassword = true;
    allowDiscards = true;
    preLVM = false; # Crucial!
  };
}

If you have issues or just want to remove the key take note of the path used to add it so you don't have to enter the whole key:

sudo cryptsetup luksRemoveKey /dev/disk/by-partlabel/luks --key-file /root/usb-luks.key
  1. Securely Remove the Keyfile from Your System:
sudo shred --remove --zero /root/usb-luks.key

Instructions for Using a USB Stick with Existing Data

  1. Generate the Keyfile
sudo dd if=/dev/urandom of=/root/usb-luks.key bs=4096 count=1
  1. Add the Keyfile to your LUKS Volume
sudo cryptsetup luksAddKey /dev/disk/by-partlabel/luks /root/usb-luks.key

(enter your existing passphrase when prompted)

  1. Copy the Keyfile to the USB Stick
  • Plug in the USB Stick and note its mount point (e.g.,/run/media/$USER/YourLabel)

  • Copy the keyfile:

sudo cp /root/usb-luks.key /run/media/$USER/YourLabel/
sync
  • You run the above as 2 commands, the second being sync.

  • You can rename it if you wish (e.g., luks.key)

  1. Securely Delete the Local Keyfile
sudo shred --remove --zero /root/usb-luks.key
  • You need to ensure the keyfile is accessible in the initrd. Since automounting (like /run/media/...) does not happen in initrd, you must manually mount the USB in the initrd using its UUID or label.

Find the USB Partition UUID:

lsblk -o NAME,UUID
# or
blkid /dev/sda1

Suppose the UUID is B7B4-863B

Add to your configuration.nix:

boot.initrd.kernelModules = [ "usb_storage" "vfat" "nls_cp437" "nls_iso8859_1" ];

boot.initrd.postDeviceCommands = lib.mkBefore ''
  mkdir -p /key
  sleep 1
  mount -n -t vfat -o ro $(findfs UUID=B7B4-863B) /key || echo "USB not found"
'';

boot.initrd.luks.devices.cryptroot = {
  device = "/dev/disk/by-partlabel/luks";
  keyFile = "/key/usb-luks.key"; # or whatever you named it
  fallbackToPassword = true;
  allowDiscards = true;
};

Sops-Nix encrypted secrets

Click to Expand Table of Contents

SOPS, short for SecretsOPerationS, is an editor of encrypted files that supports quite a few BINARY formats and encrypts with AWS KMS, GCP KMS, Azure Key Vault, age, and PGP.

Managing secrets—like API keys, SSH deploy keys, and password hashes is a critical part of system configuration, but it’s also one of the trickiest to do securely and reproducibly. Traditionally, secrets might be stored in ad hoc locations, referenced by absolute paths, or managed manually outside of version control. This approach makes it hard to share, rebuild, or audit your configuration, and increases the risk of accidental leaks or inconsistencies between systems.

sops-nix solves these problems by integrating Mozilla SOPS directly into your NixOS configuration. Instead of relying on hardcoded file paths or copying secrets around, you declare your secrets in your Nix code, encrypt them with strong keys, and let sops-nix handle decryption and placement at activation time.

Encryption with strong keys, as used by sops-nix, makes brute force attacks computationally unfeasible with current technology—the time and resources required to try every possible key would be astronomically high. However, this protection relies on using strong, secret keys and good security practices; advances in technology or poor key management can weaken this defense.

CRITICAL SECURITY NOTE: While the encryption itself is robust, this protection fundamentally relies on using strong, secret keys and diligent security practices. If your PGP passphrase is weak, your Age private key is easily guessable, or the cleartext secret itself is very short and has low entropy (e.g., "12345", "true", "admin"), an attacker might be able to compromise your secrets regardless of the encryption.

  1. Add sops to your flake.nix:
{
  inputs.sops-nix.url = "github:Mic92/sops-nix";
  inputs.sops-nix.inputs.nixpkgs.follows = "nixpkgs";

  outputs = { self, nixpkgs, sops-nix }: {
    # change `yourhostname` to your actual hostname
    nixosConfigurations.yourhostname = nixpkgs.lib.nixosSystem {
      # customize to your system
      system = "x86_64-linux";
      modules = [
        ./configuration.nix
        sops-nix.nixosModules.sops
      ];
    };
  };
}
  1. Add sops and age to your environment.systemPackages:
environment.systemPackages = [
    pkgs.sops
    pkgs.age
];
  1. Generate a key (This is your private key and MUST NEVER BE COMMITTED TO GIT OR SHARED):
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt

To get the Public Keys Value, run the following command:

age-keygen -y ~/.config/sops/age/keys.txt
age12zlz6lvcdk6eqaewfylg35w0syh58sm7gh53q5vvn7hd7c6nngyseftjxl

Copy the age value it gives you back.

  1. Create a .sops.yaml in the same directory as your flake.nix:
# .sops.yaml
keys:
  # Your personal age public key (from age-keygen -y ~/.config/sops/age/keys.txt)
  - &personal_age_key age12zlz6lvcdk6eqaewfylg35w0syh58sm7gh53q5vvn7hd7c6nngyseftjxl

  # You can also use PGP keys if you prefer, but age is often simpler
  # - &personal_pgp_key 0xDEADBEEFCAFE0123

creation_rules:
  # This rule applies to any file named 'secrets.yaml' directly in the 'secrets/' directory
  # or 'secrets/github-deploy-key.yaml' etc.
  - path_regex: "secrets/.*\\.yaml$"
    key_groups:
      - age:
          - *personal_age_key
        # Add host keys for decryption on the target system
        # sops-nix will automatically pick up the system's SSH host keys
        # as decryption keys if enabled in your NixOS config.
        # So you typically don't list them explicitly here unless you
        # want to restrict it to specific fingerprints, which is rare.
        # This part ensures your *personal* key can decrypt it.

Save it and move on, this file and sops.nix are safe to version control.

  1. sops-nix's automatic decryption feature using system SSH host keys only works with ed25519 host keys for deriving Age decryption keys. Therefore, for system decryption, ensure your using ed25519 not rsa keys:
ssh-keygen -t ed25519 -C "your_email@example.com"
# for multiple keys run something like
ssh-keygen -t ed25519 -f ~/nix-book-deploy-key -C "deploy-key-nix-book-repo"
  1. Copy the PRIVATE key for each and add them to your secrets directory:

While in your flake directory:

mkdir secrets
sops secrets/github-deploy-key.yaml  # For your github ssh key

When you call a sops command, it will handle the encryption/decryption transparently and open the cleartext file in an editor.

Editing will happen in the editor that $SOPS_EDITOR or $EDITOR is set to, sops will wait for the editor to exit, and then try to reencrypt the file.

The above command will open a default sops github-deploy-key.yaml in your $EDITOR:

Erase the default sops filler and type github_deploy_key_ed25519: |, move your cursor 1 line down and type :r ~/.ssh/id_ed25519 to read the private key into the file and repeat as needed.

github_deploy_key_ed25519: |
  -----BEGIN OPENSSH PRIVATE KEY-----
  ...
  -----END OPENSSH PRIVATE KEY-----

github_deploy_key_ed25519_nix-book: |
  -----BEGIN OPENSSH PRIVATE KEY-----
  ...
  -----END OPENSSH PRIVATE KEY-----

The -----BEGIN and the rest of the private key must be indented 2 spaces

Ensure sops can decrypt it:

sops -d secrets/github-deploy-key.yaml

❗ WARNING: Only ever enter your private keys through the sops command. If you forget and paste them in without the sops command then run git add at any point, your git history will have contained an unencrypted secret which is a nono. Always use the sops command when dealing with files in the secrets directory, save the file and inspect that it is encrypted on save. If not something went wrong with the sops process, do not add it to Git. If you do, you will be required to rewrite your entire history which can be bad if you're collaborating with others. git-filter-repo is one such solution that rewrites your history. Just keep this in mind. This happens because Git has a protection that stops you from doing stupid things.

Generate an encrypted password hash with:

mkpasswd -m SHA-512 -s > /tmp/password-hash.txt
# Enter your chosen password and copy the encrypted hash it gives you back
sops secrets/password-hash.yaml      # For your `hashedPasswordFile`

The above command will open your $EDITOR with the file password-hash.yaml, add the following content to it. Replace PasteEncryptedHashHere with the output of the mkpasswd command above:

Delete the default sops filler, type password_hash: and leave your cursor after the : and type :r /tmp/password-hash.txt

password_hash: PasteEncryptedHashHere

Ensure sops can decrypt it:

sops -d secrets/password-hash.yaml
  1. Create a sops.nix and import it or add this directly to your configuration.nix:

My sops.nix is located at ~/flake/hosts/hostname/sops.nix and the secrets directory is located at ~/flake/secrets so the path from sops.nix to secrets/pasword-hash.yaml would be ../../secrets/password-hash.yaml

Another step you can take is to copy your key to a persistent location, preparing for impermanence:

sudo mkdir /persist/sops/age
sudo cp ~/.config/sops/age/keys.txt /persist/sops/age/keys.txt

Then you would change the age.keyFile = "/persist/sops/age/keys.txt" to match this location below.

# ~/flake/hosts/magic/sops.nix  # magic is my hostname
# hosts/magic/ is also where my configuration.nix is
{...}: {
  sops = {
    defaultSopsFile = ../../.sops.yaml; # Or the correct path to your .sops.yaml
    age.sshKeyPaths = ["/etc/ssh/ssh_host_ed25519_key"];
    age.keyFile = "/home/jr/sops/age/keys.txt";

    secrets = {
      "password_hash" = {
        sopsFile = ../../secrets/password-hash.yaml; # <-- Points to your password hash file
        owner = "root";
        group = "root";
        mode = "0400";
        neededForUsers = true;
      };
      "github_deploy_key_ed25519_nix-book" = {
        sopsFile = ../../secrets/github-deploy-key.yaml;
        key = "github_deploy_key_ed25519_nix-book";
        owner = "root";
        group = "root";
        mode = "0400";
      };
      "github_deploy_key_ed25519" = {
        sopsFile = ../../secrets/github-deploy-key.yaml;
        key = "github_deploy_key_ed25519";
        owner = "root";
        group = "root";
        mode = "0400";
      };
    };
  };
}

Import sops.nix into your configuration.nix or equivalent:

# configuration.nix
imports = [
  ./sops.nix # Assuming sops.nix is in the same directory as configuration.nix, adjust path as needed
  # ... other imports
];

❗ NOTE: You may see in the sops quickstart guide that if you're using impermanence, the key used for secret decryption (sops.age.keyFile) must be in a persistent directory, loaded early enough during the boot process. If you are using the btrfs subvolume layout you don't need to worry about this because your home will be on its own partition when only the root partition is wiped on reboot. Adding neededForUsers = true; tells sops-nix to decrypt and make that secret available earlier in the boot process--specifically, before user and group accounts are created.

You typically use age.sshKeyPaths for system-level secrets with a persistent SSH host key

For user-level secrets, use age.keyFile pointing to your Age private key, stored in a safe persistent location.

For reproducibility, keep your key files in a persistent, predictable path and document which keys are used for which secrets in your .sops.yaml.

If you don't need both age.keyFile and age.sshKeyPaths it can reduce complexity to use one or the other. Although most people may choose one, it's not bad to use both it just adds complexity.

And finally use the password-hash for your hashedPasswordFile for your user, my user is jr so I added this:

# ... snip ...
    users.users = {
      # ${username} = {
      jr = {
        homeMode = "755";
        isNormalUser = true;
        # description = userVars.gitUsername;
        hashedPasswordFile = config.sops.secrets.password_hash.path;
  # ...snip...
  1. Rebuild your configuration and you should see something like this:
sops-install-secrets: Imported /etc/ssh/ssh_host_ed25519_key as age key with fingerprint age1smamdkzrwpdxw63hrxxcq8kmejsm4olknsrg72vd0qtfpmlzlvnf8uws38mzuj

By integrating SOPS with NixOS through sops-nix, you gain a modern, secure, and reproducible way to manage sensitive secrets. Unlike traditional approaches—where secrets are often scattered in ad hoc locations, referenced by absolute paths, or managed outside version control—sops-nix keeps your secrets encrypted, declarative, and version-control friendly.

Secure Boot with Lanzaboote

Click to Expand Table of Contents

⚠️ Warning: This can easily brick your system ⚠️

We will mainly follow the lanzaboote Quick Start Guide

For Windows dual-booters and BitLocker users, you should export your BitLocker recovery keys and confirm that they are correct. Refer to this Microsoft support article

❗ NOTE: There are some serious limitations to this setup when used without encryption, I'd say it could stop the average person. But an experienced hacker could easily bypass this without encryption if they had access to your computer. For more protection look into TPM2 Hardware Requirements, and full disk encryption

Important Considerations

I found This Article fairly enlightening as far as the state of Authenticated Boot and Disk Encryption on Linux.

Disk encryption means that reading the data in clear-text form is only possible if you possess a secret of some form, usually a password/passphrase. Data authentication means that no one can make changes to the data on disk unless they possess a secret of some form. Most distributions only enable the former though — the latter is a more recent addition to LUKS/cryptsetup, and is not used by default on most distributions (though it probably should be). Closely related to LUKS/dm-crypt is dm-verity (which can authenticate immutable volumes) and dm-integrity (which can authenticate writable volumes, among other things).--Pid Eins

Lanzaboote only secures the boot chain. The userspace remains unverified (i.e., the nix store, etc.), to verify userspace you need to implement additional integrity checks. It's common to rely to disk encryption to prevent tampering with and keep the Nix store safe but it's not always desirable. (i.e., unattended boot)

Requirements

To be able to setup Secure Boot on your device, NixOS needs to be installed in UEFI mode and systemd-boot must be used as a boot loader. This means if you wish to install lanzaboote on a new machine, you need to follow the install instruction for systemd-boot and then switch to lanzaboote after the first boot.

Check these prerequisits with bootctl status, this is an example output:

sudo bootctl status
System:
     Firmware: UEFI 2.70 (Lenovo 0.4720)
  Secure Boot: disabled (disabled)
 TPM2 Support: yes
 Boot into FW: supported

Current Boot Loader:
      Product: systemd-boot 251.7
...

The firmware must be UEFI and the current bootloader needs to be systemd-boot. If you check these boxes, you're good to go.

Why Use Lanzaboote (Secure Boot) on a Non-Encrypted System?

Although full disk encryption would provide the best protection it may be unnecessary for your home desktop in your bedroom. Full disk encryption is beyond the scope of this chapter.

Even if your disk is not encrypted, enabling Secure Boot with Lanzaboote brings real security improvements:

  1. Protects the Boot Process from Malware

Secure Boot ensures that only bootloaders and kernels signed with your trusted keys can run at startup. This blocks bootkits and rootkits—dangerous types of malware that try to infect your system before the operating system even loads . Without Secure Boot, malicious software could silently replace your bootloader or kernel and gain control every time your computer starts.

  1. Prevents Unauthorized Modifications

If someone (or some software) tries to tamper with your boot files—like swapping out your kernel or bootloader with a malicious version—Secure Boot will detect this and refuse to start the system, alerting you that something is wrong

. This makes it much harder for attackers to hide or persist on your machine.

  1. First Line of Defense

Secure Boot acts as a “gatekeeper” for your computer’s startup process. Even if your files aren’t encrypted, it stops unauthorized code from running before Linux loads, making it harder for malware to take hold and harder for attackers to compromise your system at the lowest level.

  1. Protects Recovery and Rescue Environments

Secure Boot also covers recovery partitions and rescue tools. Only signed, trusted recovery environments can be loaded, preventing attackers from sneaking in malicious tools during system repair.

  1. Peace of Mind for Updates and Multi-User Systems

If you share your computer or use it in a public setting, Secure Boot ensures that only approved system updates and kernels can be booted, reducing the risk of accidental or intentional tampering.

Security Requirements

To provide any security your system needs to defend against an attacker turning UEFI Secure Boot off or being able to sign binaries with the keys we are going to generate.

The easiest way to achieve this is to:

  1. Enable a BIOS password for your system, this will prevent someone from just shutting off secure boot.

  2. Use full disk encryption.

Preparation

Finding the UEFI System Partition (ESP)

The UEFI boot process revolves around the ESP, the (U)EFI System Partition. This partition is conventionally mounted at /boot on NixOS.

Verify this with the command sudo bootctl status. Look for ESP:

Creating Your Keys

First you'll need to install sbctl which is available in Nixpkgs:

# configuration.nix or equivalent
environment.systemPackages = [ pkgs.sbctl ];

Create the keys:

$ sudo sbctl create-keys
[sudo] password for julian:
Created Owner UUID 8ec4b2c3-dc7f-4362-b9a3-0cc17e5a34cd
Creating secure boot keys...✓
Secure boot keys created!

If you already have keys in /etc/secureboot migrate these to /var/lib/sbctl:

sbctl setup --migrate

Configuring Lanzaboote With Flakes

Shown all in flake.nix for brevity. Can easily be split up into a boot.nix, etc:

{
  description = "A SecureBoot-enabled NixOS configurations";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";

    lanzaboote = {
      url = "github:nix-community/lanzaboote/v0.4.2";

      # Optional but recommended to limit the size of your system closure.
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = { self, nixpkgs, lanzaboote, ...}: {
    nixosConfigurations = {
      yourHost = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";

        modules = [
          # This is not a complete NixOS configuration and you need to reference
          # your normal configuration here.

          lanzaboote.nixosModules.lanzaboote

          ({ pkgs, lib, ... }: {

            environment.systemPackages = [
              # For debugging and troubleshooting Secure Boot.
              pkgs.sbctl
            ];

            # Lanzaboote currently replaces the systemd-boot module.
            # This setting is usually set to true in configuration.nix
            # generated at installation time. So we force it to false
            # for now.
            boot.loader.systemd-boot.enable = lib.mkForce false;

            boot.lanzaboote = {
              enable = true;
              pkiBundle = "/var/lib/sbctl";
            };
          })
        ];
      };
    };
  };
}

Build it

sudo nixos-rebuild switch --flake /path/to/flake

Ensure Your Machine is Ready for Secure Boot enforcement

$ sudo sbctl verify
Verifying file database and EFI images in /boot...
✓ /boot/EFI/BOOT/BOOTX64.EFI is signed
✓ /boot/EFI/Linux/nixos-generation-355.efi is signed
✓ /boot/EFI/Linux/nixos-generation-356.efi is signed
✗ /boot/EFI/nixos/0n01vj3mq06pc31i2yhxndvhv4kwl2vp-linux-6.1.3-bzImage.efi is not signed
✓ /boot/EFI/systemd/systemd-bootx64.efi is signed

Enabling Secure Boot and Entering Setup Mode

This is where things can get tricky because BIOS are widely different and use different conventions.

You can see your BIOS from the output of bootctl status:

sudo bootctl status
doas (jr@magic) password:
System:
      Firmware: UEFI 2.70 (American Megatrends 5.19)

My BIOS is an American Megatrends 5.19, find yours and look up which key you have to hit to enter the BIOS on reboot, mine is the delete key. So I reboot and repeatedly hit delete until it brings up the BIOS settings.

The lanzaboote guide shows a few systems and how to enter setup mode for them.

For a ThinkPad the steps are:

  1. Select the "Security" tab.

  2. Select the "Secure Boot" entry.

  3. Set "Secure Boot" to enabled.

  4. Select "Reset to Setup Mode".


For my system, it would allow me to do the above steps but when I saved and exited I got a red screen then blue screen and it said No Valid Keys or something like that and eventually brought me to the MOK Manager where you can manually register keys, this is NOT what you want to do.

Even after this mistake I was able to re-enable secure boot and get back into the system.

After some tinkering, I found that I was able to enter "custom mode" without enabling secure boot, which in turn allowed me to select the "Reset to Setup Mode"

It asks if you are sure you want to erase all of the variables to enter setup mode? Hit "Yes". Then it asks if you want to exit without saving, we want to save our changes so hit "No" do not exit without saving.

After this you should see all No Keys entries.

Finally, Hit the setting to save and exit, some BIOS list an F4 or F9 keybind that saves and exits.

❗: For my system, choosing "save and reboot" would not work for some reason, I had to choose "save and exit".

After hitting "save and exit", the system boots into NixOS like normal but you are in setup mode if everything worked correctly.

Open a terminal and type:

sudo sbctl enroll-keys --microsoft
Enrolling keys to EFI variables...
With vendor keys from microsoft...✓
Enrolled keys to the EFI variables!

⚠️ If you used --microsoft while enrolling the keys, you might want to check that the Secure Boot Forbidden Signature Database (dbx) is not empty. A quick and dirty way is by checking the file size of /sys/firmware/efi/efivars/dbx-\*. Keeping an up to date dbx reduces Secure Boot bypasses, see for example: https://uefi.org/sites/default/files/resources/dbx_release_info.pdf

I then Rebooted into BIOS and enabled secure boot, saved and exited. This loads NixOS as if you just rebooted.

And finally check the output of sbctl status:

sudo sbctl status
System:
      Firmware: UEFI 2.70 (American Megatrends 5.19)
 Firmware Arch: x64
   Secure Boot: enabled (user)
  TPM2 Support: yes
  Measured UKI: yes
  Boot into FW: supported

We can see the Secure Boot: enabled (user)

What Lanzaboote (Secure Boot) Actually Secures on NixOS and Limitations

As mentioned earlier, this provides some basic protection that may be good enough for your desktop in your bedroom but there are some serious limitations. I want to be clear that this may stop an average person but an advanced threat actor with resources could still fairly easily get in.

Secure Boot (with Lanzaboote or any other tool) on NixOS primarily protects the boot chain—the bootloader, kernel, and initrd—by ensuring only signed, trusted binaries are executed at boot. This is a real and valuable security improvement, especially for defending against “evil maid” attacks (where someone with physical access tampers with your bootloader or kernel) and for preventing many forms of persistent malware.

Here are some of the caveats:

  1. Userspace Remains Unverified

    Once the kernel and initrd have booted, NixOS (by default) does not cryptographically verify the integrity of the rest of userspace (the programs and libraries in the Nix store, your configs, etc.).

    This means an attacker who can modify userspace (e.g., by gaining root access) can potentially install persistent malware, even if your boot chain is protected

    .

  2. Kernel Lockdown Is Not Enabled

    The Linux kernel’s [lockdown mode]

    is designed to prevent even root from tampering with the kernel at runtime (e.g., by loading unsigned modules, using kexec, or accessing /dev/mem).

    NixOS does not enable kernel lockdown by default, and enabling it is non-trivial, especially given how the Nix store works (modules and kernels are built dynamically and not always signed at install time).

    Without lockdown, a root user (or malware with root) can still compromise the kernel after boot.

  3. Stage 2 Verification Is Lacking

    Some distributions (like Fedora Silverblue or systems using dm-verity) cryptographically verify the entire userspace at boot, making it immutable and much harder to tamper with. This is not the default on NixOS, though there are experimental or appliance-focused solutions

    .

  4. Disk Encryption Complements Secure Boot

    Full disk encryption (e.g., LUKS) is strongly recommended alongside Secure Boot. Encryption protects your data at rest and ensures that even if someone bypasses Secure Boot, they cannot read or modify your files without your passphrase

Chapter 2

✔️ Click to Expand Table of Contents

Understanding Nix Functions

Functions are the building blocks of Nix, appearing everywhere in Nix expressions and configurations. Mastering them is essential for writing effective Nix code and understanding tools like NixOS and Home Manager. This chapter explores how Nix functions work, focusing on their single-argument nature, currying, partial application, and their role in modules.

What are Nix Functions?

A Nix Function is a rule that takes an input (called an argument) and produces an output based on that input. Unlike many programming languages, Nix functions are designed to take exactly one argument at a time. This unique approach, combined with a technique called currying, allows Nix to simulate multi-argument functions in a flexible and reusable way.

Builtins

✔️ Nix Builtin Functions (Click to Expand)

The Nix expression evaluator has a bunch of functions and constants built in:

  • toString e: (Convert the expression e to a string)

  • import path: (Load, parse and return the Nix expression in the file path)

  • throw x: (Throw an error message x. Usually stops evaluation)

  • map f list: (Apply the function f to each element in the list)

  • Built-in Functions

  • Nix Operators

Lamdas

Nix functions are anonymous (lambdas) (e.g., x: x + 2), and technically take a single parameter. However, that single parameter is very often an attribute set, allowing you to effectively pass multiple named inputs by destructuring (e.g., { arg1, arg2 }: arg1 + arg2).

Type the parameter name, followed by a colon, and finally the body of the function.

nix-repl> param: param * 2
<<lambda @ <<string>>:1:1>>

nix-repl> (param: param * 2) 2
4

The above example shows that everything in Nix returns a value. When you call a function directly (without first assigning the function itself to a variable), the result of that call is immediately evaluated and displayed/used.

In order to make our function reusable and be able to pass different values at different times we have to assign our function to a variable:

nix-repl> twoTimes = param: param * 2

Now, we can reference our function by it's name and pass our required parameter:

nix-repl> twoTimes
«lambda @ «string»:1:2»
nix-repl> twoTimes 2
4
nix-repl> twoTimes 4
8

We defined a function param: param * 2 takes one parameter param, and returns param * 2. We then assigned this function to the variable twoTimes. Lastly, we called the function with a few different arguments showing it's reusability.

Understanding Function Structure: The Role of the Colon

The colon (:) acts as a clear separator within a function definition:

  • Left of the Colon: This is the function's argument. It's a placeholder name for a value that will be provided when the function is called.

  • Right of the Colon: This is the function body. It's the expression that will be evaluated when the function is invoked.

Think of function arguments as naming values that aren't known in advance. These names are placeholders that get filled with specific values when the function is used.

Example:

greet = personName: "Hello, ${personName}!";
  • Here, personName is the argument (the placeholder).

  • "Hello, ${personName}!", is the function body (which uses the placeholder to create the greeting).

When you call the function, (click to see Output):

greet "Anonymous"
 "Hello, Anonymous!"
  • The value "Anonymous" is substituted for the personName placeholder within the function body.

  • This structure is the foundation of all Nix functions, whether simple or complex.

Single-Argument Functions: The Basics

The simplest form of a Nix function takes a single argument. In Nix, function definitions like x: x + 1 or personName: "Hello, ${personName}!"; are anonymous lambda functions. They exist as values until they are assigned to a variable.

  • Click to see Output:
# This is an anonymous lambda function value:
# x: x + 1
inc = x: x + 1;          # here we assigned our lambda to a variable `inc`
inc 5
 6
  • x is the argument.

  • x + 1 is the function body.

This straightforward design makes single-argument functions easy to understand and use. But what if you need a function that seems to take multiple arguments? That's where currying comes in.

Simulating Multiple Arguments: Currying

To create functions that appear to take multiple arguments, Nix uses currying. This involves nesting single-argument functions, where each function takes one argument and returns another function that takes the next argument, and so on.

nix-repl> multiply = x: (y: x*y)
nix-repl> multiply
«lambda»
nix-repl> multiply 4
«lambda»
nix-repl> (mul 4) 5
20

We defined a function that takes the parameter x, the body returns another function. This other function takes a parameter y and returns x*y. Therefore, calling multiply 4 returns a function like: x: 4*y. In turn, we call the returned function with 5, and get the expected result.

Currying example 2

# concat is equivalent to:
# concat = x: (y: x + y);
concat = x: y: x + y;
concat 6 6    # Evaluates to 12
12

Here, concat is actually two nested functions

  1. The first function takes x and returns another function.

  2. The second function takes y and performs x + y

Nix interprets the colons (:) as separators for this chain of single-argument functions.

Here's how it works step by step:

  • When you call concat 6, the outer function binds x to 6 and returns a new function: y: 6 + y.

  • When you call that function with 6 (i.e., concat 6 6), it computes 6 + 6, resulting in 12.

This chaining is why Nix functions are so powerful—it allows you to build flexible, reusable functions.

Currying is a powerful feature in Nix that enables you to partially apply arguments to functions, leading to increased reusability. This behavior is a direct consequence of Nix functions being "first-class citizens" (a concept we'll delve into later), and it proves invaluable for decomposing intricate logic into a series of smaller, more focused functions.

Key Insight: Every colon in a function definition separates a single argument from its function body, even if that body is another function definition.

Greeting Example

Let's explore currying with a more relatable example in the nix repl:

nix repl
nix-repl> greeting = prefix: name: "${prefix}, ${name}!";

nix-repl> greeting "Hello"
<<lambda @ <<string>>:1:10>> # partial application returns a lambda

nix-repl> greeting "Hello" "Alice"
"Hello, Alice!"         # providing both arguments returns the expected result

This function is a chain of two single-argument functions:

  1. The outer function takes prefix (e.g. "Hello") and returns a function that expects name.

  2. The inner function takes name (e.g. "Alice") and combines it with prefix to produce the final string.

Thanks to lexical scope (where inner functions can access variables from outer functions), the inner function "remembers" the prefix value.

Partial Application: Using Functions Incrementally

Because of currying, you can apply arguments to a Nix function one at a time. This is called partial application. When you provide only some of the expected arguments, you get a new function that "remembers" the provided arguments and waits for the rest.

Example:

Using our greeting function again:

nix repl
nix-repl> greeting = prefix: name: "${prefix}, ${name}!";
nix-repl> helloGreeting = greeting "Hello";
nix-repl> helloGreeting "Alice"
"Hello, Alice"
  • helloGreeting is now a new function. It has already received the prefix argument ("Hello"), when we provide the second argument we get "Hello, Alice!"

Benefits of Partial Application:

Partial application provides significant benefits by enabling you to derive specialized functions from more general ones through the process of fixing certain parameters. Additionally, it serves as a powerful tool for adapting existing functions to fit the precise argument requirements of higher-order functions like map and filter.

Nix Functions being "first class citizens"

In the context of Nix, the phrase "Nix treats functions as first-class citizens" means that functions in Nix are treated as values, just like numbers, strings, or lists. They can be manipulated, passed around, and used in the same flexible ways as other data types. This concept comes from functional programming and has specific implications in Nix.

What It Means in Nix

  1. Functions Can Be Assigned to Variables:
  • You can store a function in a variable, just like you would store a number or string.

  • Example:

greet = name: "Hello, ${name}!";
  • Here, greet is a variable that holds a function.
  1. Functions Can Be Passed as Arguments:
  • You can pass a function to another function as an argument, allowing for higher-order functions (functions that operate on other functions).

  • Example:

applyTwice = f: x: f (f x);
inc = x: x + 1;
applyTwice inc 5 # Output: 7 (increments 5 twice: 5 → 6 → 7)
 7
  • Here, applyTwice takes a function f (in this case, inc) and applies it to x twice.
  1. Functions Can Be Returned from Functions:
  • Functions can produce other functions as their output, which is key to currying in Nix.

  • Example:

greeting = prefix: name: "${prefix}, ${name}!";
helloGreeting = greeting "Hello";  # Returns a function
helloGreeting "Alice"  # Output: "Hello, Alice!"
 "Hello, Alice!"
  • The greeting function returns another function when partially applied with prefix.
  1. Functions Are Values in Expressions:
  • Functions can be used anywhere a value is expected, such as in attribute sets or lists.

  • Example:

myFuncs = {
  add = x: y: x + y;
  multiply = x: y: x * y;
};
myFuncs.add 3 4  # Output: 7
 7
  • Here, functions are stored as values in an attribute set.

  • To try this in the repl just remove the semi-colon (;)

Why This Matters in Nix:

This functional approach is fundamental to Nix's unique build system. In Nix, package builds (called derivations) are essentially functions. They take specific inputs (source code, dependencies, build scripts) and deterministically produce outputs (a built package).

This design ensures atomicity: if a build does not succeed completely and perfectly, it produces no output at all. This prevents situations common in other package managers where partial updates or corrupted builds can leave your system in an inconsistent or broken state.

Many NixOS and Home Manager modules are functions, and their first-class status means they can be combined, reused, or passed to other parts of the configuration system.

Now that we understand the "first-class" nature of Nix Functions let's see how they fit into NixOS and Home Manager modules.

The Function Nature of NixOS and Home Manager Modules

It's crucial to understand that most NixOS and Home Manager modules are fundamentally functions.

These module functions typically accept a single argument: an attribute set (remember this, it's important to understand).

Example:

A practical NixOS module example for Thunar with plugins:

# thunar.nix
{pkgs, ...}: {
  programs = {
    thunar = {
      enable = true;
      plugins = with pkgs.xfce; [
        thunar-archive-plugin
        thunar-volman
      ];
    };
  };
}
  • To use this module I would need to import it into my configuration.nix or equivalent, shown here for completeness.
# configuration.nix
# ... snip ...
imports = [ ../nixos/thunar.nix ];
# ... snip ...
  • This is actually a pretty good example of with making it a bit harder to reason where the plugins are from. You might instinctively try to trace a path like programs.thunar.plugins.pkgs.xfce because you saw pkgs.xfce in the with statement. But that's now how with works. The pkgs.xfce path exists outside the plugins list, defining the source of the items, not their nested structure within the list.

  • To follow best practices you could write the above plugins section as:

plugins = [
  pkgs.xfce.thunar-archive-plugin
  pkgs.xfce.thunar-volman
];
  • Now it's clear that each plugin comes directly from pkgs and each will resolve to a derivation.

    • To be clear either way is fine, especially in such a small self contained module. If it were in a single file configuration.nix it would be a bit more confusing to trace. Explicitness is your friend with Nix and maintaining reproducability. with isn't always bad but should be avoided at the top of a file for example to bring nixpkgs into scope, use let instead.

The entire module definition is a function that takes one argument (an attribute set):{ pkgs, ... }. When this module is included in your configuration, the NixOS module system calls this function with a specific attribute set. This attribute set contains the available packages (pkgs), and other relevant information. The module then uses these values to define parts of your system.

Understanding passing and getting back arguments

For this example we will build the Hello derivation from the Nix Pills series.

Create an autotools.nix with the following contents:

pkgs: attrs: let
  defaultAttrs = {
    builder = "${pkgs.bash}/bin/bash";
    args = [./builder.sh];
    baseInputs = with pkgs; [
      gnutar
      gzip
      gnumake
      gcc
      coreutils
      gawk
      gnused
      gnugrep
      binutils.bintools
    ];
    buildInputs = [];
    system = builtins.currentSystem;
  };
in
  derivation (defaultAttrs // attrs)

Let's create the hello derivation:

let
  pkgs = import <nixpkgs> {};
  mkDerivation = import ./autotools.nix pkgs;
in
  mkDerivation {
    name = "hello";
    src = ./hello-2.12.1.tar.gz;
  }
  • You can get the tarball here, place it in the same directory as autotools.nix

And finally the builder.sh that autotools.nix declares for the args attribute:

!#/bin/bash
set -e
unset PATH
for p in $buildInputs $baseInputs; do
    export PATH=$p/bin${PATH:+:}$PATH
done

tar -xf $src

for d in *; do
    if [ -d "$d" ]; then
        cd "$d"
        break
    fi
done

./configure --prefix=$out
make
make install

When you write:

mkDerivation = import ./autotools.nix pkgs;
  • import ./autotools.nix: This evaluates the autotools.nix file. Because it starts with pkgs: attrs: ..., it means that autotools.nix evaluates to a function that expects one argument named pkgs.

  • ... pkgs: We are immediately calling that function (the one returned by import ./autotools.nix) and passing it our pkgs variable (which is the result of import <nixpkgs> {}).

This illustrates the concept of Currying in Nix:

The function defined in autotools.nix (pkgs: attrs: ...) is a curried function. It's a function that, when given its first argument (pkgs), returns another function (which then expects attrs).

The result of import ./autotools.nix pkgs is that second, inner function: attrs: derivation (defaultAttrs // attrs). This inner function is then bound to the mkDerivation variable, making it ready to be called with just the specific attributes for your package (like name and src).

Understanding the attrs Argument

Now let's focus on the second argument of our autotools.nix function: attrs.

Recall the full function signature in autotools.nix:

pkgs: attrs: let
  # ... defaultAttrs definition ...
in
  derivation (defaultAttrs // attrs)
  1. What attrs Represents:
  • Once autotools.nix has received its pkgs argument (and returned the inner function), this inner function is waiting for its final argument, which we call attrs.

  • attrs is simply an attribute set (a key-value map in Nix). It's designed to receive all the specific properties of the individual package you want to build using this helper.

  1. How attrs is Used:
  • Look at the final line of autotools.nix: derivation (defaultAttrs // attrs).

  • The // operator in Nix performs an attribute set merge. It takes all attributes from defaultAttrs and combines them with all attributes from attrs.

  • Crucially, if an attribute exists in both defaultAttrs and attrs, the value from attrs (the second operand) takes precedence and overrides the default value.

  1. Applying attrs in the hello Derivation:
  • In the hello derivation, we call mkDerivation like this:
        mkDerivation {
          name = "hello";
          src = ./hello-2.12.1.tar.gz;
        }
  • The attribute set { name = "hello"; src = ./hello-2.12.1.tar.gz; } is what gets passed as the attrs argument to the mkDerivation function (which, remember, is the inner function returned by autotools.nix).

  • When derivation (defaultAttrs // attrs) is evaluated for "hello", the name and src provided in the attrs set will be merged with all the defaultAttrs (like builder, args, baseInputs, etc.).

In summary:

  • The pkgs argument configures the general environment and available tools for the builder.

  • The attrs argument is where you provide the unique details for each specific package you intend to build using this autotools.nix helper. It allows you to specify things like the package's name, source code, version, and any custom build flags, while still benefiting from all the sensible defaults provided by autotools.nix. This separation makes autotools.nix a reusable and flexible "template" for creating derivations.

Conclusion

Having explored the fundamental nature of functions in Nix, we can now see this concept applies to more complex areas like NixOS configuration and derivations. In the next chapter, NixOS Modules Explained. We will learn about NixOS Modules which are themselves functions most of the time.

Resources

✔️ Resources (Click to Expand)

Practical Nix Functions

✔️ If you want to follow along with this example you'll have to place the following in your project directory. Section is collapsed to focus on functions:
  1. graphviz

  2. hello

  3. autotools.nix:

# autotools.nix
pkgs: attrs:
with pkgs; let
  defaultAttrs = {
    builder = "${bash}/bin/bash";
    args = [./builder.sh];
    setup = ./setup.sh;
    baseInputs = [gnutar gzip gnumake gcc binutils-unwrapped coreutils gawk gnused gnugrep patchelf findutils];
    buildInputs = [];
    system = builtins.currentSystem;
  };
in
  derivation (defaultAttrs // attrs)
  1. setup.sh:
# setup.sh (This is a library of functions setting up the environment, not directly executable)
unset PATH
for p in $baseInputs $buildInputs; do
  if [ -d $p/bin ]; then
    export PATH="$p/bin${PATH:+:}$PATH"
  fi
  if [ -d $p/lib/pkgconfig ]; then
    export PKG_CONFIG_PATH="$p/lib/pkgconfig${PKG_CONFIG_PATH:+:}$PKG_CONFIG_PATH"
  fi
done

function unpackPhase() {
  tar -xzf $src

  for d in *; do
    if [ -d "$d" ]; then
      cd "$d"
      break
    fi
  done
}

function configurePhase() {
  ./configure --prefix=$out
}

function buildPhase() {
  make
}

function installPhase() {
  make install
}

function fixupPhase() {
  find $out -type f -exec patchelf --shrink-rpath '{}' \; -exec strip '{}' \; 2>/dev/null
}

function genericBuild() {
  unpackPhase
  configurePhase
  buildPhase
  installPhase
  fixupPhase
}
  1. And finally builder.sh:
# builder.sh (This is the actual builder script specified in the derivation and
# what `nix-build` expects)
set -e
source $setup
genericBuild

This is another example from the Nix-Pill series shown in another way to show some powerful aspects of functions.

If you have a default.nix like this:

# default.nix
{
  hello = import ./hello.nix;
  graphviz = import ./graphviz.nix;
}

It expects the files that it imports to look like this:

# graphviz.nix
let
  pkgs = import <nixpkgs> { };
  mkDerivation = import ./autotools.nix pkgs;
in
mkDerivation {
  name = "graphviz";
  src = ./graphviz-2.49.3.tar.gz;
}

And hello.nix:

# hello.nix
let
  pkgs = import <nixpkgs> { };
  mkDerivation = import ./autotools.nix pkgs;
in
mkDerivation {
  name = "hello";
  src = ./hello-2.12.1.tar.gz;
}

You would build these with:

nix-build -A hello
nix-build -A graphviz

As you can see both derivations are dependendent on nixpkgs which they both import directly. To centralize our dependencies and avoid redundant imports, we'll refactor our individual package definitions (hello.nix, graphviz.nix) into functions. Our default.nix will then be responsible for setting up the common inputs (like pkgs and mkDerivation) and passing them as arguments when it imports and calls these package functions.

Here is what our default.nix will look like:

let
  pkgs = import <nixpkgs> { };
  mkDerivation = import ./autotools.nix pkgs;
in
with pkgs;
{
  hello = import ./hello.nix { inherit mkDerivation; };
  graphviz = import ./graphviz.nix {
    inherit
      mkDerivation
      lib
      gd
      pkg-config
      ;
  };
  graphvizCore = import ./graphviz.nix {
    inherit
      mkDerivation
      lib
      gd
      pkg-config
      ;
    gdSupport = false;
  };
}

We define some local variables in the let expression and pass them around.

The whole expression in the above default.nix returns an attribute set with the keys hello, graphviz, and graphvizCore

We import hello.nix and graphviz.nix, which both return a function. We call the functions, passing them a set of inputs with the inherit construct.

Let's change hello.nix into a function to match what the default.nix now expects.

# hello.nix
{mkDerivation}:
mkDerivation {
  name = "hello";
  src = ./hello-2.12.1.tar.gz;
}

Now our graphviz attribute expects graphviz.nix to be a function that takes the arguments listed in the above default.nix, here's what graphviz.nix will look like as a function:

# graphviz.nix
{
  mkDerivation,
  lib,
  gdSupport ? true,
  gd,
  pkg-config,
}:
mkDerivation {
  name = "graphviz";
  src = ./graphviz-2.49.3.tar.gz;
  buildInputs =
    if gdSupport
    then [
      pkg-config
      (lib.getLib gd)
      (lib.getDev gd)
    ]
    else [];
}

We factorized the import of nixpkgs and mkDerivation, and also added a variant of graphviz with gd support disabled. The result is that both hello.nix and graphviz.nix are independent of the repository and customizable by passing specific inputs.

Now, we can build the package with gd support disabled with the graphvizCore attribute:

nix-build -A graphvizCore
# or we can still build the package that now defaults to gd support
nix-build -A graphviz

This example showed us how to turn expressions into functions. We saw how functions are passed around and shared between Nix expressions and derivations.

Functions and NixOS Modules

✔️ Click to Expand Table of Contents

When you start exploring NixOS configurations or tools like Home Manager, you'll encounter a concept called Nix Modules. Modules are also functions, but they behave differently regarding their arguments, which can be a source of confusion.

What are NixOS Modules?

Nix Modules are a powerful system built on top of basic Nix functions, primarily used for declarative system configurations (like NixOS, Home Manager, NixOps, etc.). They allow you to define parts of your system configuration in separate files that are then composed together.

Each module is typically a Nix function that returns an attribute set with specific keys like options, config, and imports.

Automatic Arguments in Modules

Unlike the functions we've been writing, Nix's module system automatically passes a standard set of arguments to every module function it evaluates. You don't explicitly pass these arguments when you import a module file; the module system handles it for you.

The most common automatic arguments you'll see are:

  • config: The aggregated configuration options of all modules combined. This is what you use to read other configuration values.

  • options: The definitions of all available configuration options across all modules.

  • pkgs: The standard Nixpkgs set, equivalent to import <nixpkgs> {}. This is incredibly convenient as you don't need to import it in every module.

  • lib: The Nixpkgs utility library (pkgs.lib), providing helper functions for common tasks.

  • specialArgs: An attribute set of extra arguments to be passed to the module functions.

A typical module might start like this:

# Example NixOS module
{ config, pkgs, lib, ... }: # These arguments are passed automatically by the module system
{
  # ... module options and configuration
  environment.systemPackages = [ pkgs.firefox pkgs.git ];
  services.nginx.enable = true;
  # ...
}

In the above module, the only required argument is pkgs because we explicitly use it in the module (i.e. pkgs.firefox). Editors have pretty good support for letting you know if you're missing arguments or have unnecessary ones. config, and lib and would be required if we were setting any options in this module.

This automatic passing of arguments is a core feature of the module system that simplifies writing configurations, as you always have access to pkgs, lib, and the evolving config and options without boilerplate.

specialArgs: Passing Custom Arguments to Modules

While the module system passes a standard set of arguments automatically, what if you need to pass additional, custom data to your modules that isn't part of the standard config, pkgs, lib, or options? This is where specialArgs comes in.

specialArgs is an attribute you can pass to the import function when you load a module (or a set of modules). It's typically used to provide data that your modules need but isn't something Nixpkgs would normally manage.

For example, in a configuration.nix:

# From your configuration.nix
{ config, pkgs, lib, ... }: # Standard module arguments

let
  myCustomValue = "helloWorld";
in
{
  # ... imports all modules, including your custom ones
  imports = [
    ./hardware-configuration.nix
    ./my-webserver-module.nix
  ];

  # This is where specialArgs would be used (often in import statements)
  # Example: passing a custom value to ALL modules:
  # (in module context, this is more complex, but conceptually)
  # let
  #   allModules = [ ./my-module.nix ];
  # in
  # lib.nixosSystem {
  #   modules = allModules;
  #   specialArgs = {
  #     username = "johndoe";
  #     mySecretKey = "/run/keys/ssh_key";
  #   };
  #   # ...
  # };
}

And then, inside my-webserver-module.nix:

# my-webserver-module.nix
{ config, pkgs, lib, username, mySecretKey, ... }: # username and mySecretKey come from specialArgs
{
  # ... use username and mySecretKey in your module
  users.users.${username} = {
    isNormalUser = true;
    extraGroups = [ "wheel" "networkmanager" ];
    # ...
  };
  # ...
}

Any argument listed in a module's function signature that is not one of the standard config, pkgs, lib, options (or pkgs.callPackage, etc., which are often implicit through pkgs) must be provided via specialArgs at the point where the modules are composed.

Any values listed in a module that aren’t automatically passed via Nixpkgs must be explicitly provided through specialArgs.

specialArgs and extraSpecialArgs with Flakes

NixOS modules use specialArgs and Home-Manager uses extraSpecialArgs to allow you to pass extra arguments.

Or with Flakes it would look like this:

{
  description = "My Flake";
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    home-manager.url = "github:nix-community/home-manager";
    home-manager.inputs.nixpkgs.follows = "nixpkgs";
   };

  outputs = { self, nixpkgs, home-manager, ... }:
    let
      lib = nixpkgs.lib;
      pkgs = nixpkgs.legacyPackages.${"x86_64-linux"};
      system = "x86_64-linux";
  host = "magic";
  username = "jr";
  userVars = {
    timezone = "America/New_York";
    locale = "en_US.UTF-8";
    gitUsername = "TSawyer87";
    dotfilesDir = "~/.dotfiles";
    wm = "hyprland";
    browser = "firefox";
    term = "ghostty";
    editor = "hx";
    keyboardLayout = "us";
  };
    in {
      nixosConfigurations = {
        YOURHOSTNAME = lib.nixosSystem {
          system = "x86_64-linux";
          modules = [ ./configuration.nix ];
          specialArgs = {
            inherit userVars; # == userVars = userVars;
            inherit host;
            inherit username;
          };
        };
      };
      homeConfigurations = {
        USERNAME = home-manager.lib.homeManagerConfiguration {
          inherit pkgs;
          modules = [ ./home.nix ];
          extraSpecialArgs = {
            inherit userVars;
            inherit host;
            inherit username;
            # or it can be written like this:
            # inherit userVars host username;
          };
        };
      };
    };
}

Now if I want to use any of these arguments in modules I can by any module file referenced by my configuration.

For example, the following is a git.nix module that uses the variables from the flake passed from extraSpecialArgs in this case because it's a home-manager module:

# git.nix
{ userVars, ... }: {
  programs = {
    git = {
      enable = true;
      userName = userVars.gitUsername;
    };
  };
}
FeatureRegular Nix Function (e.g., hello.nix)Nix Module (e.g., my-config-module.nix)
ArgumentsYou must explicitly pass every single argument.Automatically receives config, pkgs, lib, options, etc.
Custom ArgsPassed directly in the function call.Passed via specialArgs when the modules are composed.
BoilerplateOften needs pkgs = import <nixpkgs> {}; if not explicitly passed.pkgs and lib are always available automatically.
PurposeDefines a package, a utility, or a single value.Defines a reusable part of a declarative system configuration.

Chapter 3

✔️ Click to Expand Table of Contents

Nix Module System Explained

gruv3

TL;DR: In this chapter, we will break down the Nix module system used by both NixOS and Home-Manager. We will discuss using home-manager as a module and the flexibility that modules give us. We will touch on options and break down the vim module from the Nixpkgs collection. Finally we will display how to test modules with the repl.

Your configuration.nix is a module. For the Nixpkgs collection most modules are in nixos/modules.

The suggested way of using home-manager according to their manual is as a NixOS module. Both home-manager and NixOS use the same module system.

Module Structure

{
  imports = [
    # Paths to other modules.
    # Compose this module out of smaller ones.
  ];

  options = {
    # Option declarations.
    # Declare what settings a user of this module can set.
    # Usually this includes a global "enable" option which defaults to false.
  };

  config = {
    # Option definitions.
    # Define what other settings, services and resources should be active.
    # Usually these depend on whether a user of this module chose to "enable" it
    # using the "option" above.
    # Options for modules imported in "imports" can be set here.
  };
}

imports, options, and config are the top-level attributes of a Nix module. They are the primary, reserved keys that the Nix module system recognizes and processes to combine different configurations into a single, cohesive system or user environment. config is the same config you receive as a module argument (e.g. { pkgs, config, ... }: at the top of your module function)

Understanding config:

config is the big constantly updated blueprint of your entire system.

Every time you bring in a new module, it adds its own settings and options to this blueprint. So, when a module receives the config argument, it's getting the complete picture of everything you've asked NixOS to set up so far.

This allows the module to:

  • See what other parts of your system are doing.

  • Make smart decisions based on those settings.

  • Add its own pieces to the overall plan, building on what's already there.

  • Most modules are functions that take an attribute set and return an attribute set.

To turn the above module into a function accepting an attribute set just add the function arguments to the top, click the eye to see the whole module:

{ config, pkgs, ... }:
 {
   imports = [
     # Paths to other modules.
     # Compose this module out of smaller ones.
   ];

   options = {
     # Option declarations.
     # Declare what settings a user of this module can set.
     # Usually this includes a global "enable" option which defaults to false.
   };

   config = {
     # Option definitions.
     # Define what other settings, services and resources should be active.
     # Usually these depend on whether a user of this module chose to "enable" it
     # using the "option" above.
     # Options for modules imported in "imports" can be set here.
   };
 }

It may require the attribute set to contain:

  • config: The configuration of the entire system.

  • options: All option declarations refined with all definition and declaration references.

  • pkgs: The attribute set extracted from the Nix package collection and enhanced with the nixpkgs.config option.

  • modulesPath: The location of the module directory of NixOS.

Modularize your configuration.nix

Many people start of using a single configuration.nix and eventually their single file configuration gets too large to search through and maintain conveniently.

This is where modules come in allowing you to break up your configuration into logical parts. You're boot.nix will contain settings and options related to the actual boot process. You're services.nix will only have services and so on...

  • These modules are placed in a logical path relative to either your configuration.nix or equivalent or if you're using flakes relative to your flake.nix or equivalent.

    • The imports mechanism takes paths to other modules as its argument and combines them to be included in the evaluation of the system configuration.
{ ... }:
{
  imports = [
     # Paths to other modules

     # They can be relative paths
     ./otherModule.nix

     # Or absolute
     /path/to/otherModule.nix

     # Or to a directory
     ../modules/home/shells/nushell
  ];
}

❗: The imports mechanism includes and evaluates the Nix expression found at the given path as a module. If that path is a directory, it will automatically look for and evaluate a default.nix file within that directory as a module. It is common to have that default.nix be a function that only imports and combines all the modules in said directory. Like the above example, in the nushell directory would be a default.nix that is automatically imported and evaluated.

Crucial Distinction: imports vs. import:

Beginners often confuse the modules attribute imports = [./module.nix] here with the Nix builtins function import module.nix. The first expects a path to a file containing a NixOS module (having the same specific structure we're describing here), while the second loads whatever Nix expression is in that file (no expected structure). --NixOS Wiki.

Considering configuration.nix is a module, it can be imported like any other module and this is exactly what you do when getting started with flakes.

# flake.nix
{
  description = "NixOS configuration";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    home-manager.url = "github:nix-community/home-manager";
    home-manager.inputs.nixpkgs.follows = "nixpkgs";
  };

  outputs = inputs@{ nixpkgs, home-manager, ... }: {
    nixosConfigurations = {
      hostname = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./configuration.nix
          home-manager.nixosModules.home-manager
          {
            home-manager.useGlobalPkgs = true;
            home-manager.useUserPackages = true;
            home-manager.users.jdoe = ./home.nix;

            # Optionally, use home-manager.extraSpecialArgs to pass
            # arguments to home.nix
          }
        ];
      };
    };
  };
}

modules = [...] in flake.nix: This is effectively the initial imports list for your entire NixOS system or Home Manager user configuration. It tells the Nix module system: "Start by collecting and merging the configurations defined in these specific modules."

The above example is what you get from running: nix flake new /etc/nixos -t github:nix-community/home-manager#nixos

If you notice the home-manager.nixosModules.home-manager, that is what imports home-manager as a module.

You could also make the actual home-manager module and import it like this:

# home-manager.nix
{ inputs, outputs, ... }: {
  imports = [
    # Import home-manager's NixOS module
    inputs.home-manager.nixosModules.home-manager
  ];

  home-manager = {
    extraSpecialArgs = { inherit inputs outputs; };
    users = {
      # Import your home-manager configuration
      your-username = import ../home-manager/home.nix;
    };
  };
}

This "module" isn't much different from the one included in the flake.nix above, it is just shown here to show the flexibility of modules. They can be as big and complex or as small and simple as you want. You can break up every single program or component of your configuration into individual modules or have modules that bundle similar programs the choice is yours.

Then in your configuration.nix or equivalent you would add home-manager.nix to your imports list and you would have home-manager as a NixOS module.

✔️ Refresher (Click to Expand):

An attribute set is a collection of name-value pairs called attributes:

Attribute sets are written enclosed in curly braces {}. Attribute names and attribute values are separated by an equal sign =. Each value can be an arbitrary expression, terminated by a semicolon ;.

Example:nix.dev reference This defines an attribute set with attributes named:

  • x with the value 123, an integer
  • text with the value "Hello", a string
  • y where the value is the result of applying the function f to the attribute set {bla = 456; }
{
 x = 123;
 text = "Hello";
 y = f { bla = 456; };
}
{ a = "Foo"; b = "Bar"}.a
 "Foo"

Attributes can appear in any order. An attribute name may only occur once in each attribute set.

❗ Remember {} is a valid attribute set in Nix.

The following is a function with an attribute set argument, remember that anytime you see a : in Nix code it means this is a function. To the left is the function arguments and to the right is the function body:

{ a, b }: a + b

The simplest possible NixOS Module:

{ ... }:
{
}

NixOS produces a full system configuration by combining smaller, more isolated and reusable components: Modules. If you want to understand Nix and NixOS make sure you grasp modules!

A NixOS module defines configuration options and behaviors for system components, allowing users to extend, customize, and compose configurations declaratively.

A module is a file containing a Nix expression with a specific structure. It declares options for other modules to define (give a value). Modules were introduced to allow extending NixOS without modifying its source code.

To define any values, the module system first has to know which ones are allowed. This is done by declaring options that specify which attributes can be set and used elsewhere.

If you want to write your own modules, I recommend setting up nixd or nil with your editor of choice. This will allow your editor to warn you about missing arguments and dependencies as well as syntax errors.

Declaring Options

Options are declared under the top-level options attribute with lib.mkOption.

mkOption Creates an Option attribute set. It accepts an attribute set with certain keys such as, default, package, and example.

# options.nix
{ lib, ... }:
{
  options = {
    name = lib.mkOption { type = lib.types.str; };
  };
}

lib provides helper functions from nixpkgs.lib and the ellipsis (...) is for arbitrary arguments which means that this function is prepared to accept any additional arguments that the caller might provide, even if those arguments are not explicitly named or used within the module's body. They make the modules more flexible, without the ... each module would have to explicitly list every possible argument it might receive, which would be cumbersome and error-prone. So {lib, ... }: means that "I need the lib argument" and I acknowledge that the module system might pass other arguments automatically (like config, pkgs, etc.) and I'm fine with them being there, even if I don't use them directly in this specific module file.

Defining Values

Options are set or defined under the top-level config attribute:

# config.nix
{ ... }:
{
  config = {
    name = "Slick Jones";
  };
}

In this option declaration, we created an option name of type string and set that same option to a string.

Option Definitions can be in a separate file than Option Declarations

Evaluating Modules

Modules are evaluated with lib.evalModules lib.evalModules evaluates a set of modules, typically once per application (e.g. once for NixOS and once for Home-Manager).

Checking out the Vim module provided by Nixpkgs

The following is nixpkgs/nixos/modules/programs/vim.nix, a module that is included in the Nixpkgs collection:

{
  config,
  lib,
  pkgs,
  ...
}:

let
  cfg = config.programs.vim;
in
{
  options.programs.vim = {
    enable = lib.mkEnableOption "Vi IMproved, an advanced text";

    defaultEditor = lib.mkEnableOption "vim as the default editor";

    package = lib.mkPackageOption pkgs "vim" { example = "vim-full"; };
  };

  # TODO: convert it into assert after 24.11 release
  config = lib.mkIf (cfg.enable || cfg.defaultEditor) {
    warnings = lib.mkIf (cfg.defaultEditor && !cfg.enable) [
      "programs.vim.defaultEditor will only work if programs.vim.enable is
       enabled, which will be enforced after the 24.11 release"
    ];
    environment = {
      systemPackages = [ cfg.package ];
      variables.EDITOR = lib.mkIf cfg.defaultEditor (lib.mkOverride 900 "vim");
      pathsToLink = [ "/share/vim-plugins" ];
    };
  };
}

It provides options to enable Vim, set it as the default editor, and specify the Vim package to use.

✔️ Breakdown of the vim module.(Click to Expand) 1. Module Inputs and Structure:
{
  config,
  lib,
  pkgs,
  ...
}

Inputs: The module takes the above inputs and ... (catch-all for other args)

  • config: Allows the module to read option values (e.g. config.programs.vim.enable). It provides access to the evaluated configuration.

  • lib: The Nixpkgs library, giving us helper functions like mkEnableOption , mkIf, and mkOverride.

  • pkgs: The Nixpkgs package set, used to access packages like pkgs.vim

  • ...: Allows the module to accept additional arguments, making it flexible for extension in the future.

Key Takeaways: A NixOS module is typically a function that can include config, lib, and pkgs, but it doesn’t require them. The ... argument ensures flexibility, allowing a module to accept extra inputs without breaking future compatibility. Using lib simplifies handling options (mkEnableOption, mkIf, mkOverride) and helps follow best practices. Modules define options, which users can set in their configuration, and config, which applies changes based on those options.

  1. Local Configuration Reference:
let
  cfg = config.programs.vim;
in

This is a local alias. Instead of typing config.programs.vim over and over, the module uses cfg.

  1. Option Declaration
options.programs.vim = {
  enable = lib.mkEnableOption "Vi IMproved, an advanced text";
  defaultEditor = lib.mkEnableOption "vim as the default editor";
  package = lib.mkPackageOption pkgs "vim" { example = "vim-full"; };
};

This defines three user-configurable options:

  • enable: Turns on Vim support system-wide.

  • defaultEditor: Sets Vim as the system's default $EDITOR.

  • package: lets the user override which Vim package is used.

mkPackageOption is a helper that defines a package-typed option with a default (pkgs.vim) and provides docs + example. Using lib.mkEnableOption makes it clear exactly where this function is coming from. Same with lib.mkIf and as you can see they can be further down the configuration, further from where you defined with lib; making it less clear where they come from. Explicitness is your friend when it comes to reproducability and clarity.

  1. Conditional Configuration
config = lib.mkIf (cfg.enable || cfg.defaultEditor) {
  • This block is only activated if either programs.vim.enable or defaultEditor is set.
  1. Warnings
warnings = lib.mkIf (cfg.defaultEditor && !cfg.enable) [
  "programs.vim.defaultEditor will only work if programs.vim.enable is enabled,
   which will be enforced after the 24.11 release"
];

Gives you a soft warning if you try to set defaultEditor = true without also enabling Vim.

  1. Actual System Config Changes
environment = {
  systemPackages = [ cfg.package ];
  variables.EDITOR = lib.mkIf cfg.defaultEditor (lib.mkOverride 900 "vim");
  pathsToLink = [ "/share/vim-plugins" ];
};

It adds Vim to your systemPackages, sets $EDITOR if defaultEditor is true, and makes /share/vim-plugins available in the environment.

The following is a bat home-manager module that I wrote:

# bat.nix
{
  pkgs,
  config,
  lib,
  ...
}: let
  cfg = config.custom.batModule;
in {
  options.custom.batModule.enable = lib.mkOption {
    type = lib.types.bool;
    default = false;
    description = "Enable bat module";
  };

  config = lib.mkIf cfg.enable {
    programs.bat = {
      enable = true;
      themes = {
        dracula = {
          src = pkgs.fetchFromGitHub {
            owner = "dracula";
            repo = "sublime"; # Bat uses sublime syntax for its themes
            rev = "26c57ec282abcaa76e57e055f38432bd827ac34e";
            sha256 = "019hfl4zbn4vm4154hh3bwk6hm7bdxbr1hdww83nabxwjn99ndhv";
          };
          file = "Dracula.tmTheme";
        };
      };
      extraPackages = with pkgs.bat-extras; [
        batdiff
        batman
        prettybat
        batgrep
      ];
    };
  };
}

Now I could add this to my home.nix to enable it:

# home.nix
custom = {
  batModule.enable = true;
}

If I set this option to true the bat configuration is dropped in place. If it's not set to true, it won't put the bat configuration in the system. Same as with options defined in modules within the Nixpkgs repository.

If I had set the default to true, it would automatically enable the module without requiring an explicit custom.batModule.enable = true; call in my home.nix.

Module Composition

NixOS achieves its full system configuration by combining the configurations defined in various modules. This composition is primarily handled through the imports mechanism.

imports: This is a standard option within a NixOS or Home Manager configuration (often found in your configuration.nix or home.nix). It takes a list of paths to other Nix modules. When you include a module in the imports list, the options and configurations defined in that module become part of your overall system configuration.

You declaratively state the desired state of your system by setting options across various modules. The NixOS build system then evaluates and merges these option settings. The culmination of this process, which includes building the entire system closure, is represented by the derivation built by config.system.build.toplevel.

NixOS Modules and Dependency Locking with npins

✔️ npins example (Click to Expand) As our NixOS configurations grow in complexity, so too does the challenge of managing the dependencies they rely on. Ensuring consistency and reproducibility not only applies to individual packages but also to the versions of Nixpkgs and other external resources our configurations depend upon.

Traditionally, NixOS configurations often implicitly rely on the version of Nixpkgs available when nixos-rebuild is run. However, for more robust and reproducible setups, especially in collaborative environments or when rolling back to specific configurations, explicitly locking these dependencies to specific versions becomes crucial.

In the following example, we'll explore how to use a tool called npins to manage and lock the dependencies of a NixOS configuration, ensuring a more predictable and reproducible system. This will involve setting up a project structure and using npins to pin the specific version of Nixpkgs our configuration relies on.

This is the file structure:

❯ tree
.
├── configuration.nix
├── default.nix
├── desktop.nix
└── npins
    ├── default.nix
    └── sources.json

This uses npins for dependency locking. Install it and run this in the project

directory:

npins init

Create a default.nix with the following:

# default.nix
{ system ? builtins.currentSystem, sources ? import ./npins, }:
let
  pkgs = import sources.nixpkgs {
    config = { };
    overlays = [ ];
  };
  inherit (pkgs) lib;
in lib.makeScope pkgs.newScope (self: {

  shell = pkgs.mkShell { packages = [ pkgs.npins self.myPackage ]; };

    # inherit lib;

  nixosSystem = import (sources.nixpkgs + "/nixos") {
    configuration = ./configuration.nix;
  };

  moduleEvale = lib.evalModules {
    modules = [
      # ...
    ];
  };
})

A configuration.nix with the following:

# configuration.nix
{
  boot.loader.grub.device = "nodev";
  fileSystems."/".device = "/devst";
  system.stateVersion = "25.05";

  # declaring options means to declare a new option
  # defining options means to define a value of an option
  imports = [
    # ./main.nix
     ./desktop.nix # Files
    # ./minimal.nix
  ];

  # mine.desktop.enable = true;
}

And a desktop.nix with the following:

# desktop.nix
{ pkgs, lib, config, ... }:

{
  imports = [];

  # Define an option to enable or disable desktop configuration
  options.mine.desktop.enable = lib.mkEnableOption "desktop settings";

  # Configuration that applies when the option is enabled
  config = lib.mkIf config.mine.desktop.enable {
    environment.systemPackages = [ pkgs.git ];
  };
}

mkEnableOption defaults to false. Now in your configuration.nix you can uncomment mine.desktop.enable = true; to enable the desktop config and vice-versa.

You can test that this works by running:

nix-instantiate -A nixosSystem.system

nix-instantiate performs only the evaluation phase of Nix expressions. During this phase, Nix interprets the Nix code, resolves all dependencies, and constructs derivations but does not execute any build actions. Useful for testing.

To check if this worked and git is installed in systemPackages you can load it into nix repl but first you'll want lib to be available so uncomment this in your default.nix:

# default.nix
inherit lib;

Rerun nix-instantiate -A nixosSystem.system

Then load the repl and check that git is in systemPackages:

nix repl -f .
nix-repl> builtins.filter (pkg: lib.hasPrefix "git" pkg.name) nixosSystem.config.environment.systemPackages

This shows the path to the derivation

Check that mine.desktop.enable is true

nix-repl> nixosSystem.config.mine.desktop.enable
true

As demonstrated with npins, explicitly managing the dependencies of your NixOS modules is a powerful technique for ensuring the long-term stability and reproducibility of your system configurations. By pinning specific versions of Nixpkgs and other resources, you gain greater control over your environment and reduce the risk of unexpected changes due to upstream updates.

Best Practices

You'll see the following all throughout Nix code and is convenient although it doesn't follow best practices. One reason is static analysis can't reason about the code (e.g. Because it implicitly brings all attributes into scope, tools can't verify which ones are actually being used), because it would have to actually evaluate the files to see which names are in scope:

# utils.nix
{ pkgs, ... }: {
  environment.systemPackages = with pkgs; [
    rustup
    evcxr
    nix-prefetch-git
  ];
}

Another reason the above expression is considered an "anti-pattern" is when more then one with is used, it's no longer clear where the names are coming from.

Scoping rules for with are not intuitive, see issue --nix.dev This can make debugging harder, as searching for variable origins becomes ambiguous (i.e. open to more than one interpretation).

The following follows best practices:

{pkgs, ... }: {
  environment.systemPackages = builtins.attrValues {
    inherit (pkgs)
      rustup
      evcxr
      nix-prefetch-git;
  };
}
✔️ Above Command Summary (Click to Expand)
{
  inherit (pkgs) rustup evcxr nix-prefetch-git;
}

is equivalent to:

{
  rustup = pkgs.rustup;
  evcxr = pkgs.evcxr;
  nix-prefetch-git = pkgs.nix-prefetch-git;
}

Applying builtins.attrValues produces:

[ pkgs.evcxr pkgs.nix-prefetch-git pkgs.rustup ]

As you can see only the values are included in the list, not the keys. This is more explicit and declarative but can be more complicated, especially for a beginner.

builtins.attrValues returns the values of all attributes in the given set, sorted by attribute name. The above expression turns into something like the following avoiding bringing every attribute name from nixpkgs into scope.

A more straightforward example:

attrValues {c = 3; a = 1; b = 2;}
=> [1 2 3]

This approach avoids unintended name clashes or confusion when debugging.

Upon looking into this a bit further, most people use the following format to avoid the "anti-pattern" from using with pkgs;:

# utils.nix
{ pkgs, ... }: {
  environment.systemPackages = [
    pkgs.rustup
    pkgs.evcxr
    pkgs.nix-prefetch-git
  ];
}

While the performance differences might be negligible on modern computers, adopting this best practice from the start is highly recommended. The above approach is more explicit, it's clear exactly where each package is coming from.

If maintaining strict scope control matters, use builtins.attrValues.

If readability and simplicity are more your priority, explicitly referencing pkgs.<packageName> might be better. Now you can choose for yourself.

Conclusion

As we have seen throughout this chapter, modules are the building blocks of your NixOS system and are themselves often functions. There are a few different ways to use these modules to build your system. In the next chapter, Nix Flakes Explained we will learn about Nix Flakes as a more modern and comprehensive entrypoint for managing your entire system and its dependencies.

To further deepen your understanding of NixOS Modules and the broader ecosystem of tools and best practices surrounding them, the following resources offer valuable insights and information.

Resources on Modules

✔️ Resources (Click to Expand)

Videos

NixHour Writing NixOS modules -- This example is from this video infinisilModules

tweagModuleSystemRecursion

Chapter 4

✔️ Click to Expand Table of Contents

Nix Flakes Explained

If you're completely new, take a look at this to get flakes on your system.

For the Nix Flake man page type man nix3 flake and for a specific feature, type something like man nix3 flake-lock.

Flakes replace stateful channels (which cause much confusion among novices) and introduce a more intuitive and consistent CLI, making them a perfect opportunity to start using Nix. -- Alexander Bantyev Practical Nix Flakes

The "state" being remembered and updated by channels is the specific revision of the Nixpkgs repository that your local Nix installation considers "current" for a given channel. When this state changes on your machine, your builds diverge from others whose machines have a different, independently updated channel state.

Channels are also constantly updated on the remote servers. So, "nixos-unstable" today refers to a different set of packages and versions than "nixos-unstable" did yesterday or will tomorrow.

Flakes solve this by making the exact revision of nixpkgs (and other dependencies) an explicit input within your flake.nix file, pinned in the flake.lock. This means the state is explicitly defined in the configuration itself, not implicitly managed by a global system setting.

Evaluation time is notoriously slow on NixOS, the problem was that in the past Nix evaluation wasn't hermetic preventing effective evaluation caching. A .nix file can import other Nix files or by looking them up in the Nix search path ($NIX_PATH). This causes a cached result to be inconsistent unless every file is perfectly kept track of. Flakes solve this problem by ensuring fully hermetic evaluation.

"Hermetic" means that the output of an evaluation (the derivation itself) depends only on the explicit inputs provided, not on anything external like environment variables or pulling in files only on your system. This is the problem that Nix solves and the problem that flakes are built around.

What is a Nix Flake?

Nix flakes are independent components in the Nix ecosystem. They define their own dependencies (inputs) and what they produce (outputs), which can include packages, deployment configurations, or Nix functions for other flakes to use.

Flakes provide a standardized framework for building and managing software, making all project inputs explicit for greater reproducibility and self-containment.

At its core, a flake is a source tree (like a Git repository) that contains a flake.nix file in its root directory. This file provides a standardized way to access Nix artifacts such as packages and modules.

Flakes provide a standard way to write Nix expressions (and therefore packages) whose dependencies are version-pinned in a lock file, improving reproducibility of Nix installations. -- NixOS Wiki

Think of flake.nix as the central entry point of a flake. It not only defines what the flake produces but also declares its dependencies.

Key Concepts

flake.nix: The Heart of a Flake

The flake.nix file is mandatory for any flake. It must contain an attribute set with at least one required attribute: outputs. It can also optionally include description and inputs.

Basic Structure:

{
  description = "Package description";
  inputs = { /* Dependencies go here */ };
  outputs = { /* What the flake produces */ };
  nixConfig = { /* Advanced configuration options */ };
}

I typically see nixConfig used for extra-substituters for cachix. This is a general-purpose way to define Nix configuration oprions that apply when this flake is evaluated or built. It ties into your /etc/nix/nix.conf or ~/.config/nix/nix.conf.

For example, create a directory and add a flake.nix with the following contents, yes this is a complete flake.nix demonstrating outputs being the only required attribute:

# flake.nix
{
  outputs = _: { multiply = 2 * 2; };
}

Now evaluate it with:

nix eval .#multiply
4

In the outputs = _: { ... }; line, the _ (underscore) is a placeholder argument. It represents the inputs that the outputs function could receive (like inputs, self, pkgs, etc.), but in this specific case, we're not using any of them to define the multiply attribute. It's a common convention in Nix to use _ when an argument is required by a function but intentionally ignored.

In the command nix eval .#multiply:

  • the . signifies the current directory, indicating that Nix should look for a flake.nix file in the directory where you're running the command.

  • The # is used to select a specific attribute from the outputs of the flake. In this case, it's telling Nix to evaluate the multiply attribute.

In the next example we will create a devShells output as well as a packages output.

flake.lock auto-generated lock file

All flake inputs are pinned to specific revisions in a lockfile called flake.lock This file stores the revision info as JSON.

The flake.lock file ensures that Nix flakes have purely deterministic outputs. A flake.nix file without an accompanying flake.lock should be considered incomplete and a kind of proto-flake. Any Nix CLI command that is run against the flake—like nix build, nix develop, or even nix flake show—generates a flake.lock for you.

Here’s an example section of a flake.lock file that pins Nixpkgs to a specific revision:

$ cat flake.lock
{
  "nodes": {
    "nixpkgs": {
      "info": {
        "lastModified": 1587398327,
        "narHash": "sha256-mEKkeLgUrzAsdEaJ/1wdvYn0YZBAKEG3AN21koD2AgU="
      },
      "locked": {
        "owner": "NixOS",
        "repo": "nixpkgs",
        "rev": "5272327b81ed355bbed5659b8d303cf2979b6953",
        "type": "github"
      },
      "original": {
        "owner": "NixOS",
        "ref": "nixos-20.03",
        "repo": "nixpkgs",
        "type": "github"
      }
    },
    "root": {
      "inputs": {
        "nixpkgs": "nixpkgs"
      }
    }
  },
  "root": "root",
  "version": 5
}

Any future build of this flake will use the version of nixpkgs recorded in the lock file. If you add new inputs, they will be automatically added when you run a nix flake command like nix flake show. But it won't replace existing locks.

If you need to update a locked input to the latest version:

nix flake lock --update-input nixpkgs
nix build

The above command allows you to update individual inputs, and nix flake update will update the whole lock file.

Helper functions that are good to know for working with Flakes

lib.genAttrs: A function, given the name of the attribute, returns the attribute's value

Example:

nix repl
nix-repl> :l <nixpkgs>
nix-repl> lib.genAttrs [ "boom" "bash" ] (name: "sonic" + namd)

Output:

{
  bash = "sonicbash";
  boom = "sonicboom";
}

You will often see the following:

A common use for this with flakes is to have a list of different systems:

     systems = [
       "x86_64-linux"
       "aarch64-linux"
       "x86_64-darwin"
       "aarch64-darwin"
     ];

And use it to generate an attribute set for each listed system:

eachSystem = lib.genAttrs systems;

The above command creates an attribute set by mapping over a list of system strings. If you notice, you provide it a list (i.e. [ 1 2 3 ]) and the function returns a set (i.e. { ... })

Why genAttrs is useful:

  • It lets you define attributes (like packages, checks, devShells) per supported system in a DRY(don't repeat yourself), structured way.

  • lib.mapAttrs: A function, given an attribute's name and value, returns a new nameValuePair.

Example:

nix-repl> builtins.mapAttrs (name: value: name + "-" + value) { x = "foo"; y = "bar"; }

Output:

{
  x = "x-foo";
  y = "y-bar";
}

pkgs.mkShell: is a specialized stdenv.mkDerivation that removes some repetition when using it with nix-shell (or nix develop)

Example:

{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
  packages = [ pkgs.gnumake ];

  inputsFrom = [ pkgs.hello pkgs.gnutar ];

  shellHook = ''
    export DEBUG=1
  '';
}

A Simple flake that outputs a devshell and a package

In a new directory create a flake.nix

# flake.nix
{
  outputs = {
    self,
    nixpkgs,
  }: let
    pkgs = nixpkgs.legacyPackages.x86_64-linux;
  in {

    packages.x86_64-linux.default = pkgs.kakoune; # You could define a meta-package here

    devShells.x86_64-linux.default = pkgs.mkShell {
      packages = [
        pkgs.kakoune
        pkgs.git
        pkgs.ripgrep
        pkgs.fzf
      ];
    };
  };
}

mkShell is a wrapper around mkDerivation

This flake offers two main outputs for x86_64-linux systems:

  1. A standard package (packages.x86_64-linux.default): This simple example just re-exports kakoune from nixpkgs. You could build your own apps here.

  2. A development shell (devShells.x86_64-linux.default): This provides a convenient environment where you have specific tools available without installing them globally on your system.

To use this flake you have a few options:

  • nix run will launch kakoune

  • nix develop will activate the development environment providing all of the pkgs listed under mkShell.

  • Or more explicitly nix develop .#devShells.x86_64-linux.default, does the same thing as the command above.

Flake References

✔️ Flake References (Click to Expand)

Flake references (flakerefs) are a way to specify the location of a flake. They have two different formats:

Attribute set representation:

{
  type = "github";
  owner = "NixOS";
  repo = "nixpkgs";
}

or URL-like syntax:

github:NixOS/nixpkgs

These are used on the command line as a more convenient alternative to the attribute set representation. For instance, in the command

nix build github:NixOS/nixpkgs#hello

github:NixOS/nixpkgs is a flake reference (while hello is an output attribute). They are also allowed in the inputs attribute of a flake, e.g.

inputs.nixpkgs.url = "github:NixOS/nixpkgs";

is equivalent to

inputs.nixpkgs = {
  type = "github";
  owner = "NixOS";
  repo = "nixpkgs";
};

-- nix.dev flake-references

Nix Flake Commands

✔️ Flake Commands (Click to Expand)

nix flake provides subcommands for creating, modifying and querying Nix Flakes. Flakes are the unit for packaging Nix code in a reproducible and discoverable way. They can have dependencies on other flakes, making it possible to have multi-repository Nix projects.

— From nix.dev Reference Manual

  • The main thing to note here is that nix flake is used to manage Nix flakes and that Flake commands are whitespace separated rather than hyphen - separated.

  • Flakes do provide some advantages when it comes to discoverability of outputs.

  • For Example, two helpful commands to inspect a flake are:

    • nix flake show command: Show the outputs provided by a flake.

    • nix flake check command: check whether the flake evaluates and run its tests.

    • Any Nix CLI command that is run against a flake -- like nix build, nix develop, nix flake show -- generate a flake.lock file for you.

      • The flake.lock file ensures that all flake inputs are pinned to specific revisions and that Flakes have purely deterministic outputs.

    Example:

nix shell nixpkgs#ponysay --command ponysay "Flakes Rock!"

This works because of the [flake registry] that maps symbolic identifiers like nixpkgs to actual locations such as https://github.com/NixOS/nixpkgs. So the following are equivalent:

nix shell nixpkgs#ponysay --command ponysay Flakes Rock!
nix shell github:NixOS/nixpkgs#ponysay --command ponysay Flakes Rock!

To override the nixpkgs registry with your own local copy you could:

nix registry add nixpkgs ~/src/local-nixpkgs

Attribute Sets: The Building Blocks

✔️ Attribute set Refresher (Click to Expand)

Attribute sets are fundamental in Nix. They are simply collections of name-value pairs wrapped in curly braces {}.

  • Example, (click to see Output):
let
  my_attrset = { foo = "bar"; };
in
my_attrset.foo
 "bar"

Top-Level Attributes of a Flake:

Flakes have specific top-level attributes that can be accessed directly (without dot notation). The most common ones are inputs, outputs, and nixConfig.

Deeper Dive into the Structure of flake.nix

Flakes

inputs: Declaring Dependencies

The inputs attribute set specifies the other flakes that your current flake depends on.

Each key in the inputs set is a name you choose for the dependency, and the value is a reference to that flake (usually a URL or a Git Repo).

To access something from a dependency, you generally go through the inputs attribute (e.g., inputs.helix.packages).

See Nix Flake inputs for a flake inputs deep dive.

Example: This declares dependencies on the nixpkgs and import-cargo flakes:

inputs = {
  import-cargo.url = "github:edolstra/import-cargo";
  nixpkgs.url = "nixpkgs";
};

When Nix evaluates your flake, it fetches and evaluates each input. These evaluated inputs are then passed as an attribute set to the outputs function, with the keys matching the names you gave them in the inputs set.

The special input self is a reference to the outputs and the source tree of the current flake itself.

outputs: Defining What Your Flake Provides

The outputs attribute defines what your flake makes available. This can include packages, NixOS modules, development environments (devShells) and other Nix derivations.

Flakes can output arbitrary Nix values. However, certain outputs have specific meanings for Nix commands and must adhere to particular types (often derivations, as described in the output schema).

You can inspect the outputs of a flake using the command:

nix flake show

This command takes a flake URI and displays its outputs in a tree structure, showing the attribute paths and their corresponding types.

Understanding the outputs Function

Beginners often mistakenly think that self and nixpkgs within outputs = { self, nixpkgs, ... }: { ... } are the outputs themselves. Instead, they are the input arguments (often called output arguments) to the outputs function.

The outputs function in flake.nix always takes a single argument, which is an attribute set. The syntax { self, nixpkgs, ... } is Nix's way of destructuring this single input attribute set to extract the values associated with the keys self and nixpkgs.

Flakes output your whole system configuration, packages, and also Nix functions for use elsewhere.

  • For example, the nixpkgs repository has its own flake.nix file that outputs many helper functions via the lib attribute.

  • For a deep dive into flake outputs, see Nix Flake Outputs

The lib convention The convention of using lib to output functions is observed not just by Nixpkgs but by many other Nix projects. You’re free, however, to output functions via whichever attribute you prefer. -- Zero to Nix Flakes

Some flake outputs are required to be system specific (i.e. "x86_64-linux" for (64-bit AMD/Intel Linux) including packages, development environments, and NixOS configurations)

Variadic Attributes (...) and @-patterns

The ... syntax in the input arguments of the outputs function indicates variadic attributes, meaning the input attribute set can contain more attributes than just those explicitly listed (like lib and nixpkgs).

Example:

mul = { a, b, ... }: a * b;
mul { a = 3; b = 4; c = 2; } # 'c' is an extra attribute

However, you cannot directly access these extra attributes within the function body unless you use the @-pattern:

  • (Click for Output)
mul = s@{ a, b, ... }: a  b  s.c; # 's' now refers to the entire input set
mul { a = 3; b = 4; c = 2; } # Output: 24
 24

When used in the outputs function argument list (e.g., outputs = { pkgs, ... } @ inputs), the @-pattern binds the entire input attribute set to a name (in this case, inputs) while also allowing you to destructure specific attributes like pkgs.

What outputs = { pkgs, ... } @ inputs: { ... }; does:

  1. Destructuring: It tries to extract the value associated with the key pkgs from the input attribute set and binds it to the variable pkgs. The ... allows for other keys in the input attribute set to be ignored during this direct destructuring.

  2. Binding the Entire Set: It binds the entire input attribute set to the variable inputs.

    • Example flake.nix:
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
inputs.home-manager.url = "github:nix-community/home-manager";

outputs = { self, nixpkgs, ... } @ attrs: { # A `packages` output for the x86_64-linux platform
packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello;

    # A `nixosConfigurations` output (for a NixOS system named "fnord")
    nixosConfigurations.fnord = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      specialArgs = attrs;
      modules = [ ./configuration.nix ];
    };

};
}

Platform Specificity in Outputs

Flakes ensure that their outputs are consistent across different evaluation environments. Therefore, any package-related output must explicitly specify the target platform (a combination of architecture and OS, x86_64-linux).

legacyPackages Explained

legacyPackages is a way for flakes to interact with the traditional, less structured package organization of nixpkgs. Instead of packages being directly at the top level (e.g., pkgs.hello), legacyPackages provides a platform-aware way to access them within the flake's structured output format (e.g., nixpkgs.legacyPackages.x86_64-linux.hello). It acts as a bridge between the flake's expected output structure and nixpkgs's historical organization.

The Sole Argument of outputs

It's crucial to remember that the outputs function accepts only one argument, which is an attribute set. The { self, nixpkgs, ... } syntax is simply destructuring that single input attribute set.

Outputs of the Flake (Return Value)

The outputs of the flake refer to the attribute set that is returned by the outputs function. This attribute set can contain various named outputs like packages, nixosConfigurations, devShells, etc.

Imports: Including Other Nix Expressions

The import function in Nix is used to evaluate the Nix expression found at a specified path (usually a file or directory) and return its value.

Basic Usage: import ./path/to/file.nix

Passing Arguments During Import

import <nixpkgs> {} is calling two functions, not one.

  1. import <nixpkgs>: The first function call
  • import is a built-in Nix function. Its job is to load and evaluate a Nix expression from a specified path.

  • <nixpkgs> is a flake reference. When you use import <nixpkgs>, Nix evaluates the default.nix file (or sometimes lib/default.nix) found at that location.

  • The default.nix in nixpkgs evaluates to a function. This function is designed to be configurable, allowing you to pass arguments like system, config, etc. to customize how nixpkgs behaves and what packages it provides.

  • So, import <nixpkgs> doesn't give you the nixpkgs package set directly; it gives you the function that generates the nixpkgs package set derivation.

  1. {}: The second function call (and its argument)
  • {} denotes an empty attribute set

  • When an attribute set immediately follows a function, it means you are calling that function and passing the attribute set as its single argument.

So, the {} after import <nixpkgs> is not part of the import function iteself. It's the argument being passed to the function that import <nixpkgs> just returned.

You can also pass an attribute set as an argument to the Nix expression being imported:

let
myHelpers = import ./lib/my-helpers.nix { pkgs = nixpkgs; };
in
# ... use myHelpers

In this case, the Nix expression in ./lib/my-helpers.nix is likely a function that expects an argument (often named pkgs by convention):

# ./lib/my-helpers.nix

{ pkgs }:
let
myPackage = pkgs.stdenv.mkDerivation {
name = "my-package"; # ...
};
in
myPackage

By passing { pkgs = nixpkgs; } during the import, you are providing the nixpkgs value from your current flake.nix scope to the pkgs parameter expected by the code in ./lib/my-helpers.nix.

Importing Directories (default.nix)

When you use import with a path that points to a directory, Nix automatically looks for a file named default.nix within that directory. If found, Nix evaluates the expressions within default.nix as if you had specified its path directly in the import statement.

Conclusion: Unifying Your Nix Experience with Flakes

For some examples of more advanced outputs like devShells and checks, check out this blog post that I wrote: Nix Flakes Tips and Tricks

In this chapter, we've explored Nix Flakes as a powerful and modern approach to managing Nix projects, from development environments to entire system configurations. We've seen how they provide structure, dependency management, and reproducibility through well-defined inputs and outputs. Flakes offer a cohesive way to organize your Nix code and share it with others.

As we've worked with the flake.nix file, you've likely noticed its structure – a top-level attribute set defining various outputs like devShells, packages, nixosConfigurations, and more. These top-level attributes are not arbitrary; they follow certain conventions and play specific roles within the Flake ecosystem.

In the next chapter, Understanding Top-Level Attributes we will delve deeper into the meaning and purpose of these common top-level attributes. We'll explore how they are structured, what kind of expressions they typically contain, and how they contribute to the overall functionality and organization of your Nix Flakes. Understanding these attributes is key to effectively leveraging the full potential of Nix Flakes.

Further Resources
✔️ Resources (Click to Expand)

FlakeHub

Nix Flake Inputs

✔️ Click to Expand Table of Contents

The attribute inputs specifies the dependencies of a flake, as an attrset mapping input names to flake references.

If a repository provides a flake.nix you can include it as an input in your flake.nix.

For example, I like yazi as my file explorer and have been using helix as my editor. To be able to get yazi to work with helix I needed the latest versions of both yazi and helix. One way to get the latest versions was to add their flakes as inputs to my flake:

{
	inputs = {
		nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
		home-manager = {
			url = "github:nix-community/home-manager/release-24.11";
			inputs.nixpkgs.follows = "nixpkgs";
		};
    helix = {
      url = "github:helix-editor/helix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
		yazi.url = "github:sxyazi/yazi";
	};
	outputs = { nixpkgs, home-manager, ... } @ inputs: {
	# ... snip ... #
  • Now to use this input, I would reference these inputs in both my yazi and helix modules:
# yazi.nix
{ pkgs, config, inputs, ... }: {
	programs.yazi = {
		enable = true;
		package = inputs.yazi.packages.${pkgs.system}.default;
	};
}
# helix.nix
{ pkgs, config, inputs, ... }: {
	programs.helix = {
		enable = true;
		package = inputs.helix.packages.${pkgs.system}.helix;
	};
}

Understanding .default vs. Named Outputs (e.g., .helix) from the Source

The difference between inputs.yazi.packages.${pkgs.system}.default and inputs.helix.packages.${pkgs.system}.helix comes down to how the respective upstream flakes define their outputs. You can always inspect a flake's flake.nix or use nix flake show to understand its structure.

Helix flake.nix

Let's look at the relevant section of Helix's flake.nix click the eye to see the full flake:

 {
   description = "A post-modern text editor.";

   inputs = {
     nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
     rust-overlay = {
       url = "github:oxalica/rust-overlay";
       inputs.nixpkgs.follows = "nixpkgs";
     };
   };

   outputs = {
     self,
     nixpkgs,
     rust-overlay,
     ...
   }: let
     inherit (nixpkgs) lib;
     systems = [
       "x86_64-linux"
       "aarch64-linux"
       "x86_64-darwin"
       "aarch64-darwin"
     ];
     eachSystem = lib.genAttrs systems;
     pkgsFor = eachSystem (system:
       import nixpkgs {
         localSystem.system = system;
         overlays = [(import rust-overlay) self.overlays.helix];
       });
     gitRev = self.rev or self.dirtyRev or null;
   in {
     packages = eachSystem (system: {
       inherit (pkgsFor.${system}) helix;
       /*
       The default Helix build. Uses the latest stable Rust toolchain, and unstable
       nixpkgs.

       The build inputs can be overridden with the following:

       packages.${system}.default.override { rustPlatform = newPlatform; };

       Overriding a derivation attribute can be done as well:

       packages.${system}.default.overrideAttrs { buildType = "debug"; };
       */
      default = self.packages.${system}.helix;
    });
    checks =
      lib.mapAttrs (system: pkgs: let
        # Get Helix's MSRV toolchain to build with by default.
        msrvToolchain = pkgs.pkgsBuildHost.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
        msrvPlatform = pkgs.makeRustPlatform {
          cargo = msrvToolchain;
          rustc = msrvToolchain;
        };
      in {
        helix = self.packages.${system}.helix.override {
          rustPlatform = msrvPlatform;
        };
      })
      pkgsFor;

    # Devshell behavior is preserved.
    devShells =
      lib.mapAttrs (system: pkgs: {
        default = let
          commonRustFlagsEnv = "-C link-arg=-fuse-ld=lld -C target-cpu=native --cfg tokio_unstable";
          platformRustFlagsEnv = lib.optionalString pkgs.stdenv.isLinux "-Clink-arg=-Wl,--no-rosegment";
        in
          pkgs.mkShell {
            inputsFrom = [self.checks.${system}.helix];
            nativeBuildInputs = with pkgs;
              [
                lld
                cargo-flamegraph
                rust-bin.nightly.latest.rust-analyzer
              ]
              ++ (lib.optional (stdenv.isx86_64 && stdenv.isLinux) cargo-tarpaulin)
              ++ (lib.optional stdenv.isLinux lldb)
              ++ (lib.optional stdenv.isDarwin darwin.apple_sdk.frameworks.CoreFoundation);
            shellHook = ''
              export RUST_BACKTRACE="1"
              export RUSTFLAGS="''${RUSTFLAGS:-""} ${commonRustFlagsEnv} ${platformRustFlagsEnv}"
            '';
          };
      })
      pkgsFor;

    overlays = {
      helix = final: prev: {
        helix = final.callPackage ./default.nix {inherit gitRev;};
      };

      default = self.overlays.helix;
    };
  };
  nixConfig = {
    extra-substituters = ["https://helix.cachix.org"];
    extra-trusted-public-keys = ["helix.cachix.org-1:ejp9KQpR1FBI2onstMQ34yogDm4OgU2ru6lIwPvuCVs="];
  };
}

Dissecting inherit (pkgsFor.${system}) helix;

Imagine the Nix evaluation process for Helix flake.nix in the outputs section:

  1. packages = eachSystem (system: { ... }); Part iterates through each system (like x86_64-linux). For each system, it's creating an attribute set that will become self.packages.${system}.

  2. Inside the eachSystem function, for a specific system (e.g. x86_64-linux): The code is building an attribute set that will ultimately be assigned to self.packages.x86_64-linux.

  3. When you write inherit (sourceAttrset) attributeName;, it's equivalent to writing attributeName = sourceAttrset.attributeName;.

So, inherit (pkgsFor.${system}) helix; is equivalent to:

helix = pkgsFor.${system}.helix;

Therefore, because of inherit (pkgsFor.${system}) helix;, the helix attribute is explicitly defined under packages.${system}``. This is why you access it as inputs.helix.packages.${pkgs.system}.helix;`.

Yazi flake.nix

Now this is yazi's flake.nix, yazi's documentation tells you to use .default but lets examine the flake and see why:

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
    flake-utils.url = "github:numtide/flake-utils";
    rust-overlay = {
      url = "github:oxalica/rust-overlay";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs =
    {
      self,
      nixpkgs,
      rust-overlay,
      flake-utils,
      ...
    }:
    flake-utils.lib.eachDefaultSystem (
      system:
      let
        pkgs = import nixpkgs {
          inherit system;
          overlays = [ rust-overlay.overlays.default ];
        };
        toolchain = pkgs.rust-bin.stable.latest.default;
        rustPlatform = pkgs.makeRustPlatform {
          cargo = toolchain;
          rustc = toolchain;
        };

        rev = self.shortRev or self.dirtyShortRev or "dirty";
        date = self.lastModifiedDate or self.lastModified or "19700101";
        version =
          (builtins.fromTOML (builtins.readFile ./yazi-fm/Cargo.toml)).package.version
          + "pre${builtins.substring 0 8 date}_${rev}";
      in
      {
        packages = {
          yazi-unwrapped = pkgs.callPackage ./nix/yazi-unwrapped.nix {
            inherit
              version
              rev
              date
              rustPlatform
              ;
          };
          yazi = pkgs.callPackage ./nix/yazi.nix { inherit (self.packages.${system}) yazi-unwrapped; };
          default = self.packages.${system}.yazi;
        };

        devShells = {
          default = pkgs.callPackage ./nix/shell.nix { };
        };

        formatter = pkgs.nixfmt-rfc-style;
      }
    )
    // {
      overlays = {
        default = self.overlays.yazi;
        yazi = _: prev: { inherit (self.packages.${prev.stdenv.system}) yazi yazi-unwrapped; };
      };
    };
}

In this case using inputs.yazi.packages.${pkgs.system}.yazi would also work

  • yazi = pkgs.callPackage ./nix/yazi.nix { inherit (self.packages.${system}) yazi-unwrapped; }; This line defines the yazi variable (or, more precisely, creates an attribute named yazi within the packages.${system} set). It assigns to this yazi attribute the result of calling the Nix expression in ./nix/yazi.nix with yazi-unwrapped as an argument. This yazi attribute represents the actual, runnable Yazi package.

  • default = self.packages.${system}.yazi; This line then aliases the yazi package. It creates another attribute named default within the same packages.${system} set and points it directly to the yazi attribute that was just defined.

  • So, when you access inputs.yazi.packages.${pkgs.system}.default, you're effectively following the alias to the yazi package.

  • The choice to use .default is primarily for convenience and adherence to a common flake convention, making the flake easier for users to consume without needing to dive into its internal structure.

Nix Flake Outputs

✔️ Click to Expand Table of Contents

Flake outputs are what the flake produces when built. Flakes can have multiple outputs simultaneously such as:

  • Packages: Self-contained bundles that are built using derivations and provide either some kind of software or dependencies of software.

  • NixOS modules

  • Nix development environments

  • Nix templates

  • The outputs top-level attribute is actually a function that takes an attribute set of inputs and returns an attribute set that is essentially a recipe for building the flake.

Output Schema

Once the inputs are resolved, they're passed to the outputs attribute. This outputs attribute is, in fact, a function, as indicated by the : colon (or the lambda syntax) that follows its definition. This function takes the resolved inputs (and self, the flake's directory in the store) as arguments, and its return value dictates the outputs of the flake, following this schema:

{ self, nixpkgs, ... }@inputs:
{
  # Executed by `nix flake check`
  checks."<system>"."<name>" = derivation;
  # Executed by `nix build .#<name>`
  packages."<system>"."<name>" = derivation;
  # Executed by `nix build .`
  packages."<system>".default = derivation;
  # Executed by `nix run .#<name>`
  apps."<system>"."<name>" = {
    type = "app";
    program = "<store-path>";
  };
  # Executed by `nix run . -- <args?>`
  apps."<system>".default = { type = "app"; program = "..."; };

  # Formatter (alejandra, nixfmt or nixpkgs-fmt)
  formatter."<system>" = derivation;
  # Used for nixpkgs packages, also accessible via `nix build .#<name>`
  legacyPackages."<system>"."<name>" = derivation;
  # Overlay, consumed by other flakes
  overlays."<name>" = final: prev: { };
  # Default overlay
  overlays.default = final: prev: { };
  # Nixos module, consumed by other flakes
  nixosModules."<name>" = { config, ... }: { options = {}; config = {}; };
  # Default module
  nixosModules.default = { config, ... }: { options = {}; config = {}; };
  # Used with `nixos-rebuild switch --flake .#<hostname>`
  # nixosConfigurations."<hostname>".config.system.build.toplevel must be a derivation
  nixosConfigurations."<hostname>" = {};
  # Used by `nix develop .#<name>`
  devShells."<system>"."<name>" = derivation;
  # Used by `nix develop`
  devShells."<system>".default = derivation;
  # Hydra build jobs
  hydraJobs."<attr>"."<system>" = derivation;
  # Used by `nix flake init -t <flake>#<name>`
  templates."<name>" = {
    path = "<store-path>";
    description = "template description goes here?";
  };
  # Used by `nix flake init -t <flake>`
  templates.default = { path = "<store-path>"; description = ""; };
}

The first line { self, nixpkgs, ... }@ inputs: defines the functions parameters: It's important to understand that within the scope of the outputs function nixpkgs is available at the top-level because we explicitly passed it as an argument but for individual modules outside this flake the scope is lost, and you need to use inputs.nixpkgs (or equivalent)

  1. It explicitly names the self attribute, making it directly accessible. The variadic ... ellipses part of the function signature is what allows all your flake inputs to be brought into the function's scope without having to list each one explicitly.

  2. It destructures all other attributes (your defined inputs) into the functions scope.

  3. It gives you a convenient single variable, inputs, that refers to the entire attribute set passed to the outputs function. This allows you to access inputs either individually (e.g. nixpkgs) or through the inputs variable (e.g. inputs.nixpkgs).

You can also define additional arbitrary attributes, but these are the outputs that Nix knows about.

As you can see, the majority of the outputs within the outputs schema expect a derivation. This means that for packages, applications, formatters, checks, and development shells, you'll be defining a Nix derivation—a set of instructions that tells Nix how to build a particular software component. This is central to Nix's declarative nature.

  • The command nix flake show, takes a flake URI and prints all the outputs of the flake as a nice tree structure, mapping attribute paths to the types of values.
  ~/players/third  3s
❯ nix flake show
path:/home/jr/players/third?lastModified=1748272555&narHash=sha256-oNzkC6X9hA0MpOBmJSZ89w4znXxv4Q5EkFhp0ewehY0%3D
├───nixosConfigurations
│   └───testing: NixOS configuration
└───nixosModules
    └───default: NixOS module

To show you the structure of this little flake project:

  ~/players
❯ tree
 .
├──  first
│   ├──  flake.lock
│   ├──  flake.nix
│   └──  result -> /nix/store/701vyaanmqchd2nnaq71y65v8ws11zx0-nixos-system-nixos-24.11.20250523.f09dede
├──  second
│   ├──  flake.lock
│   └──  flake.nix
└──  third
    ├──  flake.lock
    ├──  flake.nix
    └──  result -> /nix/store/mlszr5ws3xaly8m4q9jslgs31w6w76y2-nixos-system-nixos-24.11.20250523.f09dede

Simple Example providing an output

# flake.nix
{
  outputs = { self }: {
    bada = "bing";
  };
}

You can then evaluate this specific output using nix eval:

nix eval .#bada
"bing"

Outputs understood by Nix

While the attribute set that outputs returns may contain arbitrary attributes, meaning any valid Nix value. Some of the standard outputs are understood by various nix utilities. packages is one of these:

# flake.nix
{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs";
  };

  outputs = { self, nixpkgs }: {
    # this is the re-exporting part!
    packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello;
  };
}
  • Re-exporting happens when you take the value of hello in its standard derivation format, exactly as nixpkgs produces it and assign it to an attribute in your own flake's outputs.

    • packages.x86_64-linux.hello(your flake's output path) = nixpkgs.legacyPackages.x86_64-linux.hello(the source from the nixpkgs flake's output)

    • We're saying, My flakes hello package is exactly the same as the hello package found inside the nixpkgs input flake.

    • It's important to understand that within the scope of the outputs function (i.e. within your flake), nixpkgs is available at the top-level (i.e. the = nixpkgs part) because we explicitly passed it as an argument but for individual modules outside of this flake the scope is lost, and inputs.nixpkgs is needed.

The following command builds the reexported package:

nix build .#hello

or run it with:

nix run .#hello

You might notice x86_64-linux appearing in the package path, and there's a good reason for it. Flakes are designed to provide hermetic evaluation, meaning their outputs should be identical regardless of the environment where they're built. A key factor in any build system is the platform (which combines the architecture and operating system, like x86_64-linux or aarch64-darwin).

Because of Nix's commitment to reproducibility across different systems, any flake output that involves building software packages must explicitly specify the platform. The standard approach is to structure these outputs as an attribute set where the names are platforms, and the values are the outputs specific to that platform. For the packages output, each platform-specific value is itself an attribute set containing the various packages built for that particular system.

Exporting Functions

This example outputs a sayGoodbye function, via the lib attribute, that takes a name for its input and outputs a string saying Goodbye very nicely to the person with that name:

{
  outputs = { self }: {
    lib = {
      sayGoodbye = name: "Goodbye F*** Off, ${name}!";
    };
  };
}

You could then specify this flake as an input to another flake and use sayGoodbye however you'd like.

Or load it into the nix repl like so:

nix repl
nix-repl> :lf .
nix-repl> lib.sayGoodbye
«lambda sayGoodbye @ /nix/store/665rwfvkwdx6kwvk9ldijp2a6jvcgv1n-source/flake.nix:4:20»
nix-repl> lib.sayGoodbye "Jr"
"Goodbye F*** Off, Jr!"
  • As you can see, specifying lib.sayGoodbye without any arguments returns a function. (a lambda function)

Simplifying Multi-Platform Outputs with flake-utils

Manually repeating these platform definitions for every output (packages, devShells, checks, etc.) can quickly become verbose. This is where the flake-utils helper flake comes in handy. It provides utilities to reduce boilerplate when defining outputs for multiple systems.

A commonly used function is flake-utils.lib.eachDefaultSystem, which automatically generates outputs for common platforms (like x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin). This transforms your outputs definition from manually listing each system to a more concise structure:

Example using flake-utils

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    flake-utils.url = "github:numtide/flake-utils"; # Don't forget to add flake-utils to inputs!
  };

  outputs = {
    self,
    nixpkgs,
    flake-utils,
    ...
  }:
    flake-utils.lib.eachDefaultSystem (
      system: let
        pkgs = import nixpkgs {inherit system;};
      in {
        packages.hello = pkgs.hello; # Now directly defines 'hello' for the current 'system' # packages.default = self.packages.${system}.hello; # Optional default alias
        devShells.default = pkgs.mkShell {
          packages = [pkgs.hello];
        };
      }
    );
}
  • This flake-utils pattern is particularly useful for defining consistent development environments across platforms, which can then be activated simply by running nix develop in the flake's directory.

Adding Formatter, Checks, and Devshell Outputs

This is a minimal flake for demonstration with a hardcoded system, for more portability:

{
  description = "NixOS configuration";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    home-manager.url = "github:nix-community/home-manager";
    home-manager.inputs.nixpkgs.follows = "nixpkgs";
    treefmt-nix.url = "github:numtide/treefmt-nix";
   };

  outputs = inputs@{ nixpkgs, home-manager, treefmt-nix, ... }: let

    system = "x86_64-linux";
    host = "your-hostname-goes-here";
      # Define pkgs with allowUnfree
    pkgs = import inputs.nixpkgs {
      inherit system;
      config.allowUnfree = true;
    };

        # Formatter configuration
    treefmtEval = treefmt-nix.lib.evalModule pkgs ./lib/treefmt.nix;

in {

    formatter.${system} = treefmtEval.config.build.wrapper;

    # Style check for CI
    checks.${system}.style = treefmtEval.config.build.check self;

    # Development shell
    devShells.${system}.default = import ./lib/dev-shell.nix {
      inherit inputs;
    };


    nixosConfigurations = {
      hostname = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./configuration.nix
          home-manager.nixosModules.home-manager
          {
            home-manager.useGlobalPkgs = true;
            home-manager.useUserPackages = true;
            home-manager.users.jdoe = ./home.nix;

            # Optionally, use home-manager.extraSpecialArgs to pass
            # arguments to home.nix
          }
        ];
      };
    };
  };
}

And in lib/treefmt.nix:

# treefmt.nix
{
  projectRootFile = "flake.nix";
  programs = {
    alejandra.enable = true;
    deadnix.enable = true;
    # rustfmt.enable = true;
    # shellcheck.enable = true;
    # prettier.enable = true;
    statix.enable = true;
    keep-sorted.enable = true;
    # nixfmt = {
    #   enable = true;
    #   # strict = true;
    # };
  };
  settings = {
    global.excludes = [
      "LICENSE"
      "README.md"
      ".adr-dir"
      "nu_scripts"
      # unsupported extensions
      "*.{gif,png,svg,tape,mts,lock,mod,sum,toml,env,envrc,gitignore,sql,conf,pem,*.so.2,key,pub,py,narHash}"
      "data-mesher/test/networks/*"
      "nss-datamesher/test/dns.json"
      "*.age"
      "*.jpg"
      "*.nu"
      "*.png"
      ".jj/*"
      "Cargo.lock"
      "flake.lock"
      "hive/moonrise/borg-key-backup"
      "justfile"
    ];
    formatter = {
      deadnix = {
        priority = 1;
      };
      statix = {
        priority = 2;
      };
      alejandra = {
        priority = 3;
      };
    };
  };
}

Now we have a few commands available to us in our flake directory:

  • nix fmt: Will format your whole configuration consistently

  • nix flake check: While this command was already available, it is now tied to treefmt's check which will check the style of your syntax and provide suggestions.

And this is lib/dev-shell.nix:

{
  inputs,
  system ? "x86_64-linux",
}: let
  # Instantiate nixpkgs with the given system and allow unfree packages
  pkgs = import inputs.nixpkgs {
    inherit system;
    config.allowUnfree = true;
    overlays = [
      # Add overlays if needed, e.g., inputs.neovim-nightly-overlay.overlays.default
    ];
  };
in
  pkgs.mkShell {
    name = "nixos-dev";
    packages = with pkgs; [
      # Nix tools
      nixfmt-rfc-style # Formatter
      deadnix # Dead code detection
      nixd # Nix language server
      nil # Alternative Nix language server
      nh # Nix helper
      nix-diff # Compare Nix derivations
      nix-tree # Visualize Nix dependencies

      # Code editing
      helix # Your editor

      # General utilities
      git
      ripgrep
      jq
      tree
    ];

    shellHook = ''
      echo "Welcome to the NixOS development shell!"
      echo "System: ${system}"
      echo "Tools available: nixfmt, deadnix, nixd, nil, nh, nix-diff, nix-tree, helix, git, ripgrep, jq, tree"
    '';
  }

Now you can run nix develop in the flake directory and if successfull, you'll see the echo commands above and you will have all the tools available in your environment without having to explicitly install them.

Nix Flake Examples

✔️ Click to Expand Table of Contents

This chapter provides practical examples to illustrate the concepts discussed in "Nix Flakes Explained."

Example showing the extensibility of Flakes

NixOS modules and configurations offer us a powerful and composable way to define and share system configurations. Imagine we have several independent "players," each with their own unique set of configurations or modules. How do we combine these individual contributions into a single, cohesive system without directly altering each player's original flake?

This example demonstrates how flakes can extend and compose each other, allowing you to layer configurations on top of existing ones. This is particularly useful when you want to:

  • Build upon a base configuration without modifying its source.

  • Combine features from multiple independent flakes into a single system.

  • Create specialized versions of an existing configuration.

Let's simulate this by creating a players directory with three sub-directories: first, second, and third. Each of these will contain its own flake.nix.

mkdir players
cd players
mkdir first
mkdir second
mkdir third
cd first

Now create a flake.nix with the following contents:

# flake.nix
{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11";
  };

  outputs = {
    self,
    nixpkgs,
  }: {
    nixosModules.default = {
      config,
      pkgs,
      lib,
      ...
    }: {
      # Create a file `/etc/first-file`
      environment.etc.first-file.text = "Hello player # 1!";
      boot.initrd.includeDefaultModules = false;
      documentation.man.enable = false;
      boot.loader.grub.enable = false;
      fileSystems."/".device = "/dev/null";
      system.stateVersion = "24.11";
    };
    nixosConfigurations.testing = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        self.nixosModules.default
      ];
    };
  };
}
  • This demonstrates using self to reference this flake from within its own outputs. This is the main use for self with flakes. Without self, I wouldn't have a direct way to refer to the nixosModules.default that's defined within the same flake.

Now in the players/second directory create this flake.nix:

# flake.nix
{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11";
  };

  outputs = {
    self,
    nixpkgs,
  }: {
    nixosModules.default = {
      config,
      pkgs,
      lib,
      ...
    }: {
      # Create a file `/etc/second-file`
      environment.etc.second-file.text = "Hello player # 2!";
    };
  };
}
  • nixosModules.default is a module which is a function that, when called by the NixOS module system, returns an attribute set representing a piece of system configuration.

    • Within that attribute set, it specifies that the file /etc/second-file should exist with "Hello player # 2!" as its content.

And finally in players/third create another flake.nix:

# flake.nix
{
  inputs = {
    first.url = "/home/jr/players/first";
    nixpkgs.follows = "first/nixpkgs";
    second = {
      url = "/home/jr/players/second";
      inputs.nixpkgs.follows = "first/nixpkgs";
    };
  };

  outputs = {
    self,
    nixpkgs,
    first,
    second,
  }:
    first.outputs
    // {
      nixosConfigurations.testing = first.nixosConfigurations.testing.extendModules {
        modules = [
          second.nixosModules.default
        ];
      };
    };
}
  • You'll have to change the locations to where you placed your players directory in the inputs above.

In your third directory inspect it with:

  ~/players/third
❯ nix flake show
path:/home/jr/players/third?lastModified=1748271697&narHash=sha256-oNzkC6X9hA0MpOBmJSZ89w4znXxv4Q5EkFhp0ewehY0%3D
├───nixosConfigurations
│   └───testing: NixOS configuration
└───nixosModules
    └───default: NixOS module

and build it with:

nix build .#nixosConfigurations.testing.config.system.build.toplevel
cat result/etc/first-file
Hello player # 1!
cat result/etc/second-file
Hello player # 2!

Understanding the Extension

As you saw in the flake.nix for the third player, we leveraged two key flake features to combine and extend the previous configurations:

  1. Attribute Set Union (// operator):
outputs = { ..., first, second, ... }:
first.outputs // { # ... your extensions here ...
};

The // (attribute set union) operator allows us to take all the outputs from first.outputs (which includes its nixosConfigurations and nixosModules) and then overlay or add to them on the right-hand side. This means our third flake will inherit all the outputs from first, but we can then modify or add new ones without changing the first flake itself.

  1. config.extendModules:
    nixosConfigurations.testing = first.nixosConfigurations.testing.extendModules {
      modules = [
        second.nixosModules.default
      ];
    };

This is the core of the extension. We're taking the testing NixOS configuration defined in the first flake (first.nixosConfigurations.testing) and then calling its extendModules function. This function allows us to inject additional NixOS modules into an already defined system configuration. In this case, we're adding the default module from the second flake (second.nixosModules.default).

By combining these techniques, the third flake successfully creates a NixOS configuration that includes both the settings from first (like /etc/first-file and the base system options) and the settings from second (like /etc/second-file), all without directly altering the first or second flakes. This demonstrates the incredible power of flake extensibility for building complex, modular, and composable systems.

Chapter 4.4

✔️ Click to Expand Table of Contents

Helix Logo

Understanding the Helix Flake and Modifying its Behavior

As we've seen from previous examples, the helix editor repository includes a few .nix files including a flake.nix. Their flake uses a lot of idiomatic Nix code and advanced features. First I will break down their flake.nix and default.nix to understand why they do certain things. And finally, we will change the build to "debug" mode demonstrating how easily you can modify the behavior of a package defined within a Nix flake without changing the original source code or the upstream flake directly.

  1. Let's clone the Helix repository:
git clone https://github.com/helix-editor/helix.git
cd helix

When you enter the helix directory, direnv is setup for you already. All you would have to do is direnv allow and it will ask you a few questions then you are good to go. Looking at their .envrc it mentions "try to use flakes, if it fails use normal nix (i.e., shell.nix)". If it's successful you'll see a long list of environment variables displayed.

  1. Enter the Development Shell:

The Helix project's flake.nix includes a devShells.default output, specifically designed for development.

nix develop
  1. You're now in a fully configured development environment:
  • When you run nix develop, Nix builds and drops you into a shell environment with all the dependencies specified in devShells.default. This means you don’t have to manually install or manage tools like Rust, Cargo, or Clang—it’s all handled declaratively through Nix.

You can now build and run the project using its standard tooling:

cargo check
cargo build
cargo run
  1. Making Changes and Testing Them

Since you're in a reproducible environment, you can confidently hack on the project without worrying about your system setup. Try modifying some code in helix and rebuilding with Cargo. The Nix shell ensures consistency for every contributor or device you work on.

  1. Run Just the Binary

If you only want to run the compiled program without entering the shell, use the nix run command:

nix run

This builds and runs the default package defined by the flake. In the case of Helix, this launches the hx editor directly.

  1. Build Without Running

To just build the project and get the path to the output binary:

nix build

You’ll find the compiled binary under ./result/bin.

  1. Pinning and Reproducing

Because the project uses a flake, you can ensure full reproducibility by pinning the inputs. For example, you can clone with --recurse-submodules and copy the flake.lock to ensure you're using the same dependency versions as upstream. This is great for debugging or sharing exact builds.

✅ Recap:

With flakes, projects like Helix provide everything you need for development and running in a single flake.nix. You can nix develop to get started hacking, nix run to quickly try it out, and nix build to produce binaries—all without installing or polluting your system.

Understanding the Helix flake.nix

The helix flake is full of idiomatic Nix code and displays some of the more advanced things a flake can provide:

{
  description = "A post-modern text editor.";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    rust-overlay = {
      url = "github:oxalica/rust-overlay";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = {
    self,
    nixpkgs,
    rust-overlay,
    ...
  }: let
    inherit (nixpkgs) lib;
    systems = [
      "x86_64-linux"
      "aarch64-linux"
      "x86_64-darwin"
      "aarch64-darwin"
    ];
    eachSystem = lib.genAttrs systems;
    pkgsFor = eachSystem (system:
      import nixpkgs {
        localSystem.system = system;
        overlays = [(import rust-overlay) self.overlays.helix];
      });
    gitRev = self.rev or self.dirtyRev or null;
  in {
    packages = eachSystem (system: {
      inherit (pkgsFor.${system}) helix;
      /*
      The default Helix build. Uses the latest stable Rust toolchain, and unstable
      nixpkgs.

      The build inputs can be overridden with the following:

      packages.${system}.default.override { rustPlatform = newPlatform; };

      Overriding a derivation attribute can be done as well:

      packages.${system}.default.overrideAttrs { buildType = "debug"; };
      */
      default = self.packages.${system}.helix;
    });
    checks =
      lib.mapAttrs (system: pkgs: let
        # Get Helix's MSRV toolchain to build with by default.
        msrvToolchain = pkgs.pkgsBuildHost.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
        msrvPlatform = pkgs.makeRustPlatform {
          cargo = msrvToolchain;
          rustc = msrvToolchain;
        };
      in {
        helix = self.packages.${system}.helix.override {
          rustPlatform = msrvPlatform;
        };
      })
      pkgsFor;

    # Devshell behavior is preserved.
    devShells =
      lib.mapAttrs (system: pkgs: {
        default = let
          commonRustFlagsEnv = "-C link-arg=-fuse-ld=lld -C target-cpu=native --cfg tokio_unstable";
          platformRustFlagsEnv = lib.optionalString pkgs.stdenv.isLinux "-Clink-arg=-Wl,--no-rosegment";
        in
          pkgs.mkShell {
            inputsFrom = [self.checks.${system}.helix];
            nativeBuildInputs = with pkgs;
              [
                lld
                cargo-flamegraph
                rust-bin.nightly.latest.rust-analyzer
              ]
              ++ (lib.optional (stdenv.isx86_64 && stdenv.isLinux) cargo-tarpaulin)
              ++ (lib.optional stdenv.isLinux lldb)
              ++ (lib.optional stdenv.isDarwin darwin.apple_sdk.frameworks.CoreFoundation);
            shellHook = ''
              export RUST_BACKTRACE="1"
              export RUSTFLAGS="''${RUSTFLAGS:-""} ${commonRustFlagsEnv} ${platformRustFlagsEnv}"
            '';
          };
      })
      pkgsFor;

    overlays = {
      helix = final: prev: {
        helix = final.callPackage ./default.nix {inherit gitRev;};
      };

      default = self.overlays.helix;
    };
  };
  nixConfig = {
    extra-substituters = ["https://helix.cachix.org"];
    extra-trusted-public-keys = ["helix.cachix.org-1:ejp9KQpR1FBI2onstMQ34yogDm4OgU2ru6lIwPvuCVs="];
  };
}

Top-Level Metadata

{
  description = "A post-modern text editor.";
}
  • This sets a human-readable description for the flake.

Inputs

inputs = {
  nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
  rust-overlay = {
    url = "github:oxalica/rust-overlay";
    inputs.nixpkgs.follows = "nixpkgs";
  };
};
  • nixpkgs: Uses the nixos-unstable branch of the Nixpkgs repository.

  • rust-overlay: follows the same nixpkgs, ensuring compatibility between inputs.

Outputs Function

outputs = { self, nixpkgs, rust-overlay, ... }:
  • This defines what this flake exports, including packages, devShells, etc.

Common Setup

let
  inherit (nixpkgs) lib;
  systems = [ ... ];
  eachSystem = lib.genAttrs systems;
  • systems: A list of the supported systems

  • eachSystem: A Helper to map over all platforms.

pkgsFor = eachSystem (system:
  import nixpkgs {
    localSystem.system = system;
    overlays = [(import rust-overlay) self.overlays.helix];
  });
  • This imports nixpkgs for each system and applies overlays

📦 packages

packages = eachSystem (system: {
  inherit (pkgsFor.${system}) helix;
  default = self.packages.${system}.helix;
});
  • For each platform:

    • Includes a helix package (defined in ./default.nix)

    • Sets default to helix (used by nix build, nix run)

Let's look at the helix default.nix:

{
  lib,
  rustPlatform,
  callPackage,
  runCommand,
  installShellFiles,
  git,
  gitRev ? null,
  grammarOverlays ? [],
  includeGrammarIf ? _: true,
}: let
  fs = lib.fileset;

  src = fs.difference (fs.gitTracked ./.) (fs.unions [
    ./.envrc
    ./rustfmt.toml
    ./screenshot.png
    ./book
    ./docs
    ./runtime
    ./flake.lock
    (fs.fileFilter (file: lib.strings.hasInfix ".git" file.name) ./.)
    (fs.fileFilter (file: file.hasExt "svg") ./.)
    (fs.fileFilter (file: file.hasExt "md") ./.)
    (fs.fileFilter (file: file.hasExt "nix") ./.)
  ]);

  # Next we actually need to build the grammars and the runtime directory
  # that they reside in. It is built by calling the derivation in the
  # grammars.nix file, then taking the runtime directory in the git repo
  # and hooking symlinks up to it.
  grammars = callPackage ./grammars.nix {inherit grammarOverlays includeGrammarIf;};
  runtimeDir = runCommand "helix-runtime" {} ''
    mkdir -p $out
    ln -s ${./runtime}/* $out
    rm -r $out/grammars
    ln -s ${grammars} $out/grammars
  '';
in
  rustPlatform.buildRustPackage (self: {
    cargoLock = {
      lockFile = ./Cargo.lock;
      # This is not allowed in nixpkgs but is very convenient here: it allows us to
      # avoid specifying `outputHashes` here for any git dependencies we might take
      # on temporarily.
      allowBuiltinFetchGit = true;
    };

    nativeBuildInputs = [
      installShellFiles
      git
    ];

    buildType = "release";

    name = with builtins; (fromTOML (readFile ./helix-term/Cargo.toml)).package.name;
    src = fs.toSource {
      root = ./.;
      fileset = src;
    };

    # Helix attempts to reach out to the network and get the grammars. Nix doesn't allow this.
    HELIX_DISABLE_AUTO_GRAMMAR_BUILD = "1";

    # So Helix knows what rev it is.
    HELIX_NIX_BUILD_REV = gitRev;

    doCheck = false;
    strictDeps = true;

    # Sets the Helix runtime dir to the grammars
    env.HELIX_DEFAULT_RUNTIME = "${runtimeDir}";

    # Get all the application stuff in the output directory.
    postInstall = ''
      mkdir -p $out/lib
      installShellCompletion ${./contrib/completion}/hx.{bash,fish,zsh}
      mkdir -p $out/share/{applications,icons/hicolor/{256x256,scalable}/apps}
      cp ${./contrib/Helix.desktop} $out/share/applications/Helix.desktop
      cp ${./logo.svg} $out/share/icons/hicolor/scalable/apps/helix.svg
      cp ${./contrib/helix.png} $out/share/icons/hicolor/256x256/apps/helix.png
    '';

    meta.mainProgram = "hx";
  })

Breaking Down helix/default.nix

This default.nix file is a Nix derivation that defines how to build the Helix editor itself. It's designed to be called by the main flake.nix as part of its packages output.

Here's a breakdown of its components:

  1. Function Arguments:
{
  lib,
  rustPlatform,
  callPackage,
  runCommand,
  installShellFiles,
  git,
  gitRev ? null,
  grammarOverlays ? [],
  includeGrammarIf ? _: true,
}:

lib: The Nixpkgs lib (library) functions, essential for common operations like fileset and strings.

rustPlatform: A helper function from Nixpkgs specifically for building Rust projects. It provides a buildRustPackage function, which simplifies the process significantly.

callPackage: A Nixpkgs function used to instantiate a Nix expression (like grammars.nix) with its dependencies automatically supplied from the current Nix environment.

runCommand: A Nixpkgs primitive that creates a derivation by running a shell command. It's used here to construct the runtimeDir.

installShellFiles: A utility from Nixpkgs for installing shell completion files.

git: The Git package, needed for determining the gitRev.

gitRev ? null: The Git revision of the Helix repository. It's an optional argument, defaulting to null. This is passed in from the main flake.nix.

grammarOverlays ? []: An optional list of overlays for grammars, allowing customization.

includeGrammarIf ? _: true: An optional function to control which grammars are included.

  1. Local Variables (let ... in)
let
  fs = lib.fileset;

  src = fs.difference (fs.gitTracked ./.) (fs.unions [
    ./.envrc
    ./rustfmt.toml
    ./screenshot.png
    ./book
    ./docs
    ./runtime
    ./flake.lock
    (fs.fileFilter (file: lib.strings.hasInfix ".git" file.name) ./.)
    (fs.fileFilter (file: file.hasExt "svg") ./.)
    (fs.fileFilter (file: file.hasExt "md") ./.)
    (fs.fileFilter (file: file.hasExt "nix") ./.)
  ]);

  grammars = callPackage ./grammars.nix { inherit grammarOverlays includeGrammarIf; };
  runtimeDir = runCommand "helix-runtime" {} ''
    mkdir -p $out
    ln -s ${./runtime}/* $out
    rm -r $out/grammars
    ln -s ${grammars} $out/grammars
  '';
in

fs = lib.fileset;: Aliases lib.fileset for convenient file set operations.

src: This is a crucial part. It defines the source files that will be used to build Helix by:

  • Taking all Git-tracked files in the current directory (fs.gitTracked ./.).

  • Excluding configuration files (e.g., .envrc, flake.lock), documentation (.md), images (.svg), and Nix files (.nix) using fs.difference and fs.unions. This ensures a clean build input, reducing Nix store size and avoiding unnecessary rebuilds.

  • grammars: Builds syntax grammars by calling grammars.nix, passing grammarOverlays (for customizing grammar builds) and includeGrammarIf (a filter for selecting grammars).

  • runtimeDir: Creates a runtime directory for Helix by:

    • Symlinking the runtime directory from the source.

    • Replacing the grammars subdirectory with a symlink to the grammars derivation, ensuring Helix uses Nix-managed grammars.

  1. The Build Derivation (rustPlatform.buildRustPackage)

The core of this default.nix is the rustPlatform.buildRustPackage call, which is a specialized builder for Rust projects:

in
  rustPlatform.buildRustPackage (self: {
    cargoLock = {
      lockFile = ./Cargo.lock;
      # ... comments ...
      allowBuiltinFetchGit = true;
    };

cargoLock: Specifies how Cargo dependencies are handled.

lockFile = ./Cargo.lock; Points to the Cargo.lock file for reproducible builds.

allowBuiltinFetchGit = true: Allows Cargo to fetch Git dependencies directly from repositories specified in Cargo.lock. This is discouraged in Nixpkgs because it can break build reproducibility, but it’s used here for convenience during development, eliminating the need to manually specify outputHashes for Git dependencies.

nativeBuildInputs = [
      installShellFiles
      git
    ];

nativeBuildInputs: Are tools needed during the build process but not necessarily at runtime.

buildType = "release";

buildType: Specifies that Helix should be built in "release" mode (optimized).

name = with builtins; (fromTOML (readFile ./helix-term/Cargo.toml)).package.name;
    src = fs.toSource {
      root = ./.;
      fileset = src;
    };

name: Dynamically sets the package name by reading it from the Cargo.toml file.

src: Uses the src file set defined earlier as the source for the build.

# Helix attempts to reach out to the network and get the grammars. Nix doesn't allow this.
    HELIX_DISABLE_AUTO_GRAMMAR_BUILD = "1";

    # So Helix knows what rev it is.
    HELIX_NIX_BUILD_REV = gitRev;

Environment Variables: Sets environment variables that Helix uses.

HELIX_DISABLE_AUTO_GRAMMAR_BUILD = "1": Prevents Helix from downloading grammars during the build, as Nix’s sandboxed environment disallows network access. Instead, grammars are provided via the runtimeDir derivation.

HELIX_NIX_BUILD_REV = gitRev: Embeds the specified Git revision (or null if unspecified) into the Helix binary, allowing Helix to display its version or commit hash.

doCheck = false;
   strictDeps = true;

doCheck = false;: Skips running tests during the build. This is common for faster builds, especially in CI/CD, but tests are often run in a separate checks output (as seen in the flake.nix).

strictDeps = true;: Ensures that all dependencies are explicitly declared.

# Sets the Helix runtime dir to the grammars
env.HELIX_DEFAULT_RUNTIME = "${runtimeDir}";
# Sets the Helix runtime dir to the grammars
env.HELIX_DEFAULT_RUNTIME = "${runtimeDir}";

env.HELIX_DEFAULT_RUNTIME: Tells Helix where to find its runtime files (including the Nix-managed grammars).

# Get all the application stuff in the output directory.
postInstall = ''
  mkdir -p $out/lib
  installShellCompletion ${./contrib/completion}/hx.{bash,fish,zsh}
  mkdir -p $out/share/{applications,icons/hicolor/{256x256,scalable}/apps}
  cp ${./contrib/Helix.desktop} $out/share/applications/Helix.desktop
  cp ${./logo.svg} $out/share/icons/hicolor/scalable/apps/helix.svg
  cp ${./contrib/helix.png} $out/share/icons/hicolor/256x256/apps/helix.png
'';

postInstall: A shell script that runs after the main build is complete. This is used for installing additional files that are part of the Helix distribution but not directly built by Cargo.

Installs shell completion files (hx.bash, hx.fish, hx.zsh). This enables tab completion.

Installs desktop entry files (Helix.desktop) and icons (logo.svg, helix.png) for desktop integration for GUI environments.

    meta.mainProgram = "hx";

})

meta.mainProgram: Specifies the primary executable provided by this package, allowing nix run to automatically execute hx.

A lot going on in this derivation!

Making Actual Changes

  1. Locate the packages output section. It looks like this:
packages = eachSystem (system: {
      inherit (pkgsFor.${system}) helix;
      /*
      The default Helix build. Uses the latest stable Rust toolchain, and unstable
      nixpkgs.

      The build inputs can be overridden with the following:

      packages.${system}.default.override { rustPlatform = newPlatform; };

      Overriding a derivation attribute can be done as well:

      packages.${system}.default.overrideAttrs { buildType = "debug"; };
      */
      default = self.packages.${system}.helix;
    });
  1. Modify the default package. The comments actually tell us exactly how to do this. We want to use overrideAttrs to change the buildType

Change this line:

default = self.packages.${system}.helix;

To this:

default = self.packages.${system}.helix.overrideAttrs { buildType = "debug"; };
  • This tells Nix to take the standard Helix package definition and override one of its internal attributes (buildType) to "debug" instead of "release".
  1. Build the "Hacked" Helix:
nix build
  • Nix will now rebuild Helix, but this time, it will compile it in debug mode. You'll likely notice the build takes a bit longer, and the resulting binary will be larger due to the included debugging symbols.
  1. Run the Debug Binary:
./result/bin/hx
  • You're now running your custom-built debug version of Helix! This is useful if you were, for example, attatching a debugger.

This is a simple yet powerful "hack" that demonstrates how easily you can modify the behavior of a package defined within a Nix flake without changing the original source code or the upstream flake directly. You're simply telling Nix how you'd like your version of the package to be built.

Another way to Modify Behavior

Since we are already familiar with the structure and behavior of Helix’s flake.nix, we can leverage that understanding to create our own Nix flake. By analyzing how Helix organizes its inputs, outputs, and package definitions, we gain the confidence to modify and extend a flake’s functionality to suit our specific needs—whether that’s customizing builds, adding overlays, or integrating with home-manager.

  1. Create a flake.nix in your own directory (outside the helix repo):
{
  description = "Customized Helix build with debug features";

  inputs = {
    helix.url = "github:helix-editor/helix";
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    rust-overlay = {
      url = "github:oxalica/rust-overlay";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };
  outputs = {
    self,
    helix,
    nixpkgs,
    rust-overlay,
  }: let
    system = "x86_64-linux";
    pkgs = import nixpkgs {
      system = system;
      overlays = [rust-overlay.overlay.overlays.default];
    };
  in {
    packages.${system}.default = helix.packages.${system}.helix.overrideAttrs (old: {
      buildType = "debug";

      # Add additional cargo features
      cargoBuildFlags =
        (old.cargoBuildFlags or [])
        ++ [
          "--features"
          "tokio-console"
        ];

      # Inject custom RUSTFLAGS
      RUSTFLAGS = (old.RUSTFLAGS or "") + " -C debuginfo=2 -C opt-level=1";
    });
  };
}

Check it:

nix flake check
warning: creating lock file '"/home/jr/world/flake.lock"':
• Added input 'helix':
    'github:helix-editor/helix/8961ae1dc66633ea6c9f761896cb0d885ae078ed?narHash=sha256-f14perPUk%2BH15GyGRbg0Akqhn3rxFnc6Ez5onqpzu6A%3D' (2025-05-29)
• Added input 'helix/nixpkgs':
    'github:nixos/nixpkgs/5135c59491985879812717f4c9fea69604e7f26f?narHash=sha256-Vr3Qi346M%2B8CjedtbyUevIGDZW8LcA1fTG0ugPY/Hic%3D' (2025-02-26)
• Added input 'helix/rust-overlay':
    'github:oxalica/rust-overlay/d342e8b5fd88421ff982f383c853f0fc78a847ab?narHash=sha256-3SdPQrZoa4odlScFDUHd4CUPQ/R1gtH4Mq9u8CBiK8M%3D' (2025-02-27)
• Added input 'helix/rust-overlay/nixpkgs':
    follows 'helix/nixpkgs'
• Added input 'nixpkgs':
    'github:nixos/nixpkgs/96ec055edbe5ee227f28cdbc3f1ddf1df5965102?narHash=sha256-7doLyJBzCllvqX4gszYtmZUToxKvMUrg45EUWaUYmBg%3D' (2025-05-28)
• Added input 'rust-overlay':
    'github:oxalica/rust-overlay/405ef13a5b80a0a4d4fc87c83554423d80e5f929?narHash=sha256-k0nhPtkVDQkVJckRw6fGIeeDBktJf1BH0i8T48o7zkk%3D' (2025-05-30)
• Added input 'rust-overlay/nixpkgs':
    follows 'nixpkgs'
  • The nix flake check command will generate a flake.lock file if one doesn't exist, and the warnings you see indicate that new inputs are being added and locked to specific versions for reproducibility. This is expected behavior for a new or modified flake.

Inspect the outputs:

nix flake show
path:/home/jr/world?lastModified=1748612128&narHash=sha256-WEYtptarRrrm0Jb/0PJ/b5VPqLkCk5iEenjbKYU4Xm8%3D
└───packages
    └───x86_64-linux
        └───default: package 'helix-term'
  • The └───packages line indicates that our flake exposes a top-level packages attribute.

  • └───x86_64-linux: System architecture specificity

  • └───default: package 'helix-term' Signifies that within the x86_64-linux packages, there's a package named default. This is a special name that allows you to omit the package name when using commands like nix build.

  • package 'helix-term' This is the most direct confirmation of our "hack". It tells us that our default package is helix-term. This confirms that our overrideAttrs in the packages.${system}.default section successfully targeted and modified the Helix editor package, which is internally named helix-term by the Helix flake.

What This Does:

  • overrideAttrs lets you change only parts of the derivation without rewriting everything.

  • buildType = "debug" enables debug builds.

  • cargoBuildFlags adds extra features passed to Cargo, e.g., --features tokio-console

  • RUSTFLAGS gives you even more control over compiler behavior, optimization levels, etc.

Run It:

nix run

Or drop into the dev shell:

nix develop
  • (assuming you also wire in a devShells output)

Adding the devShells output:

Since we already have the helix flake as an input to our own flake.nix we can now forward or extend Helix's devShells like this:

outputs = { self, nixpkgs, helix, rust-overlay, ... }: {
  devShells = helix.devShells;
};

Or if you want to pick a specific system:

outputs = { self, nixpkgs, helix, rust-overlay ... }:
  let
    system = "x86_64-linux";
  in {
    devShells.${system} = helix.devShells.${system};
  };

Optional: Combine with your own devShell

You can also extend or merge it with your own shell like so:

outputs = { self, nixpkgs, helix, rust-overlay, ... }:
  let
    system = "x86_64-linux";
    pkgs = import nixpkgs { inherit system; };
  in {
    devShells.${system} = {
      default = pkgs.mkShell {
        name = "my-shell";
        inputsFrom = [ helix.devShells.${system}.default ];
        buildInputs = [ pkgs.git ];
      };
    };
  };

Extending Flakes with Custom Packages using Overlays

✔️ Click to Expand Table of Contents

Pokego Logo

Overlays are Nix functions that accept two arguments, final and prev and return a set of packages. Overlays are similar to packageOverrides as a way to customize Nixpkgs, packageOverrides acts as an overlay with only the prev argument. Therefore, packageOverrides is appropriate for basic use, but overlays are more powerful and easier to distribute.

Example:

final: prev: {
  firefox = prev.firefox.overrideAttrs (old: {
    buildInputs = (old.buildInputs or []) ++ [ prev.vlc ];
    env.FIREFOX_DISABLE_GMP_UPDATER = "1";
  });
}

To see the original derivation, run nix edit -f "<nixpkgs>" firefox.

This modifies Firefox by:

  • Adding vlc to buildInputs, useful if a package requires additional dependencies.

  • Setting an environment variable (FIREFOX_DISABLE_GMP_UPDATER=1) to disable automatic updates of the Gecko Media Plugin.

It is very common to use overlays in Nix to install packages that aren't available in the standard Nixpkgs repository.

Overlays are one of the primary and recommended ways to extend and customize your Nix environment. It's important to remember that Nix overlays are made to allow you to modify or extend the package set provided by Nixpkgs (or other Nix sources) without directly altering the original package definitions. This is crucial for maintaining reproducibility and avoiding conflicts. Overlays are essentially functions that take the previous package set and allow you to add, modify, or remove packages.

  • To better understand the structure of my flake.nix it may be helpful to first read This blog post first.

Adding the overlays output to your Flake

I'll show the process of adding the pokego package that is not in Nixpkgs:

  1. In my flake.nix I have a custom inputs variable within my let block of my flake like so just showing the necessary parts for brevity:
# flake.nix
  outputs = my-inputs @ {
    self,
    nixpkgs,
    treefmt-nix,
    ...
  }: let
    system = "x86_64-linux";
    host = "magic";
    userVars = {
      username = "jr";
      gitUsername = "saylesss88";
      editor = "hx";
      term = "ghostty";
      keys = "us";
      browser = "firefox";
      flake = builtins.getEnv "HOME" + "/flake";
    };

    inputs =
      my-inputs
      // {
        pkgs = import inputs.nixpkgs {
          inherit system;
        };
        lib = {
          overlays = import ./lib/overlay.nix;
          nixOsModules = import ./nixos;
          homeModules = import ./home;
          inherit system;
        };
      };
      # ... snip ...
  • Why I Created inputs.lib in My flake.nix. In the above example, you'll notice a lib attribute defined within the main let block.

    • This might seem a bit unusual at first, as inputs are typically defined at the top level of a flake. However, this structure provides a powerful way to organize and reuse common Nix functions and configurations across my flake.

    • By bundling my custom logic and modules into inputs.lib, I can pass inputs (which now includes my custom lib) as a specialArgs to other modules. This provides a clean way for all modules to access these shared resources. For example, in configuration.nix, inputs.lib.overlays directly references my custom overlay set.

    • My inputs.lib is my own project-specific library, designed to hold functions and attribute sets relevant to my flake's custom configurations. While nixpkgs.lib is globally available, my custom lib contains my unique additions.

While defining inputs within the let block to achieve this structure is a personal preference and works well for my setup, the core benefit is the creation of a dedicated, centralized lib attribute that encapsulates my flake's reusable Nix code, leading to a more organized and maintainable configuration.

The Actual Overlay

  1. In the overlay.nix I have this helper function and the defined package:
# overlay.nix
_final: prev: let
  # Helper function to import a package
  callPackage = prev.lib.callPackageWith (prev // packages);

  # Define all packages
  packages = {
    # Additional packages
    pokego = callPackage ./pac_defs/pokego.nix {};
  };
in
  packages
  1. _final: prev:: This is the function definition of the overlay.
  • _final: This argument represents the final, merged package set after all overlays have been applied. It's often unused within a single overlay, hence the _ prefix (a Nix convention for unused variables).

  • prev: This is the crucial argument. It represents the package set before this overlay is applied. This allows you to refer to existing packages and functions from Nixpkgs.

  1. let ... in packages: This introduces a let expression, which defines local variables within the scope of this overlay function. The in packages part means that the overlay function will ultimately return the packages attribute set defined within the let block.

  2. callPackage = prev.lib.callPackageWith (prev // packages): This line defines a helper function called callPackage.

  • prev.lib.callPackageWith Is a function provided by Nixpkgs' lib. callPackageWith is like prev.lib.callPackage, but allows the passing of additional arguments that will then be passed to the package definition.

  • (prev // packages): This is an attribute set merge operation. It takes the prev package set (Nixpkgs before this overlay) and merges it with the packages attribute set defined later in this overlay.

  • By using callPackageWith with this merged attribute set, the callPackage function defined here is set up to correctly import package definitions, ensuring they have access to both the original Nixpkgs and any other packages defined within this overlay.

  1. packages = { ... };: This defines an attribute set named packages. This set will contain all the new or modified packages introduced by this overlay.

  2. pokego = callPackages ./pac_defs/pokego.nix { };: This is the core of how the pokego package is added.

  • pokego =: This defines a new attribute named pokego within the packages attribute set. This name will be used to refer to the pokego package later.

  • callPackage ./pac_defs/pokego.nix {}: This calls the callPackage helper function defined earlier.

  • ./pac_defs/pokego.nix: This is the path to another Nix file(pokego.nix) that contains the actual package definition for pokego. This file would define how to fetch, build, and install the pokego software

  • {}: This is an empty attribute set passed as additional arguments to the pokego.nix package definition. If pokego.nix expected any specific parameters (like versions or dependencies), you would provide them here. Since it's empty, it implies pokego.nix either has no required arguments or uses default values.

  1. in packages: As mentioned earlier, the overlay function returns the packages attribute set. When this overlay is applied, the packages defined within this packages set (including pokego) will be added to the overall Nix package set.

The pokego Package definition

The following is the ./pac_defs/pokego.nix:

# pokego.nix
{
  lib,
  buildGoModule,
  fetchFromGitHub,
}:
buildGoModule rec {
  pname = "pokego";
  version = "0.3.0";

  src = fetchFromGitHub {
    owner = "rubiin";
    repo = "pokego";
    rev = "v${version}";
    hash = "sha256-cFpEi8wBdCzAl9dputoCwy8LeGyK3UF2vyylft7/1wY=";
  };

  vendorHash = "sha256-7SoKHH+tDJKhUQDoVwAzVZXoPuKNJEHDEyQ77BPEDQ0=";

  # Install shell completions
  postInstall = ''
    install -Dm644 completions/pokego.bash "$out/share/bash-completion/completions/pokego"
    install -Dm644 completions/pokego.fish "$out/share/fish/vendor_completions.d/pokego.fish"
    install -Dm644 completions/pokego.zsh "$out/share/zsh/site-functions/_pokego"
  '';

  meta = with lib; {
    description = "Command-line tool that lets you display Pokémon sprites in color directly in your terminal";
    homepage = "https://github.com/rubiin/pokego";
    license = licenses.gpl3Only;
    maintainers = with maintainers; [
      rubiin
      jameskim0987
      vinibispo
    ];
    mainProgram = "pokego";
    platforms = platforms.all;
  };
}

Adding the overlay to your configuration

There are a few places you could choose to put the following, I choose to use my configuration.nix because of my setup:

# configuration.nix
nixpkgs.overlays = [inputs.lib.overlays]

Installing Pokego

  • If you are managing your entire system configuration with NixOS, you would typically add pokego to your environment.systemPackages.
# configuration.nix
environment.systemPackages = with pkgs; [
  pokego
]
  • If you prefer home-manager you can install pokego with home-manager also:
# home.nix
home.packages = [
  pkgs.pokego
]

Another Overlay Example

{
  inputs = {
    nixpkgs.url = "https://flakehub.com/NixOS/nixpkgs/*.tar.gz";

    nix.url = "https://flakehub.com/f/NixOS/nix/2.17.0.tar.gz";
  };

  outputs = { self, nixpkgs, nix }:

    let
      system = "aarch64-darwin";
      pkgs = import nixpkgs {
        inherit system;
        overlays = [
          nix.overlays.default
        ];
      };
    in
    {
     # `pkgs` is nixpkgs for the system, with nix's overlay applied
    };
}
  • Normally, pkgs = import nixpkgs { }`` imports Nixpkgs with default settings. However, the example above customizes this import by passing arguments: pkgs = import nixpkgs { inherit system; overlays = [ nix.overlays.default];}. This makes the pkgs variable represent nixpkgs specifically for the aarch64-darwin` system, with the overlay from the nix flake applied.

  • Consequently, any packages built using this customized pkgs will now depend on or use the specific nix version (2.17.0) provided by the nix flake, instead of the version that comes with the fetched nixpkgs. This technique can be useful for ensuring a consistent environment or testing specific package versions.

Customizing Nixpkgs Imports and Overlays

While overlays are typically used to add or modify packages within a single nixpkgs instance, Nix's lazy evaluation and flake inputs allow for even more powerful scenarios. You can have multiple versions of nixpkgs in a single flake, and they will only be evaluated when a package from that specific version is actually referenced. This complements overlays by giving you fine-grained control over which nixpkgs instance an overlay applies to, or which nixpkgs version a specific part of your project depends on.

Consider this example where we import nixpkgs with a specific overlay applied directly at the import site:

{
  inputs = {
    nixpkgs.url = "[https://flakehub.com/NixOS/nixpkgs/*.tar.gz](https://flakehub.com/NixOS/nixpkgs/*.tar.gz)"; # This will be the base nixpkgs

    nix.url = "[https://flakehub.com/f/NixOS/nix/2.17.0.tar.gz](https://flakehub.com/f/NixOS/nix/2.17.0.tar.gz)"; # This flake provides an overlay for a specific Nix version
  };

  outputs = { self, nixpkgs, nix }:

    let
      system = "aarch64-darwin";
      # Here, we import nixpkgs and apply the 'nix' flake's overlay.
      # This 'pkgs' variable now holds a customized Nix package set.
      # In this 'pkgs' set, the 'nix' package (and anything that depends on it)
      # will be version 2.17.0 as defined by the 'nix' flake's overlay.
      pkgs_with_custom_nix = import nixpkgs {
        inherit system;
        overlays = [
          nix.overlays.default # Apply the overlay from the 'nix' flake here
        ];
      };
    in
    {
      # We can then expose packages or devShells that use this customized `pkgs` set.
      devShells.${system}.default = pkgs_with_custom_nix.mkShell {
        packages = [
          pkgs_with_custom_nix.nix # This 'nix' package is now version 2.17.0 due to the overlay!
        ];
        shellHook = ''
          echo "Using Nix version: <span class="math-inline">\(nix \-\-version\)"
'';
};
# You can also make this customized package set available as a top-level overlay
# if other parts of your flake or configuration want to use it.
# overlays.custom-nix-version = final: prev: {
#   inherit (pkgs_with_custom_nix) nix; # Expose the specific nix package from our overlayed pkgs
# };
# You can also import multiple versions of nixpkgs and select packages from them:
# pkgs-2505 = import (inputs.nixpkgs-2505 or nixpkgs) { inherit system; }; # Example, assuming 2505 is an input
# packages.</span>{system}.my-tool-2505 = pkgs-2505.myTool; # Using a package from a specific stable version
    };
}

Normally, pkgs = import nixpkgs { } imports Nixpkgs with default settings. However, the example above customizes this import by passing arguments: pkgs = import nixpkgs { inherit system; overlays = [ nix.overlays.default];}. This makes the pkgs_with_custom_nix variable represent Nixpkgs specifically for the aarch64-darwin system, with the overlay from the nix flake applied at the time of import.

Consequently, any packages built using this customized pkgs_with_custom_nix will now depend on or use the specific Nix version (2.17.0) provided by the nix flake's overlay, instead of the version that comes with the base nixpkgs input. This technique is highly useful for ensuring a consistent environment or testing specific package versions without affecting the entire system's nixpkgs set.

NixOS Specialisations For Multiple Profiles

✔️ Click to Expand Table of Contents

NixOS specialisations are a powerful feature that allow you to define alternative system configurations variations within a single NixOS setup. Each specialisation can modify or extend the base configuration, and NixOS will generate separate boot entries for each, letting you choose at boot time (or switch at runtime) which environment to use. This is ideal for testing, hardware-specific tweaks, or separating work and personal environments without maintaining multiple configuration files

How Specialisations Work

Specialisations are defined as attributes under the specialisation option in your configuration. Each key (e.g., niri-test) represents a named specialisation, and its configuration attribute contains the NixOS options to apply on top of the base system

By default, a specialisation inherits the parent configuration and applies its changes on top. You can also set inheritParentConfig = false; to create a completely separate configuration.

After running nixos-rebuild boot, your bootloader will present extra entries for each specialisation. Selecting one boots into the system with that specialisation's settings applied

Runtime Switching: You can switch to a specialisation at runtime using activation scripts, e.g.:

nixos-rebuild switch --specialisation niri-test

or

/run/current-system/specialisation/niri-test/bin/switch-to-configuration switch

Note: Some changes (like kernel switches) require a reboot to take effect

Example: Let's create a basic specialisation to try out the Niri Window Manager:

First we have to add the niri-flake as an input to our flake.nix and add the module to install it:

# flake.nix
inputs = {
     niri.url = "github:sodiboo/niri-flake";
};
# configuration.nix
{ pkgs, inputs, ... }: {
# ... snip ...
imports = [
    inputs.niri.nixosModules.niri
];

# This is the top-level overlay
  nixpkgs.overlays = [inputs.niri.overlays.niri];

# ... snip ...

  specialisation = {
    niri-test.configuration = {
      system.nixos.tags = ["niri"];

      # Add the Niri overlay for this specialisation
      nixpkgs.overlays = [inputs.niri.overlays.niri];

      # Enable Niri session
      programs.niri = {
        enable = true;
        package = pkgs.niri-unstable;
      };

      # Optionally, add a test user and greetd for login
      users.users.niri = {
        isNormalUser = true;
        extraGroups = ["networkmanager" "video" "wheel"];
        initialPassword = "test"; # for testing only!
        createHome = true;
      };

      services.greetd = {
        enable = true;
        settings = rec {
          initial_session = {
            command = lib.mkForce "${pkgs.niri}/bin/niri";
            user = lib.mkForce "niri";
          };
          default_session = initial_session;
        };
      };

      environment.etc."niri/config.kdl".text = ''
        binds {
          Mod+T { spawn "alacritty"; }
          Mod+D { spawn "fuzzel"; }
          Mod+Q { close-window; }
          Mod+Shift+Q { exit; }
        }
      '';
      environment.systemPackages = with pkgs; [
        alacritty
        waybar
        fuzzel
        mako
        firefox
      ];

      programs.firefox.enable = true;

      services.pipewire = {
        enable = true;
        alsa.enable = true;
        pulse.enable = true;
        # Optionally:
        jack.enable = true;
      };

      hardware.alsa.enablePersistence = true;

      networking.networkmanager.enable = true;
    };
  };
}

I chose to use the nightly version so it was required to add the overlay at the top-level as well as inside the specialisation block.

On my system it sped up build times to first run:

sudo nixos-rebuild switch --flake .
# And Then Run
sudo nixos-rebuild boot --flake .

What this does:

  • Creates a boot entry called niri-test with the Niri Wayland compositor, a test user, and a greetd login manager.

  • Installs a set of packages and enables PipeWire with ALSA, PulseAudio, and JACK support.

  • Provides a custom Niri configuration file for a few keybinds and enables NetworkManager.

Using Your Specialisation After Boot

Once you have rebooted and selected your specialisation from the boot menu, you can use your system as usual. If you want to add or remove programs, change settings, or update your environment within a specialisation, simply:

  1. Edit your configuration: Add or remove packages (e.g., add ghostty to environment.systemPackages) or change any other options inside the relevant specialisation block in your NixOS configuration.

  2. Apply changes with a rebuild: Run the standard NixOS rebuild command. If you are currently running the specialisation you want to update, use:

sudo nixos-rebuild switch

This will apply your changes to the current specialisation

If you want to build and activate a different specialisation from your current session, use:

sudo nixos-rebuild switch --specialisation <name>

Or, you can activate a specialisation directly with:

sudo /run/current-system/specialisation/<name>/bin/switch-to-configuration switch

Replace with your specialisation’s name.

Reboot if needed: Most changes apply immediately, but some (like kernel or initrd changes) require a reboot for the specialisation to fully take effect

Tip:

Each specialisation can have its own set of installed programs. Only those listed in the environment.systemPackages (or enabled via modules) inside the specialisation block will be available when you boot into that context.

You manage and update your specialisation just like your main NixOS system no special commands or workflow are required beyond specifying the specialisation when rebuilding or switching.

Use Cases for Specialisations

  • Hardware Profiles: Enable/disable drivers or services for specific hardware (e.g., eGPU, WiFi, or SR-IOV setups)

  • Desktop Environments: Quickly switch between different desktop environments or compositors (e.g., GNOME, Plasma, Niri)

  • Testing: Safely try out unstable packages, new kernels, or experimental features without risking your main environment

  • User Separation: Create profiles for different users, each with their own settings, packages, and auto-login

  • Secure Environments: Combine with encrypted partitions for more secure, isolated setups

Securely Separated Contexts with NixOS Specialisations

I will just explain the concept here for completeness, if you want to implement this I recommend following:

Tweag Hard User Separation with NixOS

✔️ Click To Expand Section on Separate Contexts

If you use the same computer in different contexts such as for work and for your private life you may worry about the risks of mixing sensitive environments. For example, a cryptolocker received through a compromised work email could potentially encrypt your personal files, including irreplaceable family photos.

A common solution is to install two different operating systems and dual-boot between them, keeping work and personal data isolated. However, this approach means you have two systems to maintain, update, and configure, which can be a significant hassle.

NixOS offers a third alternative: With NixOS specialisations, you can manage two (or more) securely separated contexts within a single operating system. At boot time, you select which context you want to use work or personal. Each context can have its own encrypted root partition, user accounts, and configuration, but both share the same Nix store for packages. This means:

  • No duplicated packages: Both contexts use the same system-wide package store, saving space and simplifying updates.

  • Single system to maintain: You update and manage only one NixOS installation, not two.

  • Strong security boundaries: Each context can have its own encrypted root, so a compromise in one context (such as malware in your work environment) cannot access the data in the other context.

  • Flexible management: You can configure both contexts from either environment, making administration easier.

This approach combines the security of dual-booting with the convenience and efficiency of a single, unified system.

How It Works:

  • Encrypted Partitions: Each context (work and personal) has its own encrypted root partition. The shared /nix/store partition is also encrypted, but can be unlocked by either context.

  • Specialisations at Boot: NixOS generates multiple boot entries, one for each context. You simply choose your desired environment at boot time.

  • Separation of Data: Your work and personal home directories, settings, and documents remain isolated from each other, while still benefiting from shared system packages.

Benefits Over Traditional Dual-Boot

  • Only one system to update and configure.

  • No wasted disk space on duplicate packages.

  • Seamless switching between contexts with a reboot.

  • Consistent NixOS tooling and workflows in both environments.

What You Need

  • A physical or virtual machine supported by NixOS.

  • Willingness to erase the system disk during setup.

  • LVM (Logical Volume Manager) support: This setup requires using LVM for disk partitioning and management. LVM allows you to create multiple logical volumes on a single physical disk, making it possible to securely separate your work and personal environments while sharing a common Nix store. You will use LVM commands such as pvcreate, vgcreate, and lvcreate to prepare your disk layout

In summary: With NixOS specialisations and careful disk partitioning, you can achieve secure, convenient, and efficient context separation—no need to compromise between security and manageability.

Tips and Best Practices

  • Overriding Values: Use lib.mkDefault or lib.mkForce to make options overridable or forced in specialisations. I had to do it above because I have greetd setup for my main configuration as well.

  • Selective Configuration: If you want certain options only in the default (non-specialised) system, use:

config = lib.mkIf (config.specialisation != {}) { ... }
  • This condition checks if you're in a specialisation.

  • Any settings inside this block will not be inherited by specialisations, keeping them exclusive to the main system.

  • Runtime Limitations: Not all changes (e.g., kernel or initrd) can be fully applied at runtime; a reboot is required for those.

  • Modularity: Specialisations work well with modular NixOS configs keep hardware, user, and service configs in separate files for easier management

References to Official Documentation and Community Resources

Chapter 5

✔️ Click to Expand Table of Contents

Understanding Top-Level Attributes in NixOS Modules

This explanation is based on insights from Infinisil, a prominent figure in the Nix community, to help clarify the concept of top-level attributes within NixOS modules.

The Core of a NixOS System: system.build.toplevel

✔️ `system.build.toplevel` Explained (Click to Expand)

In a NixOS system, everything is built from a single "system derivation." The command nix-build '<nixpkgs/nixos>' -A system initiates this build process.

The -A system part tells Nix to focus on the system attribute defined in the '<nixpkgs/nixos>' file (which is essentially ./default.nix within the Nixpkgs repository).

This system attribute is specifically the NixOS option system.build.toplevel . Think of system.build.toplevel as the very top of the configuration hierarchy for your entire NixOS system. Almost every setting you configure eventually influences this top-level derivation, often through a series of intermediate steps.

Key Takeaway: system.build.toplevel is the ultimate output that defines your entire NixOS system.

How Options Relate: A Chain of Influence

Options in NixOS are not isolated; they often build upon each other.

Example: Nginx Option Chain (Click to Expand)

Here's an example of how a high-level option can lead down to a low-level system configuration:

  • You enable Nginx with services.nginx.enable = true;.
  • This setting influences the lower-level option systemd.services.nginx.
  • Which, in turn, affects the even lower-level option systemd.units."nginx.service".
  • Ultimately, this leads to the creation of a systemd unit file within environment.etc."systemd/system".
  • Finally, this unit file ends up as result/etc/systemd/system/nginx.service within the final system.build.toplevel derivation.

Key Takeaway: Higher-level, user-friendly options are translated into lower-level system configurations that are part of the final system build.

The NixOS Module System: Evaluating Options

So, how do these options get processed and turned into the final system configuration? That's the job of the NixOS module system, located in the ./lib directory of Nixpkgs (specifically in modules.nix, options.nix, and types.nix).

Interestingly, the module system isn't exclusive to NixOS; you can use it to manage option sets in your own Nix projects.

Here's a simplified example of using the module system outside of NixOS:

let
  systemModule = { lib, config, ... }: {
    options.toplevel = lib.mkOption {
      type = lib.types.str;
    };

    options.enableFoo = lib.mkOption {
      type = lib.types.bool;
      default = false;
    };

    config.toplevel = ''
      Is foo enabled? ${lib.boolToString config.enableFoo}
    '';
  };

  userModule = {
    enableFoo = true;
  };

in (import <nixpkgs/lib>).evalModules {
  modules = [ systemModule userModule ];
}

You can evaluate the config.toplevel option from this example using:

nix-instantiate --eval file.nix -A config.toplevel

Key Takeaway: The NixOS module system is responsible for evaluating and merging option configurations from different modules.

How the Module System Works: A Simplified Overview

The module system processes a set of "modules" through these general steps:

✔️ Detailed Steps (Click to Expand)
  1. Importing Modules: It recursively finds and includes all modules specified in imports = [ ... ]; statements.

  2. Declaring Options: It collects all option declarations defined using options = { ... }; from all the modules and merges them. If the same option is declared in multiple modules, the module system handles this (details omitted for simplicity).

  3. Defining Option Values: For each declared option, it gathers all the value assignments (defined using config = { ... }; or directly at the top level if no options or config are present) from all modules and merges them according to the option's defined type.

Important Note: Option evaluation is lazy, meaning an option's value is only computed when it's actually needed. It can also depend on the values of other options.

Key Takeaway: The module system imports, declares, and then evaluates option values from various modules to build the final configuration.

Top-Level Attributes in a Module: imports, options, and config

Within a NixOS module (the files that define parts of your system configuration) , the attributes defined directly at the top level of the module's function have specific meanings:

  • imports: This attribute is a list of other module files to include. Their options and configurations will also be part of the evaluation.

  • options: This attribute is where you declare new configuration options. You define their type, default value, description, etc., using functions like lib.mkOption or lib.mkEnableOption.

  • config: This attribute is where you assign values to the options that have been declared (either in the current module or in imported modules).

Key Takeaway: The top-level attributes imports, options, and config are the primary ways to structure a NixOS module.

The Rule: Move Non-Option Attributes Under config

If you define either an options or a config attribute at the top level of your module, any other attributes that are not option declarations must be moved inside the config attribute.

✔️ Examples of Correct and Incorrect Usage (Click to Expand)

Let's look at an example of what not to do:

{ pkgs, lib, config, ... }:
{
imports = [];

# Defining an option at the top level

options.mine.desktop.enable = lib.mkEnableOption "desktop settings";

# This will cause an error because 'environment' and 'appstream'

# are not 'options' and 'config' is also present at the top level.

environment.systemPackages =
lib.mkIf config.appstream.enable [ pkgs.git ];

appstream.enable = true;
}

This will result in the error: error: Module has an unsupported attribute 'appstream' This is caused by introducing a top-level 'config' or 'options' attribute. Add configuration attributes immediately on the top level instead, or move all of them into the explicit 'config' attribute.

Key Takeaway: When you have options or config at the top level, all value assignments need to go inside the config block.

The Correct Way): Using the config Attribute

To fix the previous example, you need to move the value assignments for environment.systemPackages and appstream.enable inside the config attribute:

{ pkgs, lib, config, ... }:
{
imports = [];

# Defining an option at the top level

options.mine.desktop.enable = lib.mkEnableOption "desktop settings";

config = {
environment.systemPackages =
lib.mkIf config.appstream.enable [ pkgs.git ];

    appstream.enable = true;

};
}

Now, Nix knows that you are declaring an option (options.mine.desktop.enable) and then setting values for other options (environment.systemPackages, appstream.enable) within the config block.

Key Takeaway: The config attribute is used to define the values of options.

Implicit config: When options is Absent

If your module does not define either options or config at the top level, then any attributes you define directly at the top level are implicitly treated as being part of the config.

For example, this is valid:

{ pkgs, lib, config, ... }:
{
environment.systemPackages =
lib.mkIf config.appstream.enable [ pkgs.git ];

appstream.enable = true;
}

Nix will implicitly understand that environment.systemPackages and appstream.enable are configuration settings.

Key Takeaway: If no explicit options or config are present, top-level attributes are automatically considered part of the configuration.

Removing an Option: What Happens to config

Even if you remove the options declaration from a module that has a config section, the config = { environment.systemPackages = ... }; part will still function correctly, assuming the option it's referencing (appstream.enable in this case) is defined elsewhere (e.g., in an imported module).

Key Takeaway: The config section defines values for options, regardless of whether those options are declared in the same module.

Conclusion

Understanding the nuances of top-level attributes within NixOS modules, particularly imports, options, and config, is fundamental to structuring and managing your system's configuration effectively. As we've seen, the module system provides a powerful and declarative way to define and evaluate system settings, ultimately contributing to the construction of the system.build.toplevel derivation that represents your entire NixOS environment.

The concepts of option declaration and value assignment, along with the crucial rule of organizing non-option attributes under the config attribute when options is present, provide a clear framework for building modular and maintainable configurations.

Now that we have a solid grasp of how NixOS modules are structured and how they contribute to the final system derivation, it's a natural next step to explore the tangible results of these configurations: the software and system components themselves. These are built and managed by a core concept in Nix, known as derivations.

In the next chapter, Package Definitions Explained we will shift our focus from the abstract configuration to the concrete software packages. We will learn how Nix uses package definitions to create derivations, which are the actual build plans that produce the software we use on our NixOS systems. This will bridge the gap between configuring your system and understanding how the software within it is managed.

Chapter 6

✔️ Click to Expand Table of Contents

gruv1

Package Definitions Explained

In Nix, the concept of a package can refer to two things:

  • A collection of files and data that constitute a piece of software or an artifact.

  • A Nix expression that describes how to create such a collection. This expression acts as a blueprint before the package exists in a tangible form.

The process begins with writing a package definition using the Nix language. This definition contains the necessary instructions and metadata about the software you intend to "package."

The Journey from Definition to Package

✔️ Click to Expand
  1. Package Definition:

    • This is essentially a function written in the Nix language.

    • Nix language shares similarities with JSON but includes the crucial addition of functions.

    • It acts as the blueprint for creating a package.

  2. Derivation:

    • When the package definition is evaluated by Nix, it results in a derivation.

    • A derivation is a concrete and detailed build plan.

    • It outlines the exact steps Nix needs to take: fetching source code, building dependencies, compiling code, and ultimately producing the desired output (the package).

  3. Realization (Building the Package):

    • You don't get a pre-built "package" directly from the definition or the derivation.

    • The package comes into being when Nix executes the derivation. This process is often referred to as "realizing" the derivation.

Analogy: Think of a package definition as an architectural blueprint, the derivation as the detailed construction plan, and the realized package as the finished building.

## Skeleton of a Derivation

The most basic derivation structure in Nix looks like this:

{ stdenv }:

stdenv.mkDerivation { }
  • This is a function that expects an attribute set containing stdenv as its argument.

  • It then calls stdenv.mkDerivation (a function provided by stdenv) to produce a derivation.

  • Currently, this derivation doesn't specify any build steps or outputs.

  • Further Reading:

  • The Standard Environment

  • Fundamentals of Stdenv

Example: A Simple "Hello" Package Definition

Here's a package definition for the classic "hello" program:

# hello.nix
{
  stdenv,
  fetchzip,
}:

stdenv.mkDerivation {
  pname = "hello";
  version = "2.12.1";

  src = fetchzip {
    url = "[https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz](https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz)";
    sha256 = "";
  };
}
  • This is a Nix function that takes stdenv and fetchzip as arguments.

  • It uses stdenv.mkDerivation to define the build process for the "hello" package.

    • pname: The package name.

    • version: The package version.

    • src: Specifies how to fetch the source code using fetchzip.

Handling Dependencies: Importing Nixpkgs

  • If you try to build hello.nix directly with nix-build hello.nix, it will fail because stdenv and fetchzip are part of Nixpkgs, which isn't included in this isolated file.

  • To make this package definition work, you need to pass the correct arguments (stdenv, fetchzip) to the function.

The recommended approach is to create a default.nix file in the same directory:

# default.nix

let
  nixpkgs = fetchTarball "[https://github.com/NixOS/nixpkgs/tarball/nixos-24.05](https://github.com/NixOS/nixpkgs/tarball/nixos-24.05)";
  pkgs = import nixpkgs { config = {}; overlays = []; };
in
{
  hello = pkgs.callPackage ./hello.nix { };
}
  • This default.nix imports Nixpkgs.

  • It then uses pkgs.callPackage to call the function in hello.nix, passing the necessary dependencies from Nixpkgs.

  • You can now build the "hello" package using: nix-build -A hello. The -A flag tells Nix to build the attribute named hello from the top-level expression in default.nix.

Realizing the Derivation and Handling sha256

  • Evaluation vs. Realization: While "evaluate" refers to Nix processing an expression, "realize" often specifically means building a derivation and producing its output in the Nix store.

  • When you first run nix-build -A hello, it will likely fail due to a missing sha256 hash for the source file. Nix needs this hash for security and reproducibility. The error message will provide the correct sha256 value.

  • Example Error:

  nix-build -A hello
  error: hash mismatch in fixed-output derivation '/nix/store/pd2kiyfa0c06giparlhd1k31bvllypbb-source.drv':
  specified: sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
  got: sha256-1kJjhtlsAkpNB7f6tZEs+dbKd8z7KoNHyDHEJ0tmhnc=
  error: 1 dependencies of derivation '/nix/store/b4mjwlv73nmiqgkdabsdjc4zq9gnma1l-hello-2.12.1.drv' failed to build
  • Replace the empty sha256 = ""; in hello.nix with the provided correct value: sha256 = "1kJjhtlsAkpNB7f6tZEs+dbKd8z7KoNHyDHEJ0tmhnc=";.

Building and Running the Result

After updating the sha256, you can successfully build the package:

nix-build -A hello

The output will be a result symlink pointing to the built package in the Nix store. You can then run the "hello" program:

./result/bin/hello
Hello, world!

Swaytools Package Definition

Example: The swaytools Package Definition

Let's examine a more complex, real-world package definition from Nixpkgs: nixpkgs/pkgs/tools/wayland/swaytools/default.nix.

# default.nix
{
  lib,
  setuptools,
  buildPythonApplication,
  fetchFromGitHub,
  slurp,
}:

buildPythonApplication rec {
  pname = "swaytools";
  version = "0.1.2";

  format = "pyproject";

  src = fetchFromGitHub {
    owner = "tmccombs";
    repo = "swaytools";
    rev = version;
    sha256 = "sha256-UoWK53B1DNmKwNLFwJW1ZEm9dwMOvQeO03+RoMl6M0Q=";
  };

  nativeBuildInputs = [ setuptools ];

  propagatedBuildInputs = [ slurp ];

  meta = with lib; {
    homepage = "https://github.com/tmccombs/swaytools";
    description = "Collection of simple tools for sway (and i3)";
    license = licenses.gpl3Only;
    maintainers = with maintainers; [ atila ];
    platforms = platforms.linux;
  };
}

Breakdown of the Above default.nix

Click to Expand

1 Function Structure:

  • The file starts with a function taking an attribute set of dependencies from Nixpkgs: { lib, setuptools, buildPythonApplication, fetchFromGitHub, slurp } :.
  1. Derivation Creation:
  • It calls buildPythonApplication, a specialized helper for Python packages (similar to stdenv.mkDerivation but pre-configured for Python). The rec keyword allows attributes within the derivation to refer to each other.
  1. Package Metadata:
  • pname and version define the package's name and version.

  • The meta attribute provides standard package information like the homepage, description, license, maintainers, and supported platforms.

  1. Source Specification:
  • The src attribute uses fetchFromGitHub to download the source code from the specified repository and revision, along with its sha256 hash for verification.
  1. Build and Runtime Dependencies:
  • nativeBuildInputs: Lists tools required during the build process (e.g., setuptools for Python).

  • propagatedBuildInputs: Lists dependencies needed at runtime (e.g., slurp).

  1. Build Format:
  • format = "pyproject"; indicates that the package uses a pyproject.toml file for its Python build configuration.

Integration within Nixpkgs

  • Location: The swaytools definition resides in pkgs/tools/wayland/swaytools/default.nix.

  • Top-Level Inclusion: It's made available as a top-level package in pkgs/top-level/all-packages.nix like this:

# all-packages.nix
swaytools = python3Packages.callPackage ../tools/wayland/swaytools { };
  • python3Packages.callPackage is used here because swaytools is a Python package, and it ensures the necessary Python-related dependencies are correctly passed to the swaytools definition.

Conclusion

In this chapter, we've journeyed through the fundamental concept of package definitions in Nix. We've seen how these Nix expressions act as blueprints, leading to the creation of derivations – the detailed plans for building software. Finally, we touched upon the realization process where Nix executes these derivations to produce tangible packages in the Nix store. Examining the simple "hello" package and the more complex "swaytools" definition provided practical insights into the structure and key attributes involved in defining software within the Nix ecosystem.

The crucial step in this process, the transformation from a package definition to a concrete build plan, is embodied by the derivation. This detailed specification outlines every step Nix needs to take to fetch sources, build dependencies, compile code, and produce the final package output. Understanding the anatomy and lifecycle of a derivation is key to unlocking the full power and flexibility of Nix.

In the next chapter, Introduction to Nix Derivations, we will delve deeper into the structure and components of these derivations. We will explore the attributes that define a build process, how dependencies are managed within a derivation, and how Nix ensures the reproducibility and isolation of your software builds through this fundamental concept.

Resources

Chapter 7

✔️ Click to Expand Table of Contents

Introduction to Nix Derivations

gruv10

Nix's build instructions, known as derivations, are defined using the Nix Language. These derivations can describe anything from individual software packages to complete system configurations. The Nix package manager then deterministically "realizes" (builds) these derivations, ensuring consistency because they rely solely on a predefined set of inputs.

Most things in NixOS are built around derivations. Your NixOS system is described by such a single system derivation. When you want to apply a new configuration, nixos-rebuild handles the process:

It first builds this derivation:

nix-build '<nixpkgs/nixos>' -A system

Then, once the build is complete, it switches to that new system:

result/bin/switch-to-configuration

After the build, nixos-rebuild updates a crucial symbolic link: /run/current-system This symlink always points to the active, running version of your system in the Nix store. In essence, the /run/current-system path is the currently active system derivation. This design choice gives NixOS its powerful atomic upgrade and rollback capabilities: changing your system involves building a new system derivation and updating this symlink to point to the latest version.

 ls -lsah /run/current-system
 0 lrwxrwxrwx 1 root root 85 May 23 12:11 /run/current-system -> /nix/store/
 cy2c0kxpjrl7ajlg9v3zh898mhj4dyjv-nixos-system-magic-25.11.20250520.2795c50
  • The -> indicates a symlink and it's pointing to a store path which is the result of a derivation being built (the system closure)

  • For beginners, the analogy of a cooking recipe is helpful:

    • Ingredients (Dependencies): What other software or libraries are needed.

    • Steps (Build Instructions): The commands to compile, configure, and install.

    • Final Dish (Output): The resulting package or resource.

A Nix derivation encapsulates all this information, telling Nix what inputs to use, how to build it, and what the final output should be.

Nix derivations run in pure, isolated environments, meaning they cannot access the internet during the build phase. This ensures that builds are reproducible -- they don't depend on external sources that might change over time.

There are Fixed-output-derivations that allow fetching resources during the build process by explicitly specifying the expected hash upfront. Just keep this in mind that normal derivations don't have network access.

Creating Derivations in Nix

The primary way to define packages in Nix is through the mkDerivation function, which is part of the standard environment (stdenv). While a lower-level derivation function exists for advanced use cases, mkDerivation simplifies the process by automatically managing dependencies and the build environment.

mkDerivation (and derivation) takes a set of attributes as its argument. At a minimum, you'll often encounter these essential attributes:

  1. name: A human-readable identifier for the derivation (e.g., "foo", "hello.txt"). This helps you and Nix refer to the package.

  2. system: Specifies the target architecture for the build (e.g., builtins.currentSystem for your current machine).

  3. builder: Defines the program that will execute the build instructions (e.g., bash).

How do we pass these required attributes to the derivation function?

Functions in Nix often take a single argument which is an attribute set. For derivation and mkDerivation, this takes the form functionName { attribute1 = value1; attribute2 = value2; ... }, where the {} encloses the set of attributes being passed as the function's argument.

Remember that derivation and mkDerivation take a set (i.e. {}) of attributes as its first argument. So, in order to pass the required attributes you would do something like this:

nix-repl> pkgs = import <nixpkgs> {}

nix-repl> d = derivation {
            name = "mydrv";
            builder = "${pkgs.bash}/bin/bash";
            args = [
              "-c" # Tells bash to execute the following string as a command
              ''
                # Explicitly set PATH to include coreutils bin directory
                export PATH="${pkgs.coreutils}/bin:$PATH"
                mkdir $out
              ''
            ];
            system = builtins.currentSystem;
          }

nix-repl> :b d
  • When I was starting out, seeing the above written in the following format made it clearer in my mental map that we were passing these attributes as arguments but both accomplish the same thing.
d = derivation { name = "myname"; builder = "${coreutils}/bin/true"; system = builtins.currentSystem; }
  • When you write pkgs = import <nixpkgs> {};, you are importing the Nixpkgs default.nix file, which resolves to a function. Calling that function by passing it an empty attribute set {} as its argument. The function then evaluates and returns the entire pkgs attribute set. To specify a different system for example, you could do something like:
pkgsForAarch64 = import <nixpkgs> { system = "aarch64-linux"; };

So when you see:

import <nixpkgs> { overlays = []; config = {}; }
  • Instead, these empty sets explicitly override any global or implicit overlays/configurations that Nix might otherwise pick up from environment variables (like NIXPKGS_CONFIG), default locations (like ~/.config/nixpkgs/config.nix or ~/.config/nixpkgs/overlays), or other mechanisms.

  • This is to prevent accidental partial application from other parts of your configuration and is saying "Do not pass any custom configuration options for this particular import"

  • derivation is a pre-made, built-in function in the Nix language. Here, we are passing it an attribute set as argument with the three required attributes. (name, builder, system, and we added an extra argument args.)

The Hello World Derivation

For this example, first create a hello directory and add the Hello tarball to said directory.

Now lets create the classic Hello derivation:

# hello.nix
let
  pkgs = import <nixpkgs> { };
in
derivation {
  name = "hello";
  builder = "${pkgs.bash}/bin/bash";
  args = [ ./hello_builder.sh ];
  inherit (pkgs)
    gnutar
    gzip
    gnumake
    gcc
    coreutils
    gawk
    gnused
    gnugrep
    ;
  bintools = pkgs.binutils.bintools;
  src = ./hello-2.12.1.tar.gz;
  system = builtins.currentSystem;
}
  • As you can see, this isn't the only required file but is a recipe outlining how to build the hello package. The tar.gz package can be found here You would just place the tarball in the same directory as the derivation along with the following hello_builder.sh:
# hello_builder.sh
export PATH="$gnutar/bin:$gcc/bin:$gnumake/bin:$coreutils/bin:$gawk/bin:$gzip/bin:$gnugrep/bin:$gnused/bin:$bintools/bin"
tar -xzf $src
cd hello-2.12.1
./configure --prefix=$out
make
make install

And build it with:

nix-build hello.nix

Finally execute it with:

./result/bin/hello
Hello, world!

Simple Rust Derivation

Create a simple.rs with the following contents:

fn main() {
  println!("Simple Rust!")
}

And a rust_builder.sh like this (this is our builder script):

# rust_builder.sh
# Set up the PATH to include rustc coreutils and gcc
export PATH="$rustc/bin:$coreutils/bin:$gcc/bin"

# IMPORTANT: Create the $out directory BEFORE rustc tries to write to it
mkdir -p "$out"

# Compile the Rust source code and place the executable inside $out
rustc -o "$out/simple_rust" "$src"

Now we'll enter the nix repl and build it:

❯ nix repl
Nix 2.28.3
Type :? for help.

nix-repl> :l <nixpkgs>
added 3950 variables.

# Define the variables for rustc, coreutils, bash, AND gcc from the loaded nixpkgs
nix-repl> rustc = pkgs.rustc

nix-repl> coreutils = pkgs.coreutils

nix-repl> bash = pkgs.bash

nix-repl> gcc = pkgs.gcc

# Now define the derivation
nix-repl> simple_rust_program = derivation {
            name = "simple-rust-program";
            builder = "${bash}/bin/bash";
            args = [ ./rust_builder.sh ];
            rustc = rustc;
            coreutils = coreutils;
            gcc = gcc;
            src = ./simple.rs;
            system = builtins.currentSystem;
          }

nix-repl> :b simple_rust_program
This derivation produced the following outputs:
out -> /nix/store/fmyqr2d3ph0lpnxd0xppwvwyhv3iyb7y-simple-rust-program
nix-store -r /nix/store/fmyqr2d3ph0lpnxd0xppwvwyhv3iyb7y-simple-rust-program

warning: you did not specify '--add-root'; the result might be removed by the garbage collector
/nix/store/fmyqr2d3ph0lpnxd0xppwvwyhv3iyb7y-simple-rust-program

This simple Rust example, built with a direct derivation call, illustrates:

  • How Nix explicitly manages every single tool in your build environment (bash, rustc, gcc, coreutils).

  • The strict isolation of Nix builds, where nothing is implicitly available.

  • The deterministic mapping of inputs to unique output paths in the Nix store.

  • The above example shows the fundamental structure of a Nix derivation, how it's defined within the nix-repl.

  • .drv files are intermediate files that describe how to build a derivation; it's the bare minimum information.

When Derivations are Built

Nix doesn't build derivations during the evaluation of your Nix expressions. Instead, it processes your code in two main phases (and why you need to use :b simple_rust_program or nix-store -r to actually build or realize it):

  1. Evaluation/Instantiate Phase: This is when Nix parses and interprets your .nix expression. The result is a precise derivation description (often represented as a .drv file on disk), and the unique "out paths" where the final built products will go are calculated. No actual code is compiled or executed yet. Achieved with nix-instantiate

  2. Realize/Build Phase: Only after a derivation has been fully described does Nix actually execute its build instructions. It first ensures all the derivation's inputs (dependencies) are built, then runs the builder script in an isolated environment, and places the resulting products into their designated "out paths" in the Nix store. Achieved with nix-store -r

Referring to other derivations

The way that we can refer to other packages/derivations is to use the outPath.

The outPath describes the location of the files of that derivation. Nix can then convert the derivation set into a string:

nix repl
nix-repl> :l <nixpkgs>
nix-repl> fzf
«derivation /nix/store/vw1zag9q4xvf10z24j1qybji7wfsz78v-fzf-0.62.0.drv»
nix-repl> fzf.outPath
"/nix/store/z3ayhjslz72ldiwrv3mn5n7rs96p2g8a-fzf-0.62.0"
nix-repl> builtins.toString fzf
"/nix/store/z3ayhjslz72ldiwrv3mn5n7rs96p2g8a-fzf-0.62.0"
  • As long as there is an outPath attribute, Nix will do the "set to string conversion".

Produce a development shell from a derivation

Building on the concept of a derivation as a recipe, let's create our first practical derivation. This example shows how to define a temporary development environment (a shell) using stdenv.mkDerivation, which is the primary function for defining packages in Nix.

# my-shell.nix
# We use a `let` expression to bring `pkgs` and `stdenv` into scope.
# This is a recommended practice over `with import <nixpkgs> {}`
# for clarity and to avoid potential name collisions.
let
  pkgs = import <nixpkgs> {};
  stdenv = pkgs.stdenv; # Access stdenv from the imported nixpkgs
in

# Make a new "derivation" that represents our shell
stdenv.mkDerivation {
  name = "my-environment";

  # The packages in the `buildInputs` list will be added to the PATH in our shell
  buildInputs = [
    # cowsay is an arbitrary package
    # see https://nixos.org/nixos/packages.html to search for more
    pkgs.cowsay
    pkgs.fortune
  ];
}

Usage

nix-shell my-shell.nix
fortune | cowsay
 _________________________________________
/ "Lines that are parallel meet at        \
| Infinity!" Euclid repeatedly, heatedly, |
| urged.                                  |
|                                         |
| Until he died, and so reached that      |
| vicinity: in it he found that the       |
| damned things diverged.                 |
|                                         |
\ -- Piet Hein                            /
 -----------------------------------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||
  • To exit type: exit

This Nix expression defines a temporary development shell. Let's break it down:

  • pkgs = import <nixpkgs> {};: Standard way to get access to all the packages and helper functions (i.e. nixpkgs.lib)

  • stdenv = pkgs.stdenv;: stdenv provides us mkDerivation and is from the nixpkgs collection.

  • stdenv.mkDerivation { ... };: This is the core function for creating packages.

    • stdenv provides a set of common build tools and conventions.
  • mkDerivation takes an attribute set (a collection of key-value pairs) as its argument.

  • name = "my-environment";: This gives your derivation a human-readable name.

  • buildInputs = [ pkgs.cowsay ];: This is a list of dependencies that will be available in the build environment of this derivation (or in the PATH if you enter the shell created by this derivation). pkgs.cowsay refers to the cowsay package from the imported pkgs collection.

The command nix-instantiate --eval my-shell.nix evaluates the Nix expression in the file. It does not build the derivation. Instead, it returns the Nix value that the expression evaluates to.

nix-instantiate --eval my-shell.nix

This value is a structured data type that encapsulates all the attributes (like name, system, buildInputs, etc.) required to build the derivation. Your output shows this detailed internal representation of the derivation's "recipe" as understood by Nix. This is useful for debugging and inspecting the derivation's definition.

Our Second Derivation: Understanding the Builder

Understanding the Builder (Click to Expand)
  • To understand how derivations work, let's create a very basic example using a bash script as our builder.

Why a Builder Script?

  • The builder attribute in a derivation tells Nix how to perform the build steps. A simple and common way to define these steps is with a bash script.

The Challenge with Shebangs in Nix

  • In typical Unix-like systems, you might start a bash script with a shebang (#!/bin/bash or #!/usr/bin/env bash) to tell the system how to execute it. However, in Nix derivations, we generally avoid this.

  • Reason: Nix builds happen in an isolated environment where the exact path to common tools like bash isn't known beforehand (it resides within the Nix store). Hardcoding a path or relying on the system's PATH would break Nix's stateless property.

The Importance of Statelessness in Nix

  • Stateful Systems (Traditional): When you install software traditionally, it often modifies the core system environment directly. This can lead to dependency conflicts and makes rollbacks difficult.

  • Stateless Systems (Nix): Nix takes a different approach. When installing a package, it creates a unique, immutable directory in the Nix store. This means:

    • No Conflicts: Different versions of the same package can coexist without interfering with each other.

    • Reliable Rollback: You can easily switch back to previous versions without affecting system-wide files.

    • Reproducibility: Builds are more likely to produce the same result across different machines if they are "pure" (don't rely on external system state).

The Isolated Nix Build Environment: A Quick Overview

When Nix executes a builder script, it sets up a highly controlled and pristine environment to ensure reproducibility and isolation. Here's what happens:

  1. Fresh Start: Nix creates a temporary, empty directory for the build and makes it the current working directory.

  2. Clean Environment: It completely clears the environment variables from your shell.

  3. Controlled Inputs: Nix then populates the environment with only the variables essential for the build, such as:

    • $NIX_BUILD_TOP: The path to the temporary build directory.

    • $PATH: Carefully set to include only the explicit buildInputs you've specified, preventing reliance on arbitrary system tools.

    • $HOME: Set to /homeless-shelter to prevent programs from reading user-specific configuration files.

    • Variables for each declared output ($out, etc.), indicating where the final results should be placed in the Nix store.

  4. Execution & Logging: The builder script is run with its specified arguments. All its output (stdout/stderr) is captured in a log.

  5. Clean Up & Registration: If successful, the temporary directory is removed. Nix then scans the build outputs for references to other store paths, ensuring all dependencies are correctly tracked for future use and garbage collection. Finally, it normalizes file permissions and timestamps in the output for consistent hashing.

This meticulous setup ensures that your builds are independent of the machine they run on and always produce the same result, given the same inputs.

Our builder Script

  • For our first derivation, we'll create a simple builder.sh file in the current directory:
# builder.sh
declare -xp
echo foo > $out
  • The command declare -xp lists exported variables (it's a bash builtin function).

  • Nix needs to know where the final built product (the "cake" in our earlier analogy) should be placed. So, during the derivation process, Nix calculates a unique output path within the Nix store. This path is then made available to our builder script as an environment variable named $out. The .drv file, which is the recipe, contains instructions for the builder, including setting up this $out variable. Our builder script will then put the result of its work (in this case, the "foo" file) into this specific $out directory.

  • As mentioned earlier we need to find the nix store path to the bash executable, common way to do this is to load Nixpkgs into the repl and check:

nix-repl> :l <nixpkgs>
Added 3950 variables.
nix-repl> "${bash}"
"/nix/store/ihmkc7z2wqk3bbipfnlh0yjrlfkkgnv6-bash-4.2-p45"

So, with this little trick we are able to refer to bin/bash and create our derivation:

nix-repl> d = derivation { name = "foo"; builder = "${bash}/bin/bash";
 args = [ ./builder.sh ]; system = builtins.currentSystem; }
nix-repl> :b d
[1 built, 0.0 MiB DL]

this derivation produced the following outputs:
  out -> /nix/store/gczb4qrag22harvv693wwnflqy7lx5pb-foo
  • The contents of the resulting store path (/nix/store/...-foo) now contain the file foo, as intended. We have successfully built a derivation!

  • Derivations are the primitive that Nix uses to define packages. “Package” is a loosely defined term, but a derivation is simply the result of calling builtins.derivation.

Our Last Derivation

Create a new directory and a hello.nix with the following contents:

# hello.nix
{
  stdenv,
  fetchzip,
}:

stdenv.mkDerivation {
  pname = "hello";
  version = "2.12.1";

  src = fetchzip {
    url = "https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz";
    sha256 = "";
  };
}

Save this file to hello.nix and run nix-build to observe the build failure:

  • Click to expand output:
$ nix-build hello.nix
error: cannot evaluate a function that has an argument without a value ('stdenv')
       Nix attempted to evaluate a function as a top level expression; in
       this case it must have its arguments supplied either by default
       values, or passed explicitly with '--arg' or '--argstr'. See
       https://nix.dev/manual/nix/stable/language/constructs.html#functions.

       at /home/nix-user/hello.nix:3:3:

            2| {
            3|   stdenv,
             |   ^
            4|   fetchzip,

Problem: The expression in hello.nix is a function, which only produces it's intended output if it is passed the correct arguments.(i.e. stdenv is available from nixpkgs so we need to import nixpkgs before we can use stdenv):

The recommended way to do this is to create a default.nix file in the same directory as the hello.nix with the following contents:

# default.nix
let
  nixpkgs = fetchTarball "https://github.com/NixOS/nixpkgs/tarball/nixos-24.05";
  pkgs = import nixpkgs { config = {}; overlays = []; };
in
{
  hello = pkgs.callPackage ./hello.nix { };
}

This allows you to run nix-build -A hello to realize the derivation in hello.nix, similar to the current convention used in Nixpkgs:

  • Click to expand Output:
nix-build -A hello
error: hash mismatch in fixed-output derivation '/nix/store/pd2kiyfa0c06giparlhd1k31bvllypbb-source.drv':
         specified: sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
            got:    sha256-1kJjhtlsAkpNB7f6tZEs+dbKd8z7KoNHyDHEJ0tmhnc=
error: 1 dependencies of derivation '/nix/store/b4mjwlv73nmiqgkdabsdjc4zq9gnma1l-hello-2.12.1.drv' failed to build
  • Another way to do this is with nix-prefetch-url It is a utility to calculate the sha beforehand.
nix-prefetch-url https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz
path is '/nix/store/pa10z4ngm0g83kx9mssrqzz30s84vq7k-hello-2.12.1.tar.gz'
086vqwk2wl8zfs47sq2xpjc9k066ilmb8z6dn0q6ymwjzlm196cd
  • When you use nix-prefetch-url, you get a Base32 hash when nix needs SRI format.

Run the following command to convert from Base32 to SRI:

nix hash to-sri --type sha256 086vqwk2wl8zfs47sq2xpjc9k066ilmb8z6dn0q6ymwjzlm196cd
sha256-jZkUKv2SV28wsM18tCqNxoCZmLxdYH2Idh9RLibH2yA=
  • This actually fetched a different sha than the Nix compiler returned in the example where we replace the empty sha with the one Nix gives us. The difference was that fetchzip automatically extracts archives before computing the hash and slight differences in the metadata cause different results. I had to switch from fetchzip to fetchurl to get the correct results.

    • Extracted archives can differ in timestamps, permissions, or compression details, causing different hash values.

    • A simple takeaway is to use fetchurl when you need an exact match, and fetchzip when working with extracted contents.

    • fetchurl

    • fetchurl returns a fixed-output derivation(FOD): A derivation where a cryptographic hash of the output is determined in advance using the outputHash attribute, and where the builder executable has access to the network.

Lastly replace the empty sha256 placeholder with the returned value from the last command:

# hello.nix
{
  stdenv,
  fetchzip,
}:

stdenv.mkDerivation {
  pname = "hello";
  version = "2.12.1";

  src = fetchzip {
    url = "https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz";
    sha256 = "sha256-1kJjhtlsAkpNB7f6tZEs+dbKd8z7KoNHyDHEJ0tmhnc=";
  };
}

Run nix-build -A hello again and you'll see the derivation successfully builds.

Best Practices

Reproducible source paths: If we built the following derivation in /home/myuser/myproject then the store path of src will be /nix/store/<hash>-myproject causing the build to no longer be reproducible:

let pkgs = import <nixpkgs> {}; in

pkgs.stdenv.mkDerivation {
  name = "foo";
  src = ./.;
}

❗ TIP: Use builtins.path with the name attribute set to something fixed. This will derive the symbolic name of the store path from the name instead of the working directory:

let pkgs = import <nixpkgs> {}; in

pkgs.stdenv.mkDerivation {
  name = "foo";
  src = builtins.path { path = ./.; name = "myproject"; };
}

Conclusion

In this chapter, we've laid the groundwork for understanding Nix derivations, the fundamental recipes that define how software and other artifacts are built within the Nix ecosystem. We've explored their key components – inputs, builder, build phases, and outputs – and how they contribute to Nix's core principles of reproducibility and isolated environments. Derivations are the workhorses behind the packages and tools we use daily in Nix.

As you've learned, derivations offer a powerful and principled approach to software management. However, the way we organize and manage these derivations, along with other Nix expressions and dependencies, has evolved over time. Traditionally, Nix projects often relied on patterns involving default.nix files, channel subscriptions, and manual dependency management.

A more recent and increasingly popular approach to structuring Nix projects and managing dependencies is through Nix Flakes. Flakes introduce a standardized project structure, explicit input tracking, and a more robust way to ensure reproducible builds across different environments.

In our next chapter, Comparing Flakes and Traditional Nix, we will directly compare and contrast these two approaches. We'll examine the strengths and weaknesses of traditional Nix practices in contrast to the benefits and features offered by Nix Flakes. This comparison will help you understand the motivations behind Flakes and when you might choose one approach over the other for your Nix projects.

As you can see below, there is a ton of information on derivations freely available.

Click To Expand Resources

Builders and Autotools

Chapter 8

✔️ Click to Expand Table of Contents

nixWinter

Comparing Flakes and Traditional Nix

  • This post is based on notes from Nix-Hour #4, comparing Traditional Nix and Flakes, focusing on achieving pure build results. See the YouTube video for the original content. This guide adapts the information for clarity and ease of understanding.
What is Purity in Nix? (click here)
  • A key benefit of Nix Flakes is their default enforcement of pure evaluation.

  • In Nix, an impure operation depends on something outside its explicit inputs. Examples include:

    • User's system configuration
    • Environment variables
    • Current time
  • Impurity leads to unpredictable builds that may differ across systems or time.

Building a Simple "hello" Package: Flakes vs. Traditional Nix

  • We'll demonstrate building a basic "hello" package using both Flakes and Traditional Nix to highlight the differences in handling purity.

Using Nix Flakes

Building Hello with Flakes (click here)
  1. Setup:

    mkdir hello && cd hello/
    
  2. Create flake.nix (Initial Impure Example):

    # flake.nix
    {
      outputs = { self, nixpkgs }: {
        myHello = (import nixpkgs {}).hello;
      };
    }
    
    • Note: Flakes don't have access to builtins.currentSystem directly.
  3. Impure Build (Fails):

    nix build .#myHello
    
    • This fails because Flakes enforce purity by default.
  4. Force Impure Build:

    nix build .#myHello --impure
    
  5. Making the Flake Pure:

    # flake.nix
    {
      inputs = {
        nixpkgs.url = "github:NixOS/nixpkgs";
        flake-utils.url = "github:numtide/flake-utils";
      };
    
      outputs = { self, nixpkgs, flake-utils }:
        flake-utils.lib.eachDefaultSystem (system:
          let
            pkgs = nixpkgs.legacyPackages.${system};
          in {
            packages.myHello = pkgs.hello;
          }
        );
    }
    
    • flake-utils simplifies making flakes system-agnostic and provides the system attribute.
  6. Pure Build (Success):

    nix build .#myHello
    

Using Traditional Nix

Building hello with Traditional Nix
  1. Setup:

    mkdir hello2 && cd hello2/
    
  2. Create default.nix (Initial Impure Example):

    # default.nix
    { myHello = (import <nixpkgs> { }).hello; }
    
  3. Build (Impure):

    nix-build -A myHello
    
  4. Impurity Explained:

    nix repl
    nix-repl> <nixpkgs>
    /nix/var/nix/profiles/per-user/root/channels/nixos
    
    • <nixpkgs> depends on the user's environment (Nixpkgs channel), making it impure. Even with channels disabled, it relies on a specific Nixpkgs version in the store.
  5. Achieving Purity: Using fetchTarball

    • GitHub allows downloading repository snapshots at specific commits, crucial for reproducibility.

    • Get Nixpkgs Revision from flake.lock (from the Flake example):

    # flake.lock
    "nixpkgs": {
      "locked": {
        "lastModified": 1746372124,
        "narHash": "sha256-n7W8Y6bL7mgHYW1vkXKi9zi/sV4UZqcBovICQu0rdNU=",
        "owner": "NixOS",
        "repo": "nixpkgs",
        "rev": "f5cbfa4dbbe026c155cf5a9204f3e9121d3a5fe0",
        "type": "github"
      },
    
  6. Modify default.nix for Purity:

    # default.nix
    let
      nixpkgs = fetchTarball {
        url = "[https://github.com/NixOS/nixpkgs/archive/f5cbfa4dbbe026c155cf5a9204f3e9121d3a5fe0.tar.gz](https://github.com/NixOS/nixpkgs/archive/f5cbfa4dbbe026c155cf5a9204f3e9121d3a5fe0.tar.gz)";
        sha256 = "0000000000000000000000000000000000000000000000000000"; # Placeholder
      };
    in {
      myHello = (import nixpkgs {}).hello;
    }
    
    • Replace <nixpkgs> with fetchTarball and a specific revision. A placeholder sha256 is used initially.
  7. Build (Nix provides the correct sha256):

    nix-build -A myHello
    
  8. Verification: Both Flake and Traditional Nix builds now produce the same output path.

  9. Remaining Impurities in Traditional Nix:

    • Default arguments to import <nixpkgs> {} can introduce impurity:
      • overlays: ~/.config/nixpkgs/overlays (user-specific)
      • config: ~/.config/nixpkgs/config.nix (user-specific)
      • system: builtins.currentSystem (machine-specific)
  10. Making Traditional Nix Fully Pure:

    # default.nix
    {system ? builtins.currentSystem}:
    let
      nixpkgs = fetchTarball {
        url =
          "[https://github.com/NixOS/nixpkgs/archive/0243fb86a6f43e506b24b4c0533bd0b0de211c19.tar.gz](https://github.com/NixOS/nixpkgs/archive/0243fb86a6f43e506b24b4c0533bd0b0de211c19.tar.gz)";
        sha256 = "1qvdbvdza7hsqhra0yg7xs252pr1q70nyrsdj6570qv66vq0fjnh";
      };
    in {
      myHello = (import nixpkgs {
        overlays = [];
        config = {};
        inherit system;
      }).hello;
    }
    
    • Override impure defaults for overlays, config, and make system an argument.
  11. Building with a Specific System:

    nix-build -A myHello --argstr system x86_64-linux
    
  12. Pure Evaluation Mode in Traditional Nix:

    nix-instantiate --eval --pure-eval --expr 'fetchGit { url = ./.; rev = "b4fe677e255c6f89c9a6fdd3ddd9319b0982b1ad"; }'
    
    • Example of using --pure-eval.
    nix-build --pure-eval --expr '(import (fetchGit { url = ./.; rev = "b4fe677e255c6f89c9a6fdd3ddd9319b0982b1ad"; }) { system = "x86_64-linux"; }).myHello'
    
    • Building with a specific revision and system.

Updating Nixpkgs

Updating Nixpkgs with Flakes
nix flake update
nix build .#myHello --override-input nixpkgs github:NixOS/nixpkgs/nixos-24.11

Updating Traditional Nix (using niv)

Updating with niv
nix-shell -p niv
niv init
# default.nix
{ system ? builtins.currentSystem,
  sources ? import nix/sources.nix,
  nixpkgs ? sources.nixpkgs,
  pkgs ? import nixpkgs {
    overlays = [ ];
    config = { };
    inherit system;
  }, }: {
  myHello = pkgs.hello;
}

And build it with:

nix-build -A myHello
niv update nixpkgs --branch=nixos-unstable
nix-build -A myHello
Adding Home-Manager with Flakes (click here)
# flake.nix
{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs";
    flake-utils.url = "github:numtide/flake-utils";
    home-manager.url = "github:nix-community/home-manager";
  };

  outputs = { self, nixpkgs, flake-utils, home-manager, ... }:
    flake-utils.lib.eachDefaultSystem (system:
      let pkgs = nixpkgs.legacyPackages.${system};
      in {
        packages.myHello = pkgs.hello;
        packages.x86_64-linux.homeManagerDocs =
          home-manager.packages.x86_64-linux.docs-html;
      });
}
nix flake update
nix flake show github:nix-community/home-manager
home-manager.inputs.follows = "nixpkgs";

Adding Home-Manager with Traditional Nix

Adding Home-Manager with Traditional Nix (click here) ```nix niv add nix-community/home-manager ```
nix repl
nix-repl> s = import ./nix/sources.nix
nix-repl> s.home-manager
{ system ? builtins.currentSystem, sources ? import nix/sources.nix
  , nixpkgs ? sources.nixpkgs, pkgs ? import nixpkgs {
    overlays = [ ];
    config = { };
    inherit system;
  }, }: {
  homeManagerDocs = (import sources.home-manager { pkgs = pkgs; }).docs;

  myHello = pkgs.hello;
}
nix-build -A homeManagerDocs

Conclusion

In this chapter, we've explored the key differences between traditional Nix and Nix Flakes, particularly focusing on how each approach handles purity, dependency management, and project structure. We've seen that while traditional Nix can achieve purity with careful configuration, Flakes enforce it by default, offering a more robust and standardized way to build reproducible environments. Flakes also streamline dependency management and provide a more structured project layout compared to the often ad-hoc nature of traditional Nix projects.

However, regardless of whether you're working with Flakes or traditional Nix, understanding how to debug and trace issues within your Nix code is crucial. When things go wrong, you'll need tools and techniques to inspect the evaluation process, identify the source of errors, and understand how your modules and derivations are being constructed.

In our next chapter, Debugging and Tracing Modules, we will delve into the world of Nix debugging. We'll explore various techniques and tools that can help you understand the evaluation process, inspect the values of expressions, and trace the execution of your Nix code, enabling you to effectively troubleshoot and resolve issues in both Flake-based and traditional Nix projects.

Chapter 9

✔️ Click to Expand Table of Contents

Debugging and Tracing NixOS Modules

gruv17

  • Other related post if you haven't read my previous post on modules, that may be helpful before reading this one:

    • nix-modules-explained

    • This post is my notes following Nix Hour 40. If it seems a little chaotic, try watching one. They are hard to follow if you're not extremely familiar with the concepts.

    • Nix Hour 40

Nix Code is particularly hard to debug because of (e.g. lazy evaluation, declarative nature, layered modules)

  • The following simple Nix code snippet illustrates a basic NixOS module definition and how options are declared and configured. We'll use this example to demonstrate fundamental debugging techniques using nix-instantiate.
let
  lib = import <nixpkgs/lib>;
in
lib.evalModules {
  modules = [
    ({ lib, ... }: {
      options.foo = lib.mkOption {
        # type = lib.types.raw;
        type = lib.types.anything;
        # default = pkgs;
      };
      config.foo = {
        bar = 10;
        list = [1 2 3 ];
        baz = lib.mkDefault "baz";
      };
    })
    {
      foo.baz = "bar";
    }
  ];
}
  • In the above code, adding lib to the function arguments isn't required but if you were to move the module to another file it would fail without it because lib comes from outside of it. So it's good practice to refer to lib in the modules themselves.

  • You should always assign a type to your options, if you don't know which type to use you could use raw. raw is a type that doesn't do any processing. So if you were to assign the entire packages set to the option e.g. default = pkgs; it wouldn't recurseinto all the packages and try to evaluate them. There is also anything, that is useful if you do want to recurse into the values.

  • The following is an example of how you would run this inside vim/neovim, the rest of the examples will be from the command line:

:!nix-instantiate --eval -A config.foo --strict

Output:

Click to Expand the Output
{ bar = 10; baz = "bar"; list = [ 1 2 3 ]; }

To show the difference you could uncomment the raw type and comment the anything type and run the above command again you'll see that you get an error:

error: The option 'foo' is defined multiple times while it's expected to be
unique

To execute this command on the command line:

nix-instantiate --eval --strict -A config.foo

It will show you the start of a trace. To get the full trace add:

nix-instantiate --eval --strict -A config.foo --show-trace

Example 2

Click to Expand Example 2

In the previous example, we looked at a simplified module. Now, let's examine a more realistic scenario involving a basic NixOS configuration file (configuration.nix).

This example will demonstrate how to use nix-instantiate to evaluate an entire system configuration and how --show-trace helps in diagnosing errors within this context.

Consider the following configuration.nix file:

# configuration.nix
{ lib, ... }: {
  boot.loader.grub.device = "nodev";
  fileSystems."/".device = "/devst";
  system.stateVersion = "24.11";
}
  • This configuration snippet sets the GRUB bootloader device, defines a root filesystem, and specifies the expected NixOS state version. To evaluate this entire system configuration, you can use nix-instantiate and point it to the <nixpkgs/nixos> entrypoint, providing our configuration.nix file as an argument. The -A system flag selects the top-level system attribute, which represents the instantiated system configuration.

Run it in with:

nix-instantiate '<nixpkgs/nixos>' --arg configuration ./configuration.nix -A system

Output:

/nix/store/kfcwvvpdbsb3xcks1s76id16i1mc3l5k-nixos-system-nixos-25.05pre-git.drv

Ok, we can see that this successfully instantiates. Let's introduce an error to trace:

{ lib, ... }: {
  boot.loader.grub.device = "nodev";
  fileSystems."/".device = "/devst";
  system.stateVersion = builtins.genList "24.11" null;
}

Output:

(stack trace truncated; use '--show-trace' to show the full, detailed trace)
error: expected an integer but found null: null

Rerun the command with --show-trace appended:

Or on the command line

nix-instantiate '<nixpkgs/nixos>' --arg configuration ./configuration.nix -A system --show-trace
  • This outputs a much longer trace than the first example. It shows you the file the error occured in and you can see that in this case they are a lot of internal functions. (e.g. at /nix/store/ccfwxygjrarahgfv5865x2f828sjr5h0- source/lib/attrsets.nix:1529:14:)

To show your own error message you could do something like this:

{lib, ...}: {
  boot.loader.grub.device = "nodev";
  fileSystems."/".device = "/devst";
  system.stateVersion = builtins.addErrorContext "AAAAAAAAAAAAAAAAA" (builtins.genList "24.11" null);
}

Run it:

nix-instantiate '<nixpkgs/nixos>' --arg configuration ./configuration.nix -A system --show-trace`

Output:

 … while evaluating the attribute 'value'
     at /nix/store/ccfwxygjrarahgfv5865x2f828sjr5h0-source/lib/modules.nix:770:21:
      769|             inherit (module) file;
      770|             inherit value;
         |                     ^
      771|           }) module.config

   … AAAAAAAAAAAAAAAAA

   … while calling the 'genList' builtin
     at /home/jr/tests/configuration.nix:4:71:
        3|   fileSystems."/".device = "/devst";
        4|   system.stateVersion = builtins.addErrorContext "AAAAAAAAAAAAAAAAA"
         (builtins.genList "24.11" null);
         |                                                                       ^
        5| }

   … while evaluating the second argument passed to builtins.genList

   error: expected an integer but found null: null
  • In the latest nix they actually inverted the error messages so the most relevant parts will be at the bottom.

Example 3

Click to Expand Example 3

Let's consider another example, this time demonstrating the definition of configuration options using lib.mkOption within a module structure.

# default.nix
let
  lib = import <nixpkgs/lib>;
in
lib.evalModules {
  modules = [
    ({ lib, ... }: {
      options.ints = lib.mkOption {
        type = lib.types.attrsOf lib.types.int;
      };
      options.strings = lib.mkOption {
        type = lib.types.string;
        # type = lib.types.attrsOf lib.types.string;
        default = "foo";
      };
    })
  ];
}

Instantiate this with:

nix-instantiate --eval --strict -A config.strings

Output:

evaluation warning: The type `types.string` is deprecated.
See https://github.com/NixOS/nixpkgs/pull/66346 for better alternative types.
"foo"
  • Unfortunately you won't get the same depreciation warning from lib.attrsOf

Below is an interesting way to provide nixpkgs run it on the command line:

export NIX_PATH=nixpkgs=channel:nixpkgs-unstable
echo $NIX_PATH

Output:

nixpkgs=channel:nixpkgs-unstable

The next two commands are to check that after using the above way to provide nixpkgs-unstable that they both point to the same store path, the following command will fetch nixpkgs from the channel above:

nix-instantiate --find-file nixpkgs

Output 1️⃣

/nix/store/ydrgwsibghsyx884qz97zbs1xs93yk11-source
nix-instantiate --eval channel:nixpkgs-unstable -A path

Output: 2️⃣

/nix/store/ydrgwsibghsyx884qz97zbs1xs93yk11-source
  • As you can see both commands produce the same store path

Example 4

In our previous example, we encountered a deprecation warning for lib.types.string. This next example delves deeper into why that type was deprecated and demonstrates the consequences of its behavior, along with the recommended fix.

# default.nix
let
  lib = import <nixpkgs/lib>;
in
  lib.evalModules {
    modules = [
      ({lib, ...}: {
        options.ints = lib.mkOption {
          type = lib.types.attrsOf lib.types.int;
        };
        options.strings = lib.mkOption {
          # type = lib.types.string;
          type = lib.types.attrsOf lib.types.string;
          default = {
            x = "foo";
          };
        };
        config = {
          strings = lib.mkOptionDefault {
            x = "bar";
          };
        };
      })
    ];
  }

Evaluate it with:

nix-instantiate --eval --strict -A config.strings
  • types.string depricated because it silently concatenates strings

  • The above command has two options with the same priority level and evaluates to { x = "foobar"; }

Output:

evaluation warning: The type `types.string` is deprecated. See https://github.
com/NixOS/nixpkgs/pull/66346 for better alternative types.
{ x = "foobar"; }
  • types.str was the replacement for the depricated types.string:
# default.nix
let
  lib = import <nixpkgs/lib>;
in
  lib.evalModules {
    modules = [
      ({lib, ...}: {
        options.ints = lib.mkOption {
          type = lib.types.attrsOf lib.types.int;
        };
        options.strings = lib.mkOption {
          # type = lib.types.string;
          type = lib.types.attrsOf lib.types.str;
          # Sets the value with a lower priority: lib.mkOptionDefault
          default = {
            x = "foo";
          };
        };
        config = {
          strings = lib.mkOptionDefault {
            x = "bar";
          };
        };
      })
    ];
  }

Output:

error:
… while evaluating the attribute 'x'

… while evaluating the attribute 'value'
 at /nix/store/ydrgwsibghsyx884qz97zbs1xs93yk11-source/lib/modules.nix:1148:41:
 1147|
 1148|     optionalValue = if isDefined then { value = mergedValue; } else { };
     |                                         ^
 1149|   };

… while calling the 'foldl'' builtin
 at /nix/store/ydrgwsibghsyx884qz97zbs1xs93yk11-source/lib/options.nix:508:8:
  507|     else
  508|       (foldl' (
     |        ^
  509|         first: def:

(stack trace truncated; use '--show-trace' to show the full, detailed trace)

error: The option `strings.x' has conflicting definition values:
- In `<unknown-file>': "foo"
- In `<unknown-file>': "bar"
Use `lib.mkForce value` or `lib.mkDefault value` to change the priority on any of these definitions.

shell returned 1

Summary

  • So types in the module system aren't just types in the conventional sense but they also specify the emerging behavior of these values.

  • If we switch the type in the above example to types.lines you get this returned, { x = "foo\nbar"; }

  • mkOptionDefault isn't typically something you should generally use, instead options have a default setting

  • If you want to make sure that you set a default but if the user specifies it, it shouldn't get overridden. You should not set it in the following:

options.strings = lib.mkOption {
  type = lib.types.attrsOf lib.types.lines;
  default = {
    x = "foo";
  };
}

Because the above uses mkOptionDefault but instead in under the config attribute like the following:

# ...snip...
options.strings = lib.mkOption {
  type = lib.types.attrsOf lib.types.lines;
  # default = {
    # x = "foo";
  # };
};
config = {
  strings = {
    x = lib.mkDefault "foo";
  };
};
# ...snip...
let
  lib = import <nixpkgs/lib>;
in
  lib.evalModules {
    modules = [
      ({lib, ...}: {
        options.ints = lib.mkOption {
          type = lib.types.attrsOf lib.types.int;
        };
        options.strings = lib.mkOption {
          # type = lib.types.string;
          type = lib.types.attrsOf lib.types.str;
          # Sets the value with a lower priority: lib.mkOptionDefault
          #default = {
          #  x = "foo";
          #};
        };
        config.strings = {
          x = "foo";
        };
      })
      {
        config.strings = {
          y = "bar";
        };
      }
    ];
  }

Output:

  • This works now because there's no difference between x and y
{ x = "foo"; y = "bar"; }

More Functionality between modules

let
  lib = import <nixpkgs/lib>;
in
  lib.evalModules {
    modules = [
      ({lib, ...}: {
        options.ints = lib.mkOption {
          type = lib.types.attrsOf lib.types.int;
        };
        options.strings = lib.mkOption {
          # type = lib.types.string;
          type = lib.types.attrsOf lib.types.str;
          # Sets the value with a lower priority: lib.mkOptionDefault
          #default = {
          #  x = "foo";
          #};
        };
        config.strings = {
          x = lib.mkDefault "foo";
        };
      })
      {
        config.strings = {
          x = "x";
          y = "bar";
        };
      }
    ];
  }
  • The above command would cause a conflict without the x = lib.mkDefault foo And this is typically what you want to do for defaults and modules in things like nested configuration.

Output:

{ x = "x"; y = "bar"; }

Infinite recursion error

  1. A common pitfall is to introduce a hard to debug error infinite recursion when shadowing a name. The simplest example for this is:
let a = 1; in rec { a = a; }

💡TIP: Avoid rec. Use let ... in Example:

let
 a = 1;
in {
 a = a;
 b = a + 2;
}
Click to Expand a more involved infinite recursion error

We'll separate the logic for this example, this will be the default.nix this is where having lib defined in your inline modules is helpful because you can just delete the section and paste it into your modules.nix:

# default.nix
let
  lib = import <nixpkgs/lib>;
in
  lib.evalModules {
    modules = [
      ./module.nix
    ];
  }

And in the module.nix:

# module.nix
{ lib, pkgs, ...}: {
  options.etc = lib.mkOption {
    type = lib.types.attrsOf lib.types.path;
    default = { };
    description = ''
      Specifies which paths are is /etc/
    '';
  };

  config._module.args.pkgs = import <nixpkgs> {
    config = {};
    overlays = [];
  };
  config.etc.foo = pkgs.writeText "foo" ''
    foo configuration
  '';
}
  • If you evaluate this with the following you will get an infinite recursion error.
nix-instantiate --eval --strict -A config.etc
  • This happens because --strict evaluates the etc, then it goes into the attrsOf, and the path
nix repl
nix-repl> :l <nixpkgs>
nix-repl> hello.out.out.out

In this example:

  • :l <nixpkgs> loads the Nixpkgs library into the repl environment, making its definitions available.

  • hello refers to the hello package definition within Nixpkgs. Packages in Nixpkgs are defined as derivations.

  • .out is a common attribute name for the main output of a derivation (e.g., the installed package). Some packages, especially those with complex build processes or multiple outputs, might have nested output attributes. In the case of hello, accessing .out.out.out ultimately leads us to the derivation itself.

The key takeaway here is that when you evaluate a package in the nix repl, you're often interacting with its derivation or one of its output paths in the Nix store. The «derivation ...» indicates that hello.out.out.out evaluates to a derivation – the blueprint for building the hello package. This is in contrast to --eval --strict, which tries to fully evaluate values, potentially leading to infinite recursion if it encounters a derivation that refers back to itself indirectly during attribute evaluation.

Output:

«derivation /nix/store/b1vcpm321dwbwx6wj4n13l35f4y2wrfv-hello-2.12.1.drv»
  • So it recurses through the entire thing and tries to evaluate its string.

So we want to change the command from --eval --strict which is only based on evaluation to at least nix-instantiate which is based on derivations:

nix-instantiate -A config.etc

Output:

warning: you did not specify '--add-root'; the result might be removed by the garbage collector
/nix/store/abyfp1rxk73p0n5kfilv7pawxwvc7hsg-foo.drv
  • We don't really have a derivation yet for example:
# module.nix
{
  lib,
  pkgs,
  ...
}: {
  options.etc = lib.mkOption {
    type = lib.types.attrsOf (lib.types.attrsOf lib.types.path);
    default = {};
    description = ''
      Specifies which paths are in /etc/
    '';
  };

  config._module.args.pkgs = import <nixpkgs> {
    config = {};
    overlays = [];
  };
  config.etc.foo.bar = pkgs.writeText "foo" ''
    foo configuration
  '';
}

Try to evaluate the above command with nix-instantiate -A config.etc and Nix doesn't even try to build it. With nested attrsOf

nix repl -f default.nix
nix-repl> config.etc
{
  foo = { ... };
}
nix-repl> config.etc.foo
{
  bar = «derivation /nix/store/abyfp1rxk73p0n5kfilv7pawxwvc7hsg-foo.drv»;
}
  • So config.foo is an attribute set and config.etc.foo is also an attribute set but it's not a derivation by itself. So nix-instantiate does this one level of recursion here and it would have built foo value if it were a derivation.

Example 5

Click to Expand Example 5

We'll use the same module.nix and default.nix from the previous example.

Building More Complex Configurations with Modules In this next example, we'll focus on a common task in system configuration: managing files within the /etc/ directory. We'll define a module that allows us to specify the content of arbitrary files in /etc/ and then use a special Nix function to combine these individual file definitions into a single, manageable entity.

We'll introduce a new option, options.etc, which will allow us to define the content of files within /etc/. Then, we'll use pkgs.linkFarm to create a derivation that represents the entire /etc/ directory as a collection of symbolic links pointing to the individual file contents we've defined. This demonstrates how modules can abstract away the details of creating complex system configurations, providing a declarative and reproducible way to manage even fundamental aspects of the operating system.

Let's show how we can use Nix modules to declaratively manage the /etc/ directory

# default.nix
let
  lib = import <nixpkgs/lib>;
in
  lib.evalModules {
    modules = [
      ./module.nix
    ];
  }

# module.nix
{
  lib,
  pkgs,
  config,
  ...
}: {
  options.etc = lib.mkOption {
    type = lib.types.attrsOf (lib.types.attrsOf lib.types.path);
    default = {};
    description = ''
      Specifies which paths are in /etc/
    '';
  };
  options.etcCombined = lib.mkOption {
    type = lib.types.package;
    default =
      pkgs.linkFarm "etc"
      (lib.mapAttrsToList (name: value: {
        name = name;
        path = value;
      }) config.etc);
  };

  config._module.args.pkgs = import <nixpkgs> {
    config = {};
    overlays = [];
  };
  config.etc.foo = pkgs.writeText "foo" ''
    foo configuration
  '';
  config.etc.bar = pkgs.writeText "bar" ''
    bar configuration
  '';
}

Run it with:

nix-instantiate -A config.etcCombined

Output:

/nix/store/3da61nmfk546qn2zpxsm57mq6vz6fjx8-etc.drv
  • So we can see that it will instantiate, lets see if it will build:
nix-build -A config.etcCombined

Output:

these 3 derivations will be built:
/nix/store/41yfxq4af1vrs0rrgfk5gc36kmjc7270-bar.drv
/nix/store/abyfp1rxk73p0n5kfilv7pawxwvc7hsg-foo.drv
/nix/store/3da61nmfk546qn2zpxsm57mq6vz6fjx8-etc.drv
building '/nix/store/41yfxq4af1vrs0rrgfk5gc36kmjc7270-bar.drv'...
building '/nix/store/abyfp1rxk73p0n5kfilv7pawxwvc7hsg-foo.drv'...
building '/nix/store/3da61nmfk546qn2zpxsm57mq6vz6fjx8-etc.drv'...
/nix/store/ca3wyk5m3qhy8n1nbn0181m29qvp1klp-etc
nix-build -A config.etcCombined && ls result/ -laa

Output:

/nix/store/ca3wyk5m3qhy8n1nbn0181m29qvp1klp-etc
dr-xr-xr-x - root 31 Dec  1969  .
drwxrwxr-t - root 16 May 15:13  ..
lrwxrwxrwx - root 31 Dec  1969  bar -> /nix/store/1fsjyc2hmilab1qw6jfkf6cb767kz858-bar
lrwxrwxrwx - root 31 Dec  1969  foo -> /nix/store/wai5dycp0zx1lxg0rhpdxnydhiadpk05-foo
  • We can see that foo and bar link to different derivations

  • When trying to figure out which default to use for etcCombined infinisil went to the Nixpkgs Reference Manual. Make sure to go to the correct version.

Or in your local copy of Nixpkgs you could go to nixpkgs/pkgs/build-support/ trivial-builders/default.nix. Then use your editors search feature, with nvim and helix you press /symlinkjoin or /linkFarm hit enter then press n to cycle to the next match. It will bring you to comments and up to date information.

# linkFarm "myexample" [ { name = "hello-test"; path = pkgs.hello; }
# { name = "foobar"; path = pkgs.stack; } ]

Tests

Click to Expand Test Example
  • How to create a Derivation with passthru.tests outside of Nixpkgs and then run tests available to your package set?
mkdir passthru-tests && cd passthru-tests

Create a default.nix with the following:

# default.nix
let
  pkgs = import <nixpkgs> {};

  package = pkgs.runCommand "foo" {
    passthru.tests.simple = pkgs.runCommand "foo-test" {} ''
      if [[ "$(cat ${package})" != "foo" ]]; then
        echo "Result is not foo"
        exit 1
      fi
      touch $out
  '';
  } ''
    echo foo > $out
  '';
in
package

See if it will build:

nix-build

Try running the test:

nix-build -A passthru.tests
this derivation will be built:
/nix/store/pqpqq9x1wnsabzbsb52z4g4y4zy6p7yx-foo-test.drv
building '/nix/store/pqpqq9x1wnsabzbsb52z4g4y4zy6p7yx-foo-test.drv'...
/nix/store/7bbw2ban0mgkh4d59yz3cnai4aavwvb6-foo-test

Test 2

  • passthru.tests is the convention for defining tests associated with a derivation. The attributes in passthru are preserved and accessible after the derivation is built.
let
  pkgs = import <nixpkgs> {};

  package =
    pkgs.runCommand "foo" {
      passthru.tests.simple = pkgs.runCommand "foo-test" {} ''
        if [[ "$(cat ${package})" != "foo" ]]; then
          echo "Result is not foo"
          exit 1
        fi
        touch $out
      '';

      passthru.tests.version = pkgs.testers.testVersion {
         package = package;
         version = "1.2";
     };

      # pkgs.writeShellApplication
      script = ''
        #!${pkgs.runtimeShell}
        echo "1.2"
      '';
      passAsFiles = [ "script" ];

    } ''
      cp "$scriptPath" "$out"
    '';
in
  package

Try to build it:

nix-build -A passthru.tests
  • testers.testVersion checks if an executable outputs a specific version string.

  • nix-build -A passthru.tests specifically targets the derivations defined within the tests attribute of the main derivation.

these 3 derivations will be built:
  /nix/store/lyz86bd78p7f3yjy1qky6annmggymcwd-foo.drv
  /nix/store/s4iawjy5zpv89dbkc3zz7z3ngz4jq2cv-foo-test.drv
  /nix/store/z3gi4pb8jn2h9rvk4dhba85fiphp5g4z-foo-test-version.drv
building '/nix/store/lyz86bd78p7f3yjy1qky6annmggymcwd-foo.drv'...
cp: cannot stat '': No such file or directory
error: builder for '/nix/store/lyz86bd78p7f3yjy1qky6annmggymcwd-foo.drv'
 failed with exit code 1;
     last 1 log lines:
     > cp: cannot stat '': No such file or directory
     For full logs, run:
       nix log /nix/store/lyz86bd78p7f3yjy1qky6annmggymcwd-foo.drv
error: 1 dependencies of derivation '/nix/store/z3gi4pb8jn2h9rvk4dhba85fiphp5g4z
-foo-test-version.drv' failed to build
error: build of '/nix/store/s4iawjy5zpv89dbkc3zz7z3ngz4jq2cv-foo-test.drv',
 '/nix/store/z3gi4pb8jn2h9rvk4dhba85fiphp5g4z-foo-test-version.drv' failed

Run nix-build with no arguments:

nix-build
nix derivation show /nix/store/lyz86bd78p7f3yjy1qky6annmggymcwd-foo.drv | jq '.[].env'

Output:

{
  "__structuredAttrs": "",
  "buildCommand": "cp \"$scriptPath\" \"$out\"\n",
  "buildInputs": "",
  "builder": "/nix/store/xg75pc4yyfd5n2fimhb98ps910q5lm5n-bash-5.2p37/bin/bash",
  "cmakeFlags": "",
  "configureFlags": "",
  "depsBuildBuild": "",
  "depsBuildBuildPropagated": "",
  "depsBuildTarget": "",
  "depsBuildTargetPropagated": "",
  "depsHostHost": "",
  "depsHostHostPropagated": "",
  "depsTargetTarget": "",
  "depsTargetTargetPropagated": "",
  "doCheck": "",
  "doInstallCheck": "",
  "enableParallelBuilding": "1",
  "enableParallelChecking": "1",
  "enableParallelInstalling": "1",
  "mesonFlags": "",
  "name": "foo",
  "nativeBuildInputs": "",
  "out": "/nix/store/9mcrnddb6lf1md14v4lj6s089i99l5k7-foo",
  "outputs": "out",
  "passAsFile": "buildCommand",
  "passAsFiles": "script",
  "patches": "",
  "propagatedBuildInputs": "",
  "propagatedNativeBuildInputs": "",
  "script": "#!/nix/store/xg75pc4yyfd5n2fimhb98ps910q5lm5n-bash-5.2p37/bin/bash\necho \"1.2\"\n",
  "stdenv": "/nix/store/lgydi1gl5wqcw6k4gyjbaxx7b40zxrsp-stdenv-linux",
  "strictDeps": "",
  "system": "x86_64-linux"
}
nix derivation show /nix/store/lyz86bd78p7f3yjy1qky6annmggymcwd-foo.drv | jq
 '.[].env.buildCommand'

Output:

"cp \"$scriptPath\" \"$out\"\n"
  • raw mode below
nix derivation show /nix/store/lyz86bd78p7f3yjy1qky6annmggymcwd-foo.drv | jq
 '.[].env.buildCommand' -r

Output:

cp "$scriptPath" "$out"
  • It turns out the correct command was passAsFile not passAsFiles but that change wasn't enough to fix it. passAsFiles expects a list of files, not a single file path. Running nix-build -A passthru.tests failed saying > foo --version returned a non-zero exit code.
let
  pkgs = import <nixpkgs> {};

  package =
    pkgs.runCommand "foo" {
      #passthru.tests.simple = pkgs.runCommand "foo-test" {} ''
      #  if [[ "$(cat ${package})" != "foo" ]]; then
      #    echo "Result is not foo"
      #    exit 1
      #  fi
      #  touch $out
      #'';

      passthru.tests.version = pkgs.testers.testVersion {
        package = package;
        version = "1.2";
      };

      # pkgs.writeShellApplication
      script = ''
        #!${pkgs.runtimeShell}
        echo "1.2"
      '';
      passAsFile = ["script"];
    } ''
      mkdir -p "$out/bin"
      cp "$scriptPath" "$out/bin/foo"
      chmod +x "$out/bin/foo"
    '';
in
  package

Build it:

nix-build -A passthru.tests

Output:

these 2 derivations will be built:
  /nix/store/lqrlcd64dmpzkggcfzlnsnwjd339czd3-foo.drv
  /nix/store/c3kw4xbdlrig08jrdm5wis1dmv2gnqsd-foo-test-version.drv
building '/nix/store/lqrlcd64dmpzkggcfzlnsnwjd339czd3-foo.drv'...
building '/nix/store/c3kw4xbdlrig08jrdm5wis1dmv2gnqsd-foo-test-version.drv'...
1.2
/nix/store/zsbk5zawak68ailvkwi2gad2bqbqmdz9-foo-test-version

Key Takeaways for Debugging NixOS Modules

  • nix-instantiate is Your Friend: Use nix-instantiate to evaluate your NixOS modules and pinpoint errors.

  • Unlock Details with --show-trace: When errors occur, always append --show-trace to get a comprehensive stack trace, revealing the origin of the problem. Remember that in newer Nix versions, the most relevant parts of the trace are often at the bottom.

  • Understand Option Types: Nix option types (raw, anything, string/str, lines, attrsOf) are not just about data types; they also dictate how values are merged and processed within the module system.

  • Be Mindful of mkOptionDefault: While useful in specific scenarios, mkOptionDefault sets a lower priority default. For standard defaults that can be overridden by user configuration, define them directly within the config attribute using lib.mkDefault.

  • Use builtins.addErrorContext: Enhance your custom error messages by providing specific context relevant to your module's logic using builtins.addErrorContext.

  • Derivations vs. Evaluation: Be aware of the difference between evaluating expressions (--eval --strict) and instantiating derivations (nix-instantiate). Strict evaluation can trigger infinite recursion if it encounters unevaluated derivations with cyclic dependencies during attribute access.

  • Explore with nix repl: The nix repl allows you to interactively explore Nix expressions and the outputs of derivations, providing insights into the structure and values within Nixpkgs.

Conclusion

This chapter has equipped you with essential techniques for debugging and tracing NixOS modules. We've explored how to use nix-instantiate and --show-trace to pinpoint errors, how to interpret Nix's often-verbose error messages, and how to leverage the nix repl for interactive exploration. Understanding option types and the nuances of mkOptionDefault is crucial for writing robust and predictable modules. We've also touched upon the distinction between evaluation and instantiation, and how that impacts debugging.

While these tools and techniques are invaluable for understanding and troubleshooting your own Nix configurations, they also become essential when you want to contribute to or modify the vast collection of packages and modules within Nixpkgs itself. Nixpkgs is where the majority of Nix packages and NixOS modules reside, and learning how to navigate and contribute to it opens up a whole new level of control and customization within the Nix ecosystem.

In the next chapter, Working with Nixpkgs Locally, we'll shift our focus to exploring and modifying Nixpkgs. We'll cover how to clone Nixpkgs, how to make changes to package definitions, and how to test those changes locally before contributing them back upstream. This chapter will empower you to not just use existing Nix packages, but also to customize and extend them to fit your specific needs.

Chapter 10

✔️ Click to Expand Table of Contents

gruv18

Working with Nixpkgs Locally: Benefits and Best Practices

  • Nixpkgs, the package repository for NixOS, is a powerful resource for building and customizing software.

  • Working with a local copy enhances development, debugging, and contribution workflows.

  • This post covers setting up a local Nixpkgs repository, searching for dependencies, and leveraging its advantages, incorporating tips from the Nix community.

I. Why Work with Nixpkgs Locally?

  • A local Nixpkgs repository offers significant advantages for Nix developers:

    A. Faster Development Cycle

    • Local searches for packages and dependencies are significantly quicker than querying remote repositories or channels.

    • This speedup is crucial for efficient debugging and rapid prototyping of Nix expressions.

    B. Enhanced Version Control

    • By pinning your local repository to specific commits or branches (e.g., nixos-unstable), you ensure build reproducibility.

    • This prevents unexpected issues arising from upstream changes in Nixpkgs.

    C. Flexible Debugging Capabilities

    • You can directly test and modify package derivations within your local copy.

    • This allows for quick fixes to issues like missing dependencies without waiting for upstream updates or releases.

    D. Streamlined Contribution Workflow

    • Developing and testing new packages or patches locally is essential before submitting them as pull requests to Nixpkgs.

    • A local setup provides an isolated environment for experimentation.

    E. Up-to-Date Documentation Source

    • The source code and comments within the Nixpkgs repository often contain the most current information about packages.
    • This can sometimes be more up-to-date than official, external documentation.

    F. Optimized Storage and Performance

    • Employing efficient cloning strategies (e.g., shallow clones) and avoiding unnecessary practices (like directly using Nixpkgs as a flake for local development) minimizes disk usage and build times.

II. Flake vs. Non-Flake Syntax for Local Nixpkgs

  • When working with Nixpkgs locally, the choice between Flake and non-Flake syntax has implications for performance and storage:

    A. Flake Syntax (nix build .#<package>)

    • Treats the current directory as a flake, requiring evaluation of flake.nix.

    • For local Nixpkgs, this evaluates the flake definition in the repository root.

    • Performance and Storage Overhead: Flakes copy the entire working directory (including Git history if present) to /nix/store for evaluation. This can be slow and storage-intensive for large repositories like Nixpkgs.

    B. Non-Flake Syntax (nix-build -f . <package> or nix build -f . <package>)

    • -f . specifies the Nix expression (e.g., default.nix or a specific file) in the current directory.

    • Efficiency: Evaluates the Nix expression directly without copying the entire worktree to /nix/store. This is significantly faster and more storage-efficient for local development on large repositories.

III. Setting Up a Local Nixpkgs Repository Efficiently

Click To See How to set up Nixpkgs Locally
  • Cloning Nixpkgs requires careful consideration due to its size.

A.a Initial Clone: Shallow Cloning

It is common to place your local clone in the /src directory:

mkdir src && cd src

❗ Warning, A shallow clone (--depth 1) is not recommended for general development or contributing changes back to Nixpkgs via pull requests. It's primarily suitable for:

  • Quick checks or builds: If you only need to verify a package's current state or build a specific version without needing historical context.
  • CI/CD environments: Where disk space and clone time are critical, and only the latest commit is needed for automated tests or builds.

With that said, to avoid downloading the entire history, perform a shallow clone:

git clone [https://github.com/NixOS/nixpkgs](https://github.com/NixOS/nixpkgs) --depth 1
cd nixpkgs

A.b A few Examples exploring Nixpkgs

While in the nixpkgs directory, you can check the version of a package:

nix-instantiate --eval -A openssl.version
"3.4.1"

Or to directly edit the file you can use nix edit:

nix edit nixpkgs#openssl

It uses the nix registry and openssl.meta.position to locate the file.

man nix3 registry

The above command will open the openssl/default.nix in your $EDITOR.

A.1 Full Fork and Clone of Nixpkgs

If you want to contribute to Nixpkgs, you need to set up a local version following the Contributing guide

You'll need to, this is directly from the Contributing.md:

  1. Fork the Nixpkgs repository

  2. Clone the forked repo into a local nixpkgs directory.

  3. Configure the upstream Nixpkgs repo

B. Managing Branches with Worktrees

  • Use Git worktrees to manage different branches efficiently:

    git fetch --all --prune --depth=1
    git worktree add -b nixos-unstable nixos-unstable # For just unstable
    
  • Explanation of git worktree: Allows multiple working directories attached to the same .git directory, sharing history and objects but with separate checked-out files.

  • git worktree add: Creates a new working directory for the specified branch (nixos-unstable in this case).

IV. Debugging Missing Dependencies: A Practical Example

Click to see icat Example
  • Let's say you're trying to build icat locally and encounter a missing dependency error:
nix-build -A icat
# ... (Error log showing "fatal error: X11/Xlib.h: No such file or directory")
  • The error fatal error: X11/Xlib.h: No such file or directory indicates a missing X11 dependency.

A. Online Search with search.nixos.org

  • The Nixpkgs package search website (https://search.nixos.org/packages) is a valuable first step.
  • However, broad terms like "x11" can yield many irrelevant results.
  • Tip: Utilize the left sidebar to filter by package sets (e.g., "xorg").

B. Local Source Code Search with rg (ripgrep)

  • Familiarity with searching the Nixpkgs source code is crucial for finding dependencies.

  • Navigate to your local nixpkgs/ directory and use rg:

    rg "x11 =" pkgs # Case-sensitive search
    

    Output:

    pkgs/tools/X11/primus/default.nix
    21:  primus = if useNvidia then primusLib_ else primusLib_.override { nvidia_x11 = null; };
    22:  primus_i686 = if useNvidia then primusLib_i686_ else primusLib_i686_.override { nvidia_x11 = null; };
    
    pkgs/applications/graphics/imv/default.nix
    38:    x11 = [ libGLU xorg.libxcb xorg.libX11 ];
    
  • Refining the search (case-insensitive):

    rg -i "libx11 =" pkgs
    

    Output:

    # ... (Output showing "xorg.libX11")
    
  • The key result is xorg.libX11, which is likely the missing dependency.

V. Local Derivation Search with nix-locate

Click to Expand nix-locate command Example
  • nix-locate (from the nix-index package) allows searching for derivations on the command line.

    Note: Install nix-index and run nix-index to create the initial index.

    nix-locate libx11
    # ... (Output showing paths related to libx11)
    
  • Combining online and local search tools (search.nixos.org, rg, nix-locate) provides a comprehensive approach to finding dependencies.

VI. Key Benefits of Working with Nixpkgs Locally (Recap)

  • Speed: Faster searches and builds compared to remote operations.

  • Control: Full control over the Nixpkgs version.

  • Up-to-Date Information: Repository source often has the latest details.

VII. Best Practices and Tips from the Community

✔️ Click To Expand Best Practices and Tips from the community
  • Rebasing over Merging: Never merge upstream changes into your local branch. Always rebase your branch onto the upstream (e.g., master or nixos-unstable) to avoid accidental large-scale pings in pull requests (Tip from soulsssx3 on Reddit).

  • Tip from ElvishJErrico: Avoid using Nixpkgs directly as a flake for local development due to slow copying to /nix/store and performance issues with garbage collection on large numbers of small files. Use nix build -f . <package> instead of nix build .#<package>.

  • Edge Cases for Flake Syntax: Flake syntax might be necessary in specific scenarios, such as NixOS installer tests where copying the Git history should be avoided.

  • Base Changes on nixos-unstable: For better binary cache hits, base your changes on the nixos-unstable branch instead of master. Consider the merge-base for staging branches as well.

  • Consider jujutsu: Explore jj-vcs, a Git-compatible alternative that can offer a more intuitive workflow, especially for large monorepos like Nixpkgs. While it has a learning curve, it can significantly improve parallel work and branch management.

  • Intro-To-Jujutsu

Fork, Clone, Contribute

  • In the Nixpkgs Repository.

  • Click Fork, then Create a new Fork.

  • Uncheck the box "Only fork the master branch", for development we will need more branches.

    • If you only fork master, you won't have the nixos-XX.YY release branches available on your fork when you later try to create a PR against them, or when you want to create a feature branch from them on your fork.
  • Click <> Code and Clone the Repo. sayls8 is the name of my GitHub, yours will obviously be different.

git clone git@github.com:sayls8/nixpkgs.git

Figure out the branch that should be used for this change by going through this section

When in doubt use master, that's where most changes should go. This can be changed later by rebasing

cd nixpkgs

git remote add upstream https://github.com/NixOS/nixpkgs.git
# Make sure you have the latest changes from upstream Nixpkgs
git fetch upstream
# Show currently configured remote repository
git remote -v
origin  git@github.com:sayls8/nixpkgs.git (fetch)
origin  git@github.com:sayls8/nixpkgs.git (push)
upstream        https://github.com/NixOS/nixpkgs.git (fetch)
upstream        https://github.com/NixOS/nixpkgs.git (push)

Understanding Your Remotes

This output confirms that:

  • origin is your personal fork on GitHub (sayls8/nixpkgs.git). When you git push origin ..., your changes go here.

  • upstream is the official Nixpkgs repository (NixOS/nixpkgs.git). When you git fetch upstream, you're getting the latest updates from the main project.

This setup ensures you can easily pull updates from the original project and push your contributions to your own fork.

# Shows a ton of branches
git branch -r | grep upstream
# Narrow it down
git branch -r | grep upstream | grep nixos-

Next Steps for Contributing

  1. Ensure master is up to date with upstream
git checkout master
git fetch upstream
git pull upstream master
git push origin master

This keeps your fork in sync to avoid conflicts.

If targeting another branch, replace master with nixos-24.11 for example.

  1. Create a Feature Branch
git checkout master
git checkout -b my-feature-branch # name should represent the feature
  1. Make and Test Changes

Packaging Conventions

New package: Add to pkgs/by-name/<first-two-letters>/<package-name>/default.nix.

Example structure:

{ lib, stdenv, fetchFromGitHub }: stdenv.mkDerivation {
pname = "xyz"; version = "1.2.3"; src = fetchFromGitHub { ... }; ... }

Update package: Edit version and sha256 in the package’s default.nix. Use nix-prefetch-url to update hashes:

nix-prefetch-url <source-url>

Fix a bug: Modify files in pkgs/, nixos/modules/, or elsewhere.

Test locally:

Build:

nix-build -A <package-name>

Test in a shell:

 bash nix-shell -p <package-name>

For NixOS modules:

nixos-rebuild test

Follow the Nixpkgs Contributing Guide.

  1. Commit and Push

Commit with a clear message, make sure to follow commit conventions:

Commit Conventions

  • Create a commit for each logical unit.

  • Check for unnecessary whitespace with git diff --check before committing.

  • If you have commits pkg-name: oh, forgot to insert whitespace: squash commits in this case. Use git rebase -i. See Squashing Commits for additional information.

  • For consistency, there should not be a period at the end of the commit message's summary line (the first line of the commit message).

  • When adding yourself as maintainer in the same pull request, make a separate commit with the message maintainers: add . Add the commit before those making changes to the package or module. See Nixpkgs Maintainers for details.

Format the commit messages in the following way:

(pkg-name): (from -> to | init at version | refactor | etc)

(Motivation for change. Link to release notes. Additional information.)

a) For example, for the airshipper package:

git add pkgs/by-name/ai/airshipper/
git commit -m "airshipper: init at 0.1.0"

Adds the airshipper tool for managing game assets.
Upstream homepage: https://github.com/someuser/airshipper"

b) Updating airshipper to a new version

git add pkgs/by-name/ai/airshipper/
git commit -m "airshipper: 0.1.0 -> 0.2.0

Updated airshipper to version 0.2.0. This release includes:
- Improved asset fetching logic
- Bug fixes for network errors

Release notes: https://github.com/someuser/airshipper/releases/tag/v0.2.0"

c) Fixing a bug in airshipper's package definition

git add pkgs/by-name/ai/airshipper/
git commit -m "airshipper: fix: build with latest glibc

Resolved build failures on unstable channel due to changes in glibc.
Patched source to use updated API calls.
"

Examples:

  • nginx: init at 2.0.1

  • firefox: 122.0 -> 123.0

  • vim: fix build with gcc13

Push:

git push origin my-feature-branch
  1. Create a Pull Request

Go to https://github.com/sayls8/nixpkgs. (your fork) Click the PR prompt for my-feature-branch. Set the base branch to NixOS/nixpkgs:master (or nixos-24.11). Write a PR description: Purpose of the change. Related issues (e.g., Fixes #1234). Testing steps (e.g., nix-build -A <package-name>). Submit and respond to feedback.

  1. Handle Updates

For reviewer feedback or upstream changes:

Edit, commit, and push:

git add . git commit -m "<package-name>: address feedback" git push origin my-feature-branch

Rebase if needed: bash

git fetch upstream
git rebase upstream/master  # or upstream/nixos-24.11
git push origin my-feature-branch --force
  1. Cleanup

After PR merge:

Delete branch:

git push origin --delete my-feature-branch

Sync master:

git checkout master
git pull upstream master
git push origin master

Addressing the Many Branches

  • No need to manage all branches: The nixos-branches are just metadata from upstream. You only check out the one you need (e.g., master or nixos-24.11).

  • Focus on relevant branches: The filter (grep nixos-) shows the key release branches. Ignore -small branches and older releases unless specifically required. Confirm latest stable: If you’re targeting a stable branch, nixos-24.11 is likely the latest (or nixos-25.05 if it’s active). Verify via NixOS status.

Creating and Building a Local Package within a Nixpkgs Clone

While an actual submission to Nixpkgs involves more steps, this chapter demonstrates the fundamental pattern for creating a package. Every package recipe is a file that declares a function. This function takes the packages dependencies as argument.

In this example we'll make a simple package with coreutils and build it. Demonstrating the process of building and testing a local package.

Clone Nixpkgs

First, we'll clone Nixpkgs and try to find a good spot to put our package. We're just building a test package so nixpkgs/pkgs/misc could be a good place to start. We'll call our package testPackage.

cd ~
mkdir src && cd src
git clone https://github.com/NixOS/nixpkgs.git
cd nixpkgs/pkgs
ls # Try to find a catagory that your pkg fits in
╭────┬────────────────┬──────┬─────────┬─────────────╮
│  # │      name      │ type │  size   │  modified   │
├────┼────────────────┼──────┼─────────┼─────────────┤
│  0 │ README.md      │ file │ 50.6 kB │ 2 hours ago │
│  1 │ applications   │ dir  │   398 B │ 2 hours ago │
│  2 │ build-support  │ dir  │  2.5 kB │ 2 hours ago │
│  3 │ by-name        │ dir  │  2.9 kB │ 2 hours ago │
│  4 │ common-updater │ dir  │   286 B │ 2 hours ago │
│  5 │ data           │ dir  │    82 B │ 2 hours ago │
│  6 │ desktops       │ dir  │   164 B │ 2 hours ago │
│  7 │ development    │ dir  │   882 B │ 2 hours ago │
│  8 │ games          │ dir  │  1.5 kB │ 2 hours ago │
│  9 │ kde            │ dir  │   116 B │ 2 hours ago │
│ 10 │ misc           │ dir  │   390 B │ 2 hours ago │
│ 11 │ os-specific    │ dir  │    42 B │ 2 hours ago │
│ 12 │ pkgs-lib       │ dir  │    68 B │ 2 hours ago │
│ 13 │ servers        │ dir  │  1.0 kB │ 2 hours ago │
│ 14 │ shells         │ dir  │    46 B │ 2 hours ago │
│ 15 │ stdenv         │ dir  │   178 B │ 2 hours ago │
│ 16 │ test           │ dir  │   702 B │ 2 hours ago │
│ 17 │ tools          │ dir  │   342 B │ 2 hours ago │
│ 18 │ top-level      │ dir  │  2.3 kB │ 2 hours ago │
╰────┴────────────────┴──────┴─────────┴─────────────╯

Ad-hoc semi-regular structure, if you need to make a new package we first make a directory with the name of the package and a default.nix in said directory:

❗ NOTE: In this example we will use the misc directory, it is now recommended to use the by-name directory. Explained further down.

Create your Package directory and a default.nix

cd misc
mkdir testPackage && cd testPackage
hx default.nix
# default.nix
{
  runCommand,
  coreutils,
}:
runCommand "testPackage" {
  nativeBuildInputs = [
    coreutils
  ];
} ''

  echo 'This is a Test' > $out
''

Now we need to add our testPackage to all-packages.nix

cd pkgs/top-level
hx all-packages.nix

all-packages.nix is a centralized module that defines all available package expressions.

We'll add our package in the list alphabetically:

# all-packages.nix
# `/msc` # editor search inside file
# Scroll down to t's
# snip ...
termusic = callPackage ../applications/autio/termusic { };

# we add our package here
testPackage = callPackage ../misc/testPackage { };

tfk8s = callPackage ../applications/misc/tfk8s { };
# snip ...

callPackage is a core utility in Nixpkgs. It takes a Nix expression (like our default.nix file, which defines a function) and automatically provides the function with any arguments it declares, by looking them up within the pkgs set (or the scope where callPackage is invoked). This means you only need to list the dependencies your package needs in its default.nix function signature, and callPackage will "inject" the correct versions of those packages. This is what the callPackage Nix Pill demonstrates at a lower level.

Understanding pkgs/by-name/ and other locations

Nixpkgs uses different conventions for package placement:

  • Older categories (e.g., pkgs/misc/, pkgs/applications/): Packages within these directories typically use default.nix as their definition file (e.g., pkgs/misc/testPackage/default.nix). These packages are NOT automatically included in the top-level pkgs set; they must be explicitly added via a callPackage entry in pkgs/top-level/all-packages.nix. This is the method demonstrated in this chapter for our testPackage.

  • The new pkgs/by-name/ convention: This is the preferred location for new packages.

    • Packages here are placed in a directory structure like pkgs/by-name/<first-two-letters>/<package-name>/.

    • Crucially, their main definition file is named package.nix (e.g., pkgs/by-name/te/testPackage/package.nix).

    • Packages placed within pkgs/by-name/ are automatically discovered and exposed by Nixpkgs' top-level pkgs set. They do not require a manual callPackage entry in all-packages.nix. This results in a more modular and scalable approach, reducing manual maintenance.

❗ : While this example uses pkgs/misc/ to demonstrate explicit callPackage usage, when contributing a new package to Nixpkgs, you should nearly always place it within pkgs/by-name/ and name its definition file package.nix.

Previously, packages were manually added to all-packages.nix. While this is no longer needed in most cases, understanding the old method provides useful context for troubleshooting legacy configurations or custom integrations.

Try Building the Package

Move to the root directory of Nixpkgs:

cd ~/src/nixpkgs

Try building it:

nix-build -A testPackage
this derivation will be built:
this derivation will be built:
  /nix/store/yrbjsxmgzkl24n75sqjfxbpv5cs3b9hc-testPackage.drv
building '/nix/store/yrbjsxmgzkl24n75sqjfxbpv5cs3b9hc-testPackage.drv'...
/nix/store/3012zlv30vn6ifihr1jxbg5z3ysw0hl3-testPackage

runCommand is a simple builder, it takes 3 arguments. The first is the package name the second is the derivation attributes, and the third is the script to run.

cat ~/src/nixpkgs/result
───────┬──────────────────────────────
       │ File: result
───────┼──────────────────────────────
   1   │ This is a Test
───────┴──────────────────────────────
nix-instantiate --eval -A testPackage.meta.position
"/home/jr/src/nixpkgs/pkgs/misc/testPackage/default.nix:6"

Tools like nix search and the Nixpkgs website use the meta information for documentation and discoverability. It can also be useful for debugging and helps to provide better error messages. The above command shows that the meta.position attribute points to the file and line where the package definition begins, which is very useful for debugging.

Typically a file will have a meta attribute that looks similar to the following:

meta = with lib; {
    homepage = "https://www.openssl.org/";
    description = "A cryptographic library that implements the SSL and TLS protocols";
    license = licenses.openssl;
    platforms = platforms.all;
} // extraMeta;

For example, the following shows how Nix is able to discover different parts of your configuration:

Launch the nix repl and load your local flake:

cd /src
nix repl
nix-repl> :lf nixpkgs
nix-repl> outputs.legacyPackages.x86_64-linux.openssl.meta.position
"/nix/store/syvnmj3hhckkbncm94kfkbl76qsdqqj3-source/pkgs/development/libraries/openssl/default.nix:303"
nix-repl> builtins.unsafeGetAttrPos "description" outputs.legacyPackages.x86_64-linux.openssl.meta
{
  column = 9;
  file = "/nix/store/syvnmj3hhckkbncm94kfkbl76qsdqqj3-source/pkgs/development/libraries/openssl/default.nix";
  line = 303;
}

Lets create just the meta.description for demonstration purposes.

Adding the meta attribute

Since we don't have a meta attribute this points to a default value that's incorrect.

Let's add the meta attribute and try it again:

# default.nix
{
  runCommand,
  coreutils,
}:
runCommand "testPackage" {
  nativeBuildInputs = [
    coreutils
  ];

  meta = {
    description = "test package";
};
} ''

  echo 'This is a Test' > $out
''
nix-instantiate --eval -A testPackage.meta.position
"/home/jr/src/nixpkgs/pkgs/misc/testPackage/default.nix:11"

Now it points us to the 11'th line, right where our meta.description is.

Let's stage our package so nix recognises it:

cd ~/nixpkgs
git add pkgs/misc/testPackage/
nix edit .#testPackage

The default.nix that we've been working on should open in your $EDITOR

Nixpkgs Overlays

The following is done with a local clone of Nixpkgs located at ~/src/nixpkgs.

In this example, we will create an overlay to override the version of btrfs-progs. In the root directory of our local clone of Nixpkgs (i.e.~/src/nixpkgs) we can run the following command to locate btrfs-progs within Nixpkgs:

fd 'btrfs-progs' .
./pkgs/by-name/bt/btrfs-progs/

Open the package.nix in the above directory and copy the src block within the stdenv.mkDerivation block like so:

# package.nix
  version = "6.14";

  src = fetchurl {
    url = "mirror://kernel/linux/kernel/people/kdave/btrfs-progs/btrfs-progs-v${version}.tar.xz";
    hash = "sha256-31q4BPyzbikcQq2DYfgBrR4QJBtDvTBP5Qzj355+PaE=";
  };

When we use the above src block in our overlay we'll need to add src = self.fetchurl for our overlay to have access to fetchurl.

We will replace the version with our desired version number. To find another version that actually exists we need to check their github repos btrfs-progs Releases. I can see that the previous version was v6.13, lets try that.

cd ~/src/nixpkgs
hx overlay.nix

We will change the version to 6.13 for demonstration purposes. All that is really required is changing the version and 1 character in the hash which would cause a refetch and recalculation of the hash. We will use an empty string to follow convention:

# overlay.nix
self: super: {
  btrfs-progs = super.btrfs-progs.overrideAttrs (old: rec {
      version = "6.13";

      # Notice the `self` added here
      src = self.fetchurl {
        url = "mirror://kernel/linux/kernel/people/kdave/btrfs-progs/btrfs-progs-v${version}.tar.xz";
        hash = "";
      };
    };
  });
}

To build this with the file right from the root of the local Nixpkgs (i.e. ~/src/nixpkgs) you could run the following. Running the command this way avoids the impurity of looking it up in the ~/.config directory:

nix-build -A btrfs-progs --arg overlays '[ (import ./overlay.nix) ]'

The compiler will give you back the correct hash:

specified: sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
got:    sha256-ZbPyERellPgAE7QyYg7sxqfisMBeq5cTb/UGx01z7po=

Replace the empty hash with the new hash value we just got from the compiler so the overlay.nix would look like this:

self: super: {
  btrfs-progs = super.btrfs-progs.overrideAttrs (old: rec {
    version = "6.13";

    src = self.fetchurl {
      url = "mirror://kernel/linux/kernel/people/kdave/btrfs-progs/btrfs-progs-v${version}.tar.xz";
      hash = "sha256-ZbPyERellPgAE7QyYg7sxqfisMBeq5cTb/UGx01z7po=";
    };
  });
}

Try building it again:

nix-build -A btrfs-progs --arg overlays '[ (import ./overlay.nix) ]'
checking for references to /build/ in /nix/store/szd6lizahidjniz85a0g1wsrfknirhwb-btrfs-progs-6.13...
gzipping man pages under /nix/store/szd6lizahidjniz85a0g1wsrfknirhwb-btrfs-progs-6.13/share/man/
patching script interpreter paths in /nix/store/szd6lizahidjniz85a0g1wsrfknirhwb-btrfs-progs-6.13
/nix/store/szd6lizahidjniz85a0g1wsrfknirhwb-btrfs-progs-6.13/bin/fsck.btrfs: interpreter directive changed from "#!/bin/sh -f" to "/nix/store/xy4jjgw87sbgwylm5kn047d9gkbhsr9x-bash-5.2p37/bin/sh -f"
stripping (with command strip and flags -S -p) in  /nix/store/szd6lizahidjniz85a0g1wsrfknirhwb-btrfs-progs-6.13/lib /nix/store/szd6lizahidjniz85a0g1wsrfknirhwb-btrfs-progs-6.13/bin
/nix/store/szd6lizahidjniz85a0g1wsrfknirhwb-btrfs-progs-6.13

We can inspect it with the repl:

cd ~/src/nixpkgs
nix repl
nix-repl> :a import ./. { overlays = [ (import ./overlay.nix) ]; }
nix-repl> btrfs-progs
«derivation /nix/store/6yxhj84cwcsnrd87rcxbd6w08l9ikc6p-btrfs-progs-6.13.drv»
nix-repl> btrfs-progs.drvAttrs.buildInputs
[
  «derivation /nix/store/yg4llzkcla5rppv8r1iikyamfxg3g4sg-acl-2.3.2.drv»
  «derivation /nix/store/vqczbcwjnid6bs4cv3skl7kyd6kkzcfx-attr-2.5.2.drv»
  «derivation /nix/store/xrvx0azszpdh2x0lnldakqx25vfxab19-e2fsprogs-1.47.2.drv»
  «derivation /nix/store/iil4b8adk615zhp6wmzjx16z1v2f8f4j-util-linux-minimal-2.41.drv»
  «derivation /nix/store/wwld8wp91m26wz69gp8vzh090sh5ygxd-lzo-2.10.drv»
  «derivation /nix/store/w4ncw24gdfkbx9779xpgjli5sagi506m-systemd-minimal-libs-257.5.drv»
  «derivation /nix/store/dmh4lvmq6n8hy56q93kplvnfnlwqzzv5-zlib-1.3.1.drv»
  «derivation /nix/store/h8iwhnr636dwb72qqcyzp111ajjxgzr2-zstd-1.5.7.drv»
]
nix-repl> btrfs-progs.drvAttrs.version
"6.13"
nix-repl> btrfs-progs.drvAttrs.src
«derivation /nix/store/y5nkz1xczxha4xl93qq3adndyc46dcvf-btrfs-progs-v6.13.tar.xz.drv»

Using :a adds the attributes from the resulting set into scope and avoids bringing the entire nixpkgs set into scope.

To see whats available, you can for example type btrfs-progs.drvAttrs. then hit TAB.

Another way to do this is to move our overlay to the ~/.config/nixpkgs/overlays directory and rename the file like the following, agian this adds an impurity because it relies on your ~/.config directory which is different from user to user:

mv overlay.nix ~/.config/nixpkgs/overlays/btrfs-progs.nix
cd ~/src/nixpkgs
nix-build -A btrfs-progs
checking for references to /build/ in /nix/store/szd6lizahidjniz85a0g1wsrfknirhwb-btrfs-progs-6.13...
gzipping man pages under /nix/store/szd6lizahidjniz85a0g1wsrfknirhwb-btrfs-progs-6.13/share/man/
patching script interpreter paths in /nix/store/szd6lizahidjniz85a0g1wsrfknirhwb-btrfs-progs-6.13
/nix/store/szd6lizahidjniz85a0g1wsrfknirhwb-btrfs-progs-6.13/bin/fsck.btrfs: interpreter directive changed from "#!/bin/sh -f" to "/nix/store/xy4jjgw87sbgwylm5kn047d9gkbhsr9x-bash-5.2p37/bin/sh -f"
stripping (with command strip and flags -S -p) in  /nix/store/szd6lizahidjniz85a0g1wsrfknirhwb-btrfs-progs-6.13/lib /nix/store/szd6lizahidjniz85a0g1wsrfknirhwb-btrfs-progs-6.13/bin
/nix/store/szd6lizahidjniz85a0g1wsrfknirhwb-btrfs-progs-6.13

Overlays with Flakes

In a flake, overlays are defined in the outputs.overlays attribute set of the flake.nix.

They are then applied to nixpkgs inputs using inputs.nixpkgs.follows = "nixpkgs"; (or similar) and the overlays attribute on the input.

Example of flake usage:

# flake.nix
{
  description = "My NixOS flake with custom overlays";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  };

  outputs = { self, nixpkgs, ... }: {

    overlays.myCustomOverlay = final: prev: {
      btrfs-progs = prev.btrfs-progs.overrideAttrs (old: rec {
        version = "6.13";
        src = self.fetchurl {
          url = "mirror://kernel/linux/kernel/people/kdave/btrfs-progs/btrfs-progs-v${version}.tar.xz";
          hash = "sha256-ZbPyERellPgAE7QyYg7sxqfisMBeq5cTb/UGx01z7po=";
        };
      });
    };

    nixosConfigurations.my-system = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        # Apply the overlay
        { nixpkgs.overlays = [ self.overlays.myCustomOverlay ]; }
        ./configuration.nix
      ];
    };
  };
}
nix flake show
path:/home/jr/btrfs-progs?lastModified=1749655369&narHash=sha256-ln6dLiqo7TxStQSXgcIwfbdt7STGw4ZHftZRfWpY/JQ%3D
├───nixosConfigurations
│   └───my-system: NixOS configuration
└───overlays
    └───myCustomOverlay: Nixpkgs overlay

Chapter 11

✔️ Click to Expand Table of Contents

gruv16

Nix Pull Requests

Pull requests communicate changes to a branch in a repository. Once a pull request is opened, you can review changes with collaborators and add follow-up commits.

  • A pull request is a proposal to merge a set of changes from one branch into another. In a pull request, collaborators can review and discuss the proposed set of changes before they integrate the changes into the main codebase.

  • Pull requests display the differences, or diffs, between the content in the source branch and the content in the target branch.

graph LR
    A[Your Local Repository] --> B(Feature Branch);
    B --> C{GitHub Repository};
    C -- "Open Pull Request" --> D[Pull Request on GitHub];
    D -- "Review & Discussion" --> D;
    D -- "Merge" --> E(Main Branch on GitHub);
    E --> F[Nixpkgs Users];

Explanation of the Diagram:

✔️ Click to see Explanation
  • A[Your Local Repository]: This represents the copy of the Nixpkgs repo on your computer where you make changes.

  • B (Feature Branch): You create a dedicated branch (e.g.my-pack-update) to isolate your changes.

  • C {GitHub Repository}: This is the central online repo for Nixpkgs on Github. You push your feature branch to this repo.

  • C -- "Open Pull Request" -- D [Pull Request on Github]: You initiate a pull request from your feature branch to the main branch (usually master or main) through the GitHub interface.

  • D [Pull Request on GitHub]: This is where collaborators can see your proposed changes, discuss them, and provide feedback.

  • D -- "Review & Discussion" --> D: The pull request facilitates communication and potential revisions based on the review.

  • D -- "Merge" --> E (Main Branch on GitHub): Once the changes are approved, they are merged into the main branch of the Nixpkgs repository.

  • E (Main Branch on GitHub): The main branch now contains the integrated changes.

  • E --> F [Nixpkgs Users]): Eventually, these changes become available to all Nixpkgs users through updates to their Nix installations.

Flakes often rely on having access to the full history of the Git repository to correctly determine dependencies, identify specific revisions of inputs, and evaluate the flake. Not in all situations will a shallow clone work and this is one of them.

If you have any changes to your local copy of Nixpkgs make sure to stash them before the following:

git stash -u
  • This command saves your uncommited changes (including staged files) temporarily. You can restore them later with git stash pop

Step 1 Clone Nixpkgs Locally

If you don't have Nixpkgs locally, you'll need to clone it:

git clone https://github.com/NixOS/nixpkgs.git

Step 2 Find a Relevant Pull Request

To find specifig commits and releases:

status.nixos.org provides the latest tested commits for each release - use when pinning to specific commits. List of active release channels - use when tracking latest channel versions.

The complete list of channels is available at nixos.org/channels

To find a relevant PR you can go to:

  • Nixpkgs Pull Requests

  • The following example actually uses the Nix Pull Requests the process is the same, but that is an important distinction.

  • In the Filters enter stack trace for this example.

  • The pull request I chose was 8623

Step 3 Add the Remote Repository (if necessary)

If the pull request is from a different repository than your local clone (as in the case of the nix PR while working in a nixpkgs clone), you need to add that repository as a remote. It's common to name the main Nixpkgs remote origin and other related repositories like nix as upstream.

Assuming you are in your nixpkgs clone and want to test a PR from the nix repository:

git remote add upstream https://github.com/NixOS/nix.git

Step 4 Fetch the Pull Request Changes

Fetch the Pull Request Information:

git fetch upstream refs/pull/8623/head:pr-8623
  • This command fetches the branch named head from the pull request 8623 in the upstream remote and creates a local branch named pr-8623 that tracks it.

Output:

✔️ Output (Click to Enlarge)
remote: Enumerating objects: 104651, done.
remote: Counting objects: 100% (45/45), done.
remote: Compressing objects: 100% (27/27), done.
remote: Total 104651 (delta 33), reused 20 (delta 18), pack-reused 104606 (from 1)
Receiving objects: 100% (104651/104651), 61.64 MiB | 12.56 MiB/s, done.
Resolving deltas: 100% (74755/74755), done.
From https://github.com/NixOS/nix
 * [new ref]             refs/pull/8623/head -> pr-8623
 * [new tag]             1.0                 -> 1.0
 * [new tag]             1.1                 -> 1.1
 * [new tag]             1.10                -> 1.10
 * [new tag]             1.11                -> 1.11
 * [new tag]             1.11.1              -> 1.11.1
 * [new tag]             1.2                 -> 1.2
 * [new tag]             1.3                 -> 1.3
 * [new tag]             1.4                 -> 1.4
 * [new tag]             1.5                 -> 1.5
 * [new tag]             1.5.1               -> 1.5.1
 * [new tag]             1.5.2               -> 1.5.2
 * [new tag]             1.5.3               -> 1.5.3
 * [new tag]             1.6                 -> 1.6
 * [new tag]             1.6.1               -> 1.6.1
 * [new tag]             1.7                 -> 1.7
 * [new tag]             1.8                 -> 1.8
 * [new tag]             1.9                 -> 1.9
 * [new tag]             2.0                 -> 2.0
 * [new tag]             2.2                 -> 2.2

Step 5 Checkout the Local Branch:

git checkout pr-8623

Or with the gh cli:

gh pr checkout 8623

Build and Test the Changes

  • Now we want to see if the code changes introduced by the pull request actually build correctly within the Nix ecosystem.
nix build

Output:

✔️ Output (Click to Enlarge)
error: builder for '/nix/store/rk86daqgf6a9v6pdx6vcc5b580lr9f09-nix-2.20.0pre20240115_20b4959.drv' failed with exit code 2;
   last 25 log lines:
   >
   >         _NIX_TEST_ACCEPT=1 make tests/functional/lang.sh.test
   >
   >     to regenerate the files containing the expected output,
   >     and then view the git diff to decide whether a change is
   >     good/intentional or bad/unintentional.
   >     If the diff contains arbitrary or impure information,
   >     please improve the normalization that the test applies to the output.
   > make: *** [mk/lib.mk:90: tests/functional/lang.sh.test] Error 1
   > make: *** Waiting for unfinished jobs....
   > ran test tests/functional/selfref-gc.sh... [PASS]
   > ran test tests/functional/store-info.sh... [PASS]
   > ran test tests/functional/suggestions.sh... [PASS]
   > ran test tests/functional/path-from-hash-part.sh... [PASS]
   > ran test tests/functional/gc-auto.sh... [PASS]
   > ran test tests/functional/path-info.sh... [PASS]
   > ran test tests/functional/flakes/show.sh... [PASS]
   > ran test tests/functional/fetchClosure.sh... [PASS]
   > ran test tests/functional/completions.sh... [PASS]
   > ran test tests/functional/build.sh... [PASS]
   > ran test tests/functional/impure-derivations.sh... [PASS]
   > ran test tests/functional/build-delete.sh... [PASS]
   > ran test tests/functional/build-remote-trustless-should-fail-0.sh... [PASS]
   > ran test tests/functional/build-remote-trustless-should-pass-2.sh... [PASS]
   > ran test tests/functional/nix-profile.sh... [PASS]
   For full logs, run:
     nix log /nix/store/rk86daqgf6a9v6pdx6vcc5b580lr9f09-nix-2.20.0pre20240115_20b4959.drv
  • nix build (Part of the Nix Unified CLI):

    • Declarative: when used within a Nix flake (flake.nix), nix build is a bit more declarative. It understands the outputs defined in your flake.

    • Clearer Output Paths: nix build typically places build outputs in the ./result directory by default (similar to nix-build's result symlink)

    • Better Error Reporting: It gives more informative error messages.

    • Future Direction

Benefits of using nix build:

  • Flake Integration: nix build naturally understands the flake's outputs.

  • Development Shells: When you are in a nix develop shell, nix build is the more idiomatic way to build packages defined in your dev environment.

  • Consistency: Using the unified CLI promotes a more consistent workflow.

Next Steps

As you can see this build failed, as for why the build failed, the key part of the error message is:

make: *** [mk/lib.mk:90: tests/functional/lang.sh.test] Error 1
  • This suggests that one of the functional tests (lang.sh.test) failed. This happens when the expected output of the test doesn't match the actual output.

This can heppen when:

  1. The test expectations are outdated due to changes in the codebase.

  2. The test captures environment-specific or transient outputs that are not properly normalized.

  3. The test includes impure or non-deterministic information, making it hard to verify.

To address this, _NIX_TEST_ACCEPT=1 is used as an override mechanism that tells the test framework: > "Accept whatever output is generated as the new expected result."

The message advises running:

_NIX_TEST_ACCEPT=1 make tests/functional/lang.sh.test
  • This will regenerate the expected output files, allowing you to inspect what changed with git diff:
git diff tests/functional/lang.sh.test
  • Verifies if Changes are Intentional: If the difference is reasonable and expected (due to a legitimate update in the logic), you can commit these changes to update the test suit. If not, you have to refine the test normalization process further.

If the changes seem valid, commit them:

git add tests/functional/lang.sh.test
git commit -m "Update expected test output for lang.sh.test"

Running the following will provide the full logs:

nix log /nix/store/rk86daqgf6a9v6pdx6vcc5b580lr9f09-nix-2.20.0pre20240115_20b4959.drv

Conclusion

Testing Nixpkgs pull requests is a vital part of contributing to a healthy and reliable Nix ecosystem. By following these steps, you can help ensure that changes are well-vetted before being merged, ultimately benefiting all Nix users. Your efforts in testing contribute significantly to the quality and stability of Nixpkgs.

Chapter 12

Nu

Intro to Nushell on NixOS

  • TL;DR:I recently switched default shells from zsh to nushell, this post is about some of the challenges and advantages of using nushell with NixOS.

  • While the average user might not immediately see significant advantages, those who frequently work with structured data formats like JSON, YAML, and CSV – such as developers interacting with APIs, system administrators managing configurations, and data professionals – will likely find Nushell's native data handling and powerful pipeline capabilities a plus. Additionally, users who value a more consistent and safer scripting experience might appreciate Nushell's language-first design and features like strong typing.

  • I'll start with some of the unique build design choices and unique features that I think make Nushell special, then show an example using Nushell to manipulate JSON data. Finally, I will highlight some of the visually appealing aspects of Nushell and lastly I share some resources for learning more.

The Good

  • Nushell borrows concepts from many shells and languages and is itself both a programming language and a shell. Because of this, it has its own way of working with files, directories, websites, and more.

  • Nushell is powerful and has many essential commands built directly into the shell ("internal" commands) rather than a link to an executable. You can use this set of commands across different operating systems, having this consistency is helpful when creating cross-platform code.

  • When internal Nushell commands (like ls, open, where, get, sort-by, etc.) produce output, they generally do so in Nushell's structured data format (tables or records). This is the shell's native way of representing information.

  • Beyond these foundational strengths, Nushell offers a range of unique features that enhance its functionality and make it particularly well-suited for data-heavy tasks. Here are some highlights that showcase its versatility.

Some Unique Features:

  • Besides the built-in commands, Nushell has a standard library Nushell operates on structured data. You could call it a "data-first" shell and programming language.

  • Also included, is a full-featured dataframe processing engine using Polars if you want to process large data efficiently directly in your shell, check out the Dataframes-Docs

  • Multi-Line Editing:

  • When writing a long command you can press Enter to add a newline and move to the next line. For example:

ls            |    # press enter
where name =~ |    # press enter, comments after pipe ok
get name      |    # press enter
mv ...$in ./backups/
  • This allows you to cycle through the entire multi-line command using the up and down arrow keys and then customize different lines or sections of the command.

  • You can manually insert a newline using Alt+Enter or Shift+Enter.

  • The Reedline-Editor is powerful and provides good vi-mode or emacs support built in.

  • It's default Ctrl+r history command is nice to work with out of the box.

  • The explore command, is nu's version of a table pager, just like less but for table structured data:

$nu | explore --peek
  • With the above command you can navigate with vim keybinds or arrow keys.

  • These features demonstrate Nushell’s user-friendly interface, but what truly sets it apart is its underlying design as a structured data scripting language. This “language-first” approach powers many of its distinctive capabilities.

explore

Unique design:

  • Fundamentally designed as a structured data scripting language: and then it acts as a shell on top of that foundation. This "language first" approach is what gives it many of its distinctive features and makes it a powerful scripting language. I reiterate this here because of the implications of this. A few of those features are:

    • Pipelines of structured data: Unlike traditional shells that primarily deal with plain text streams, Nushell pipelines operate on tables of structured data. Each command can understand and manipulate this structured data directly.

    • Consistent syntax: Its syntax is more consistent and predictable compared to the often quirky syntax of Bash and Zsh, drawing inspiration from other programming languages.

    • Strong typing Nushell has a type system, which helps catch errors early and allows for more robust scripting.

    • First-class data types: It treats various data formats (like JSON, CSV, TOML) as native data types, making it easier to work with them. Because of this, Nushell aims to replace the need for external tools like jq, awk, sed, cut, and even some uses of grep and curl.

  • Variables are Immutable by Default: Nushell's commands are based on a functional-style of programming which requires immutability, sound familiar?

  • Nushell's Environment is Scoped: Nushell takes many design cues from compiled languages, one is that languages should avoid global mutable state. Shells have commonly used global mutation to update the environment, Nushell attempts to steer clear of this increasing reproducability.

  • Single-use Environment Variables:

FOO=BAR $env.FOO
# => BAR
  • Permanent Environment Variables: In your config.nu
# config.nu
$env.FOO = 'BAR'
  • Coming-From-Bash

  • These design principles make Nushell a powerful tool for scripting, but they’re best understood through a hands-on example. Let’s see how Nushell’s structured data capabilities shine in a common task: processing a JSON file.

Example: I wanted to provide a practical example to illustrate some of these "Good" features in action. And break it down for better understanding.

  • Let's consider a common task: processing data from a JSON file. Imagine you have a file containing a list of users with their names and ages. With traditional shells, you'd likely need to rely on external tools like jq to parse and filter this data. However, Nushell can handle this directly within its own commands.

  • For this example you could create a test directory and move to it:

mkdir test ; cd test
  • Create a users.json with the following contents:
[
  { "name": "Alice", "age": 25 },
  { "name": "Bob", "age": 30 },
  { "name": "Charlie", "age": 20 }
]
  • And create the following filter.nu that first converts users.json into its own internal structured data format with the open command, then to filters out people under 21 with the where control flow construct, then selects the name and age columns, sorts them by age, and finally converts them back to json and saves them to a file called filtered_users.json. A lot happening in a 6 line script.
open users.json           # Read JSON file into structured data
| where age > 21         # Filter users older than 21
| select name age        # Select only name and age columns
| sort-by age            # Sort by age
| to json                # Convert back to JSON
| save filtered_users.json # Save result to a new file
  • The open command takes data from a file (or even a URL in some cases) and parses it and converts it into Nushells own internal structured data format. So this command isn't just showing you the contents of users.json but doing a conversion to Nu's special structured format.
open users.json
╭───┬─────────┬─────╮
│ # │  name   │ age │
├───┼─────────┼─────┤
│ 0 │ Alice   │  25 │
│ 1 │ Bob     │  30 │
│ 2 │ Charlie │  20 │
╰───┴─────────┴─────╯
  • The source command in Nushell is used to execute the commands within a script file (like filter.nu) in the current Nushell environment. It's similar to running the script directly in the shell, but keeps the shell open for further use. In this example, source filter.nu runs the commands inside filter.nu, processing the users.json file and creating the filtered_users.json file:
source filter.nu
bat filtered_users.json
───────┬──────────────────────────────────────────────────────────────────────────────────────
       │ File: filtered_users.json
───────┼──────────────────────────────────────────────────────────────────────────────────────
   1   │ [
   2   │   {
   3   │     "name": "Alice",
   4   │     "age": 25
   5   │   },
   6   │   {
   7   │     "name": "Bob",
   8   │     "age": 30
   9   │   }
  10   │ ]
───────┴───────────────────────────────────────────────────────────────────────────────────
  • As you can see, without needing any external tools, Nushell was able to read, filter, select, sort, and then re-serialize JSON data using a clear and concise pipeline. This demonstrates its power in handling structured data natively, making common data manipulation tasks within the shell significantly more streamlined and readable compared to traditional approaches.

In the filter.nu example:

open users.json           # Read JSON file into structured data
| where age > 21         # Filter users older than 21
| select name age        # Select only name and age columns
| sort-by age            # Sort by age
| to json                # Convert back to JSON
| save filtered_users.json # Save result to a new file
✔️ Summary of above Command (Click to Expand)
  1. open users.json: Produces a Nushell table representing the data.

  2. | where age > 21: Receives the table, filters rows based on the age column, and outputs a new, filtered table.

  3. | select name age: Receives the filtered table, selects only the name and age columns, and outputs a table with fewer columns.

  4. | sort-by age: Receives the table, sorts the rows based on the age column, and outputs a sorted table.

  5. | to json: Receives the sorted table and converts it back into JSON text.

  6. | save filtered_users.json: Receives the JSON text and saves it to a file.

  • So, while the concept of piping is the same, the nature of the data flowing through the Nushell pipeline is richer and more structured, enabling more powerful and direct data manipulation.

  • While Nushell’s strengths, like its structured data pipelines, make it a game-changer for many tasks, it’s not without its challenges, especially when integrated with NixOS’s Bash-centric ecosystem. Let’s explore some of the limitations you might encounter when adopting Nushell as your default shell.

The Bad

  • While the project is still maturing, the active community and ongoing improvements are promising. Don't get too discouraged by the following, there would be a bad section for any shell imo.

  • There are many similarities so it can be easy to forget that some Bash (and POSIX in general) style constructs just won't work in Nushell. Considering that NixOS seems to have been designed for bash, even Zsh isn't fully compatable you may want to think twice before you choose Nushell as your default.

  • The documentation is incomplete and written by devs for devs imo, it is quite a bit different from anything else I've seen so there is a bit of a learning curve. Nushell is generally still considered to be in a stage where it might not be the most seamless or trouble-free experience as a daily driver default shell for most users, especially on a system like NixOS known for its unique approach.

  • The any-nix-shell project doesn't include Nushell as with many others because of it's lack of maturity.

  • The following addition comes from Joey_McKur's sugggestion, on mentioning the job command as one of the biggest criticisms against Nu because it doesn't support background tasks. I should also note that Nushell's team is aware of these criticisms and actively working on improving job control.

Limited Feature Set Compared to Traditional Job Control:

  • Lack of Full POSIX Job Control: Nushell's job control doesn't yet fully implement all the features and signals defined by POSIX job control (e.g., more nuanced signal handling, stopped jobs). While it covers the basics, users accustomed to advanced Bash job control might find it lacking.

  • Foregrounding Behavior: There have been criticisms about how foregrounding jobs interacts with the terminal and potential issues with signal propagation.

Output Handling Challenges:

  • Interleaved Output: Managing the output of multiple backgrounded jobs can sometimes be messy, with output from different jobs potentially interleaving in the terminal. While Nushell tries to handle this, it's not always as clean as desired.

  • Redirection Complexity: Redirecting the input and output of backgrounded jobs can be less straightforward than in Bash, sometimes requiring more explicit handling.

Integration with Pipelines:

  • Backgrounding Pipelines: Backgrounding complex pipelines with multiple stages can sometimes lead to unexpected behavior or difficulties in managing the entire pipeline as a single job.

Error Reporting:

  • Difficult to Track Errors in Background Jobs: Identifying and debugging errors in backgrounded jobs can be less direct than with foreground processes, and the job command's output might not always provide sufficient information for troubleshooting.

  • Many of Nushell’s challenges stem from its departure from traditional shell conventions, particularly those of Bash, which NixOS heavily relies on. To better understand these differences and how they impact your workflow, let’s compare Nushell’s static, structured approach to Bash’s dynamic, text-based model.

Key Differences Between Nushell & Bash

FeatureBash (Dynamic)Nushell (Static)
Code ExecutionLine-by-lineWhole script parsed first
Error DetectionRuntime errors onlyCatches errors before running
Support for eval✅ Allowed❌ Not supported
Custom ParsingLimitedBuilt-in semantic analysis
IDE FeaturesBasic syntax highlightingAdvanced integration, linting, and formatting
  • && doesn't work use ; instead.

  • > is used as the greater-than operator for comparisons:

"hello" | save output.txt

is equivalent to the following in bash:

echo "hello" > output.txt
  • If you notice above the nushell command doesn't require an echo prefix, this is because Nushell has Implicit Return:
"Hello, World" == (echo "Hello, World")
# => true
  • The above example shows that the string, "Hello, World" is equivalent to the output value from echo "Hello, World"

  • Every Command Returns a Value:

let p = 7
print $p  # 7
$p * 6    # 42
  • Understanding these differences highlights why Nushell feels so distinct from Bash, but it’s the shell’s advanced features and integrations that truly make it shine. Let’s dive into some of the beautiful and powerful tools and custom commands that elevate Nushell for NixOS users.

The Beautiful and Powerful

Custom Nushell Commands

  • The following command allows you to choose which input to update interactively with fzf.
✔️ Click to See Command
# nix.nu
# upgrade system packages
# `nix-upgrade` or `nix-upgrade -i`
def nix-upgrade [
  flake_path: string = "/home/jr/flake", # path that contains a flake.nix
  --interactive (-i) # select packages to upgrade interactively
]: nothing -> nothing {
  let working_path = $flake_path | path expand
  if not ($working_path | path exists) {
    echo "path does not exist: $working_path"
    exit 1
  }
  let pwd = $env.PWD
  cd $working_path
  if $interactive {
    let selections = nix flake metadata . --json
    | from json
    | get locks.nodes
    | columns
    | str join "\n"
    | fzf --multi --tmux center,20%
    | lines
    # Debug: Print selections to verify
    print $"Selections: ($selections)"
    # Check if selections is empty
    if ($selections | is-empty) {
      print "No selections made."
      cd $pwd
      return
    }
    # Use spread operator to pass list items as separate arguments
    nix flake update ...$selections
  } else {
    nix flake update
  }
  cd $pwd
  nh os switch $working_path
}

nu5

  • The ns command is designed to search for Nix packages using nix search and present the results in a cleaner format, specifically removing the architecture and operating system prefix that nix search often includes.
✔️ Click To Expand
def ns [
    term: string # Search target.
] {

    let info = (
        sysctl -n kernel.arch kernel.ostype
        | lines
        | {arch: ($in.0|str downcase), ostype: ($in.1|str downcase)}
    )

    nix search --json nixpkgs $term
        | from json
        | transpose package description
        | flatten
        | select package description version
        | update package {|row| $row.package | str replace $"legacyPackages.($info.arch)-($info.ostype)." ""}
}

nu10

  • nufetch command:
✔️ Click To Expand
# `nufetch` `(nufetch).packages`
def nufetch [] {
{
"kernel": $nu.os-info.kernel_version,
"nu": $env.NU_VERSION,
"packages": (ls /etc/profiles/per-user | select name | prepend [[name];
["/run/current-system/sw"]] | each { insert "number" (nix path-info --recursive
 ($in | get name) | lines | length) | insert "size" ( nix path-info -S
 ($in | get name) | parse -r '\s(.*)' | get capture0.0 | into filesize) | update
 "name" ($in | get name | parse -r '.*/(.*)' | get capture0.0 | if $in == "sw"
 {"system"} else {$in}) | rename "environment"}),
"uptime": (sys host).uptime
}
}

nu1

  • duf command, I have mine aliased to df:

nu8

  • ps command:

ps

  • Adding the following to your configuration.nix will show you the diff of the closures on rebuild:
✔️ Click To Expand
# configuration.nix
# During system activation, compare the closure size difference between the
# current and new system and display a formatted table if significant changes are
# detected.
system.activationScripts.diff = ''
  if [[ -e /run/current-system ]]; then
    ${pkgs.nushell}/bin/nu -c "let diff_closure = (${pkgs.nix}/bin/nix store
     diff-closures /run/current-system '$systemConfig'); let table =
     (\$diff_closure | lines | where \$it =~ KiB | where \$it =~ → | parse -r
     '^(?<Package>\S+): (?<Old>[^,]+)(?:.*) → (?<New>[^,]+)(?:.*), (?<DiffBin>.*)$'
     | insert Diff { get DiffBin | ansi strip | into filesize } | sort-by -r Diff
     | reject DiffBin); if (\$table | get Diff | is-not-empty) { print \"\"; \$table
    | append [[Package Old New Diff]; [\"\" \"\" \"\" \"\"]] | append [[Package Old
     New Diff]; [\"\" \"\" \"Total:\" (\$table | get Diff | math sum) ]]
    | print; print \"\" }"
  fi
'';

conf1

  • nix-list-system command lists all installed packages:
# list all installed packages
def nix-list-system []: nothing -> list<string> {
  ^nix-store -q --references /run/current-system/sw
  | lines
  | filter { not ($in | str ends-with 'man') }
  | each { $in | str replace -r '^[^-]*-' '' }
  | sort
}

nu6

  • These custom Nushell commands showcase its flexibility, but sometimes you need to work around Nushell’s limitations, like compatability with certain NixOS tools. This is where just and justfiles come in, simplifying complex workflows and bridging gaps in Nushell’s functionality.

Using Just and Justfiles

  • The following is my justfile that I keep right next to my flake.nix it simplifies some commands and makes things work that weren't working with nushell for my case, you'll have to change it to match your configuration. It's not perfect but works for my use case, take whats useful and leave the rest.

  • You'll first need to install just to make use of justfiles.

# nix shell nixpkgs#just nixpkgs#nushell
set shell := ["nu", "-c"]
flake_path := "/home/jr/flake"
hostname := "magic"
home_manager_output := "jr@magic"

utils_nu := absolute_path("utils.nu")

default:
    @just --list
# Rebuild
[group('nix')]
fr:
    nh os switch --hostname {{hostname}} {{flake_path}}

# Flake Update
[group('nix')]
fu:
    nh os switch  --hostname {{hostname}} --update {{flake_path}}

# Update specific input
# Usage: just upp nixpkgs
[group('nix')]
upp input:
    nix flake update {{input}}
# Test
[group('nix')]
ft:
    nh os test --hostname {{hostname}} {{flake_path}}
# Collect Garbage
[group('nix')]
ncg:
    nix-collect-garbage --delete-old ; sudo nix-collect-garbage -d ; sudo /run/current-system/bin/switch-to-configuration boot

[group('nix')]
cleanup:
    nh clean all

  • To list available commands type, (you must be in the same directory as the justfile): just

just

  • So just fmt will run nix fmt.

  • A lot of the .nu files came from this repo by BlindFS:

    • modern-dot-files he uses Nix Darwin so there are a few changes for NixOS. I found this through this_week_in_nu.

    • my-nu-config

    • The examples use this starship configAylur-dotfiles The logic on the bottom enables starship for Nushell, Zsh, and Bash!

    • If you wan't to use my config you'll have to enable the experimental-feature pipe-operators in the same place you enable flakes and nix-command.

  • There are still situations where I need to switch to zsh or bash to get something to work i.e. nix-shell and a few others.

  • From custom commands to justfile integrations, Nushell offers a wealth of tools to enhance your NixOS experience, even if occasional workarounds are needed. To dive deeper into Nushell and tailor it to your needs, here are some valuable resources to explore, from official documentation to community-driven configurations.

Resources

✔️ Click to Expand Resources

NixOS Containers

NixOS containers are lightweight systemd-nspawn containers managed declaratively through your NixOS configuration. They allow you to run separate, minimal NixOS instances on the same machine, each with its own services, packages, and (optionally) network stack.

Common Use Cases

  • Isolating services: Run a web server, database, or any service in its own container, so it can’t interfere with the main system or other services

  • Testing and development: Try out new configurations, packages, or services in a sandboxed environment.

  • Reproducible deployments: Because containers are defined declaratively, you can reproduce the exact same environment anywhere.

  • Running multiple versions of a service: For example, testing different versions of Git or HTTP servers side by side.

Hosting an mdBook or Offline Git Server

Let’s say you want to host your mdBook or run a Git server for offline use. You can define a NixOS container that runs only the necessary service, isolated from your main system:

containers.mdbook = {
  ephemeral = true;         # Container resets on restart (optional)
  autoStart = true;         # Starts automatically at boot
  config = { config, pkgs, ... }: {
    # Example: Serve static files with httpd (Apache)
    services.httpd.enable = true;
    services.httpd.adminAddr = "you@example.org";
    networking.firewall.allowedTCPPorts = [ 80 ];
    # You could also use nginx, or run the mdbook server directly
    # Or, for a git server:
    # services.gitDaemon.enable = true;
  };
};
  • ephemeral: if true, the container resets on each restart.

  • autoStart: Ensures the container starts automatically at boot.

  • config: Defines the containers NixOS configuration, just like a regular NixOS system.