Chapter1
✔️ Click to Expand Table of Contents
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:
Concept | Description |
---|---|
Pure | Functions don't cause side effects. |
Functional | Functions can be passed as arguments and returned as results. |
Lazy | Not evaluated until needed to complete a computation. |
Declarative | Describing a system outcome. |
Reproducible | Operations 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 thezmap
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 (likeprograms.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., forprograms.zsh
orgit
) 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.
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
'';
}
- 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.
- 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 namedresult
in your current directory, pointing to the final build output in the Nix store.
- 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.
Related Sub-Chapters
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 Core Ecosystem, Nix, NixOS, Nix Lang, Nixpkgs are all distinctly different; related things which can be confusing for beginners this article explains them.
-
nixpkgs: Vast package repository
-
Nix Reference Manual Data Types The main Data Types you'll come across in the Nix ecosystem
-
nix.dev: Has become the top respected source of information in my opinion. There is a lot of great stuff in here, and they actively update the information.
Nix Language
✔️ Click to Expand Table of Contents
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 thelet
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 acceptsy
andz
.
@-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 asa
, usingargs.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;
orwith 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
with
gets less priority thanlet
. This can be confusing, especially if you like to writewith 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
.
- 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:
{}
- 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 + z
→ 1 + 2 + 3 = 6
.
- 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
- 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 fromwith
. -
with
doesn't create bindings - it only makes attributes available within its scope. -
The order matters: If
let x = 4
is outsidewith
, thenx = 4
already exists beforewith
runs, sowith pkgs; x
resolves to4
, not the value frompkgs
.
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
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:
Feature | Nix Channels (traditional) | Nix Flakes (modern approach) |
---|---|---|
Input Source | Global 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/machines | Highly reproducible due to locked inputs (flake.lock ) |
Dependency Mgmt. | Implicitly managed by global channel | Explicitly declared and version-locked within flake.nix |
Sharing | Relies on users having same channel version | Self-contained; flake.lock ensures everyone gets same inputs |
Learning Curve | Simpler initial setup, but tricky reproducibility debugging | Higher 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 argumentpkgs
. We need Nixpkgs to access standard build environments likestdenv
. -
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
andinstallPhase
-
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
- 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 thenix/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 myconfiguration.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
- 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.
- 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
- 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
- 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:
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 inpkgs.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
⚠️ 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
oragenix
) 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
-
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
orgit 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
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 calledorigin
. -
You can see which URL
origin
points to withgit 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.
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
- 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 withgit commit
is at the heart of how Git tracks and manages changes over time.
- 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
- 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, andgit 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 onmain
. 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 asmain
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:
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 togit 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:
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:
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.
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 withcustom.git.enable = true;
in yourhome.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
⚠️ 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
- 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
orgit commit
for every change. Modifications are always tracked in the current commit.
- 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
andjj commit
move the working copy, but the bookmark stays were it was. Usejj 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.
- Automatic Tracking and Simpler Workflow
-
Adding or removing files is automatically tracked, no need for explicit
add
orrm
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.
- 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.
- 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
.
- 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.
Feature | Git | Jujutsu (jj) |
---|---|---|
Staging Area | Yes (git add/index) | No, working copy is always a commit |
Commit Workflow | Stage → Commit | All changes auto-recorded in working commit |
Branches | Central to workflow | Optional, bookmarks used for sharing |
Undo/Redo | Limited, complex | Easy, operation log for undo |
Conflict Handling | Manual, can be confusing | Conflicts tracked in commits, easier to fix |
Integration with Git | Native | Fully compatible, can switch back anytime |
- 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
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 .
, runninggit 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 haslazyjj
. I've seen that it's recommended to use jj withmeld
. I'll share myjj.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
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 withjj 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
- 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 themain
branch so lets create our change on top of that. We can also see that it's (@-
), and this is actually whatmain
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 tomain@origin
, but could be named something else. This means that whenever you usetrunk()
in JJ commands, it will resolve to the latest commit onmain@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:
- 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
- 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 @
- 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
- 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.
- 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
- Set the bookmark to the Parent commit that was squashed into:
jj bookmark set wyt
- 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
, runjj-squash-push
instead. It will prompt you for a squashed commit message, then push for you. You can easily remove the lastjj git push
step to have ajj-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.
- Switch to
main
(if not already there):
jj bookmark set main
- 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 togit branch -D
to clean up. Instead commits that are no longer needed can be "abandoned" withjj 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 injj
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:
-
Describe the work we want to do with
jj desc -m "message"
-
We create a new empty change on top of that one with
jj new
-
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:
-
Create a new change to work on the new feature with
jj new
-
If everything works exactly as planned, we're done.
-
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.
-
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
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 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
-
Best for:
-
Users who want a straightforward and quick setup.
-
You can still add Lanzaboote and sops secrets after the install for a more secure system. To get the full benefits of Lanzaboote it is recommended to use full disk encryption.
-
2. Encrypted Disko Btrfs Subvol Installation
-
Encrypted Install Guide: Encrypted Install
-
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
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
- 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 withchmod +x Ventoy2Disk.sh
, and finally execute it withsudo ./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
- 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
- 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
- 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 3device = "/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
- 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
- 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 ...
- Generate necessary files, here we use
--no-filesystems
because disko handles thefileSystems
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
- Create the flake in your home directory, then move it to
/mnt/etc/nixos
. This avoids needing to usesudo
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 yournetworking.hostName = "magic";
in yourconfiguration.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
- 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 benixos
. -
Networking,
networking.networkmanager.enable = true;
-
hardware-configuration.nix
&disk-config.nix
for this setup -
initialHashedPassword
: Runmkpasswd -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";
}
- Move the flake to
/mnt/etc/nixos
and runnixos-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
andpassword
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 likesudo 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. -
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
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:
- 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.
- 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
- 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
- Add impermanence to your
flake.nix
. You will change thehostname
in the flake to match yournetworking.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
];
};
};
};
}
- 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.
- 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.
- Navigate to your NixOS configuration directory (where your
flake.nix
is located).
cd /path/to/your/flake
- Rebuild and Switch: Execute the
nixos-rebuild switch
command. This command will:
-
Evaluate your
flake.nix
and the modules it imports (including your newimpermanence.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 whenenvironment.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 yourenvironment.persistence."/nix/persist".directories
list in yourimpermanence.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
- 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 yourenvironment.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
- 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
- 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:
-
Boot a Live ISO
Boot from a NixOS (or any recent Linux) live USB.
Open a terminal and become root:
sudo -i
- Identify Your Devices
Your main disk is /dev/nvme0n1
-
EFI partition:
/dev/nvme0n1p1
(mounted at /boot) -
Root partition:
/dev/nvme0n1p2
(Btrfs, with subvolumes)
- 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
- 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
.
- Mount the EFI Partition
mkdir -p /mnt/boot mount /dev/nvme0n1p1 /mnt/boot
- (Optional) Mount Virtual Filesystems
mount --bind /dev /mnt/dev mount --bind /proc /mnt/proc mount --bind /sys
/mnt/sys mount --bind /run /mnt/run
- 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!
Related Material
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.
- For Unencrypted layout Click Here
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
-
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 withchmod +x Ventoy2Disk.sh
, and finally execute it withsudo bash Ventoy2Disk.sh
Follow the prompts to finish the install. -
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
- 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
- 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.
- 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.
- Unlock the LUKS device, if not already unlocked as it should be from running disko:
sudo cryptsetup open /dev/disk/by-partlabel/luks cryptroot
- Mount the Btrfs top-level (
subvolid=5
):
sudo mount -o subvolid=5 /dev/mapper/cryptroot /mnt
- List the contents:
ls /mnt
# you should see something like
root home nix persist log lib ...
- Now we can take a snapshot of the
root
subvolume:
sudo btrfs subvolume snapshot -r /mnt/root /mnt/root-blank
- 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
- 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
- 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 ...
- Generate necessary files, here we use
--no-filesystems
because disko handles thefileSystems
attribute for us.
nixos-generate-config --no-filesystems --root /mnt
- The above command will place a
configuration.nix
andhardware-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
- 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 yournetworking.hostName = "magic";
in yourconfiguration.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
];
};
};
};
}
- 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 benixos
. -
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
orhx
and redirect the hashed pass to a/tmp/pass.txt
, you can then read it into yourusers.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;
};
};
- Move the flake to
/mnt/etc/nixos
and runnixos-install
:
sudo mv ~/flake /mnt/etc/nixos/
- Give everything a quick once over, insuring your host is set in both your
flake.nix
, andconfiguration.nix
. Ensure you changed the username in theconfiguration.nix
fromnixos
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
andpassword
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 likesudo 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
-
To set up impermanence for this specific layout, follow the link Encrypted Impermanence
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
- Add impermanence to your
flake.nix
. You will change thehostname
in the flake to match yournetworking.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
];
};
};
};
}
- 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 thedisk-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
- 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 yourconfiguration.nix
matches thehostname
in yourflake.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.
- Navigate to your NixOS configuration directory (where your
flake.nix
is located).
cd /path/to/your/flake
- Rebuild and Switch: Execute the
nixos-rebuild switch
command. This command will:
-
Evaluate your
flake.nix
and the modules it imports (including your newimpermanence.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
- 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 yourenvironment.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
- 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
- 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 socryptsetup
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.
- 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
- 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
- Create a New Partition and Format (Optional)
sudo fdisk /dev/sda
-
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. -
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).
- 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.
- Copy the keyfile to your USB Stick
sudo cp /root/usb-luks.key /run/media/jr/B7B4-863B/
sync
- 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
- Securely Remove the Keyfile from Your System:
sudo shred --remove --zero /root/usb-luks.key
Instructions for Using a USB Stick with Existing Data
- Generate the Keyfile
sudo dd if=/dev/urandom of=/root/usb-luks.key bs=4096 count=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)
- 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
)
- 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 ininitrd
, you must manually mount the USB in theinitrd
using itsUUID
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.
- 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
];
};
};
}
- Add
sops
andage
to yourenvironment.systemPackages
:
environment.systemPackages = [
pkgs.sops
pkgs.age
];
- 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.
- Create a
.sops.yaml
in the same directory as yourflake.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.
- 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"
- 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 thesops
command then rungit add
at any point, your git history will have contained an unencrypted secret which is a nono. Always use thesops
command when dealing with files in thesecrets
directory, save the file and inspect that it is encrypted on save. If not something went wrong with thesops
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
- Create a
sops.nix
and import it or add this directly to yourconfiguration.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. AddingneededForUsers = true;
tellssops-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...
- 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:
- 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.
- 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.
- 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.
- 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.
- 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:
-
Enable a BIOS password for your system, this will prevent someone from just shutting off secure boot.
-
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:
-
Select the "Security" tab.
-
Select the "Secure Boot" entry.
-
Set "Secure Boot" to enabled.
-
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:
-
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
.
-
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.
-
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
.
-
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 expressione
to a string) -
import path
: (Load, parse and return the Nix expression in the filepath
) -
throw x
: (Throw an error messagex
. Usually stops evaluation) -
map f list
: (Apply the functionf
to each element in thelist
)
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 thepersonName
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
-
The first function takes
x
and returns another function. -
The second function takes
y
and performsx + 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 bindsx
to6
and returns a new function:y: 6 + y
. -
When you call that function with
6
(i.e.,concat 6 6
), it computes6 + 6
, resulting in12
.
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:
-
The outer function takes
prefix
(e.g."Hello"
) and returns a function that expectsname
. -
The inner function takes
name
(e.g."Alice"
) and combines it withprefix
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 theprefix
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
- 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.
- 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 tox
twice.
- 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.
- 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 likeprograms.thunar.plugins.pkgs.xfce
because you sawpkgs.xfce
in thewith
statement. But that's now howwith
works. Thepkgs.xfce
path exists outside theplugins
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 bringnixpkgs
into scope, uselet
instead.
- To be clear either way is fine, especially in such a small self contained
module. If it were in a single file
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 theautotools.nix
file. Because it starts withpkgs: attrs: ...
, it means thatautotools.nix
evaluates to a function that expects one argument namedpkgs
. -
... pkgs
: We are immediately calling that function (the one returned byimport ./autotools.nix
) and passing it ourpkgs
variable (which is the result ofimport <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)
- What
attrs
Represents:
-
Once
autotools.nix
has received itspkgs
argument (and returned the inner function), this inner function is waiting for its final argument, which we callattrs
. -
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.
- 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 fromdefaultAttrs
and combines them with all attributes fromattrs
. -
Crucially, if an attribute exists in both
defaultAttrs
andattrs
, the value fromattrs
(the second operand) takes precedence and overrides the default value.
- Applying attrs in the hello Derivation:
- In the
hello
derivation, we callmkDerivation
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 theattrs
argument to themkDerivation
function (which, remember, is the inner function returned byautotools.nix
). -
When derivation
(defaultAttrs // attrs)
is evaluated for "hello", thename
andsrc
provided in theattrs
set will be merged with all thedefaultAttrs
(likebuilder
,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 thisautotools.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 byautotools.nix
. This separation makesautotools.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:
# 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)
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
}
- 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 toimport <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;
};
};
}
Feature | Regular Nix Function (e.g., hello.nix ) | Nix Module (e.g., my-config-module.nix ) |
---|---|---|
Arguments | You must explicitly pass every single argument. | Automatically receives config , pkgs , lib , options , etc. |
Custom Args | Passed directly in the function call. | Passed via specialArgs when the modules are composed. |
Boilerplate | Often needs pkgs = import <nixpkgs> {}; if not explicitly passed. | pkgs and lib are always available automatically. |
Purpose | Defines 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
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 thenixpkgs.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 yourflake.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.
- The
{ ... }: { 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 thatdefault.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 adefault.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 value123
, an integertext
with the value"Hello"
, a stringy
where the value is the result of applying the functionf
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 fromnixpkgs.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 thelib
argument" and I acknowledge that the module system might pass other arguments automatically (likeconfig
,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 likemkEnableOption
,mkIf
, andmkOverride
. -
pkgs
: The Nixpkgs package set, used to access packages likepkgs.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
, andpkgs
, but it doesn’t require them. The...
argument ensures flexibility, allowing a module to accept extra inputs without breaking future compatibility. Usinglib
simplifies handling options (mkEnableOption, mkIf, mkOverride) and helps follow best practices. Modules define options, which users can set in their configuration, andconfig
, which applies changes based on those options.
- 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
.
- 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. Usinglib.mkEnableOption
makes it clear exactly where this function is coming from. Same withlib.mkIf
and as you can see they can be further down the configuration, further from where you definedwith lib;
making it less clear where they come from. Explicitness is your friend when it comes to reproducability and clarity.
- Conditional Configuration
config = lib.mkIf (cfg.enable || cfg.defaultEditor) {
- This block is only activated if either
programs.vim.enable
ordefaultEditor
is set.
- 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.
- 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
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 aflake.nix
file in the directory where you're running the command. -
The
#
is used to select a specific attribute from theoutputs
of the flake. In this case, it's telling Nix to evaluate themultiply
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 newnameValuePair
.
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:
-
A standard package (
packages.x86_64-linux.default
): This simple example just re-exportskakoune
fromnixpkgs
. You could build your own apps here. -
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 undermkShell
. -
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 (whilehello
is an output attribute). They are also allowed in theinputs
attribute of a flake, e.g.inputs.nixpkgs.url = "github:NixOS/nixpkgs";
is equivalent to
inputs.nixpkgs = { type = "github"; owner = "NixOS"; repo = "nixpkgs"; };
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 aflake.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.
- The
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
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 ownflake.nix
file that outputs many helper functions via thelib
attribute. -
For a deep dive into flake outputs, see Nix Flake Outputs
The
lib
convention The convention of usinglib
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:
-
Destructuring: It tries to extract the value associated with the key
pkgs
from the input attribute set and binds it to the variablepkgs
. The...
allows for other keys in the input attribute set to be ignored during this direct destructuring. -
Binding the Entire Set: It binds the entire input attribute set to the variable inputs.
- Example
flake.nix
:
- Example
{
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.
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 useimport <nixpkgs>
, Nix evaluates thedefault.nix
file (or sometimeslib/default.nix
) found at that location. -
The
default.nix
innixpkgs
evaluates to a function. This function is designed to be configurable, allowing you to pass arguments likesystem
,config
, etc. to customize hownixpkgs
behaves and what packages it provides. -
So,
import <nixpkgs>
doesn't give you thenixpkgs
package set directly; it gives you the function that generates thenixpkgs
package set derivation.
{}
: 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.
- For more advanced examples see Nix Flake Examples
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)
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
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:
-
packages = eachSystem (system: { ... });
Part iterates through eachsystem
(likex86_64-linux
). For eachsystem
, it's creating an attribute set that will becomeself.packages.${system}
. -
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 toself.packages.x86_64-linux
. -
When you write
inherit (sourceAttrset) attributeName;
, it's equivalent to writingattributeName = 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 thepackages.${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 nameddefault
within the samepackages.${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.
-
Nix development environments
-
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)
-
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. -
It destructures all other attributes (your defined
inputs
) into the functions scope. -
It gives you a convenient single variable,
inputs
, that refers to the entire attribute set passed to theoutputs
function. This allows you to access inputs either individually (e.g.nixpkgs
) or through theinputs
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 asnixpkgs
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 thenixpkgs
flake's output) -
We're saying, My flakes
hello
package is exactly the same as thehello
package found inside thenixpkgs
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, andinputs.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 forself
with flakes. Withoutself
, I wouldn't have a direct way to refer to thenixosModules.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.
- Within that attribute set, it specifies that the file
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 theinputs
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:
- 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.
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
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.
- 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.
- Enter the Development Shell:
The Helix project's flake.nix
includes a devShells.default
output,
specifically designed for development.
nix develop
- 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 indevShells.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
- 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.
- 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.
- 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
.
- 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 thenixos-unstable
branch of the Nixpkgs repository. -
rust-overlay
: follows the samenixpkgs
, 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
tohelix
(used bynix 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:
- 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.
- 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
) usingfs.difference
andfs.unions
. This ensures a clean build input, reducing Nix store size and avoiding unnecessary rebuilds. -
grammars
: Builds syntax grammars by callinggrammars.nix
, passinggrammarOverlays
(for customizing grammar builds) andincludeGrammarIf
(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 thegrammars
derivation, ensuring Helix uses Nix-managed grammars.
-
- 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
- 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;
});
- Modify the
default
package. The comments actually tell us exactly how to do this. We want to useoverrideAttrs
to change thebuildType
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".
- 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.
- 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.
- 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 aflake.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-levelpackages
attribute. -
└───x86_64-linux
: System architecture specificity -
└───default: package 'helix-term'
Signifies that within thex86_64-linux
packages, there's a package nameddefault
. This is a special name that allows you to omit the package name when using commands likenix build
. -
package 'helix-term'
This is the most direct confirmation of our "hack". It tells us that ourdefault
package ishelix-term
. This confirms that ouroverrideAttrs
in thepackages.${system}.default
section successfully targeted and modified the Helix editor package, which is internally namedhelix-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
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
tobuildInputs
, 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:
- 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 Myflake.nix
. In the above example, you'll notice alib
attribute defined within the mainlet
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 passinputs
(which now includes my customlib
) as aspecialArgs
to other modules. This provides a clean way for all modules to access these shared resources. For example, inconfiguration.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. Whilenixpkgs.lib
is globally available, my customlib
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
- 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
_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.
-
let ... in packages
: This introduces alet
expression, which defines local variables within the scope of this overlay function. Thein packages
part means that the overlay function will ultimately return thepackages
attribute set defined within thelet
block. -
callPackage = prev.lib.callPackageWith (prev // packages)
: This line defines a helper function calledcallPackage
.
-
prev.lib.callPackageWith
Is a function provided by Nixpkgs'lib
.callPackageWith
is likeprev.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 theprev
package set (Nixpkgs before this overlay) and merges it with thepackages
attribute set defined later in this overlay. -
By using
callPackageWith
with this merged attribute set, thecallPackage
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.
-
packages = { ... };
: This defines an attribute set namedpackages
. This set will contain all the new or modified packages introduced by this overlay. -
pokego = callPackages ./pac_defs/pokego.nix { };
: This is the core of how thepokego
package is added.
-
pokego =
: This defines a new attribute namedpokego
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 thepokego.nix
package definition. Ifpokego.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.
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 yourenvironment.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 fetchednixpkgs
. 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 agreetd
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:
-
Edit your configuration: Add or remove packages (e.g., add
ghostty
toenvironment.systemPackages
) or change any other options inside the relevantspecialisation
block in your NixOS configuration. -
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
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
, andlvcreate
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
orlib.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 finalsystem.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)
-
Importing Modules: It recursively finds and includes all modules specified in
imports = [ ... ];
statements. -
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). -
Defining Option Values: For each declared option, it gathers all the value assignments (defined using
config = { ... };
or directly at the top level if nooptions
orconfig
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 likelib.mkOption
orlib.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
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
-
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.
-
-
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).
-
-
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.
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 bystdenv
) to produce a derivation. -
Currently, this derivation doesn't specify any build steps or outputs.
-
Further Reading:
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 usingfetchzip
.
-
Handling Dependencies: Importing Nixpkgs
-
If you try to build
hello.nix
directly withnix-build hello.nix
, it will fail becausestdenv
andfetchzip
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 inhello.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 = "";
inhello.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 }
:.
- Derivation Creation:
- It calls
buildPythonApplication
, a specialized helper for Python packages (similar tostdenv.mkDerivation
but pre-configured for Python). Therec
keyword allows attributes within the derivation to refer to each other.
- Package Metadata:
-
pname
andversion
define the package's name and version. -
The
meta
attribute provides standard package information like the homepage, description, license, maintainers, and supported platforms.
- Source Specification:
- The
src
attribute usesfetchFromGitHub
to download the source code from the specified repository and revision, along with itssha256
hash for verification.
- 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
).
- Build Format:
format = "pyproject";
indicates that the package uses apyproject.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 becauseswaytools
is a Python package, and it ensures the necessary Python-related dependencies are correctly passed to theswaytools
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
- Creating Derivations in Nix
- The Hello World Derivation
- Simple Rust Derivation
- When Derivations are Built
- Referring to other derivations
- Produce a development shell from a derivation
- Our Second Derivation: Understanding the Builder
- Our builder Script
- Our Last Derivation
- Best Practices
Introduction to Nix Derivations
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:
-
name: A human-readable identifier for the derivation (e.g., "foo", "hello.txt"). This helps you and Nix refer to the package.
-
system: Specifies the target architecture for the build (e.g.,
builtins.currentSystem
for your current machine). -
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 Nixpkgsdefault.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 entirepkgs
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 argumentargs
.)
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. Thetar.gz
package can be found here You would just place the tarball in the same directory as the derivation along with the followinghello_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):
-
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
-
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 usmkDerivation
and is from thenixpkgs
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 thePATH
if you enter the shell created by this derivation).pkgs.cowsay
refers to thecowsay
package from the importedpkgs
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'sPATH
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:
-
Fresh Start: Nix creates a temporary, empty directory for the build and makes it the current working directory.
-
Clean Environment: It completely clears the environment variables from your shell.
-
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 explicitbuildInputs
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.
-
-
Execution & Logging: The builder script is run with its specified arguments. All its output (stdout/stderr) is captured in a log.
-
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 filefoo
, 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 fromfetchzip
tofetchurl
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, andfetchzip
when working with extracted contents. -
fetchurl
returns afixed-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 thename
attribute set to something fixed. This will derive the symbolic name of the store path from thename
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.
Links To Articles about Derivations
Click To Expand Resources
-
Sparky/blog-creatingASuperSimpleDerivation # How to learn Nix
Builders and Autotools
Chapter 8
✔️ Click to Expand Table of Contents
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)
-
Setup:
mkdir hello && cd hello/
-
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.
- Note: Flakes don't have access to
-
Impure Build (Fails):
nix build .#myHello
- This fails because Flakes enforce purity by default.
-
Force Impure Build:
nix build .#myHello --impure
-
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 thesystem
attribute.
-
Pure Build (Success):
nix build .#myHello
Using Traditional Nix
Building hello with Traditional Nix
-
Setup:
mkdir hello2 && cd hello2/
-
Create
default.nix
(Initial Impure Example):# default.nix { myHello = (import <nixpkgs> { }).hello; }
-
Build (Impure):
nix-build -A myHello
-
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.
-
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" },
-
-
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>
withfetchTarball
and a specific revision. A placeholdersha256
is used initially.
- Replace
-
Build (Nix provides the correct
sha256
):nix-build -A myHello
-
Verification: Both Flake and Traditional Nix builds now produce the same output path.
-
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)
- Default arguments to
-
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 makesystem
an argument.
- Override impure defaults for
-
Building with a Specific System:
nix-build -A myHello --argstr system x86_64-linux
-
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.
- Example of using
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
-
Other related post if you haven't read my previous post on modules, that may be helpful before reading this one:
-
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 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 becauselib
comes from outside of it. So it's good practice to refer tolib
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 alsoanything
, 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 ourconfiguration.nix
file as an argument. The-A system
flag selects the top-levelsystem
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 depricatedtypes.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 adefault
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
andy
{ 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
- 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
. Uselet ... 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 theetc
, then it goes into theattrsOf
, and thepath
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 thehello
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 ofhello
, 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 andconfig.etc.foo
is also an attribute set but it's not a derivation by itself. Sonix-instantiate
does this one level of recursion here and it would have builtfoo
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
andbar
link to different derivations -
When trying to figure out which
default
to use foretcCombined
infinisil went to the Nixpkgs Reference Manual. Make sure to go to the correct version.-
25.05pre-git (i.e. unstable)
-
Once at the website press
Ctrl+f
and typesymlinkjoin
and hit enter.
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 inpassthru
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
notpassAsFiles
but that change wasn't enough to fix it.passAsFiles
expects a list of files, not a single file path. Runningnix-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: Usenix-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 theconfig
attribute usinglib.mkDefault
. -
Use
builtins.addErrorContext
: Enhance your custom error messages by providing specific context relevant to your module's logic usingbuiltins.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
: Thenix 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
- Working with Nixpkgs Locally: Benefits and Best Practices
- I. Why Work with Nixpkgs Locally?
- II. Flake vs. Non-Flake Syntax for Local Nixpkgs
- A. Flake Syntax (
nix build .#<package>
) - B. Non-Flake Syntax (
nix-build -f . <package>
ornix build -f . <package>
) - III. Setting Up a Local Nixpkgs Repository Efficiently
- A.a Initial Clone: Shallow Cloning
- A.b A few Examples exploring Nixpkgs
- A.1 Full Fork and Clone of Nixpkgs
- B. Managing Branches with Worktrees
- A. Flake Syntax (
- IV. Debugging Missing Dependencies: A Practical Example
- V. Local Derivation Search with
nix-locate
- VI. Key Benefits of Working with Nixpkgs Locally (Recap)
- VII. Best Practices and Tips from the Community
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>
ornix 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
:
-
Clone the forked repo into a local
nixpkgs
directory.
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 userg
: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 thenix-index
package) allows searching for derivations on the command line.Note: Install
nix-index
and runnix-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
ornixos-unstable
) to avoid accidental large-scale pings in pull requests (Tip fromsoulsssx3
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. Usenix build -f . <package>
instead ofnix 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 thenixos-unstable
branch instead ofmaster
. 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.
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.
- If you only fork master, you won't have the
-
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 yougit push origin ...
, your changes go here. -
upstream
is the official Nixpkgs repository (NixOS/nixpkgs.git
). When yougit 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
- Ensure
master
is up to date withupstream
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.
- Create a Feature Branch
git checkout master
git checkout -b my-feature-branch # name should represent the feature
- Make and Test Changes
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.
- 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. Usegit 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
- 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.
- 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
- 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
ornixos-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 (ornixos-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 theby-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 ourdefault.nix
file, which defines a function) and automatically provides the function with any arguments it declares, by looking them up within thepkgs
set (or the scope wherecallPackage
is invoked). This means you only need to list the dependencies your package needs in itsdefault.nix
function signature, andcallPackage
will "inject" the correct versions of those packages. This is what thecallPackage
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 usedefault.nix
as their definition file (e.g.,pkgs/misc/testPackage/default.nix
). These packages are NOT automatically included in the top-levelpkgs
set; they must be explicitly added via acallPackage
entry inpkgs/top-level/all-packages.nix
. This is the method demonstrated in this chapter for ourtestPackage
. -
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-levelpkgs
set. They do not require a manualcallPackage
entry inall-packages.nix
. This results in a more modular and scalable approach, reducing manual maintenance.
-
❗ : While this example uses
pkgs/misc/
to demonstrate explicitcallPackage
usage, when contributing a new package to Nixpkgs, you should nearly always place it withinpkgs/by-name/
and name its definition filepackage.nix
.
-
There are some Limitations to this approach.
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
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
ormain
) 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:
-
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 request8623
in theupstream
remote and creates a local branch namedpr-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 tonix-build
'sresult
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:
-
The test expectations are outdated due to changes in the codebase.
-
The test captures environment-specific or transient outputs that are not properly normalized.
-
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
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
orShift+Enter
. -
The Reedline-Editor is powerful and provides good
vi-mode
oremacs
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.
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 ofgrep
andcurl
.
-
-
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'
-
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 convertsusers.json
into its own internal structured data format with theopen
command, then to filters out people under21
with thewhere
control flow construct, then selects thename
andage
columns, sorts them by age, and finally converts them back tojson
and saves them to a file calledfiltered_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 ofusers.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 (likefilter.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 insidefilter.nu
, processing theusers.json
file and creating thefiltered_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)
-
open users.json
: Produces a Nushell table representing the data. -
| where age > 21
: Receives the table, filters rows based on theage
column, and outputs a new, filtered table. -
| select name age
: Receives the filtered table, selects only thename
andage
columns, and outputs a table with fewer columns. -
| sort-by age
: Receives the table, sorts the rows based on theage
column, and outputs a sorted table. -
| to json
: Receives the sorted table and converts it back into JSON text. -
| 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
Feature | Bash (Dynamic) | Nushell (Static) |
---|---|---|
Code Execution | Line-by-line | Whole script parsed first |
Error Detection | Runtime errors only | Catches errors before running |
Support for eval | ✅ Allowed | ❌ Not supported |
Custom Parsing | Limited | Built-in semantic analysis |
IDE Features | Basic syntax highlighting | Advanced 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 fromecho "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
-
Ctrl+t
List Commands with carapace and fzf: -
Carapace
Carapace-Bin Install: -
Carapace
man example:
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
}
- The
ns
command is designed to search for Nix packages usingnix 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)." ""}
}
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
}
}
duf
command, I have mine aliased todf
:
ps
command:
- 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
'';
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
}
- 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
andjustfiles
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 myflake.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
-
So
just fmt
will runnix 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.
-
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
-
nu_scripts some of the custom commands came from here.
-
discord You can find custom commands, configurations, etc here.
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.