In the
last episode of “sldr learns Nix for
Great Good”, I managed to mostly successfully break apart a NixOS configuration
into modules, then bash those modules back together into a functioning system.
Granted, I got tired along the way, and dumped way more config than is
reasonable into the legacy
module, just to get the machine out of the
provisioning stage and into the “able to be used” stage.
Since then, I have made exactly zero progress on continuing the configuration — I discovered that World of Warcraft works out of the box under Wine these days, so that’s what I’ve been doing. 10/10 productivity.
What we’ll be looking at in this post is how to not define everything systemwide
— using a Flakes-based configuration lets us use a seemingly very popular tool
called home-manager
to manage per-user applications and configuration, which
seems quite useful. But first…
An editor that won’t make me cry #
The downside of retiring my old laptop to server duty is that I lost all the
nice IDEs I had on it. Specifically, my installation of
VSCodium (open source Visual Studio Code) which I had
painstakingly set up in the system configuration.nix
file. Now, of course, I
could copy all that code over to mercury
and call it a day, but we’re trying
to learn to be better here.
The boffins over in the Nix Community have set up a flake that lets you install VS Code/Codium with any extensions you might want — they’re spidering the entire VS Code Marketplace, as well as a few other sources, daily and baking the extensions into the flake. It’s really rather clever stuff, and a far better solution than me having to manually define over half of the extensions in my previous build. So, that’s what we want to do.
Now, there is a second issue: how do we define this? Defining a NixOS module
which adds vscodium
to environment.systemPackages
is not okay, since I’ll
primarily not want to install this system-wide. But at the same time, until
home-manager
is in place, how do I do it?
The answer turns out to be an ad-hoc module, defined straight in the root flake. But before we get to that, let’s look at how we define the VSCodium flake to begin with.
# nix/home/pkgs/vscodium/flake.nix
{
inputs = {
nix-vscode-extensions.url = "github:nix-community/nix-vscode-extensions";
flake-utils.follows = "nix-vscode-extensions/flake-utils";
nixpkgs.follows = "nix-vscode-extensions/nixpkgs";
};
outputs =
inputs:
inputs.flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = inputs.nixpkgs.legacyPackages.${system};
extensions = inputs.nix-vscode-extensions.extensions.${system};
# Extension list
exts = (with extensions; [ open-vsx.editorconfig.editorconfig ]);
inherit (pkgs) vscode-with-extensions vscodium;
packages.default = vscode-with-extensions.override {
vscode = vscodium;
vscodeExtensions = exts;
};
devShells.default = pkgs.mkShell {
buildInputs = [ packages.default ];
shellHook = ''
printf "VSCodium with extensions:\n"
codium --list-extensions
'';
};
in
{
inherit packages devShells;
}
);
}
This looks like a lot of work, but this is almost identical to the sample flake
you get through nix flake init <name> -t github:nix-community/nix-vscode-extensions
. My contribution is adding the
exts
list, and plugging that into the vscodeExtensions
argument a little
further down. This way, I can define my own list of extensions relatively
cleanly, and it’ll just work.
Plugging this into the root flake was a little more of an exercise, but this is how it eventually turned out:
# flake.nix
{
...
inputs = {
...
# Package flakes
codium.url = "path:./nix/home/pkgs/vscodium";
...
};
outputs =
inputs@{
self,
nixpkgs,
nixos-hardware,
flake-utils,
codium,
sldr-meta,
...
}:
...
{
# Create the system configurations
nixosConfigurations = {
mercury = nixpkgs.lib.nixosSystem rec {
system = "x86_64-linux";
modules =
defaultModules
...
++
# Evil hacking
[ { environment.systemPackages = [ codium.packages.${system}.default ]; } ];
...
A couple of important notes here.
Firstly, I tried to simply declare environment.systemPackages
within the
nixpkgs.lib.nixosSystem
call, but that was seemingly not okay, so I had to
create a tiny module and glue it onto the list of modules. I guess this is also
how I can add any other config via the root flake.
Secondly, note that the set given to nixpkgs.lib.nixosSystem
is now recursive
(via the rec
keyword) — this is to allow me to reference the system
variable inside my new ad-hoc module.
However, between these two additions, I now have VSCodium up and running, and I can use it — rather than Vim — to continue my configuration.
No really, look at the difference #
I’ve extracted the two most heinous examples from my old VSCodium configuration, and want to contrast them against how I’d install the same extensions using the new flake.
[
...
{
name = "EditorConfig";
publisher = "EditorConfig";
version = "0.16.4";
sha256 = "j+P2oprpH0rzqI0VKt0JbZG19EDE7e7+kAb3MGGCRDk=";
}
] ++ [
(codium-pkgs.vscode-utils.buildVscodeExtension (rec {
basename = "open-remote-ssh";
name = "${basename}-${version}";
version = "0.0.45";
src = fetchurl {
url = "https://open-vsx.org/api/${vscodeExtPublisher}/${basename}/${version}/file/${vscodeExtUniqueId}-${version}.vsix";
hash = "sha256-YoeUNvxLSmy3OftZp2AnqRU+TKe3KYLt3zZ0B5XGgeE=";
name = "${vscodeExtPublisher}-${basename}.zip";
};
vscodeExtPublisher = "jeanp413";
vscodeExtName = "Open Remote - SSH";
vscodeExtUniqueId = "${vscodeExtPublisher}.${basename}";
}))
];
The second extension there — jeanp413.open-remote-ssh
— took me about an
hour to configure, which required perusing the internal workings of how the
vscode-utils
functions were pulling and installing extensions, then finagling
the information I could extract about the extension into a form that would work.
Doing the exact same configuration using the flake — which you’ve already seen, so I won’t copy it all again — looks like this:
# Extension list
exts = (with extensions; [
open-vsx.editorconfig.editorconfig
open-vsx.jeanp413.open-remote-ssh
]);
Boy howdy that’s a lot easier.
Was that actually the whole story? No, of course not.
Since the codium
config is now trapped inside a flake, I need to make sure Nix
actually updates it. However, I really just want to update the codium
flake,
not all flakes (since that would result in a full system update).
The trick was:
nix flake lock --update-input codium
Having done that, I could rebuild my system so that codium
actually contained
the correct extensions.
Home Manager, then… #
Having painstakingly ported all my VSCode extensions (and installed them system-wide; we’re getting to that), I guess it’s time to finally look into the Home Manager situation.
Ostensibly, it’s a handy-dandy way to configure each user’s home directory using Nix. However, it’s a sub-system that’s about as large as all of NixOS in scope, at least at first glance.
The official documentation helpfully explains the trivial case of how to slot the Home Manager flake into a system configuration, but it also places a lot of configuration in the root flake. I’d like to be able to hide that behind sane defaults and abstractions, but it reveals an issue in how I’m going about this…
NixOS Modules revisited: what even are they? #
As I’ve kind of touched on previously, NixOS modules can be either simple sets
(i.e. { #stuff }
) or functions which return sets (i.e. { ... }: { #stuff }
). The ad-hoc module I stapled into my root flake above falls into the first
category.
But, of course, that isn’t the whole story. There’s a third category for
modules, which declare options
and config
separately — options
, as I
understand it, are options which this module makes available to other modules,
whereas config
is definitions which may or may not depend on those options.
The previous two categories is shorthand for just config
without any
options
. There’s also the imports
list, which seems to exist sometimes and
sometimes not… whatever.
Every module we’ve defined and dealt with so far falls into one of the first two
categories. The nixosModules.home-manager
module provided by Home Manager
falls into the third category, and now I’m scared.
A sudden flash of understanding #
So I was staring at the example configuration again, trying to make heads of how they were calling the Home Manager module as a function, until I realized… They’re not.
Look here.
...
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 = import ./home.nix;
# Optionally, use home-manager.extraSpecialArgs to pass
# arguments to home.nix
}
...
This is the salient part of the minimal flake that’s in the documentation. Pay
special attention to the modules
list.
For far too long I thought home-manager.nixosModules.home-manager { ... }
meant that they were invoking the module as a function, with the home-manager
options in the set passed as arguments. I was staring blindly at the code for
the home-manager
module, trying to figure out how the 🏴☠️ that
works.
But of course that’s not what’s happening, otherwise — by the same logic —
configuration.nix
would be “called” using the home-manager
module. The
modules
list is just that: a list. That set containing the
home-manager
options is an ad-hoc module, just like the one I made!
This answers a couple of questions:
- By simply adding the
home-manager
module to the modules list, you bring thehome-manager
options into scope. That makes things a lot easier if I want to abstract things away into submodules. - The
home-manager
module code serves as an example on how to define one of those Category 3 modules I was banging on about earlier — I am planning on making a module like that to handle GUI setup on a system.
Back to the home
module
#
As shown above, home-manager
comes with some global settings which I’d like to
abstract out of the root flake. Having now finally figured out how the
home-manager
module behaves, this turns out to be relatively easy:
{
description = "Home Manager wrapper flake";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
inputs@{ nixpkgs, home-manager, ... }:
{
nixosModules.default = {
imports = [ home-manager.nixosModules.home-manager ];
home-manager.useGlobalPkgs = true;
home-manager.useUserPackages = true;
};
};
}
That’s my entire home
flake, which wraps home-manager
and sets some sane
defaults. As we’ve learned, adding this module to the system’s modules list will
bring all the home-manager
options into scope… which makes me realize, I
should probably be diligent and actually declare things as defaults.
outputs =
inputs@{ nixpkgs, home-manager, ... }:
let
lib = nixpkgs.lib;
in
{
nixosModules.default = {
imports = [ home-manager.nixosModules.home-manager ];
home-manager.useGlobalPkgs = lib.mkDefault true;
home-manager.useUserPackages = lib.mkDefault true;
};
};
There.
Now, the next trick is, of course, setting up a user’s home environment.
According to the home-manager
documentation, that’s usually handled via a
configuration file in ~/.config/home-manager/home.nix
, but that won’t fly with
Kepler; that file needs to live alongside all the other config files.
Now, I’m weighing whether I should abstract out the user definitions into the
root flake. On the one hand, that would preserve the idea that all “settings”
live there, but it also seems like a pretty sane default that my user exists
and has home-manager
set up, so why should that even be an option?
Looking through how the home-manager
configuration files are set up, it seems
like I should stuff that under the home
flake. The config file can have two
modes — a “normal” module or a flake, where I obviously want a flake. So, to
avoid having to deal with importing the home-manager
user config flake at the
root level, let’s hide that away under the home
flake. Does that make sense?
The way I’m trying to think about it is — again — the demarcation between system-wide configuration and user-specific configuration. While it’s not a perfect line — there will be differences in user-specific configuration depending on which system I’m working on — it makes more sense than dragging everything up to the top/root level.
But, staring at the sample flake further, it looks a lot like the configuration
is per-username, so if I want separate home setups for different machines, then
I’ll need separate files. Also, the flake is just a wrapper for the home.nix
file, so nothing too fun there.
Do the thing #
So, I installed home-manager
, in the sense that I slotted in the module and
set those two settings. I didn’t set up any user, so I guess I shouldn’t be
surprised that nothing seems to have changed after a reboot.
My hope is that I will, in difference from the legacy
flake in my previous
post, be able to use the system’s hostname for enabling/disabling home modules.
That way I can have a single configuration for my user, but still — hopefully
easily — maintain different configurations across different machines.
Though, I’ve hit a new snag — the system
variable. This corresponds to the
architecture of the host system. In the sample home-manager
flake, that’s set
outside the user configurations, implying that all users share the same
architecture, but I want to do the opposite — make a user config that can
apply on any (reasonable) architecture.
Inspecting the system #
While still being a novice in the NixOS world, the simple process of “figure out
what a given option is set to” isn’t particularly easy. I found a command called
nixos-option
which… didn’t help one bit. This may be because I’m bad, or
because the system is flake-based.
However, I did have some success in using the nix repl
to review the system
configuration:
# Inside the kepler directory
$ nix repl
Welcome to Nix 2.18.4. Type :? for help.
nix-repl> :lf . # Load the root kepler flake
Added 15 variables.
nix-repl> m = outputs.nixosConfigurations.mercury # Shorthand
# From here, I can start to dig around the config using tab completion
nix-repl> m.config.nixpkgs.hostPlatform.system
"x86_64-linux"
Bingo.
Now, here’s a discrepancy between what’s in the config files and what’s in the
actual system config. Looking at Mercury’s hardware-configuration.nix
file, we
find the line:
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
However, the config.nixpkgs.hostPlatform
value does not contain just that
value. No, it is a massive set of different values, booleans, and who knows
what else. I found the system
key in the set contained the string I was
looking for, but there’s clearly some behind-the-scenes processing of the config
I set in the files and how it ends up stored inside NixOS.
Trial by syntax error #
The time has come to stop faffing about. Let’s see if we can get this thing working.
# nix/home/flake.nix
{
description = "Home Manager wrapper flake";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
inputs@{
self,
nixpkgs,
home-manager,
...
}:
let
lib = nixpkgs.lib;
in
{
nixosModules.default = {
imports = [ home-manager.nixosModules.home-manager ];
home-manager.useGlobalPkgs = lib.mkDefault true;
home-manager.useUserPackages = lib.mkDefault true;
home-manager.users.sldr = self.homeConfigurations.sldr;
};
homeConfigurations.sldr = home-manager.lib.homeManagerConfiguration {
modules = [ ./users/sldr.nix ];
};
};
}
Instead of setting up a user-specific flake, I decided to bake the
homeManagerConfiguration
into the Home Manager flake, then do a little cheeky
self-reference to get it to work. The flake really just references the
home.nix
file anyway, so there’s no real reason for it to even be a flake.
# nix/home/users/sldr.nix
inputs@{ config, pkgs, ... }:
{
# Preamble
home.username = "sldr";
home.homeDirectory = "/home/sldr";
# Enable home-manager itself
programs.home-manager.enable = true;
# Set the state
home.stateVersion = "24.05";
}
The user file is the bare minimum that seems to be required, just to see if I can get it to build. Which, of course, I can’t.
The homeManagerConfiguration
function (because this time it is a function)
requires pkgs
to be defined. In the documentation, this is dependent on the
system
— as discussed above — but I’m not sure I can reference the
config
set in the flake. Maybe?
The solution, after so much wailing, turned out to be to cut out the middle man:
nixosModules.default = {
imports = [ home-manager.nixosModules.home-manager ];
home-manager.useGlobalPkgs = lib.mkDefault true;
home-manager.useUserPackages = lib.mkDefault true;
home-manager.users.sldr = import ./users/sldr.nix;
};
Doing a straight import does not require me to define pkgs
, which begs the
question why the Home Manager config needs it in the first place.
This does raise another slight problem — since sldr.nix
isn’t a flake, I’m
going to run into trouble when I want to include flake-defined applications like
codium
. There is the bonus layer of flake-compat
which I can use, but it
feels like extra chaff to be bouncing back and forth between flakes and
not-flakes.
Since home-manager
seems to expect the entries under the users
set to be
modules, I should be able to finagle such a module inside a flake, so that I can
handle all my flaky applications that way. Probably.
But now, let’s see if this runs… after changing the inputs
to
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
home-manager = {
url = "github:nix-community/home-manager/release-24.05";
inputs.nixpkgs.follows = "nixpkgs";
};
};
For reasons entirely beyond my understanding, home-manager
was using the
24.11
version. Somehow. Despite it only being July. At least it was kind
enough to raise an error that there was a version mismatch between Nix and Home
Manager.
With that pegged, let’s build and reboot.
Seeing it in action #
The system started alright and… nothing of note had changed. Perhaps not
entirely surprising, but still. I checked my shell and found no home-manager
utility, so maybe things hadn’t worked after all?
Being the impatient kind, I opened the configuration back up and added
# nix/home/users/sldr.nix
...
home.packages = with pkgs; [
hello
cowsay
];
Some stupid programs just to see if they would appear. I triggered a system rebuild — I guess that’s the disadvantage of this method, that I’ll have to rebuild the entire system to change my home configuration, but they’re also pretty tightly linked. I digress.
I triggered a rebuild and something seemed to be happening. After all was done, I noticed something in the output.
restarting the following units: home-manager-sldr.service
That sure wasn’t there before.
I try hello
and, sure enough, my terminal replies Hello, world!
. I switch
user — though definitely not to the root user, because that would be
irresponsible — and hello
is no longer on the PATH.
Home Manager is up and running.
About that machine-specific configuration #
The final hurdle, before I’m willing to call this a successful experiment, is somehow conditionally adding a package depending on the hostname of the machine.
This time, I figured I could be more savvy and dig through the REPL straight
away. There was something in the documentation about a extraSpecialArgs
which
would be passed to the home-manager
module, but I couldn’t find any reference
to how. Or, for that matter, how to access it inside the module. The REPL
didn’t answer the second question, but I did find the extraSpecialArgs
lurking
inside the home-manager
configuration.
$ nix repl
Welcome to Nix 2.18.4. Type :? for help.
nix-repl> :lf .
Added 14 variables.
nix-repl> m = outputs.nixosConfigurations.mercury
nix-repl> m.config.home-manager.extraSpecialArgs
{ nixosConfig = { ... }; }
Is that, by any chance, the system NixOS configuration being passed by default?
nix-repl> m.config.home-manager.extraSpecialArgs.nixosConfig.networking.hostName
"mercury"
Why, yes it is.
[Hacker voice] I’m in
The next hurdle, as mentioned, is to work out how it’s referenced inside the module. That’s also not something I can diagnose using the REPL, so I have to be a bit more clever.
The easiest way to test my hypothesis is to conditionally install, say,
fortune
. Skipping past me getting a deluge of syntax errors, here’s how we do
that:
# nix/home/users/sldr.nix
...
home.packages =
with pkgs;
[
hello
cowsay
]
++ (if <hostname-somehow> == "mercury" then [ fortune ] else [ ]);
I’m sure there are more clever ways of doing this, but this seems to work. The idea is simply that we append a list containing our conditionally installed packages if the conditional passes, otherwise we append an empty list. Appending an empty list is a no-op — it doesn’t change the list, but is still a valid operation.
So, how do we get the extraSpecialArgs
? The easiest scenario would be if the
contents of that set were passed to this function verbatim, so let’s try that.
...
++ (if inputs.nixosConfig.networking.hostName == "mercury" then [ fortune ] else [ ]);
...
To my great surprise, that worked. Now, we can make it even more compact by
bringing the nixosConfig
set into scope explicitly.
inputs@{
config,
pkgs,
nixosConfig,
...
}:
{
...
home.packages =
with pkgs;
[
hello
cowsay
]
++ (if nixosConfig.networking.hostName == "mercury" then [ fortune ] else [ ]);
I mean, it’s not very compact, but that’s the formatter doing its thing. The
important point is that it works. I tried changing the name to something that
wouldn’t match, and then fortune
vanished.
As a final note, I’ve learned that nixosConfig.system.name
is a neater way to
reference the hostname. I think. They may be different things, but both say
mercury
, so I’m gonna use that going forward.
Like with the previous post, I’ve gone on for quite long enough. Lessons have been learned, though.
Until next time, beware of the Nix.