Blogg
Här finns tekniska artiklar, presentationer och nyheter om arkitektur och systemutveckling. Håll dig uppdaterad, följ oss på LinkedIn
Här finns tekniska artiklar, presentationer och nyheter om arkitektur och systemutveckling. Håll dig uppdaterad, följ oss på LinkedIn
In this post, I’ll talk about Nix, provide an overview of Nix flakes, explain their benefits, describe how I migrated my existing setup, and share the code structure I settled on.
In the last post: My declarative journey with NixOS, I shared the first steps of my NixOS journey, covering the basics and how I set up my configuration following the declarative nix-channel route.
While functional, it’s time to delve deeper into Nix and start looking into modularizing the setup, incorporating “Flakes,” and using “Home Manager” to handle my dotfile household.
Nix Flakes were introduced to the ecosystem in 2021 and are considered an experimental feature of the Nix package manager.
A Flake is at its core simply a directory that contains a flake.nix
file. This file contains a structured set of Nix expressions that can be evaluated, just like in classic Nix, but this time using the experimental nix
cli command.
A very simple flake.nix
file can be generated using the command nix flake init
{
description = "A very basic flake";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
};
outputs = { self, nixpkgs }: {
packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello;
packages.x86_64-linux.default = self.packages.x86_64-linux.hello;
};
}
There can only be one root-level flake.nix
per Flake, but Flakes can be treated as functions that takes in inputs and produces outputs, and the input to a Flake can be a Flake as well.
This allows complex systems to be built by using smaller, dedicated Flakes as building blocks and composing them, either as independent pieces or as Flakes calling each other.
Flakes calling Flakes calling Flakes, it’s Flakes all the way down.
Another component of a Flake is the flake.lock
file that is generated after evaluation.
This file is used, much like a NodeJS package-lock.json
or Rusts Cargo.lock
, to pin dependency versions, but with the magic of the Nix store to back it up.
Some of the key concepts of Flakes include:
Standardized Structure: Flakes enforce a schema, where other Flakes are referenced as dependencies, and outputs are predefined. This format describes:
Inputs: Dependencies the Flake uses (e.g., the specific version of nixpkgs, what releases to follow, or any “third-party” Flake it might depend on).
Outputs: What the Flake provides (e.g., NixOS configurations, packages, developer shells). This structure describes the results of a Flake and how others can use it as a dependency.
Composability: This input/output scheme allows for one Flakes output to be used as input to other Flakes, making sharing configurations, packages, or modules much more straightforward.
Sealed Evaluation: Flakes aim for “purity” in their evaluation. By default, They cannot access the user’s environment variables or other system-specific data that could be considered ephemeral. A flake is intended to be self-contained and only rely on explicitly declared inputs, no “outside” influences allowed, like environment variables, files outside the flake directory or anything else that could change between evaluations.
Given the layout described above, what makes Flakes a worthy addition to an existing Nix ecosystem? What do they bring to the table that the traditional channel-based approach can’t solve?
Flakes aim to add several benefits over channels:
Improved reproducibility: Often said to be the primary advantage. The introduction of the flake.lock
file pins the exact commit hash of all inputs used in a Flake build. This provides a much tighter, more reliable control over the package versions being used, effectively banishing the “works on my machine” problem.
Explicit dependecies: Everything used in a Flake is explicitly declared. Anyone looking at a flake.nix
can see what external sources the build relies upon, and the lock file ensures that the same version of each dependency is used every time, no matter what machine is running the build.
Build consistency: Based on the sealed evaluation promise, Flake builds are more consistent. Since a Flake evaluation doesn’t take system state into consideration, the output will remain consistent, no matter what’s going on outside the Flakes own bubble.
Simplified Sharing: The reproducibility, explicit dependencies, and standardized structure of Flakes makes sharing Nix configurations much easier. With Flakes you can build smaller, easy-to-digest Flakes that are purpose-built to solve a single issue: Creating a filesystem, stand up a development environment, or hosting a web application, anything can be done with just a Flake.
Community momentum: Despite being marked as ‘experimental’, the community has widely adopted and embraced Flakes, tossing the ‘Experimental’ flag to the side. The fact that people keep building new tools to use and enhance Flakes shows what a pillar of the ecosystem they already have become. In this 2023 survey, 83% of replies relied on experimental features.
In summary, Flakes adds new layers of reproducibility and control on top of the existing Nix foundation, further improving the already impressive Infrastructure-As-Code offering Nix provides.
While Nix’s configuration.nix
is great for system-wide setups and settings, it’s not ideal for managing user-specific configurations.
For dotfiles, the channel-based configuration.nix
falls short, and this is where Home Manager steps in to manage the home of dotfiles.
Home Manager lets you declare your dotfiles, configurations, and settings using Nix expressions, just like you can manage your system with Nix and channels.
You define your desired state (packages, dotfiles content, environment variables, services) in a home.nix
file (or directory for more complex setups), and then Home Manager builds, initializes and links everything you need.
Benefits include, but are not limited to:
Dotfile Management: Define dotfile contents directly in Nix or have Home Manager source existing files. Gone are the days of manual symlinking or complex setup scripts, sturdy as a house of cards.
User Packages: Install packages specifically for your user.
User Services: Manage user-level systemd services, such as a personal web server, a custom backup script, or a task scheduler for different projects.
Consistency: Keep your user environment consistent across different machines.
Integration: Home manager can be used standalone as the only Nix part of a system, bundled in a Flake, or as I am about to demonstrate, as a NixOS module.
With the theory out of the way, let’s get some digital dirt under the fingernails, and look at implementation.
Migrating my existing setup involved adding Flakes, modularizing the config, including Home Manager, and splitting up the code into bitesize pieces.
Here’s a breakdown based on the structure in my GitHub repo:
First, let’s make sure Flake commands are available. the quick and dirty option is to append the flag
--experimental-features "nix-command flakes"
to every nix command, but a better route would be adding the line
nix.settings.experimental-features = [ "nix-command" "flakes" ];
To /etc/nixos/configuration.nix
, which enables Flakes permanently.
For this new setup, I moved out of the /etc/nixos
directory and instead handled everything in a directory closer to home.
The main reason to leave /etc/nixos
behind was to get out of slogging through the sudo swamp, because in a root managed directory like /etc/
, I had to fight against git exceptions, mismatched groups and readonly files for every file created, moved or updated.
So instead I decided to set up shop in ~/Dev/nixos
.
Once here, I ran nix flake init
to generate a basic Flake file:
{
description = "A very basic Flake";
# the list of inputs our Flake should use
# in this case, where we should fetch our packages from
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
};
# Include references to the Flake itself, as well as the nixpkgs input defined above
outputs = { self, nixpkgs }: {
# fetch a simple hello package from the nix package repository
# and make it available in our system
packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello;
packages.x86_64-linux.default = self.packages.x86_64-linux.hello;
};
}
Let’s grab the old configuration.nix
and hardware-configuration.nix
created last time and include them in the Flake.
Both files are moved into a modules
sub-directory and then referenced from the root flake.nix
file.
{
description = "Nix with Flakes";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
};
outputs = {
self,
nixpkgs,
...
} @ inputs: {
# create a nixOS-system module i call 'nixos' (hostname)
nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
# define system architecture, so packages know how to install
system = "x86_64-linux";
# let the modules include my inputs using the builtin 'specialArgs' attribute
specialArgs = {inherit inputs;};
modules = [
# Point to a Nix file containing my system configuration
./modules/linux/configuration.nix
];
};
};
}
The system can be rebuilt as a Flake in this state, using sudo nixos-rebuild switch --flake .#nixos
but it would not change much.
The end result would be the same as before, except for the small detail that the release version Nix uses to fetch all packages is now declared inside the Flake.
But that’s not good enough! Let’s rip out some config and start incorporating Home Manager.
Here, I start by including ’home-manager’ as an input and adding its Nix module to the Flakes list of modules. I also took the liberty of including a third-party Flake that installs the Firefox-based browser “Zen.”
{
description = "Nix with Flakes";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
# 3rd-party Flake, since Zen is not packaged in the main nixpkgs channel
zen-browser.url = "github:0xc000022070/zen-browser-flake";
# include home-manager as an input, and let it 'follow' the main nixpkgs branch
# letting it install packages from nixpkgs instead of keeping its own repository
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = {
self,
nixpkgs,
home-manager,
zen-browser,
...
} @ inputs: {
# same nixos module as before
nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
specialArgs = {inherit inputs;};
modules = [
./modules/linux/configuration.nix
# creating a home-manager module as part of my systems config
home-manager.nixosModules.home-manager
{
home-manager.useGlobalPkgs = true;
home-manager.useUserPackages = true;
# settings for the user hest (that me!)
# sets which user to configure and where Nix should find the correct files
home-manager.users.hest = import ./home/linux.nix;
home-manager.extraSpecialArgs = {
# bring in the list of inputs into the home-manager module
inherit inputs;
system = "x86_64-linux";
};
}
];
};
};
}
Regarding the home-manager
modules, there are a couple of interesting things to look at.
Not only does Home Manager allow you to install packages for a specific user instead of system-wide, but many also come bundled as “programs”.
A program comes with a set of predefined keys, which allows you to set the program’s configuration declaratively. This is what allows Home Manager to handle all our dotfile needs.
Some options come with a list of enums to choose from, while others provide a more open-ended approach, allowing you to write the config directly in the program’s desired DSL.
Here is a quick example from my own config:
programs.bat = {
enable = true;
# custom config, here we can write whatever we like
# and it will end up in bats dotfile, valid or not
config = {
theme = "Nord";
style = "numbers,changes,header";
};
};
This code snippet tells Home-manager to enable the program Bat (cat replacement) and write the provided config into ~/.config/bat/config
, which will look like this:
--style=numbers,changes,header
--theme=Nord
When it comes to how you should structure Home-manager files, the answer is, as always, “it depends.” How modular do you want the config to be? Do you need it to be composable, allowing you to pick and choose from a smorgasbord of available configurations?
Or would you rather keep it short and sweet and stick to a single, dense file with minimal boilerplate? In the end, it all comes down to taste.
My own approach to home-manager is to separate the config into multiple files, trying to balance simplicity and composability.
By keeping simple configuration together and separating more complex and optional programs in their own boxes for easier management, I’m atleast trying to steer clear from creating a monolithic beast, and from being flooded by an army of tiny .nix
files.
Every package/program starts out in a core.nix
file, and if it requires little to no config, it stays there.
home.packages = with pkgs; [
alejandra
cargo
cargo-nextest
...
wget
zellij
];
If a couple of lines of config is all the program needs? Then it can still be kept around in the core file, just move it down under the programs.*
umbrella, like the Bat config we looked at before.
What if it’s starting to get complex? Or is it a program I don’t need on all systems? Then it’s time to move it to its own file.
My git configuration, with custom commands, global ignores, and handcrafted ‘IncludeIf’ conditions spanning over 100 lines, does feel unwieldy enough to deserve its own space for some extra breathing room.
{
config,
lib,
pkgs,
...
}: {
# Delete the default gitconfig file if it exists
at home.activation.removeExistingGitconfig = lib.hm.dag.entryBefore ["checkLinkTargets"] ''
rm -f ~/.gitconfig
'';
programs.git = {
enable = true;
lfs.enable = true;
diff-so-fancy.enable = true;
# using gits 'IncludeIf' to manage multiple users/ssh keys in different repos
includes = [
{
condition = "gitdir:${builtins.getEnv "M_DIR"}";
contents = {
core.sshCommand = "ssh -i ~/.ssh/${builtins.getEnv "M_ID"}";
user = {
name = "${builtins.getEnv "M_USER"}";
email = "${builtins.getEnv "M_MAIL"}";
};
};
}
{
condition = "gitdir:~/.config/";
contents = {
core.sshCommand = "ssh -i ~/.ssh/${builtins.getEnv "M_ID"}";
user = {
name = "${builtins.getEnv "M_USER"}";
email = "${builtins.getEnv "M_MAIL"}";
};
};
}
{
condition = "gitdir:${builtins.getEnv "C_DIR"}";
contents = {
core.sshCommand = "ssh -i ~/.ssh/${builtins.getEnv "C_ID"}";
user = {
name = "${builtins.getEnv "C_USER"}";
email = "${builtins.getEnv "C_MAIL"}";
};
};
}
{
condition = "gitdir:${builtins.getEnv "G_DIR"}";
contents = {
core.sshCommand = "ssh -i ~/.ssh/${builtins.getEnv "G_ID"}";
user = {
name = "${builtins.getEnv "G_USER"}";
email = "${builtins.getEnv "G_MAIL"}";
};
};
}
];
extraConfig = {
init.defaultBranch = "main";
push.autoSetupRemote = true;
pull.rebase = true;
};
# some nice to have aliases
aliases = {
br = "branch";
co = "checkout";
st = "status";
ls = "log --pretty=format:\"%C(yellow)%h%Cred%d\\\\ %Creset%s%Cblue\\\\ [%cn]\" --decorate";
ll = "log --pretty=format:\"%C(yellow)%h%Cred%d\\\\ %Creset%s%Cblue\\\\ [%cn]\" --decorate --numstat";
lg = "log --color --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit";
cm = "commit -m";
ca = "commit -am";
dc = "diff --cached";
amend = "commit --amend -m";
# aliases for submodule
update = "submodule update --init --recursive";
foreach = "submodule foreach";
};
# global ignore-file
ignores = [
"!.vscode/extensions.json"
"!.vscode/launch.json"
"!.vscode/settings.json"
"!.vscode/tasks.json"
"!flake.lock"
".classpath"
".env"
".flattened-pom.xml"
".gradle/"
".idea/"
".mise.toml"
".project"
".rtx.toml"
".vscode/*"
"*.code-workspace"
"*.iml"
"*.lock"
"*.settings"
"*.tgz"
"bin/"
"build/"
"out/"
"**/.DS_Store"
];
};
}
A third option I use when there is too much configuration to store in a single file is to symlink an entire system directory.
Looking at my Neovim
config, which has 20+ different files spread out over multiple directories and receives updates and tweaks almost daily, it would be quite the chore to fiddle around with if I was forced to rebuild the entire system every time a setting is tweaked or a plugin is updated.
My answer to this little predicament is to use a built-in Nix feature, mkOutOfStoreSymlink,
to symlink the ~/.config/nvim
directory to my dotfiles directory inside my Flake instead.
This way, any update made to Nvim in my Nix repo is instantly applied to the local system and still managed through a single repository, so updating symlinked dotfiles does not require me to do a full nixos-rebuild
.
# point point the source
nvimPath = "${config.home.homeDirectory}/Dev/nixos/dotfiles/nvim";
# symlink the nvim folder inside xdg config directory (`~/.config/` by default)
xdg.configFile."nvim".source = config.lib.file.mkOutOfStoreSymlink nvimPath;
With this approach applied, the finalized repository looks like this:
.
├── dotfiles
│ ├── kanata
│ │ └── kanata.kdb
│ ├── nvim
│ ...
│ ├── raycast
│ │ └── Raycast 2025-03-23 21.25.07.rayconfig
│ └── zellij
│ ...
├── flake.lock
├── flake.nix
├── home
│ ├── core.nix
│ ├── linux.nix
│ └── programs
│ ├── fish-functions
│ │ └── insulter.fish
│ ├── fish.nix
│ ├── ghostty.nix
│ ├── git.nix
│ ├── gnome.nix
│ ├── k9s.nix
│ └── mise.nix
├── justfile
└── modules
└── linux
├── configuration.nix
└── hardware-configuration.nix
I won’t go over every line of code here, but if you are interested in the details, my repository can be found here on Github
As they say, the proof is in the pudding, so it is now time to follow my time-honored tradition of reinstalling my entire system from scratch and see what’s missing on the other side.
Boot from NixOS Installer: Start with a minimal NixOS installation ISO. Pick out desktop, locale, keyboard, users et.c, and install. Same procedure as last year.
Reboot from USB: Jump out of the USB image and start up the fresh install.
Mount Filesystems: Mount the extra hard-drives (e.g., to /mnt
) and make sure they use the correct permissions.
Clone Configuration Repository: Git is nowhere to be found, so let’s create a temporary Nix shell with git (nix-shell -p git
) and clone my NixOS configuration repository (e.g., git clone https://github.com/HestHub/nixos ~/Dev/nixos
).
generate hardware-configuration: Since the extra harddrive got mounted after boot, I update the hardware configuration sudo nixos-generate-config
and copy the update hardware file to my repo.
configuration.nix
, run the NixOS install command pointing to the Flake in the cloned repository:
sudo nixos-rebuild boot --flake .
By the gods! Everything is gone, nothing works and I deleted it all. What have i done? What a fool I have been. so many hours lost. Thats it, I’m giving up, never touching Nix or any config again for as longs as i live.
No no no, that’s not the way it happened. Shall I start again?
Everything just works, everything is in place right away, all my programs, all my config, colours, fonts, keybinds and shortcuts. Even my git and ssh config was in place right away.
The only thing I had to do was start up a couple of programs to login for the first time, so, I guess there isn’t that much to say, no great insights or struggles to share, except to mention that I don’t think it is any way to do a fresh install as pain-less as this.
And on that highnote, let’s wrap up for today, but before we head out, let’s look at some pros and cons and what’s in store for the future.
Reproducibility: flake.lock
guarantees that the same packages are used every time you build, a rebuild feels even safer with Flakes.
Clear Dependencies: I think flake.nix
is easier to parse when I’m looking at examples online, trying to decipher Nix shared by others when solving my problems.
Enhanced Modularity: Breaking down configuration into logical units made it easier for me to manage all the moving parts.
Clean Separation: System config (configuration.nix
and its modules) is separated from user config (home.nix
). It’s clear which part is system specific and what could be shared between different machines.
Easier Sharing & Collaboration: Flakes are designed to be shared and used as building blocks, so hunting around the internet for config to steal or be inspired from is a breeze.
Atomic User Updates: Home Manager updates, config and dotfiles are managed within the Nix store, similar to system updates. So no worries about losing my config after an update.
Learning Curve: Flakes and Home Manager introduce new concepts and syntax, more puzzle pieces that needs to fit together, requiring more mental gymnastics.
Experimental Status: Even if Flakes feel quite stable and are common to see in the wild, they are still marked as experimental, so things could change in the future.
Boilerplate: Setting up the initial flake.nix can feel a bit verbose compared to just having configuration.nix.
Tooling Differences: You need to use nix build .#package
, nix run .#app
, nixos-rebuild switch --flake .#host
, etc., instead of older commands. It’s not a one-to-one mapping between them, so i have run into trouble when looking at older guides to solve an issue.
Yes, Flakes introduce another layer of abstraction on top of Nix, which is always a risk: more moving parts that could fail, more complicated troubleshooting, more sharp edges, and strange corner cases. But for the most part, I do think they are easier to use and reason about, at least once you get the hang of the structure and spend some time in the seat.
The ability to compose a system from a set of Flakes can simplify setting up any future OS. All I need to do now is pick out the pieces I need, create a new module, and install it. All the software I’m using and all my config, tailored to my liking, will be part of the new build right away.
The structure shown here has laid the groundwork for my setup, but I still have some plans for improvements in the future:
Unified NixOS and Nix Darwin Configs: My flake.nix
already includes Nix-Darwin (macOS module) as an input. The next step is to abstract common configurations (packages, shell settings, base editor configs within Home Manager) and share them between my NixOS machine and my MacOS one, managed by Nix Darwin, all from this single repository.
Project-Specific Environments: While Home Manager handles my user environment, Nix Flakes can also specify development shells (devShells). I’m planning to explore tools like devenv, direnv, and Flox to manage project-specific dependencies (SDKs, linters, tools) the Nix way. The end goal here is that cloning a project and entering its directory automatically provides the correct development environment.
Secrets-management: one limitation to my current setup is managing accounts, logins, SSH keys, and all things auth. Today, a fresh install gets me all the software and config I want, but I still have to do the rounds of logging into and authenticating the different tools I’m using, which feels rather tedious and un-Nix-like.
A solution to this could be adding a layer of secrets management to the config. Some well-regarded alternatives I have found while browsing seem to be Agenix, Sops-nix, or git-crypt. Some research needs to be done to see what best fits my use case.
Would I recommend Flakes to a Nix user? Does a Nix build produce a hash?
Absolutely!
They provide even more opportunities to manage systems and software, lets you keep all your configurations in one place, and all the new, shiny tools today seem to be Flake-based.
Would I recommend jumping straight to Flakes for a first-timer? On this, I haven’t entirely made up my mind, but it leans towards a yes.
On the one hand, it felt easier to adapt to Flakes once I understood the Nix core, and like David Heinemeier Hansson said in his Rails World Talk: “It’s more fun to be competent”.
Adding Flakes to my setup doesn’t mean that I stopped using core features when it comes to my NixOS installation, but for non-NixOS machines, that might not be the case.
On the other, if the goal is to get things done, jumping straight into Flakes could be the faster approach, just find a good Flakes that solves your problem, fire and forget. I can see the allure of that.
While my own Nix path involved the basics first, the current lay of the land is very much Flake flavoured. Finding a starter Flake might be the fastest route to Nixify a setup today, especially if you just want to try it out and not replace the entire OS.
So, while ‘it depends’ still reigns supreme, Flakes are a very compelling option, even for newcomers taking their first plunge.