The Pain Before the Solution

I started with Gentoo. If you know, you know: USE flags, emerge --sync, watching a browser compile for six hours, and the quiet satisfaction of a system shaped entirely to your will. It was maximum configuration at maximum cost - every package exactly how you wanted it, every kernel option hand-chosen, every dependency chain personally audited. It was also completely unreproducible. The system on disk was the result of hundreds of commands typed across months. If the disk died, so did the configuration. You could document it, but documentation and reality drift apart fast.

nixos journey

After a few years of that, I migrated my servers to Debian. Less customization, fewer surprises, faster iteration. Debian is rock solid. But the gap between “I’ve configured this” and “this is reproducible” never closed. The configuration lived partly in files, partly in database state, partly in my memory. Upgrading a server meant hoping nothing drift-collided with the new packages. I learned to fear dist-upgrades.

Multi-machine management made this worse. When I had three servers to keep consistent, I reached for Ansible. Later tried Salt. Both are genuinely useful tools - they brought the configuration into version control, made state more explicit, handled idempotent runs reasonably well. But they’re scaffolding over a fundamentally mutable OS. Ansible describes actions to take, not the state to achieve. The playbook says “ensure this package is installed” and “write this file,” but it says nothing about what happens to the thousand other files the system has accumulated over the years. You run the playbook, the system mostly matches what you expect, and you’re left with a lingering uncertainty: what else is in there?

Building a WireGuard mesh with Salt illustrates the problem. You write YAML templates, manage state files, think carefully about idempotency edge cases, and you still have hosts where salt-minion mysteriously fell out of sync six months ago and nobody noticed. The tool is fighting the problem. You’re layering abstraction on top of a mutable foundation and hoping the accretion doesn’t bite you.

NixOS is different in kind, not just in degree. The whole system is declared as a pure function over text files. Run the same config on two machines and you get the same system. Roll back to yesterday’s config and you get yesterday’s system. The “what else is in there” question dissolves because the system is a direct consequence of what’s in the repo.

That framing clicked for me when I started this config, now covering a dozen machines across servers, desktops, a gaming rig, and a VR setup. This isn’t a single desktop experiment. It’s a homelab, and the whole thing lives in one git repository.


What NixOS Actually Is

The central insight: your entire NixOS system is a pure function. Given the same inputs - configuration files, package hashes, module options - you get the same, bit-for-bit reproducible system. Not “mostly the same.” The same.

Three consequences fall out of this:

Reproducibility. If a coworker asks for the same toolchain you use, you share a Nix expression instead of a wiki page. It works the first time.

Rollback. Every nixos-rebuild switch creates a new system generation. If the new kernel panics at boot, you select the previous generation from the bootloader menu. The old generation is still there on disk, unchanged. You lose nothing.

Fearless experimentation. Want to try a different init setup, a new compositor, a service you’ve never configured before? Add it, switch, try it. If it’s wrong, roll back. The cost of experimentation drops to nearly zero.

It’s worth being clear about what NixOS is not. It’s not just a package manager (though it has one). It’s not Docker - there are no containers or images involved in basic NixOS usage, and your system packages live in the Nix store, not a layered filesystem. It’s not Ansible - there are no playbooks, no target hosts, no SSH connections during a normal rebuild. The system is built from a description; it doesn’t describe operations to run against a running system.

The honest note on the learning curve: Nix the language is genuinely strange. It’s lazy and functional, variables are declarations rather than assignments, and error messages can be opaque. The first two weeks feel like reading documentation written for people who already understand it. That phase ends, and what’s on the other side is worth it. But it is a real ramp.


The Flake: One Entry Point for Everything

A flake is Nix’s mechanism for pinned, reproducible builds. A flake.nix declares what your project depends on (inputs) and what it produces (outputs). The flake.lock file pins every input to an exact commit hash and content hash, giving you a complete bill of materials for your infrastructure.

The inputs section of this config shows the pinning strategy clearly:

inputs = {
  nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
  home-manager = {
    url = "github:nix-community/home-manager";
    inputs.nixpkgs.follows = "nixpkgs";
  };
  sops = {
    url = "github:Mic92/sops-nix";
    inputs.nixpkgs.follows = "nixpkgs";
  };
  disko = {
    url = "github:nix-community/disko";
    inputs.nixpkgs.follows = "nixpkgs";
  };
  hyprland = {
    url = "github:hyprwm/Hyprland/v0.54.2";
    inputs.nixpkgs.follows = "nixpkgs";
  };
  hy3 = {
    url = "github:outfoxxed/hy3?ref=hl0.54.2";
    inputs.hyprland.follows = "hyprland";
  };
  lanzaboote = {
    url = "github:nix-community/lanzaboote";
    inputs.nixpkgs.follows = "nixpkgs";
  };
};

