I have been using Home Manager to manage my dotfiles on my Pop! OS laptop for about seven months now. I highly recommend it over other dotfile schemes. In the past 3 months changed my server from Windows to Pop! OS as well.
I was impressed with the ability to scp my dotfiles over and, after installing Nix , have my command-line tools at my disposal in 2 minutes flat. But, installing dependencies I did not want on that machine bothered me. I don’t need flutter on my headless server. This weekend I have decided to tackle this problem and I thinking came up with a pretty good solution that I would like to share!
I won’t go deep on Home Manager itself, here are some resources for that:
- https://seroperson.me/2024/01/16/managing-dotfiles-with-nix/
- https://juliu.is/tidying-your-home-with-nix/
The Basics
If you are here, I expect you to have a flake.nix
, a home.nix
and are now
looking to add a new host configuration. To start:
- Change
homeConfigurations."{user}"
tohomeConfigurations."{user}@{host}"
- Move
home.nix
into a new directory./home/{host}.nix
1{
2 description = "Home Manager configuration of neal";
3
4 inputs = {
5 nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
6 home-manager = {
7 url = "github:nix-community/home-manager";
8 inputs.nixpkgs.follows = "nixpkgs";
9 };
10 };
11
12 outputs = { nixpkgs, home-manager, ... }:
13 let
14 system = "x86_64-linux";
15 pkgs = nixpkgs.legacyPackages.${system};
16 in
17 {
18 homeConfigurations."neal@devone" = home-manager.lib.homeManagerConfiguration {
19 inherit pkgs;
20 modules = [ ./home/devone.nix ];
21 extraSpecialArgs = {
22 username = "neal";
23 homeDirectory = "/home/neal";
24 };
25 };
26 };
27}
Defining username and homeDirectory in
extraSpecialArgs
will make sense later. Just know, those are now being passed to your{host}.nix
file as arguments
Now to add a new host you can add a new homeConfigurations and copy your
existing ./home/{host}.nix
as ./home/{new_host}.nix
1{
2 ...
3
4 outputs = { nixpkgs, home-manager, ... }:
5 let
6 system = "x86_64-linux";
7 pkgs = nixpkgs.legacyPackages.${system};
8 in
9 {
10 homeConfigurations."neal@devone" = home-manager.lib.homeManagerConfiguration {
11 ...
12 }
13 homeConfigurations."neal@server" = home-manager.lib.homeManagerConfiguration {
14 inherit pkgs;
15 modules = [ ./home/server.nix ];
16 extraSpecialArgs = {
17 username = "neal";
18 homeDirectory = "/home/neal";
19 };
20 }
21}
You now have two distinct Home Manager configurations in a single repository. Yay! 🥳
The Crux
Okay, this is the purpose of this blog post. Why in gods name would I want to
distinct Home Manager configurations? The entire purpose of dotfiles is
to have easy transitions between machines and having to edit two distinct files
is a quick way to have them diverge. The solution, modules and using Home
Manager’s imports
feature.
Now this is pretty common idea. From the dotfiles I have looked at the common
scheme is breaking up programs
into their own file with their configuration.
With that scheme I would have a single file fish.nix
that installs fish and
configures it. This doesn’t necessarily work with a non-NixOS install as my system
has some packages installed with apt or flatpak that need to be added
to the PATH
. My solution to this is broader modules, here is a snippet from
my ./modules/shell.nix
1{ pkgs, homeDirectory, ... }:
2{
3 home.packages = [
4 pkgs.fish
5
6 pkgs.starship
7 pkgs.eza
8 pkgs.fd
9 pkgs.zoxide
10 pkgs.ripgrep
11 ];
12
13 home.sessionVariables = {
14 EMAIL = "neal@joslin.io";
15 }
16
17 home.shell.enableFishIntegration = true;
18
19 programs.fish = {
20 enable = true;
21 interactiveShellInit = ''
22 set fish_greeting
23 '';
24 shellInit = /* fish */ ''
25 fish_vi_key_bindings
26 fish_add_path --global ${homeDirectory}/.local/bin
27 '';
28 shellAliases = {
29 ls = "eza --icons";
30 }
31 }
32
33 programs.bash = {
34 enable = true;
35 initExtra = ''
36 if [[ $(${pkgs.procps}/bin/ps --no-header --pid=$PPID --format=comm) != "fish" && -z ''${BASH_EXECUTION_STRING} ]]
37 then
38 shopt -q login_shell && LOGIN_OPTION='--login' || LOGIN_OPTION=""
39 exec ${pkgs.fish}/bin/fish $LOGIN_OPTION
40 fi
41 '';
42 };
43
44 programs.zoxide.enable = true;
45}
This is how you would use this file inside of a ./home/{host}.nix
This shows the reason for defining username and homeDirectory inside of
flake.nix
. They are automatically passed to all imports!
Cool… Modules?
So far this is nice and we can breakup the configuration, but if we have
some flatpak package that needs to be added to the PATH
in fish
everything falls apart because it overwrites the existing shellInit
in
shell.nix
, right?- NO, instead it adds both of them.
1{ config, pkgs, username, homeDirectory, ... }:
2{
3 imports = [
4 ../modules/shell.nix
5 ];
6
7 home = {
8 inherit username homeDirectory;
9 stateVersion = "24.05";
10 };
11
12 programs.fish.shell.shellInit = /* fish */ ''
13 fish_add_path --global ${homeDirectory}/Android/Sdk/emulator
14 fish_add_path --global ${homeDirectory}/Android/Sdk/platform-tools
15 '';
16
17programs.home-manager.enable = true;
18}
Now this outputs a fish configuration file that contains:
I’m unsure why I was surprised about this. I understood doing this with
dictionary settings, like shellAliases
, merge the dictionary. The same
going for string settings is cool and opens up a lot of
opportunities.
Expanding
The next step of this idea is away from host specific apt/flatpak programs and
instead module specific. A prime example of this is my development.nix
file.
Here is a trimmed down example:
1{ pkgs, homeDirectory, ... }:
2{
3 home.packages = [
4 pkgs.python311Full
5 pkgs.python311Packages.pipx
6 pkgs.nodejs_20
7
8 pkgs.ruff
9 pkgs.typos
10 pkgs.markdownlint-cli
11 ];
12
13 home.sessionVariables = {
14 WORKON_HOME = "${homeDirectory}/.virtualenvs";
15 VIRTUAL_ENV_DISABLE_PROMPT = 1;
16 };
17
18 home.file = {
19 ".textlintrc.json".source = ../config/textlintrc.json;
20 ".local/scripts/work".source = ../scripts/work;
21 };
22
23 programs.fish = {
24 shellInit = /* fish */ ''
25 fish_add_path --global ${homeDirectory}/.dotty/node_modules/.bin
26 fish_add_path --global ${homeDirectory}/.local/scripts/work
27 '';
28 shellAliases = {
29 pyunit = "python -m unittest discover";
30 pycoverage = "coverage run -m unittest discover";
31 pyreport = "coverage report -m";
32 pysecret = "python -c 'import secrets; print(secrets.token_hex())'";
33 djrun = "python manage.py runserver";
34 djshell = "python manage.py shell";
35 djmigrations = "python manage.py makemigrations";
36 djmigrate = "python manage.py migrate";
37 };
38 };
39
40 programs.ruff = {
41 enable = true;
42 settings = {
43 line-length = 80;
44 target-version = "py311";
45 format = {
46 quote-style = "double";
47 };
48 };
49 };
50}
After adding this module I have access to my common development aliases
and work scripts. I have a mail.nix
module that follows the same
scheme, after I add that module I have access to all my neomutt aliases
and email sending scripts. No host gets extra aliases, PATH
s or scripts it
doesn’t need.
Conclusion
Wrapping up, If you are looking to add multi-host support to your Home Manager dotfiles give this scheme a shot. Don’t try to cram all settings for a program into a single module when it doesn’t make sense. This scheme is broad while still being flexible and is probably the most efficient way to organize.