My system configuration is defined in a Nix flake. Flakes are a (technically experimental) way to share and manage Nix data. You can think of a flake.nix file as a package.json for an npm package, or Cargo.toml for a Rust crate. Generally, they’re used to create a package, define a devshell, or define a system configuration, but technically a flake can output anything. There are many incredibly useful libraries can be used as flakes. A flake works by listing a set of inputs and a set of outputs. Inputs are other flakes, sourced from the internet, and outputs are… outputs. Other flakes can use your output as an input, or the rebuild service can use your output to get a system config, or nix-shell can use your output to create a devshell, or any number of other possibilities. It’s extremely versatile.
There are a number of benefits to using a flake to configure your system. First, you can very easily choose and manage which version of nixpkgs you want to use. The default system configuration method pins your nixpkgs to a specific revision of the repo (hosted on GitHub), which is obscured from the user. Flakes have a flake.lock file which contains the revision info and can easily be updated with the nix flake update command. You can even have multiple branches of nixpkgs as inputs, and selectively use packages from the stable or unstable branch.
Second, you can very easily use packages that are not included in nixpkgs (and have a flake.nix file). This is very difficult to achieve with the default configuration method.
Third, you can place your system configuration anywhere in the filesystem you like. This makes it very easy to version control your config.
Let’s dive into the code.
{
description = "Nixos config flake";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
home-manager = {
url = "github:nix-community/home-manager/master";
inputs.nixpkgs.follows = "nixpkgs";
};
nixvim = {
url = "github:justdeeevin/nvim-config";
inputs.nixpkgs.follows = "nixpkgs";
};
drg-mod-manager = {
url = "github:trumank/mint";
inputs.nixpkgs.follows = "nixpkgs";
};
zen-browser = {
url = "github:marcecoll/zen-browser-flake";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = {nixpkgs, ...} @ inputs: let
mkSystem = {
configPath,
stateVersion,
home ? null,
modules ? [],
}:
nixpkgs.lib.nixosSystem {
specialArgs = {
inherit inputs;
inherit stateVersion;
inherit home;
};
modules =
[
configPath
./global
inputs.home-manager.nixosModules.default
]
++ modules;
};
in {
nixosConfigurations = {
devin-pc = mkSystem {
configPath = ./hosts/desktop/configuration.nix;
stateVersion = "23.11";
home = ./hosts/desktop/home.nix;
};
devin-gram = mkSystem {
configPath = ./hosts/lg-gram/configuration.nix;
stateVersion = "24.05";
home = ./hosts/lg-gram/home.nix;
};
};
};
}
Some syntax clarification:
Attribute assignment is done with =, not :, and is always terminated with a ;. You can assign specific sub-attributes by pointing to them (e.g. nixpkgs.url = …, which automatically makes the attribute nixpkgs and populates it with the url attribute). Arrays are enclosed with brackets ([]), can hold elements of any type, and are space-seperated. Paths are distinct datatypes. This is because, when a file is referred to with a path in the code, it is automatically moved to the Nix store and the expression is evaluated using the path to that item in the store.
You can see how inputs are defined, with a url attribute. Notice the common inputs.nixpkgs.follows line. This ensures that inputs that rely upon nixpkgs will use the specific version that is used by my flake. This prevents from me having multiple different versions of nixpkgs downloaded because of desynced flake.lock files.
My outputs are a function that takes in the inputs. I use the @ symbol to place all the argument attributes that arent explicitly defined into a variable I can use. For instance, nixvim is actually getting passed into my outputs function as an attribute argument, but because I’m not including it in my function declaration, it gets shoved into the inputs variable.
The let and in keywords allow for the creation of variables that are scoped to the block. Here, I create a utility function called mkSystem. I do this because this flake actually contains the configurations for both my laptop and my desktop. Making this function dries up the flake a bit. Note how two of the arguments use the ? symbol to create default values.
I use the nixpkgs.lib.nixosSystem function to create a system configuration that the rebuild service can use. specialArgs is an attrset to pass into the all of the modules. The inherit keyword simply assigns an attribute to a value of the same name in that scope. Modules are basically just sets of configuration. However, modules can add valid attributes to the configuration as well (home-manager is a NixOS module, as you can see here. I’ll be explaining that more soon). Take Cosmic DE as an example. Cosmic isn’t currently in nixpkgs, and thus doesn’t have an option in the NixOS config to enable it. However, there is a flake that adds the option services.desktopManager.cosmic to NixOS, using NixOS modules.
The only output of my flake is nixosConfigurations, since that’s all the flake is for. When I pass this flake to the rebuild service, it will use the nixosConfiguration with the same name as the system’s hostname, but I can specify which config if I need to.