Notice two things: inputs.nixpkgs.follows = "nixpkgs" propagates throughout, ensuring all these tools share one nixpkgs version rather than each pulling their own. And Hyprland is pinned to v0.54.2 with hy3 at the matching hl0.54.2 ref - intentional, because the Hyprland plugin API changes between releases.

For that last point, pinning isn’t enough if you need the pinned version available system-wide. The overlay pattern solves it. An overlay in Nix is a function that takes the final and previous package sets and returns overrides. The hyprland overlay replaces the nixpkgs hyprland package with the pinned one:

hyprlandOverlay = final: prev:
  let
    hyprlandPkgs = inputs.hyprland.packages.${system};
    hy3Pkgs = inputs.hy3.packages.${system};
    hyprlandPkg = hyprlandPkgs.hyprland or hyprlandPkgs.default;
    hy3Pkg = hy3Pkgs.hy3 or hy3Pkgs.default;
  in
  {
    hyprland = hyprlandPkg;
    hyprland-unwrapped = hyprlandPkgs.hyprland-unwrapped or hyprlandPkg;
    hyprlandPackages = hyprlandPkgs;
    hyprlandPlugins = (prev.hyprlandPlugins or { }) // { hy3 = hy3Pkg; };
    hy3 = hy3Pkg;
  };

This overlay gets applied in nixpkgs.overlays, so every module in the entire config that references pkgs.hyprland gets the pinned version. No special imports, no passing versions around - the whole system agrees on one build.

The most satisfying part of the flake setup is how new hosts appear automatically. There’s no host registration, no list to update, no imports to add:

hostDirs = lib.attrNames (lib.filterAttrs (_: v: v == "directory") (builtins.readDir ./hosts));
mkHosts = lib.genAttrs hostDirs;

builtins.readDir ./hosts returns an attrset mapping names to types ("directory", "regular", etc.). Filter to directories, take the names, and generate a NixOS configuration for each one. Every directory inside hosts/ automatically becomes a machine. Add a folder, add a default.nix inside it, run nixos-rebuild or deploy - that’s it.


Roles: One Module System for 12 Different Machines

Twelve machines sound like twelve configs to maintain. They’re not. Machines are variations on a theme, and the theme is expressed as roles.

The role system starts with a typed option in modules/default.nix:

options.ox.roles = mkOption {
  default = [ "desktop" ];
  type = with lib.types; listOf (enum [ "desktop" "laptop" "server" "games" "vr" "nas" ]);
  description = ''
    The machine roles.
  '';
};

options.ox.role =
  lib.genAttrs [ "desktop" "laptop" "server" "games" "vr" "nas" ] (name:
    mkOption {
      type = lib.types.bool;
      readOnly = true;
      default = lib.elem name config.ox.roles;
      description = "Whether this machine has the ${name} role.";
    }
  ) // {
    enduser = mkOption {
      type = lib.types.bool;
      readOnly = true;
      default = config.ox.role.desktop || config.ox.role.laptop;
      description = "Whether this machine has a desktop or laptop role.";
    };
  };

ox.roles is a list you set per-host: ["server"], ["desktop" "games" "vr"], whatever the machine is. ox.role derives boolean attributes from that list automatically - role.server, role.desktop, and so on - plus the computed role.enduser which is true for any machine a human sits in front of.

Those booleans then drive defaults for every feature in the system:

ox = {
  auth.enable = lib.mkDefault true;
  boot.enable = lib.mkDefault true;
  samba.enable = lib.mkDefault role.enduser;
  bluetooth.enable = lib.mkDefault role.laptop;
  printing.enable = lib.mkDefault role.enduser;
  gui.enable = lib.mkDefault role.enduser;
  k8s.enable = lib.mkDefault (role.desktop || role.laptop || role.server);
  k8s.server.enable = lib.mkDefault role.server;
  ffmpeg.enable = lib.mkDefault role.enduser;
  hyprland.enable = lib.mkDefault role.enduser;
  packages.nixEnable = lib.mkDefault role.enduser;
  ssh.enable = lib.mkDefault true;
};

