return 12; // good enough for now

Bootstrapping a multi-host NixOS configuration using flakes and home manager

I’ve fallen in love with the idea of Nix and I have decided to take the plunge. I hate how messy and bit rotten my machines can feel after a few months of use, especially with my addiction to setting up development evironments, so I deliberately never take full system backups (everything important is already in the cloud). The ability to cleanly blitz and start again at any point, knowing exactly what was installed and why, and without having to worry about losing subtle tweaks I’ve made, is very attractive.

My plan is to ditch my mess of a Ubuntu partition and shift my daily driver to NixOS. Then create a similarly configured WSL version for when I need/want to be in Windows.

Browsing various dotfile repos gave me a glimpse into what is possible with a flake+home manager set up, but I found the number of techniques a bit overwhelming. So I tried to condence what I could find into the simplest thing that just works.

This is the flake.nix I ended up with:

# ~/dotfiles/flake.nix

{
  description = "NixOS configuration and home-manager configurations";
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-22.05";
    home-manager = {
      url = "github:nix-community/home-manager";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };
  outputs = { home-manager, nixpkgs, ...}:
  {
    nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        ./hosts/nixos/configuration.nix
        home-manager.nixosModules.home-manager {
          home-manager.useUserPackages = true;
          home-manager.users.return12 = {
            imports = [
              ./home/default.nix
            ];
          };
        }
      ];
    };
  };
}

Here hosts/nixos/configuration.nix contains the configuration.nix created from a fresh install, and home/default.nix holds the packages and programs I want available to my user account.

# ~/dotfiles/home/default.nix 

{ pkgs, ... }:
{
  imports = [
    # Individual home manager program configurations, e.g.:
    # ./direnv.nix
    # ./zsh.nix
    # ./starship.nix
    # ./neovim.nix
    # ./git.nix
    # ./taskwarrior.nix
  ];

  home.packages = with pkgs; [
    htop
    ack
  ];

  home.stateVersion = "22.05";

  home.sessionVariables = {
    EDITOR = "vim";
  };
}

Once this skeleton was in place it was easy enough to add a second nixConfigurations entry for the WSL host and start refactoring to extract commonly used pieces or to separate config when the needs differed. It’s a bit clunky but I’m hoping the smells I uncover guide me to a much more idiomatic solution.

These are the steps I followed to get there…

Install NixOS

I decided the that a simple, and non-destructive, first step to bootstrapping my NixOS install would be to install inside a VM. Once happy, I can give up my current Ubuntu partition and install there for real with (hopefully) portable configuration and minimal fuss.

Note: I had to do this under Linux. For some reason booting the NixOS installer failed consistently in Windows

I opted for the graphical installer. The blurb for the download says it will install Plasma but you can opt for no desktop. I decided to go with this because I want to pick my own window manager, etc (I have r/unixporn pretentions).

Test a simple configuration change

Once installed, and rebooted (unmount installation media), and logged in you’ll be dropped into your barebones instal. Your OS configuration can be found at /etc/nixos.

configuration.nix contains details on system-wide configuration (hostname and the user account you setup during installation) and installed packages. It should be pretty empty but contains some hints on how to go about installing more. hardware-configuration.nix is automatically generated based on hardward detected. You can mostly leave it alone.

To prove everything is working as expected make an edit to configuration.nix

sudo nano /etc/nixos/configuration.nix  # File is owned by root so you'll need sudo 

Find the line that lists the installed system packages and uncomment vim.

environmend.systemPackages = with pkgs; [
  vim
];

Once saved you can apply the new configuration.

sudo nix-rebuild switch 

You’ll see a bunch of output which will reward you with the ability to launch vim.

You can stop here and build out your system by editing configuration.nix if you like.

Enable flake support

Flakes support is still experimental so needs to be explicitly enabled. Add this to configuration.nix just after the imports definition.

# Enable nix flakes
nix.package = pkgs.nixFlakes;
nix.extraOptions = ''
  experimental-features = nix-command flakes
'';

Then apply with sudo nixos-rebuild switch as before.

Set up your skeleton in version control

# Create homes for our host and home manager configs
mkdir -p ~/dotfiles/hosts/nixos ~/dotfiles/home

# Copy across the existing, auto-generated configuration so they're under
# version control
cp /etc/nixos/configuration.nix ~/dotfiles/hosts/nixos/
cp /etc/nixos/hardware-configuration ~/dotfiles/hosts/nixos/

# Create your multi-host set up. Use the above example changing user/host name
# when needed
vi ~/dotfiles/flake.nix

# Create your default home manager set up. Home manager itself is installed
# system(s)-wide with flake.nix
# Add separate nix files for whichever programs you want and add them to the
# imports list. I recommend setting up a basic git install so you don't need the
# nix-shell (below) in future.
# See https://github.com/jammus/dotfiles/blob/455f08/home/git.nix for a minimal
# git setup.
vi ~/dotfiles/home/default.nix

# Add to version control. Git is required for flakes to work but as we haven't
# yet installed it for real we need to create a temporary shell with it inside
nix-shell -p git 
cd ~/dotfiles
git init && git add .

You can now apply the config from your dotfiles directory with:

nixos-rebuild switch --upgrade --flake '.#' --use-remote-sudo

This will use the nixConfigurations that matches your current hostname. If you want to be specific (or your hostname doesn’t currently match what’s defined) you can use:

nixos-rebuild switch --upgrade --flake '.#hostname' --use-remote-sudo

That’s it!

Commit your changes and push somewhere safe. You can then follow similar steps when adding a new host to your line up. I’d recommend create a home/gui.nix file to host specific config (window manager, vscode, etc) for desktop machines e.g.:

# ~/dotfiles/flake.nix

{
  description = "NixOS configuration and home-manager configurations";
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-22.05";
    home-manager = {
      url = "github:nix-community/home-manager";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };
  outputs = { home-manager, nixpkgs, ...}:
  {
    nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        ./hosts/nixos/configuration.nix
        home-manager.nixosModules.home-manager {
          home-manager.useUserPackages = true;
          home-manager.users.return12 = {
            imports = [
              ./home/default.nix
            ];
          };
        }
      ];
    };

    nixosConfigurations.nixos-dekstop = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        ./hosts/nixos-desktop/configuration.nix
        home-manager.nixosModules.home-manager {
          home-manager.useUserPackages = true;
          home-manager.users.return12 = {
            imports = [
              ./home/default.nix
              ./home/gui.nix
            ];
          };
        }
      ];
    };
  };
}

This copy/pasted, one nixosConfigurations per host method will soon get ugly and is crying out for a refactor. Once you need to do it, jump in.

Potential errors

Some errors I encountered when test driving the above (mostly from missing steps out):