In this repository I'm writing down my approach to learn the nix language as well as how to use NixOS.
- Basics
- Data Types
- Language Constructs
- Immutability
- Code Organization and Imports
builtinspkgs.lib- Derivations
- Overrides and Overlays
- Flakes
- NixOS
Nix is a purely functional, lazily evaluated, dynamically typed programming language.
The Nix language consists of expressions that evaluate to values.
2 + 2 # evaluates to 4
"foo" + "bar" # evaluates to "foobar"
[1 "hi" true] # list with three items of different typeTo easily test this out yourself, and for trying out the examples in the following chapters Nix provides a REPL (Read-eval-print loop). You can use the REPL like follows:
- Start the REPL with
nix repl - Enter
2 + 2(or any other expression) - Press
Enter/Returnand observe the evaluation/result.
Additionally, the following commands are very helpful for working with the REPL:
:?: Prints the available commands:q: Quits the REPL:l: Load a Nix expression from a file (later covered in this guide):lf: Load flake:r: Reload all files:t: Describe the result of an evaluation
As these first expressions might have shown, Nix expressions are made up of values and operators. The following chapter introduces the data types Nix values can have, and which operators can be used with each of those data types.
All data types you'll get to know in the following chapters, can be compared
with the == and != operators.
Nix supports integer numbers as well as floating point numbers like so:
42
-7
3.14
-0.1Those numbers can be used with the common mathematical operators +, -,
*, /:
5 + 3 # evaluates to 8
7 - 2.5 # evaluates to 4.5
2 * 3 # evaluates to 6
10 / 4 # evaluates to 4 (integer division)
10 / 4. # evaluates to 2.5Be aware, that the spaces around the operators are only needed for the
division operator /. If you leave out the spaces like so 10/4, Nix won't
interpret this as an arithmetic expression, but as a Path (which is another
data type covered later).
Strings in Nix use double quotes and can easily concatenated with the +
operator:
"Hello World!" # evaluates to "Hello World!"
"Hello, " + "Tim" + "!" # evaluates to "Hello, Tim!"Strings also allow for interpolation with the following syntax:
"Hello ${name}" # assuming "name" is a string variableThere are also "indented strings", which are denoted by double single quotes:
''
multi
line
string
''
# evaluates to: "multi\nline\nstring\n"Nix obviously also supports booleans. But there's not a lot to say about those:
true
falseBooleans can be combined with && and ||:
true && true # evaluates to: true
true && false # evaluates to: false
true || false # evaluates to: trueNix contains a null value, which represents the absence of a value.
nullAs Nix is primarily used for building and distributing software in a deterministic way, file paths are primitive data type in Nix.
Depending on where your Nix file is evaluated or where you start your REPL,
the evaluated paths differ. For the examples below I started the REPL in
the root directory /:
./foo # evaluates to: /foo
./. # evaluates to: / (-> this represents the CURRENT PATH)
../. # denotes the PARENT DIRECTORYThe current path is represented by ./., because paths in Nix
must always contain at least one /.
Paths can be concatenated and manipulated like this:
./foo + "bar" # evaluates to: /foobar (adding a string to a path)
./foo + ./bar # evaluates to: /foo/bar
(x: ./foo${x}bar) "X" # evaluates to: /fooXbar
(x: ./foo + ./${x}) "x" # evaluates to: /foo/xAbsolute paths always start with a
/. Relative paths contain at least one/but do not start with one. They evaluate the path relative to the path of the file, which contains the expression.
A list in Nix is an ordered list of items, which can be of varying types. Here are some examples of lists:
[1 2 3 4 5] # list of numbers
[1 "hello" true] # list of different typesList items are separated by whitespaces.
Items from a list can be accessed by indices (0-based), with a function
from builtins:
builtins.elemAt [1 "hello" true] 0 # evaluates to 1
builtins.elemAt [1 "hello" true] 1 # evaluates to "hello"Furthermore, lists can be concatenated with the ++ operator:
[1 2] ++ [3] # evaluates to [1 2 3]The primary data type you'll encounter when using Nix is the set. It's comparable to dictionaries or maps known from other programming languages. A set contains key/value-pairs called attributes.
{} # the empty set
{ x=5; y=2; } # a set with two attributes
{ x=5; }.x # evaluates to xAttributes have a mandatory trailing semicolon. As demonstrated
above, the attributes of a set can be accessed with the . (dot) syntax.
Nix also provides a merge operator (//) which applies the attributes of the
right set to the left one:
{ a=1; } // { a=2; b=3; } # evaluates to: { a=2; b=3; }
{ a=1; } // { b=2; } # evaluates to: { a=1; b=2; }Be aware, that the merge operatore does not work for deeply nested sets.
{ sub_set = { a=1; }; } // { sub_set = { b=2; }; }
# evaluates to: { sub_set = { b=2; }; }
# -> the top-level attributes are applied, which overwrites the nested values!In some cases it may be necessary, to access a set's attributes from within
the same set. This is possible in Nix when using the rec keyword:
rec {
a = 1;
b = 2;
c = a+b;
}
.c
# evaluates to 3Functions are first-class citizen in Nix and anonymous by default. Despite of that, anonymous functions can be assigned to variables, to handle them like named functions (as we'll later see).
Functions technically only have one parameter. Multiple parameter functions can be created by using higher-order functions (chaining functions like in Haskell).
(x: x + 1) 5 # evaluates to 6
(x: y: x + y) 1 # evaluates to y: 1+y
{ a, b }: a + b # function with set as parameter (named arguments)For passing large sets as parameter to a function, it's helpful to ignore
all attributes of the parameter set, which are not needed. This is done with
the ... syntax:
({ a, b, ... }: a + b)
{ a = 1; b = 2; c = 3; }
# evaluates to: 3Functions with a set as parameter may define default values/arguments for some attributes:
let
f = {a, b ? 0}: a + b;
in
f { a = 1; }
# evaluates to: 1When an attributes set is passed as parameter to a function, it can also be assigned a name to be accessible as a whole:
let
func = {a, b, ...}@args: a + b + args.c;
# Alternative
alt = args@{a, b, ...}: a + b + args.c;
in
func { a=1; b=2; c=3; } # evaluates to: 6
# alt { a=1; b=2; c=3; } # evaluates to: 6Let expressions allow you to bind variables within a local scope.
let
a = "Hi";
b = "Max";
in
"${a} ${b}!"As this expression might be hard to type out in the REPL, you can also create
a file (e.g., playground.nix) and write out the Nix expression in that file.
When you now open the REPL in the same folder as where the file resides in,
you can evaluate the file by using the following command:
nix-repl> import ./playground.nixAlternatively, Nix files can also be evaluated with the following command (without the need for a REPL):
nix-instantiate --eval playground.nixLet expressions can also be nested, whereby variables of the inner let binding shadow outer variables of the same name:
let
x = 1;
y = 2; # <- this variables is not used at all!
z = let y = 10; in x + y;
in
z
# evaluates to 11 (NOT 3)let
# this is a function assigned to a variable
listLength = lst:
if lst == [] then 0
else 1 + listLength (builtins.tail lst);
in
listLength [1 2 3 4]
# evaluates to: 4The with keyword allows referencing attributes from a set, without always
specifying the set's name:
let
age = {
max = 23;
lea = 26;
tim = 32;
};
in {
without_with = [ age.max age.lea ]; # [ 23 26 ]
with_with = with age; [ max lea]; # [ 23 26 ]
}The inherit is shorthand for assigning the value of a name from an existing
scope to the same name in a nested scope.
let
x = 1;
y = 2;
in
{
inherit x y;
}
# evaluates to: { x=1; y=2; }It's also possible to inherit names from a specific attribute set with
parentheses:
let
a = { x=1; y=2; };
in
{
inherit (a) x y; # equivalent to: x = a.x; y = a.y;
}
# evaluates to: { x=1; y=2; }Inherit also works inside let expressions:
let
inherit ({ x = 1; y = 2; }) x y;
in
[ x y ]
# evaluates to: [ 1 2 ]In Nix all values are immutable. Once a variable is set, it cannot be changed.
Nix allows you to organize code into multiple .nix files and import them as
needed.
# utils.nix
{
greet = name: "Hello ${name}!";
}# main.nix
let
utils = import ./utils.nix;
in
utils.greet "John"
# evaluates to: "Hello John!"If the path points to a directory, the file default.nix in that directory
is imported.
Nix comes with many functions that are built into the language. They are
implemented in C++ as part of the Nix language interpreter.
These functions are available under the builtins constant.
All available builtins are listed here:
Nix Reference Manual
The following sections show some examples of built-in functions.
Return the first element of a list.
builtins.head [1 2 3] # evaluates to: 1Return the list without its first item.
builtins.tail [1 2 3] # evaluates to: [2 3]Return the names of the attributes in the parameter set in an alphabetically sorted list.
builtins.attrNames { y=1; x="test"; } # evaluates to: [ "x" "y" ]Return the values of the attributes in the parameter set in the order corresponding to the sorted attribute names.
builtins.attrValues { y=1; x="test"; } # evaluates to: [ "test" 1 ]Construct a set from a list specifying the names and values of each attribute.
Each element of the list should be a set consisting of a string-valued attribute
name specifying the name of the attribute, and an attribute value
specifying its value.
In case of duplicate occurrences of the same name, the first takes precedence.
builtins.listToAttrs
[
{ name = "foo"; value = 123; }
{ name = "bar"; value = 456; }
{ name = "bar"; value = 420; }
]
# evaluates to: { foo = 456; foo = 123; }Apply f to each element in list.
builtins.map (x: "foo" + x) [ "1" "2" "3" ]
# evaluates to: [ "foo1" "foo2" "foo3" ]Apply functions f to every element of attrset.
builtins.mapAttrs (name: value: value * 10) { a = 1; b = 2; }
# evaluates to: { a = 10; b = 20; }The nixpkgs repository contains an attribute set called lib, which provides
a large number of useful functions. Tey are implemented in the Nix language,
as opposed to builtins, which are part of the language itself.
Derivations are at the core of both Nix and the Nix language:
- The Nix language is used to describe derivations.
- Nix runs derivations to produce build results.
- Build results can in turn be used as inputs for other derivations.
The Nix language primitive to declare a derivation is the built-in impure
function derivation. It is usually wrapped by the Nixpkgs build mechanism
stdenv.mkDerivation, which hides much of the complexity involved in
non-trivial build procedures.
Whenever you encounter mkDerivation, it denotes something that Nix will
eventually build.
The evaluation result of derivation (and mkDerivation) is an attribute set
with a certain structure and a special property: It can be used in
string interpolation, and in that case evaluates to the Nix store path of
its build result, like the following examples shows:
let
pkgs = import <nixpkgs> {};
in "${pkgs.nix}"
# evaluates to: "/nix/store/ixjixjj9f1k7h8rqab8frjhl6gm8k466-nix-2.18.8"Derivations are created using the built-in derivation function, but you should
typically use a helper function from nixpkgs (which we'll talk about in a
moment) rather than calling derivation yourself.
However, under the hood, all helper functions eventually call the built-in
derivation function.
A derivation, upon evaluation, creates an immutable .drv file in the Nix store
(typically located at /nix/store) named by the hash of the derivation's
inputs. A separate step, called realisation, ensures that the outputs of the
derivation's builder programm are available as an immutable directory in the
Nix store, either by running the builder program or downloading the results of a
previous run from a cache. Since Nix takes precautions to make the builder
invocation hermetic
(details here),
these outputs can be shared safely between machines of the same OS and
architecture.
TODO
Reading about Nix you also probably stumbled across the term "Flake". From a language perspective, flakes are nothing but an attribute set with a standardized structure.
That's what a flake looks like:
{
description = "...";
inputs = {
# ...
};
outputs = {
# ...
};
}A flake.nix file is an attribute set with two attributes called inputs and
outputs. The inputs attribute describes the other flakes that you would like to
use; things like nixpkgs or home-manager. You have to give it the url where
the code for that other flake is, and usually people use GitHub. The outputs
attribute is a function, which is where we really start getting into the nix
programming language. Nix will go and fetch all the inputs, load up their
flake.nix files, and it will call your outputs function with all of their
outputs as arguments. The outputs of a flake are just whatever its outputs
function returns, which can be basically anything the flake wants it to be.
Finally, nix records exactly which revision was fetched from GitHub in
flake.lock so that the versions of all your inputs are pinned to the same thing
until you manually update the lock file.
Now, I said that the outputs of a flake can be basically anything you want, but
by convention, there's a schema that most flakes adhere to. For instance,
flakes often include outputs like packages.x86_64-linux.foo as the derivation
for the foo package for x86_64-linux. But it's important to understand that
this is a convention, which the nix CLI uses by default for a lot of commands.
The reason I consider this important to understand is because people often
assume flakes are some complicated thing, and that therefore flakes somehow
change the fundamentals of how nix works and how we use it. They don't. All
the flakes feature does is look at the inputs you need, fetch them, and call
your outputs function. It's truly that simple. Pretty much everything else that
comes up when using flakes is actually just traditional nix, and not
flakes-related at all.
The only other significant difference from traditional nix is "purity". Flakes disallow a lot of "impure" ideas, like depending on config files or environment variables that aren't a part of the flake's source directory or its inputs'. This makes it so that a derivation / system / etc. is reproducible no matter what context you're evaluating the flake from.
In the following chapter we'll learn how to define a whole NixOS system
using such flakes. For this, it's important to know, that the output attribute
set is usually defined as a function, which takes in the inputs (defined the
inputs section) and produces an attribute set as return value.
This output function can also take a special parameter called self, which
is best explained with an example:
# flake.nix
{
outputs = { self }: {
a = 1;
b = self.a + 1;
};
}Such a flake.nix file can be evaluated using the following command:
$ nix eval .#b # evaluates to: 2But self is much more than that. It references inputs, outputs and many
other things. It essentially is a reference to the entire flake "object".
With the basic understanding of what a flake is, we can now continue with the probably most exciting part about Nix - namely NixOS. A NixOS configuration can be defined in two different ways - as flake and "traditionally". I'll only focus on the "flake way" of configuring NixOS.
Configuring NixOS with a flake means, that the "main entry point" of the configuration is a flake. For a flake to be a NixOS configuration, we need some special inputs, as well as certain outputs.
The inputs attribute set must at least contain nixpkgs, which is the
collection of Nix packages and therefore the source of all packages and
programs.
The outputs attribute set is usually defined as a function, which takes
the inputs and returns an attribute set. The returned attribute set must
then contain the attribute nixosConfiguration which again is an attribute
set with attributes for each system. Here's an example:
{
description = "...";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
};
outputs = { self, nixpkgs, ...}@inputs: {
nixosConfiuration = {
machine-1 = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
# ...
];
};
machine-2 = nixpkgs.lib.nixosSystem { ... };
};
};
}The function nixosSystem takes in an attribute set which must contain two
attributes:
system: defines the system architecture (e.g.x86_64-linux)modules: a list of NixOS modules (explained later)
The list of modules passed to the nixosSystem function is then finally
the place, where the actual system configuration "happens". They are explained
in the next section.
A NixOS module is a reusable, declarative configuration unit that defines how various aspects of a NixOS system should be set up. Modules allow you to configure services, system settings and packages in a modular and composable way.
NixOS Modules are not anything special in regards to the Nix language.
A NixOS Module is just a Nix function, which returns an attribute set with
the attributes imports, options and config. Like so:
{ config, options, pkgs, lib, ... }: # <- Parameters of a NixOS module
{
imports = [
# ...
];
options = {
# define config options
};
config = {
# set actual values for your system
};
}Explanation:
-
options: Defines configurable parameters (options) for the module, including their types, default values and descriptions. Example:options.myService.enable = mkOption { type = types.bool; default = false; description = "Whether to enable my custom service."; };
-
config: Accesses the current system configuration and allows the module to modify or extend it based on the options.config = mkIf config.myService.enable { systemd.services.myService = { description = "My Custom Service"; after = [ "network.target" ]; serviceConfig = { ExecStart = "${pkgs.myService}/bin/myService"; Restart = "always"; }; wantedBy = [ "multi-user.target" ]; }; };
-
imports: Modules can import other NixOS modules
Under the hood, the NixOS configuration machinery merges all these module sets together into one giant configuration.
Before clarifying where the parameters are coming from, we first have to understand what they mean:
config: the configuration of the entire systemoptions: all option declarations refined with all definition and declaration referenceslib: an instance of thenixpkgs"standard library", providing what usually is inpkgs.libpkgs: the attribute set extracted from the nix package collection and enhanced with thenixpkgs.configoptionmodulesPath: location of themoduledirectory of NixOS
These parameters are all passed automatically to a NixOS module, when it is imported.
The
configargument is not the same as theconfigattribute:The
configargument holds the result of the module system’s lazy evaluation, which takes into account all modules passed to evalModules and their imports.The
configattribute of a module exposes that particular module’s option values to the module system for evaluation.
The question now is, how does the nixosSystem function "get" the pkgs
and lib values, to pass them to all the modules?
And to be honest with you, I tried following the Nix source code, but couldn't fully understand. But still, I want to try and explain the basic concept.
The nixosSystem function "knows" nixpkgs from our inputs and system,
because the system attribute is provided to the function as parameter.
nixpkgs is just a function, which returns the packages that are compatible
with your system, by providing the system variable. In simple terms this means
something like this: pkgs = nixpkgs { inherit system };
With that the nixosSystem function now has the pkgs variable and the
lib variable is easily extracted from it like so: pkgs.lib.
The nixosSystem function has an optional attribute set parameter called
specialArgs, which passes its attributes to all modules. Example:
nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
specialArgs = {
myCustomArg = 1234;
anotherValue = "hello";
};
modules = [
./configuration.nix
];
}And here is the corresponding module:
{ config, pkgs, lib, myCustomArg, anotherValue, ... }:
{
# Now we can use these custom arguments inside the module
environment.systemPackages = [
pkgs.hello
];
# Example usage of myCustomArg:
boot.initrd.luks.devices = {
myEncDev = {
device = "/dev/disk/by-uuid/${myCustomArg}";
preLVM = true;
};
};
}Should You Use specialArgs for Everything?
If it’s truly a global or advanced argument not covered by the normal NixOS
options system, specialArgs can be handy. But if you want to expose an option
that behaves like other NixOS configuration, you usually define a NixOS option
(using mkOption) and then set it in your configuration.nix.
Example:
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05"; # stable default
nixpkgs-unstable.url = "github:nixos/nixpkgs/nixos-unstable"; # unstable
};
outputs = { self, nixpkgs, nixpkgs-unstable, ... }:
let
system = "x86_64-linux";
in {
nixosConfigurations.yourSystem = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
# 0) Optional: Enable unfree for stable pkgs
{
nixpkgs.config.allowUnfree = true;
}
# 1) Overlay the stable pkgs so that `pkgs.unstable` points to
# an imported unstable set
{
nixpkgs.overlays = [
(final: prev: {
unstable = import nixpkgs-unstable {
inherit system;
# Optionally set allowUnfree, overlays, etc.
config = {
allowUnfree = true;
};
};
})
];
}
# 2) Normal system config
{
# Now you can use pkgs.stableStuff or
# pkgs.unstable.someNewerPackage
environment.systemPackages = [
pkgs.hello # from stable
pkgs.unstable.firefox-nightly # from unstable
];
}
];
};
};
}When writing a new configuration it's oftentimes helpful to check if a
configuration is actually applied or if you might have an error in your code
somewhere. Setting up language servers like nil and nixd is very helpful
in this case, but only to a certain extend.
First, to get a basic view of what outputs a flake has, we can use
$ nix flake show .
# Example output when executing command on my system configuration
git+file:///...
└───nixosConfigurations
├───nuc: NixOS configuration
└───p14s: NixOS configurationTo actually expect what your Nix flake evaluates to you can load it up in the repl, and query the configurations/options.
$ nix repl
nix-repl> :lf . # <- loads flake from the current directory
Added X variables
# Example from my system configuration
nix-repl> nixosConfigurations.<machine-name>.config.programs.sway.enable
trueHome Manager is a tool in the Nix ecosystem that allows you to declaratively manage your user configuration (e.g. dotfiles, application settings, environment variables, etc.) in a reproducible way. Instead of manually editing and maintaining a variety of configuration files, you define your configuration once in Nix expressions, and Home Manager takes care of setting everything up.
Home Manager supports two main modes of operation, each differing in how it integrates with your system:
-
Standalone Mode
-
Overview: You use Home Manager purely for configuring your user environment, without tying it into the system configuration. This means you run commands like home-manager switch directly to activate or update your configuration.
-
Use Cases:
- On non-NixOS systems (e.g., Ubuntu, macOS) where you want the benefits of declarative configurations for your dotfiles and user packages.
- On NixOS too, if you prefer to keep user config fully separate from your system config.
-
-
NixOS Module Mode
- Overview: Home Manager is integrated into your NixOS system configuration, leveraging the NixOS module system. This is often referred to as the “NixOS module” mode.
- Use Cases:
- You run NixOS and want one, unified configuration where your system and user settings are managed together.
- You prefer to keep your user configs in the same repository or set of files as the system-level configs for a completely declarative OS.
As I like to use Home Manager as a NixOS Module, that's what this guide will cover.
In your NixOS configuration, the Home Manager module can be added like this:
{
modules = [
# ...
home-manager.nixosModules.home-manager {
home-manager.useGlobalPkgs = true;
home-manager.useUserPackages = true;
home-manager.users = {
john = {
# ...
};
doe = {
# ...
}
};
}
];
}The attribute set of each use is essentially a Home Manager module which is very similar to a NixOS module. The following chapter explains what a Home Manager module is.
Home Manager modules are very similar to NixOS modules. Just as their counterpart, home-manager modules are also a function with certain parameters which returns an attribute set with a standardized structure:
{ config, options, pkgs, lib, ... }: {
imports = [];
options = {};
config = {};
}Even though home-manager modules and NixOS modules both take an config
parameter, it's important to know their differences:
- in a NixOS module,
configis the merged system configuration - in a Home Manager module,
configis the merged user configuration
The "root" home-manager module of each user, is usually not written as
function and just defined as attribute set. The home-manager modules imported
via this "root" module's imports attribute, can be defined as functions.
home-manager.users.john-doe = {
imports = [
# import more home-manager modules here
];
config = {
home.packages = [
# ...
];
};
};The Home Manager NixOS module takes care of passing parameters like pkgs and
lib to the home-manager modules too.
Also, when using Home Manager as NixOS module (like we do in this guide),
additional parameters for NixOS modules (which are defined in specialArgs)
are also passed to home-manager modules automatically.
If you use the Home-Manager NixOS module, you can access the system level
config (which also includes the options) values, through the osConfig
attribute. Home Manager passes osConfig as a module argument to any
home-manager module. Here's an example use case:
{ pkgs, lib, osConfig, ... }:
{
home.packages = [
pkgs.hello
]
++
lib.optionals (osConfig.services.myService.enable) [
pkgs.someExtraPackage
];
}As the options of custom NixOS modules are part of the system configuration,
they can also be accessed through osConfig:
# NixOS Module
{ lib, ... }:
{
options.myModule.myOption = lib.mkOption {
type = lib.types.str;
default = "defaultValue";
description = "A custom option for my module";
};
config = {
# Here you could also provide some default behavior
# based on myModule.myOption if you wish.
};
}# Home-Manager Module
{ pkgs, lib, osConfig, ... }:
{
# For instance, use it conditionally:
home.packages = lib.optionals (osConfig.myModule.myOption == "customValue")
[ pkgs.somePackage ];
}