When a machine is a server, gui.enable, hyprland.enable, bluetooth.enable, printing.enable all default to false. When it’s a laptop, bluetooth turns on. When it’s an enduser machine, you get Hyprland, Samba mounts, printing, and the Nix development packages. All without writing those conditions in twelve separate host configs. mkDefault means any individual host can still override a specific option - the defaults are overridable, not forced.

Look at the full brunnen host config, a production server:

ox = {
  hostName = "brunnen";
  boot = {
    type = "grub";
    efi.enable = false;
  };
  roles = [ "server" ];
  networking.enable = true;
};

That’s it. Eight lines of meaningful configuration plus boilerplate imports. The role system handles the rest - no GUI, no Bluetooth, no printing, no Hyprland, no gaming tools. Clean.

Now look at itw, the gaming desktop:

ox = {
  hostName = "itw";
  boot = {
    efi.dir = "/boot/EFI";
    cryptPart = "crypt";
  };
  roles = [ "desktop" "games" "vr" ];
  monitor.with4k = true;
  monitor.with4kFix = true;
  video = "nvidia";
  wayland.enable = true;
};

environment.etc.crypttab = {
  text = ''
    games /dev/disk/by-uuid/0292b609-...  /etc/luks-keys/games.key luks
  '';
};
fileSystems = {
  "/games" = {
    device = "/dev/mapper/games";
    fsType = "ext4";
  };
  "/mnt/win" = {
    device = "/dev/disk/by-partuuid/3d69d556-...";
    fsType = "ntfs";
    options = [ "defaults" "noauto" ];
  };
};

This is the most complex host in the repo. NVIDIA GPU, 4K display with DPI fixes, encrypted /games partition with a keyfile, dual-boot Windows on an NTFS mount, VR role on top of gaming. And the entire description still fits on a screen. The role system handles enabling Steam, Lutris, Gamescope, and related tools via role.games; the VR tools via role.vr; the Hyprland Wayland compositor via role.enduser. Setting video = "nvidia" injects the correct Wayland environment variables automatically - more on that in a moment.

That’s the full range of the fleet: from a minimal server to a maxed-out gaming and VR workstation, with nothing special needed in between.


home-manager: Your Dotfiles Are Config Too

NixOS manages the system - packages, services, kernel configuration, networking. home-manager extends the same declarative model into the user’s home directory. It manages dotfiles, user services, user packages, and shell configuration, all in the same language and the same git repo.

The modules/home.nix bridges the NixOS config and home-manager with one critical pattern: gconfig. When home-manager modules are evaluated, they need access to the top-level NixOS configuration to ask role questions. gconfig is passed as an extra special argument:

home-manager.extraSpecialArgs = {
  gconfig = config;
  inherit oxpkgs;
};

Now any home-manager module can ask gconfig.ox.role.enduser or gconfig.ox.wayland.enable and get the host-level answer. The home.nix uses this immediately:

ox.home.enable = mkDefault config.ox.role.enduser;

Servers don’t get a user home-manager setup at all. Desktops and laptops do.

The Emacs configuration is the best example of what this buys you. In a single declaration:

home = {
  packages = [ emacs emacsedit ];
  file.".emacs".source = ./dot_emacs;
  activation.emacs-config = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
    [ -d "$HOME/git" ] || run mkdir -p "$HOME/git"
    [ -d "$HOME/git/emacs-config" ] || run ${pkgs.git}/bin/git clone \
      https://github.com/0x17de/emacs-config "$HOME/git/emacs-config"
    [ -f "$HOME/git/emacs-config/custom.el" ] || run touch \
      "$HOME/git/emacs-config/custom.el"
  '';
};
systemd.user.services.emacs-daemon = {
  Unit = {
    Description = "Emacs Daemon";
    After = [ "graphical-session.target" ];
  };
  Service = {
    Type = "notify";
    ExecStart = "${pkgs.emacs-pgtk}/bin/emacs --fg-daemon";
    ExecStop = "${pkgs.emacs-pgtk}/bin/emacsclient --eval '(kill-emacs)'";
    Restart = "on-failure";
    RestartSec = "2s";
  };
  Install = {
    WantedBy = [ "graphical-session.target" ];
  };
};

Four things at once: installs emacs-pgtk and wrapper scripts (emacs and emacsedit), writes a .emacs bootstrap file, registers a systemd user service that starts the daemon after the graphical session is up, and clones the external Emacs configuration repository on first activation. Fresh machine, run the rebuild, open Emacs - everything is there.

