Back to blog

NixOS from scratch · Part 1

Managing 4 NixOS machines with one flake

My declarative setup for 2 desktops and 2 laptops. Overlays for custom packages, Home Manager for user configs, and why I stopped configuring things by hand.

I run NixOS on everything. Two desktops, two laptops (mine and my partner’s). They all share one flake with per-host hardware overrides and per-user Home Manager configs.

This post isn’t a NixOS tutorial. If you’re here you probably know what a flake is. This is more about the structure I settled on after two years of iteration and why it looks the way it does.

Flake structure

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
    nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
    home-manager.url = "github:nix-community/home-manager";
    zen-browser.url = "...";
    stamusctl.url = "...";
    nur.url = "...";
  };
}

Stable base (25.11) with unstable overlaid for specific packages that need newer versions. The flake has two host builder functions:

mkHost = { hostname, system, users }: nixpkgs.lib.nixosSystem {
  inherit system;
  modules = [
    ./hosts/${hostname}/configuration.nix
    ./hosts/${hostname}/hardware-configuration.nix
    home-manager.nixosModules.home-manager
    { home-manager.users = users; }
  ];
};

mkHostWithVscodeServer = args: mkHost (args // {
  modules = args.modules ++ [ vscode-server.nixosModules.default ];
});

The mkHostWithVscodeServer variant adds the VS Code remote server module for machines I SSH into. The desktops get this, the laptops don’t.

Four hosts:

  • lanath-desktop (my desktop, VSCode server)
  • lanath-laptop (my laptop)
  • mushu-desktop (partner’s desktop, VSCode server)
  • mushu-laptop (partner’s laptop)

Each host gets a configuration.nix for system-level stuff (networking, services, hardware quirks) and the relevant Home Manager users.

Home Manager is the real config manager

Most of my per-user configuration lives in Home Manager, not the system NixOS config. This is a deliberate split:

System config (configuration.nix): Hardware, networking, boot, system services, firewall, locale, users. Things that are about the machine.

Home Manager (home/lanath.nix): Neovim, git, shell, terminal, browser, editors, theme system, application settings. Things that are about the user.

This split means my partner and I share the same system packages and services, but have completely different shell configs, editor setups, and browser settings. Her Home Manager config is simpler than mine (she doesn’t need stamusctl overlays or 9-theme NixOS ricing).

The Home Manager configs are also easier to iterate on. home-manager switch is faster than nixos-rebuild switch because it doesn’t touch the kernel, boot, or system services.

Overlays for everything weird

I have overlays for packages that either don’t exist in nixpkgs or need patching:

overlays/
├── waybar.nix          # Status bar config tweaks
├── curseforge.nix      # WoW mod manager (not in nixpkgs)
├── wago-addons.nix     # WoW addon manager (not in nixpkgs)
├── warcraftlogs.nix    # Combat log uploader (not in nixpkgs)
├── claude-code.nix     # AI coding CLI
└── codex.nix           # My RPG system's codex viewer

Yes, three of those are World of Warcraft tools. NixOS is great for gaming because you can declare your entire gaming setup, including mod managers and log uploaders, in version-controlled code. git diff tells you when you installed CurseForge.

Overlays are better than maintaining full package derivations. You patch what you need and nixpkgs handles the rest. The stamusctl overlay patches the vendor hash when the upstream Go build changes, which is a one-line fix but took me an hour to debug the first time because the error message just says “hash mismatch” without telling you which dependency changed.

Neovim config

My neovim config is a single init.lua managed by Home Manager. Plugins come through nixpkgs: treesitter, lualine, bufferline, nvim-tree, telescope, LSP with nil_ls (Nix language server), nvim-cmp with LuaSnip.

The colorscheme is dynamic: the init.lua reads from ~/.cache/nvim-colorscheme (written by the theme switcher, which is another post entirely) and falls back to Nord if the file doesn’t exist:

local colorscheme_file = vim.fn.expand("~/.cache/nvim-colorscheme")
local ok, content = pcall(vim.fn.readfile, colorscheme_file)
if ok and #content > 0 then
  vim.cmd("colorscheme " .. content[1])
else
  vim.cmd("colorscheme nord")
end

All 9 theme plugins are pre-installed via programs.neovim.plugins so the colorscheme command always finds the theme. Live-switching works because the theme switcher sends :colorscheme <name> to every running neovim instance via their RPC sockets.

I could use NixVim or a more complex neovim manager, but a single Lua file is easier to debug than a DSL that generates Lua. When something breaks, I read one file instead of tracing through Nix attribute sets.

Multi-user considerations

Having two users on the same flake revealed some things I didn’t anticipate:

Home directory paths. Some configs need absolute paths (qt5ct, for example). Home Manager doesn’t always know the home directory at evaluation time, especially in activation hooks. I ended up using __HOME__ placeholders in generated files and sed to expand them at activation time. It’s ugly but it works.

Theme independence. My partner doesn’t want 9 themes, and she definitely doesn’t want my rofi picker changing her desktop when I’m testing themes remotely. The theme system writes to $HOME/.cache/current-theme, not a system-wide path. Each user has independent theme state.

Shared Nix store. All packages are shared in /nix/store/. This means my gaming overlays are technically available to both users (they’re in the store), but only I have them in my Home Manager config. The store is content-addressed, so duplicate packages don’t waste space.

Why NixOS

The honest answer: I bricked a laptop once doing an apt upgrade that conflicted with a manual kernel module install. Spent a weekend fixing it. With NixOS, I can roll back in 30 seconds with nixos-rebuild switch --rollback. I can also blow away a machine and rebuild it from the flake in about 20 minutes.

The less honest but equally true answer: it’s satisfying to have my entire system as version-controlled code. git log tells me exactly when I added a package, changed a setting, or broke something. Every config change is a commit with a message explaining why. Try getting that from /etc and ~/.config scattered across four machines.

The learning curve is real. Nix the language is not intuitive. The documentation has gaps. Some things that are trivial on other distros (like “install this AppImage”) require writing a derivation. But once the system is set up, maintenance is trivial. nixos-rebuild switch and Home Manager handle everything. No more “did I install that on this machine or the other one.”

What I’d change

The flake is getting big. The overlays should probably be a separate flake. The mkHost/mkHostWithVscodeServer split is clunky. There should be a cleaner way to compose host features without function variants.

I also haven’t set up remote building yet. When I change the flake, all four machines rebuild locally. A Nix cache or remote builder would let me build once and distribute. I just haven’t needed it badly enough to set it up.

The repo has been going since September 2023. It started as a single configuration.nix and grew from there. Most of the complexity came from the theme system (March 2026), which is now the biggest module. That deserves its own post.