Declarative Workstation Setup
I have three computers I work on: a desktop, a laptop, and a work MacBook. All three
of those machines are described by a single git repository.
The desktop and laptop run NixOS; the Mac runs
nix-darwin. When I want a new tool, I add a line to a
.nix file and rebuild. When something breaks, I roll back to the previous generation and the
machine is exactly what it was before. There are no untracked changes, because untracked
changes don’t survive a rebuild.
❄️ The whole thing is public
Everything described here lives at github.com/dannylongeuay/nixos-config. If a module looks useful, steal it — that’s most of what the repo is good for.
This post is in three parts: why I picked NixOS over other popular distros, how the flake and modules are organized, and then a long walkthrough of every package I install and the conventional tool it replaces. The package tour is the bulk of it, and it doubles as a tour of what a terminal-first, keyboard-driven Linux desktop looks like in 2026.
Why NixOS over Arch, Fedora, or Ubuntu
On a normal distro you configure your system by doing things:
pacman -S this, edit that file in /etc, enable this systemd service, drop a script in
~/.local/bin. Each action is a one-time imperative mutation, and the real state of the machine
is the sum of every mutation you’ve ever made, most of which you’ve forgotten.
The result is drift. Six months in, your laptop and your desktop have diverged in a hundred small ways you can’t enumerate, and reinstalling means rediscovering all of them. Your dotfiles repo captures some of it, but not the system packages, not the services, not the kernel parameters, not the font config.
NixOS flips the model. Instead of describing a sequence of changes, you describe the end state
you want, declaratively, in the Nix language. Then nixos-rebuild makes the machine match that
description. The whole system — bootloader, kernel, drivers, services, packages, user dotfiles —
is one big expression that evaluates to one big result. A few properties fall out of this that I
now find hard to live without:
- Reproducibility. The same config plus the same pinned inputs produces the same system, on any machine. My desktop and laptop genuinely share their configuration instead of approximating each other.
- Atomic upgrades and rollbacks. Every rebuild creates a new generation. If an update breaks something, I pick the previous generation from the boot menu — or run one rollback command — and I’m back. The old generation was never mutated; it was sitting there the whole time.
- No drift. Anything not written in the repo doesn’t exist after a rebuild. There’s no “mystery setting from 2023.” If I want to understand why my machine behaves a certain way, I read the config. It’s all there, in git.
- One repo, many machines. Adding a machine is writing two small files and wiring them into the flake, not repeating a setup ritual.
There are trade-offs. The Nix language has a steep learning curve and a reputation for it that’s mostly earned. Documentation is improving, but I’ve found my self going down the rabbit hole of Google and forums to figure some things out. For me the payoff is a system I understand and can rebuild from scratch in minutes.
The flake: one repo, three machines
The entry point is flake.nix. A flake is just a standardized
way to package Nix code with pinned dependencies — its inputs are locked in flake.lock, so the
build is reproducible down to the exact revision of every dependency. Here’s the whole input
section:
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
nix-darwin = {
url = "github:LnL7/nix-darwin";
inputs.nixpkgs.follows = "nixpkgs";
};
catppuccin.url = "github:catppuccin/nix";
claude-code.url = "github:sadjow/claude-code-nix";
};
Five inputs, each pinned:
nixpkgson thenixos-unstablechannel — the rolling-release package set. I want recent packages and I’m comfortable on unstable; it’s been very stable in practice.home-manager— manages the user level (dotfiles, shell, editor) as opposed to the system level. More on the split below.nix-darwin— the macOS equivalent ofnixos-rebuild, so the work Mac is managed by the same kind of config.catppuccin— theme modules that paint everything from the TTY to the status bar in one consistent palette.claude-code— a community overlay that keeps Claude Code packaged and up to date.
The inputs.nixpkgs.follows = "nixpkgs" lines are a small but important detail: they force
home-manager and nix-darwin to use my nixpkgs instead of their own pinned copies. Without
that, I’d be building against two or three different nixpkgs revisions and bloating the store
with duplicate closures.
The outputs define one entry per machine. Two NixOS systems, one Darwin system, and a home-manager configuration for each:
nixosConfigurations.desktop = lib.nixosSystem {
inherit system;
inherit pkgs;
modules = [
./modules/nixos/hosts/desktop
catppuccin.nixosModules.catppuccin
];
};
homeConfigurations."cyberdan@desktop" = home-manager.lib.homeManagerConfiguration {
inherit pkgs;
modules = [
./modules/home-manager/user/cyberdan/desktop.nix
catppuccin.homeModules.catppuccin
{ home.packages = [ pkgs.claude-code ]; }
];
};
The pkgs everything shares is built once, with unfree software allowed (for the NVIDIA driver
and Steam) and the claude-code overlay applied:
pkgs = import nixpkgs {
system = system;
config.allowUnfree = true;
overlays = [ claude-code.overlays.default ];
};
Why system and user are kept separate
One deliberate choice worth calling out: home-manager runs standalone, not as a NixOS module. The system level (NixOS / nix-darwin) and the user level (home-manager) are applied with separate commands:
sudo nixos-rebuild switch --flake .#desktop # system level
home-manager switch --flake .#cyberdan@desktop # user level
I could have bolted home-manager onto NixOS so one nixos-rebuild did both. I keep them split on
purpose. The user level (shell config, editor, dotfiles) is where I iterate constantly, and I
want to rebuild just that without touching the system configuration. It also means the
exact same home-manager modules work unchanged on the Mac, where there’s no NixOS underneath them
at all. The price is remembering to run two commands; in practice they’re one-letter abbreviations
(nos and nhs), so it’s a non-issue.
The module layout
The repo is organized as a tree of small, single-purpose modules. The shape:
flake.nix # inputs + all machine outputs
modules/
├── nixos/ # system level
│ ├── common/ # boot, nix, networking, fonts, services, user, …
│ ├── gui/ # hyprland, steam
│ ├── nvidia/ # graphics + drivers
│ ├── sound/ # pipewire
│ └── hosts/
│ ├── desktop/ # default.nix + hardware-configuration.nix
│ └── laptop/ # default.nix + hardware-configuration.nix
├── home-manager/ # user level
│ ├── common/ # cross-platform packages, programs, theme
│ ├── shell/ # fish (+tide), zsh, bat, fzf, zoxide, direnv
│ ├── tui/ # helix (+ LSPs), yazi, bottom, k9s
│ ├── gui/ # kitty, hyprland stack, zathura, theme
│ ├── vc/ # git, delta, gh
│ └── user/
│ ├── cyberdan/ # common.nix + desktop.nix + laptop.nix
│ └── daniel.longeuay/ # workLaptop.nix + aerospace.toml
└── nix-darwin/
└── hosts/workLaptop.nix # macOS system (homebrew casks, defaults)
The pattern that makes this scale: a host file is thin, and it imports fat shared modules.
A NixOS host just sets what’s unique to that machine, then pulls in the shared feature subtrees.
Here’s the desktop’s default.nix, essentially in full:
{
networking.hostName = "desktop";
users.users.cyberdan = { ... };
hardware.logitech.wireless.enable = true;
nix.gc.dates = "03:00";
imports = [
./hardware-configuration.nix
../../common
../../gui
../../nvidia
../../sound
];
}
That’s the whole difference between the desktop and the laptop, give or take: a hostname, the
hardware config that nixos-generate-config produced, a couple of machine-specific options, and
which feature subtrees to import. Everything substantial lives in common, gui, nvidia, and
sound, shared between both.
The user side mirrors this. The per-host home file sets host variables and rebuild
abbreviations, then imports a shared common.nix. Here’s the laptop’s, in full:
{
programs.fish.shellAbbrs = {
hms = "home-manager switch --flake .#cyberdan@laptop";
nhs = "nh home switch --ask --configuration cyberdan@laptop .";
nrs = "sudo nixos-rebuild switch --flake .#laptop";
nos = "nh os switch --ask --hostname laptop .";
};
hyprland_input_sensitivity = -0.6;
hyprland_startup_apps = [
"[workspace 1 silent] firefox"
"[workspace 2 silent] kitty"
];
imports = [ ./common.nix ];
}
Those bare attributes like hyprland_input_sensitivity and hyprland_startup_apps are custom
options the modules read, allowing custom configurations between machines. It’s a clean way to
parameterize a shared module per-host without if hostname == ... branching scattered everywhere.
One flake gotcha that bites every newcomer, straight from my README:
Gotcha: flake evaluation only sees files tracked by git. After creating new files (a new host, a copied
hardware-configuration.nix, etc.) you mustgit add -Abefore rebuilding, or Nix won’t find them.
The system layer
The modules/nixos/common tree and its siblings describe everything below the user: boot,
kernel, drivers, audio, networking. The highlights, with the reasoning:
Bootloader and kernel. systemd-boot instead of GRUB — it’s simpler, faster, and I boot via UEFI anyway. The kernel is the Zen kernel, tuned for desktop responsiveness over raw throughput:
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
boot.kernelPackages = pkgs.linuxPackages_zen;
Display server: Wayland, no display manager. The desktop is Hyprland,
a Wayland compositor. There’s no login greeter — Hyprland starts its own session, and I
explicitly disable LightDM (which NixOS would otherwise pull in as the default once X is enabled).
NIXOS_OZONE_WL = "1" is set so Electron and Chromium apps run natively on Wayland instead of
through XWayland.
When the machine starts, I am dropped into a TTY session. I then start Hyprland with the aptly named
start-hyprlandcommand.
Audio: PipeWire. PipeWire replaces PulseAudio as the audio server, with the ALSA and PulseAudio
compatibility shims enabled (and 32-bit ALSA support, so Steam games are happy). security.rtkit
gives audio real-time scheduling priority:
services.pipewire = {
enable = true;
alsa.enable = true;
alsa.support32Bit = true;
pulse.enable = true;
};
security.rtkit.enable = true;
Graphics: NVIDIA proprietary. I run the closed-source NVIDIA driver (open = false) with
modesetting on and 32-bit graphics enabled for Steam/Proton. NVIDIA on Wayland used to be a
horror story; these days it’s mostly fine with modesetting and the right environment variables
(which the Hyprland module sets: LIBVA, GBM, GLX all pointed at NVIDIA).
Networking is NetworkManager. Localization is en_US.UTF-8 with the timezone set to
America/New_York. A handful of services round it out: CUPS for printing, libinput for
input devices, and upower for battery status.
Nix itself is configured to enable flakes and garbage-collect old generations automatically:
nix = {
settings = {
experimental-features = [ "nix-command" "flakes" ];
warn-dirty = false;
trusted-users = [ "root" "@wheel" ];
};
gc = {
automatic = true;
options = "--delete-older-than 30d";
};
};
Generations older than 30 days get cleaned up on a per-host schedule (the desktop runs GC at 03:00, the laptop at 20:00). That keeps the rollback safety net around for a month without letting the Nix store grow forever.
Fonts are a set of Nerd Fonts — patched fonts that include the icon glyphs the status bar and prompt rely on — with JetBrains Mono as the primary face.
The package walkthrough
Now the main event. The philosophy across the whole config is modern, terminal-first
replacements for traditional Unix tools. The shell
abbreviations don’t just offer eza; they make ls mean eza. Where there’s a conventional
tool being replaced, I’ll name it.
A note on scope: only two packages live at the system level —
environment.systemPackages = [ helix vim ];
— Helix and Vim, present system-wide so there’s always an editor in a rescue shell even before home-manager runs. Everything else is installed per-user through home-manager, which is exactly where user tooling belongs.
Shell and prompt
The interactive shell is Fish, but with a twist. The login shell is technically Zsh, which immediately execs into Fish:
programs.zsh = {
enable = true;
dotDir = config.home.homeDirectory;
initContent = lib.mkBefore "exec ${pkgs.fish}/bin/fish";
};
Why the dance? Fish isn’t POSIX-compatible, which occasionally upsets tooling that expects a
POSIX login shell. Keeping Zsh as the registered shell sidesteps that whole category of problem,
while the exec means every interactive session is actually Fish from the first prompt. I get
Fish’s autosuggestions and sane defaults without the compatibility headaches.
The prompt is Tide, themed in painstaking detail to match
Catppuccin — every segment (git, kubernetes context, AWS profile, nix shell, language versions)
gets its colors from the palette. Alongside it, fzf.fish
wires the fuzzy finder into the shell’s history and file search.
A few small touches in the Fish config I’m fond of. fish_greeting runs fastfetch so every new
shell shows system info. And there’s this bit of self-discipline:
shellAbbrs = {
c = "clear";
# break muscle memory
clear = "# stop typing 'clear', use 'c' instead!";
};
Typing clear expands to a comment that scolds me into using c. Petty, effective.
Modern coreutils replacements
This is the heart of the “terminal-first replacements” idea. Each of these is aliased over the classic tool it improves on:
| Tool | Replaces | Why |
|---|---|---|
eza | ls | Colors, icons, git status, tree view. ls → eza, ll → eza -la. |
fd | find | Sane syntax, fast, respects .gitignore by default. find → fd. |
ripgrep | grep | Recursive, fast, gitignore-aware. grep → rg. |
bat | cat | Syntax highlighting and a built-in pager. |
zoxide | cd | Frecency-based jumping — z proj takes me to the directory I visit most matching “proj”. |
fzf | — | The fuzzy finder everything else builds on. |
jq | — | JSON slicing and transforming. |
ncdu | du | Interactive disk-usage browser for “what ate my disk.” |
httpie | curl | Human-friendly HTTP client (http GET ...). |
just | make | A command runner without Make’s tab-sensitivity and build-graph baggage. |
watchexec | entr | Re-run a command whenever files change. Aliased to we. |
fastfetch | neofetch | System info; neofetch is unmaintained now. |
libqalculate | bc | A genuinely powerful calculator (qalc), units and all. |
You can see the choices that didn’t make the cut sitting in comments in packages.nix — xh
as a Rust alternative to httpie, mask as a markdown-defined task runner instead of just,
television as an fzf alternative. The config doubles as a lab notebook of tools I’ve evaluated.
Nix-specific tooling
A few tools exist specifically to make living on Nix pleasant:
-
nh(Nix Helper) — a friendlier wrapper aroundnixos-rebuildandhome-manager. Its--askflag shows a build/diff preview before activating, so I can see what a rebuild will change before I commit to it. This is whatnosandnhsactually call. -
direnv+nix-direnv— per-directory environments. Drop a.envrcin a project and the rightnix developshell loads the moment Icdin, with caching so it’s instant. -
nix-your-shell— keeps me in Fish when I enter anix shellornix develop, instead of getting dumped into bash. -
nix-search-tv— an interactive fuzzy search over nixpkgs. I wrap its helper script as a tinynscommand:(pkgs.writeShellScriptBin "ns" (builtins.readFile "${pkgs.nix-search-tv.src}/nixpkgs.sh"))Searching for a package before adding it is constant, so having it one keystroke away matters.
Editor: Helix
My editor is Helix, set as the default editor system-wide. It’s a modal editor in the Vim/Kakoune lineage, but with a selection-first model (you select, then act) and — the part that sold me — language servers and tree-sitter built in, no plugin ecosystem to assemble. Coming from years of hand-tending a Neovim config, having a batteries-included editor that “just works” with LSP out of the box is a relief.
The config is small. A few editor preferences:
settings.editor = {
mouse = false;
line-number = "relative";
cursor-shape = { insert = "bar"; select = "underline"; };
inline-diagnostics.cursor-line = "hint";
};
The real substance is the language tooling. Helix gets a pile of language servers and formatters
declared right alongside it as extraPackages, so they’re always on PATH when Helix needs them:
nil (Nix), lua-language-server, bash-language-server, marksman and ltex-ls (Markdown +
grammar), taplo (TOML), yaml-language-server, terraform-ls, dockerfile-language-server,
vscode-langservers-extracted (HTML/CSS/JSON/ESLint), jq-lsp, cuelsp, and deno for
TypeScript. Per-language formatting is wired up to run on save where it makes sense — nixpkgs-fmt
for Nix, goimports for Go, ruff/pyright for Python, ocamlformat for OCaml. This is the
NixOS payoff again: my editor’s entire language toolchain is declared in one file and reproduces
perfectly on every machine.
Terminal: Kitty
The terminal emulator is Kitty — GPU-accelerated, fast, and configured in plain text. The config is minimal: JetBrains Mono Nerd Font at 14pt, no audio bell, no “are you sure?” on close. It beats Alacritty for me mainly on built-in features (tabs, splits, graphics protocol) without needing a multiplexer for basic things.
File manager and system monitors
yazi— a fast TUI file manager (the modern answer torangerorlf), aliased toyy. Great for bulk operations and previewing files without leaving the terminal.bottom— a resource monitor (btm) replacingtop/htop, with nicer graphs.k9s— a Kubernetes TUI. Since I run a whole platform on Kubernetes, having a fast cluster dashboard in the terminal is essential.
Version control: git, delta, gh
Git, of course — but with two companions. delta replaces
git’s default diff pager with syntax-highlighted, side-by-side, navigable diffs:
programs.delta = {
enable = true;
enableGitIntegration = true;
options = { navigate = true; line-numbers = true; side-by-side = true; };
};
And gh, the GitHub CLI, configured to use SSH. Git itself is set up
with OpenPGP commit signing, main as the default branch, and diff3 conflict style (which shows
the common ancestor in conflicts — much easier to resolve).
The fun part is the workflow layer built on top in Fish. There’s the usual pile of abbreviations
(gs → git status, ga → git add -A, gco → checkout, and so on), but also a set of
conventional-commit helpers. A small _gcm function builds a properly formatted commit message,
and these wrap it with a type and an emoji:
gfeat = "_gcm feat \":sparkles:\" $argv";
gfix = "_gcm fix \":bug:\" $argv";
gperf = "_gcm perf \":zap:\" $argv";
gchore = "_gcm chore \":wrench:\" $argv";
grefactor = "_gcm refactor \":recycle:\" $argv";
So gfeat "add the thing" produces feat(*): :sparkles: add the thing. If you scroll through
this repo’s commit history you’ll see exactly that format — it’s self-hosting.
Cloud and Kubernetes
The infra tooling: kubectl (aliased to k), awscli2, and doctl (the DigitalOcean CLI, since
that’s where my cluster lives). The nice ergonomic
touches are again Fish functions that pair these with fzf:
aprof = "export AWS_PROFILE=(aws configure list-profiles | fzf)";
kprof = "kubectl config use-context (kubectl config get-contexts -o name | fzf)";
aprof fuzzy-picks an AWS profile; kprof fuzzy-switches kube context. No more copy-pasting
profile names. There’s also a cht function that queries cheat.sh through fzf
for quick command references.
The Hyprland desktop stack
The graphical environment is a collection of small Wayland-native pieces, each doing one job. This is the opposite of a monolithic desktop environment like GNOME or KDE — every component is chosen and configured independently:
| Tool | Replaces | Role |
|---|---|---|
| Hyprland | GNOME/KDE/i3/sway | The tiling Wayland compositor itself |
| Waybar | — | The status bar (workspaces, CPU, memory, battery, network, clock) |
| Tofi | dmenu / rofi | The application launcher |
| Mako | dunst | Notification daemon |
| Hyprlock | — | Screen locker |
| Hyprpaper | — | Wallpaper daemon |
| hyprshot | — | Screenshots (bound to PRINT) |
wl-clipboard | xclip | Wayland clipboard access |
brightnessctl | — | Backlight control (laptop) |
Hyprland itself is configured Vim-style: SUPER+H/J/K/L to focus windows, with Ctrl to swap and
Alt to resize, SUPER+SPACE for the Tofi launcher, SUPER+T for a terminal. Caps Lock is remapped
to Escape (a habit that follows me to every machine, including the Mac). One detail I like: the
Hyprland config is generated from Nix as Lua, which lets me use helper functions and loops — for
instance, the ten workspace keybindings are generated with lib.range 1 10 instead of being
typed out by hand.
Browser, PDF, and the rest
Firefox is the browser. Zathura is the PDF viewer — keyboard-driven and minimal, replacing something heavy like Evince. GTK theming is wired through home-manager so even GTK apps pick up the Catppuccin look.
AI tooling
Two LLM tools live in the terminal: aichat for quick
terminal LLM chat, and claude-code — Anthropic’s coding
CLI — pulled from that dedicated flake overlay so it stays current. (This post, in fact, was
drafted with Claude Code reading the actual config files.)
Fun and misc
Not everything earns its keep on utility. asciinema records terminal sessions (genuinely
useful). asciiquarium puts a fish tank in the terminal and cbonsai grows a little ASCII bonsai
— pure eye candy, and worth it.
Gaming: Steam
Steam is enabled at the system level (programs.steam.enable = true). The 32-bit graphics and ALSA
support enabled earlier exist largely so Steam and Proton can run Windows games. It’s one line in
the config; NixOS handles the rest.
Theming: one palette, everywhere
A thread running through all of this is Catppuccin — specifically the
Mocha flavor with a mauve accent. The catppuccin flake provides modules that theme the TTY,
GTK, the cursor, Helix, and more, so the look is consistent from the boot console to the desktop.
The part I find genuinely elegant is that the palette is a single source of truth. Instead of copy-pasting hex codes into a dozen config files, modules import the palette JSON and reference colors by name. From the Fish config:
palette = (lib.importJSON
"${config.catppuccin.sources.palette}/palette.json").${config.catppuccin.flavor}.colors;
fishColor = color: lib.removePrefix "#" "${palette.${color}.hex}";
Now the Tide prompt’s git color is ${fishColor "green"}, not #a6e3a1. If I ever switch flavors,
every color across every app moves together. Waybar does the same thing to color its module icons.
This is the kind of consistency that’s tedious to maintain by hand and trivial when your config is
a programming language.
The work Mac: same flake, different OS
The third machine is a work MacBook, and it’s managed by the same repository via nix-darwin. This is where the system/user split earns its keep: the system layer is completely different (it’s macOS, not NixOS), but the home-manager modules are largely shared.
The Darwin host config (modules/nix-darwin/hosts/workLaptop.nix) handles the macOS-specific
system bits — and notably, it even reuses some of the NixOS common modules:
imports = [
../../nixos/common/environment.nix
../../nixos/common/fonts.nix
../../nixos/common/nix.nix
../../nixos/common/programs.nix
];
The same Nix settings, the same fonts, the same shell setup as the Linux machines. On top of that it declares macOS system defaults (Caps Lock → Escape again, a left-side auto-hiding dock, fast key repeat) and — pragmatically — uses Homebrew for the GUI apps that only ship as Mac casks:
homebrew = {
enable = true;
casks = [ "nikitabobko/tap/aerospace" "doll" "raycast" "stats" ];
onActivation = { autoUpdate = true; cleanup = "uninstall"; upgrade = true; };
};
AeroSpace is the tiling window manager — the closest
thing macOS has to Hyprland — configured from a checked-in aerospace.toml. Raycast
is the launcher, Stats is a menu-bar monitor.
The user side imports the same common, shell, tui, and vc modules as my personal machines,
so my shell, Helix, git workflow, and Kitty config are identical across Linux and macOS. It
diverges only where work demands it: a few extra packages (sops for secrets, glab for GitLab,
aws-nuke), Mac-specific Kitty settings, and work-specific Fish functions for AWS SSO login and
decoding JWTs:
awsso = "AWS_PROFILE=(aws configure list-profiles | grep sso | fzf) aws sso login; aprof";
decode-jwt = "echo $argv | jq -R 'split(\".\") | .[1] | @base64d | fromjson'";
The point is work/personal separation without forking the config. The muscle memory I build at home works at work, because it’s literally the same modules.
Living with it day to day
Two commands cover almost everything. After editing the config:
nos # nh os switch --ask --hostname <host> . (system)
nhs # nh home switch --ask --configuration cyberdan@<host> . (user)
Both go through nh with --ask, so I see a preview of what changes before anything activates. To
pull in newer package versions, nix flake update bumps the pinned inputs, I commit the resulting
flake.lock, and rebuild. If an update misbehaves, the previous generation is right there in the
boot menu — or one nixos-rebuild switch --rollback away — and I’m back to a known-good system in
seconds. Old generations age out automatically after 30 days.
What this actually buys me
After running this setup across three machines, the concrete wins:
- A new machine is minutes, not a weekend. Install NixOS, clone the repo, copy the hardware config, run two commands, reboot. The machine comes up exactly like my others.
- Fearless experimentation. I’ll try a window manager tweak or a kernel parameter knowing that the worst case is a one-line revert and a rebuild. There’s no “I changed something three weeks ago and now I can’t undo it.”
- The config is the documentation. When someone asks how I have something set up, I link the file. There’s no gap between what I think my machine does and what it actually does.
It’s not for everyone — the learning curve is real, and if you just want a computer that works, Arch or Ubuntu will get you there with far less Nix-wrangling. But if you’ve ever wanted your entire machine to be as version-controlled, reviewable, and reproducible as your code, NixOS delivers that better than anything else I’ve used.
❄️ Go read the config
The whole thing is at github.com/dannylongeuay/nixos-config. The README has a full package table and a cheatsheet. Clone it, lift the modules you like, and ignore the rest — that’s exactly what it’s there for.