Where to draw the line: Nix manages the install, the service lifecycle, and the initial bootstrap. The Emacs config itself (keybindings, packages, themes) lives in its own git repository and manages itself through package.el or use-package. Nix doesn’t try to own what the application is good at managing.


The Desktop Stack

Hyprland and the Overlay Pattern

Hyprland’s Wayland compositor is pinned to v0.54.2 across the whole fleet. This is intentional - the plugin API changes with releases, and the hy3 tiling plugin must match. The overlay from Section 3 means every machine that runs Hyprland gets the same version without any per-host specification.

Monitor configuration is generated per-host from ox.hyprland.monitors. The itw host sets monitor.with4k = true and monitor.with4kFix = true, which triggers the correct scaling and DPI configuration in the Hyprland module. When ox.video = "nvidia", the module automatically injects the Wayland-specific NVIDIA environment variables (GBM_BACKEND, __GLX_VENDOR_LIBRARY_NAME, LIBVA_DRIVER_NAME, and the rest). No hunting through Arch Wiki pages at 2am for the magic env var combination. Declare the GPU, get the right environment.

Rebuilding the Running System

The most-used command in the workflow is nho, defined in modules/nushell/config/nix.nu:

def nho --wrapped [...rest] {
    cd ~/git/nix
    nh os switch . ...$rest
}

nh is a wrapper around nixos-rebuild that adds better output formatting, diff reporting, and some quality-of-life features. This alias changes into the repo directory and invokes nh os switch - rebuild from the current flake, apply immediately. The --wrapped flag in Nushell passes extra arguments through unchanged, so nho --hostname other-machine works for remote deploys.

Make a config change, type nho, the system rebuilds and switches atomically. If something is wrong, the previous generation is one bootloader selection away. That feedback loop changes how you interact with your configuration.

Nushell as Primary Shell

Nushell runs as the primary interactive shell on all enduser machines. Its typed pipeline model, structured data handling, and built-in table rendering make it particularly good for homelab work. The config includes a suite of plugins: polars for dataframe operations, query for SQL-like filtering, net for network diagnostics, formats for parsing structured file formats. These are declared in the nushell module and activated automatically.

Gaming on NixOS

NixOS has a reputation for being difficult for gaming. That reputation is stale.

Steam, Lutris, Heroic Games Launcher, ProtonUp-Qt, MangoHUD, and Gamescope are all declared in the games module, enabled automatically when role.games is true. Steam’s FHS compatibility layer handles most Windows titles without any special configuration. Proton versions are managed via ProtonUp-Qt.

The occasional game that absolutely requires an FHS environment - a proprietary launcher that hardcodes library paths, for instance - can be wrapped in a custom buildFHSEnv expression. That’s about ten lines of Nix and not a deep rabbit hole. nix-alien was tried and found unreliable; the buildFHSEnv approach is more transparent and predictable.

NVIDIA gaming is handled by the video = "nvidia" flag described above. On itw, Gamescope handles frame pacing and fullscreen correctly for both native Linux titles and Windows games via Proton.

The role system means gaming tools are only present on machines that declare role.games. Servers stay lean. Desktops used for work but not gaming skip the Steam runtime and all the associated tooling. Nothing to manage, nothing to drift.


Secrets Without Headaches: SOPS + Age

The natural objection to “everything is in git”: what about passwords?

The answer is SOPS with age keys. Secrets are encrypted in the repository - committed plaintext is never stored - and decrypted at activation time by the sops-nix NixOS module. Each secret is mounted as a file with explicit ownership and permissions.

The PowerDNS module shows this concretely. The primary DNS server needs an API key, and that key must be accessible to the service at runtime but must never appear in plaintext in git or the Nix store:

config = mkIf cfg.enable {
  sops.templates."pdns-env" = lib.mkIf (!cfg.secondary) {
    content = ''
      PDNS_API_KEY=${config.sops.placeholder."powerdns/api-key"}
    '';
    owner = "pdns";
    group = "pdns";
    mode  = "0400";
  };

  services.powerdns = {
    enable = true;
    extraConfig = baseExtraConfig;
    secretFile = lib.mkIf (!cfg.secondary) config.sops.templates."pdns-env".path;
  };
};

