The time has come to kind of, sort of actually learn Nix. I’ve been faffing around in
NixOS for the better part of a year now, so I have seen plenty of room for improvement
in how I manage my systems. For instance, my “main” machine’s configuration.nix
file
is pushing 500 lines, which is… a bit of a quagmire.
This post exists in order to detail my learning experience in figuring out the Nix language and how to actually configure systems in a way that won’t just turn into spaghetti.
Nix #
Nix is a functional programming language. So far, so good — I know Haskell to a passing degree.
What this means, to the unfamiliar, is that it’s a programming style that’s way more akin to mathematics than conventional imperative programming. Imperative programming is a series of commands, executed in order. Functional programming is stringing functions together in order to work the input data into a suitable output.
My great problem with Nix so far is that I don’t know what “suitable output” is, nor really what the inputs are. In designing the “Kepler” system — reminder: that’s my be-all, end-all configuration solution for all my machines — I want things to be modular, self-contained, and relatively easy to use. I’ve tried faffing about with declaring my own modules for configuration, but I keep getting type errors.
The official Nix documentation is, in my experience, incredibly hard to read. Moreover, there seems to be a schism in the “official” Nix community on either side of the “nix flakes” debate — the documentation either doesn’t mention flakes at all, or mentions nothing but flakes. This makes it a little difficult to get anywhere.
Thankfully, there are a myriad blogs available which go into the topics. Some useful examples I’ve found are:
- Rapid Introduction to Nix by the NixOS Asia community
- Install NixOS with Flake configuration on Git by the same
- Nix – taming Unix with functional programming by Valentin Gagarin
- Flakes aren’t real and can’t hurt you by “jade”
The starting point #
Up until recently, I had one (1) NixOS machine — my janky laptop. I then got a second
— a discount server in a datacenter in Finland. Managing these two by smacking the
configuration.nix
file until it worked was well enough, but I have plenty of old
derelict computers I want to get back into gear, and then making sure that each machine
has some sort of unified configuration starts to become an issue.
I recently retired my old desktop computer and shoved it into a closet I’ve dubbed a “Server Room” to live out its days as a server. Step one of this process — after the obligatory data rescue of stray files I had on the soon-to-be-wiped disk — was to shunt NixOS onto the machine.
Anyhow, once nixos-generate-config
had done it’s thing and bootstrapped a
configuration.nix
and hardware-configuration.nix
file, I could step in and inject my
own config.
Here’s an example, taken from the above mentioned old desktop:
# configuration.nix
{ config, lib, pkgs, ... }:
{
imports =
[
./hardware-configuration.nix
];
boot.loader.grub.enable = true;
boot.loader.grub.device = "/dev/sdb";
boot.tmp.cleanOnBoot = true;
networking.hostName = "mercury";
networking.domain = "kepler";
networking.networkmanager.enable = true;
services.openssh.enable = true;
services.openssh.settings.PermitRootLogin = "no";
services.resolved.enable = true;
users.mutableUsers = false;
users.users.sldr = {
isNormalUser = true;
description = "Jonas A. Hultén";
extraGroups = [
"networkmanger"
"wheel"
];
hashedPassword = "[redacted]";
openssh.authorizedKeys.keys = [
"[redacted]"
];
};
nix.settings.experimental-features = [ "nix-command" "flakes" ];
time.timeZone = "Europe/Amsterdam";
environment.systemPackages = with pkgs; [ vim curl neofetch nixfmt bat ripgrep htop ];
system.stateVersion = "24.05";
}
This is then simply used as a module in the core system flake.
# flake.nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
};
outputs = inputs@{ self, nixpkgs, ... }: {
nixosConfigurations.mercury = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [ ./configuration.nix ];
};
};
}
So that’s where we’re starting off from.
The next step(s) are to split out segments from the “legacy” configuration.nix
file
into flakes, which I can then reuse across machines.
However, the very first thing I tried to do was to restructure the files so that I could
maintain all my machines in the same directory structure. This means moving the “legacy”
files into legacy/mercury
and putting a flake.nix
in legacy
to handle importing them.
What I mean by “legacy” in this instance are the configuration.nix
and
hardware-configuration.nix
files which were created during system installation. It
seems like the path of least resistance to handle them using a flake before I start
gutting them and re-implementing whatever config is in them using more flakes.
However, since I vehemently believe in not repeating myself, I’d like this flake to take an argument — the hostname — which points to the “legacy” files to include, rather than needing to update the flake any time I add a new machine.
This is where I got stuck.
A recursive mess #
As mentioned above, I initially thought I could implement the legacy
flake with a
function that took the system’s hostname as an argument. Then I thought to myself “Hey,
I can be more clever than that.” NixOS should already know the system hostname, so
shouldn’t I just be able to reference it via ${networking.hostName}
?
Long story short: no.
Or, in all honesty, probably, but that would involve me figuring out how to feed the system configuration into a function from inside the flake which is defining the system configuration. NixOS (and Nix in general, I guess) is supposedly good at doing that sort of iterative evaluation stuff, but I couldn’t figure out how to do it. While I am exhausted from banging my head against this, I can’t seem to find any examples of anyone doing this either.
So, we’re back to creating a flake that has a function that handles the imports. After far too many syntax errors, this is what fell out:
{
description = "Hardware and unported interface flake";
inputs = { };
outputs = { self, ... }: {
nixosModules.default = ({ name, ... }: {
imports = [
./${name}/configuration.nix
./${name}/hardware-configuration.nix
];
});
};
}
I could then — successfully — roll that into the root flake by calling the module like a function:
inputs = {
...
legacy.url = "./legacy";
};
...
modules = [
(legacy.nixosModules.default { name = "mercury"; })
];
...
A word of warning: if you decide to rename a flake, make sure to rename all references
to it. I spent far too long trying to work out why Nix was insisting the legacy
flake was still called fundamentals
(the old name) until I found that it still said
fundamentals
in the root flake outputs
function arguments, i.e.
...
outputs = inputs@{ self, nixpkgs, fundamentals, ... }: {
...
The Nix error messages were less than helpful, just saying that it couldn’t find the
fundamentals
flake in any registry, while I banged my head against the desk, thinking
I’d scrubbed all references to it, up to and including destroying the flake.lock
file
and digging through the garbage collector roots to see if Nix had cached my old sins
anywhere.
Nope. Just a SNAFU on my end.
Refactoring Kepler #
Unrelated to the above, I already had a little Kepler git repo set up which mostly
contained some loose ansible
and tofu
code, as well as a flake to create a dev
shell. Now, it was time for the two to meet, making a be-all, end-all flake.
Now, it turns out I’ve learned a thing or two about Nix since I messed with this flake last time, so step zero was cleaning up the implementation of the dev shell.
For some ungodly reason, I’d implemented flakes inside Kepler to use
flake-utils
, but not the root flake.
Ostensibly, this doesn’t matter, since I currently don’t run Nix on anything that
isn’t x86_64 Linux, but there’s a good chance I’ll try out an ARM server at some point
(or put NixOS on one of my myriad Raspberry Pis, now that I think about it), so might as
well make the dev shell multi-system.
Skipping over what it used to look like, here’s the current state of affairs:
{
...
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
flake-utils.url = "github:numtide/flake-utils";
...
};
outputs = inputs@{ self, nixpkgs, flake-utils, sldr-meta, ... }:
# Create the dev shells
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
sldr = sldr-meta.packages.${system};
in {
devShell = with pkgs;
mkShell {
name = "keplersh";
buildInputs = [ ansible ansible-lint dhall opentofu sldr.gitgum ];
shellHook = ''
source .secrets
'';
};
});
}
sldr-meta
flake which is being used. That’s just a
helper flake to access some of my own applications and helper scripts. In the above
example, it’s being used to include my
gitgum
utility.
It took me a while to work out how I could use the eachDefaultSystem
function while
also adding other flake outputs, until I learned/realized that I could use the //
operator to glue on another set, which will then contain the nixosConfigurations
.
Glueing it together #
Copying over some code from the experimental directory, and organizing it a bit better, we get the following:
...
legacy.url = "./nix/system/legacy";
};
outputs = inputs@{ self, nixpkgs, flake-utils, sldr-meta, legacy, ... }:
{
# Create the system configurations
nixosConfigurations = {
mercury = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [ (legacy.nixosModules.default { name = "mercury"; }) ];
};
};
} //
# Create the dev shells
flake-utils.lib.eachDefaultSystem (system:
...
And, according to nix flake info
, that works as intended.
Now we’re cooking with Nix.
Breaking it apart again #
The end goal of this whole flake journey is to modularize my configuration, so that I can slot in well-known pieces of configuration across my machines — and update all machines at once, for that matter. So that’s what we’re going to get to next.
The first, and maybe most obvious chunk of code to break out is the definition of my user. But first, we have to take a detour down Pragmatism Lane.
When flakes aren’t the right answer #
Going into this, I envisioned my entire configuration broken up into a slew of flakes,
each defining it’s little piece of system config. Now, that makes sense — to a point.
Each flake can have its own inputs, like a reference to nixpkgs
or some other fun
stuff, but consider the hypothetical flake that contains my user definition.
It won’t have any inputs — everything that it’s defining is a NixOS builtin. It’s not going to be installing packages. It’s just defining my user and some related bits and bobs. Consequently, there’s no need for putting it in a flake, since there’s no need for it to have a lock file to track and lock its inputs.
Indeed, I’ve read that the general best practice when using flakes for defining packages
is to still have a sidecar default.nix
which does all the heavy lifting, while the
flake just imports it. That’s what my
gitgum
flake
does.
So, it’s now time to start breaking apart some chunks into good ol’ fashioned Nix modules.
The users
modules
#
This part turned out easier than I thought it would be, honestly. I cut out the user
config, stuck it in a new file, and listed it in the modules
list. Bing bang boom, it
works.
# nix/system/users/sldr.nix
{ ... }:
{
users.users.sldr = {
isNormalUser = true;
description = "Jonas A. Hultén";
extraGroups = [
"networkmanger"
"wheel"
];
hashedPassword =
"[redacted]";
openssh.authorizedKeys.keys = [
"[redacted]"
];
};
}
# flake.nix
...
nixosConfigurations = {
mercury = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
(legacy.nixosModules.default { name = "mercury"; })
./nix/system/users/general.nix
./nix/system/users/sldr.nix
];
...
The users/general.nix
file just contains the single line users.mutableUsers = false;
, but establishes the general structure I intend to have for my configuration —
there are specifics, which have some sort of name, and there is general config, which
should apply to everything.
After some more hacking and slashing, here’s the structure that’s starting to emerge:
nix
├── sldr-meta
│ └── flake.nix
└── system
├── general.nix
├── legacy
│ ├── flake.nix
│ └── mercury
│ ├── configuration.nix
│ └── hardware-configuration.nix
└── users
├── general.nix
└── sldr.nix
The system/general
module contains misc. settings which should apply to all systems,
regardless of architecture or type (such as running an SSH server). However, it
currently also installs some system packages, and I should probably migrate that
elsewhere.
Code formatting is important, so here’s a tip.
Nix flakes support providing a default formatter, which you can then invoke by running
nix fmt
, which will auto-format all .nix
files in the current directory as well as
any subdirectories.
In my case, I set it inside the flake-utils.lib.eachDefaultSystem
function — since
formatters are applications, you need to specify the architecture — and then it was as
easy as:
# Set a formatter
formatter = pkgs.nixfmt-rfc-style;
Package management #
Package management is where it actually makes sense to use subflakes. For instance, I have LaTeX installed on my old laptop. Since it isn’t flake-configured, any time I update my system, there’s a good chance I also update LaTeX — which, as some of you may know, is a huge amount of packages. The same goes for my printer drivers, which for some reason have to be built from source. It’d be nicer to stick those packages behind flakes and then only update them as needed.
At the same time, there are a fair few packages I want on the bleeding edge — ssh
is one, just for the security reasons. The kernel is another.
A good place to start is the general packages module, which currently looks like this:
# nix/pkgs/general.nix
{ lib, pkgs, ... }:
{
# Install basic system packages
environment.systemPackages = with pkgs; [
bat
curl
git
git-crypt
gnupg
htop
neofetch
nixfmt-rfc-style
openssl
pinentry-all
ripgrep
tree
vim
];
# Enable direnv globally - might as well
programs.direnv.enable = true;
}
These are the packages which I expect to have on all machines, just because… because.
Now, a question of semantics: do I treat services like packages and split them off into
their own little category too? Or does that belong under system
? Technically, this
also belongs under system
, since these are packages which are installed
system-wide, so where do I draw the distinction?
I was going to go back and edit this part to reflect what I actually settled on, but my
thinking turned out to be that putting packages under nix/pkgs
was a mistake — the
top-level concern is whether something is system-wide or user-specific. Consequently,
the newly created pkgs/general.nix
file has been moved to nix/system/pkgs
and I will
create a services
module there as well.
I’d like to think that it’s more honest — if more verbose — to show my work, rather than edit my work down to what turned out as the “best” idea.
With some additional jiggery-pokery, I have abstracted out the ssh
service, and I’m
getting closer to where I want to be.
Hardware support #
Here, it once again turns out that the nerds over in the Nix community have done the
hard work for me. Rather than needing to set up my own modules and import/exclude kernel
modules and what not, the
nixos-hardware
repo exists. Better yet, it has a flake, meaning I don’t even need to write anything to
handle it — just slap that into the flake inputs and off we go.
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
nixos-hardware.url = "github:NixOS/nixos-hardware";
...
};
outputs =
inputs@{
...
}:
let
defaultModules = [
./nix/system/general.nix
./nix/system/pkgs/general.nix
./nix/system/services/ssh.nix
./nix/system/users/general.nix
./nix/system/users/sldr.nix
];
in
{
# Create the system configurations
nixosConfigurations = {
mercury = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules =
defaultModules
++
# Hardware config
[
nixos-hardware.nixosModules.lenovo-ideapad-slim-5
nixos-hardware.nixosModules.common-cpu-amd-pstate
]
++
# Custom flakes
[ (legacy.nixosModules.default { name = "mercury"; }) ];
};
...
You’ll note that I’ve also started defining my own list of defaultModules
which I can
slot into any future machine I want to set up. To me, this seems like a better approach
than having a super-module which, in turn, includes those other modules — it means the
actual system definition lives here in the root flake, rather than elsewhere.
Revisiting the legacy
flake
#
You may have realized it before me, but the legacy
flake is completely superfluous in
light of my earlier discussion about flakes vs. modules. By all rights, it should be a
module — it has no independent inputs. So why isn’t it?
Put simply: I forgor 💀
However, the problem DID rear its ugly head when I got tired of just configuration,
jammed all the remaining config into the legacy
files, and triggered an actual
rebuild.
Now, keep in mind: I’d been running nixos-rebuild dry-build
routinely throughout this
work. I was relatively sure this would behave as expected.
So I built a new boot generation (since I’d switched desktop manager) and rebooted. Then, after reboot, I’m greeted by… a black screen with a white login prompt.
I double check the settings. services.xserver.enable
is true
. So… why no X?
Cutting to the chase, the legacy
flake wasn’t actually working. It referenced the
files like it should, but for one reason or another, they weren’t actually imported.
Consequently, all my legacy config was unapplied.
The solution was to use a regular ol’ fashioned module. Weirdly. To the best of my
understanding, the legacy
flake was correctly making a module, but… I’m not going
to dig into it deeper. Using a module solves the same problem with fewer lines of code.
Here it is, in its entirety:
# nix/system/legacy/default.nix
{ name }:
{
imports = [
./${name}/configuration.nix
./${name}/hardware-configuration.nix
];
}
This does present an interesting conundrum, because this file technically isn’t a module — it’s a Nix function which returns a module. Consequently, I have to handle it a bit differently.
outputs =
inputs@{
...
}:
let
legacy = import ./nix/system/legacy;
...
in
{
# Create the system configurations
nixosConfigurations = {
mercury = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules =
defaultModules
++
# Legacy config
[ (legacy { name = "mercury"; }) ]
++
...
And that is where I’ll call it a day.
Been working on this post for well over two weeks now, and I want to play with my computer for a bit, rather than just configure it. Also, I need coffee, so let’s get to that.
Tot ziens