config.sops.placeholder."powerdns/api-key" is a reference to an encrypted value in secrets/. During activation, sops-nix decrypts it and writes the rendered template to a path in /run/secrets/, owned by pdns:pdns, mode 0400. services.powerdns.secretFile tells the service to load environment variables from that path. The API key never appears in the Nix store. The secret file in secrets/ is committed encrypted with age - anyone with the host’s age key (generated at first boot, stored in /etc/ssh/ssh_host_ed25519_key) can decrypt it; everyone else sees ciphertext.

This pattern applies uniformly to database passwords, API keys, WireGuard private keys, and anything else that shouldn’t be readable by arbitrary processes. The secrets/ directory lives in the same repo as everything else. The repo truly is the single source of truth.


Deploying a New Machine in One Command

New machine provisioning follows a single script:

nix run github:nix-community/nixos-anywhere -- \
  --flake .#"$NAME" "$REMOTE" \
  --generate-hardware-config nixos-generate-config \
  "./hosts/${NAME}/hardware-configuration.nix"

nixos-anywhere SSHs into a running NixOS installer (booted from a USB stick or network image), generates hardware configuration for the specific machine, partitions disks according to the host’s disko.nix, and installs the full system from the flake. The only prerequisites are a host config in hosts/$NAME/ and SSH access to the target.

Disk layout is declared per-host in disko.nix. LUKS encryption, partition sizes, filesystem types, subvolumes - all expressed as Nix. The itw host has an encrypted system partition, a separate encrypted /games partition with a keyfile, and an NTFS Windows partition, all described in code and applied automatically during provisioning.

After the initial bootstrap, all future updates are nho - atomic, with rollback available from the bootloader. The setup cost is one remote command; the ongoing cost is nearly zero.

The PowerDNS setup illustrates what this means at scale. Two servers - schere (primary) and stein (secondary) - run the DNS infrastructure. Both configs live in the same repo. Adding or changing a DNS-related NixOS option is one file edit, then two deploys: nho --hostname schere and nho --hostname stein. No config management layer to synchronize, no state to reconcile, no “did I update both servers?” uncertainty.


The Hard Parts

In the interest of accuracy: NixOS has rough edges.

The language. Nix is lazy and functional, with an evaluation model that can surprise you. Error messages have improved significantly in recent Nix versions, but a type mismatch deep in a module evaluation can still produce a backtrace that takes time to interpret. The learning curve is front-loaded; it gets easier, but the first weeks are real.

nixos-unstable breakage. This config tracks nixos-unstable, which means nix flake update occasionally introduces a broken package or module API change. The fix is usually in nixpkgs within a day or two, and rollback handles the immediate problem - but it does happen. The stable channels are calmer.

Proprietary FHS binaries. Most software works fine. Occasionally a binary with hardcoded library paths needs a buildFHSEnv wrapper. It’s ten lines of Nix and not scary once you’ve done it once, but it’s a known rough edge.

First-time secure boot setup. Getting lanzaboote (Secure Boot), LUKS encryption, and disko all working correctly on first install has a real setup cost. The pieces fit together well once configured, but the first time through the documentation takes effort.

Honest scope assessment. If you have one machine and enjoy imperative tinkering, the learning investment may not be worth the return. NixOS pays off at scale, for reproducibility across machines, or for users who want the peace of mind that comes from knowing exactly what’s on each system. Single-machine casual users are not the target audience.


Where This Leads

A year ago, provisioning a new server meant: boot the installer, partition manually, install the base system, copy configs from another machine, run Ansible, debug what drifted, repeat. With this setup, it’s ./install-remote.sh newhost root@192.168.1.x and wait. The system that comes up is defined entirely by the config in git, with no variation possible.

That mental shift - from “the system is the result of what I’ve done to it” to “the system is a function of what I’ve declared” - is the core of what NixOS offers.

A few threads worth pulling on from here:

disko and LUKS in depth. The declarative disk setup deserves its own writeup. Combining disko, LUKS, and lanzaboote (Secure Boot) on a single machine, from a declarative base, is a solved problem that’s worth documenting step by step.

Writing your own NixOS module. The ox.* namespace in this repo is a collection of custom modules. The pattern - options, defaults, conditional config - is learnable in an afternoon and immediately useful for your own abstraction layer.

home-manager on non-NixOS. The home-manager configuration described here works on any Linux (or macOS) system, not just NixOS. If NixOS on bare metal is too much commitment, running home-manager standalone on Debian or Ubuntu gets you the dotfile management and reproducible user environment without changing the OS.

Where to start: nixos.orgnix.dev → find a real flake on GitHub and read it → start with one machine.

The whole system is a function. Write better inputs; get a better system. That’s the deal.