From .bashrc to Nix
The same job done twice: aliases and shell config as loose files, and then the same thing in my repo. For people who keep dotfiles in git and have never touched Nix.
Where everyone starts
If you keep a dotfiles repo, this probably looks familiar:
# ~/.bashrc
alias ll='ls -la'
alias gs='git status'
alias vim='nvim'
export EDITOR=vim
And a script that puts it in place:
#!/usr/bin/env bash
ln -sf "$PWD/bashrc" ~/.bashrc
ln -sf "$PWD/gitconfig" ~/.gitconfig
ln -sf "$PWD/starship.toml" ~/.config/starship.toml
And then, somewhere else, a list of packages: a brew list, a few apt install lines, a gist you dig up when you set
up a new machine, a README with a “things to remember” section.
Two things about that setup are worse than they look.
The first is that the repo describes files, not the machine. alias ll='ls -la' works because ls is there, which is
fine. But alias vim='nvim' only works if nvim got installed, and alias cat='bat' only works if bat did. The install
step is not in the repo. It’s in your memory, or in that README nobody updates. Forget one package and the repo is
still correct while the machine is broken.
The second is that nothing checks any of it. A config file and a machine can disagree for a year without saying anything. You find out when you type the alias on a fresh install.
The same aliases in my repo
Nix is a language and a package manager that describes a whole machine, and mine are all described by the same repo: a laptop, a Raspberry Pi 400 that hangs off the TV, a VM at Hetzner that runs the services, and two Macs.
This is the real file now. The top of it, where the tools are named:
let
eza = lib.getExe pkgs.eza;
nh = lib.getExe pkgs.nh;
inAnd then the aliases themselves, from a bit further down the same file:
ls = "${eza} --color=always --group-directories-first --icons";
ll = "${eza} -la --icons --octal-permissions --group-directories-first";Those two blocks aren’t pasted into this post. The site pulls them out of the repo when it builds, which is also why my aliases can’t be right here and wrong on the machine: they are the same file.
Three differences, and they’re all in those few lines.
The alias and the tool are one statement. lib.getExe pkgs.eza isn’t the word “eza”, it’s a path into the nix
store, something like /nix/store/<hash>-eza-0.21.0/bin/eza, and that path exists because this same file asked for the
package. There is no gap between “here is my alias” and “make sure eza is installed” for me to fall into. The second
half of that stopped being something I do by hand.
The version comes from the lock file. Every version I use is pinned in flake.lock, a file that sits next to the
config in git. pkgs.eza comes out of that lock, so the laptop, the Pi and the VM run the same eza at the same version,
and upgrading it is one commit instead of three upgrades that slowly drift apart.
Nothing here is per machine. The same file is used on every machine I own, Macs included. The store path differs per platform (a Pi can’t run a binary built for a Mac), the alias means the same thing on each, and I wrote it once.
Every file has the same shape
That aliases file is one of about ninety in the repo, and they all look like this:
options.modules.terminal.starship = {
enable = lib.mkEnableOption "Enable Starship configuration.";
};
config = lib.mkIf cfg.enable {
programs.starship = {
enable = true;Two parts, always in that order: what can be switched on, and what happens when it is. Nothing else. I open a file written two years ago, whether it configures the prompt or the firewall, and the shape is familiar.
The other half of that is what a machine becomes. Both attrValues lines pull in every module in the repo, and nothing
is switched on until a host asks for it. This is the Surface I use for work:
modules =
builtins.attrValues self.modules.homeManager
++ (builtins.attrValues self.modules.standaloneHomeManager)
++ [
{
modules.profiles.wsl.enable = true;
inherit username;
inherit hostname;Adding a machine isn’t copying your home directory around and fixing the places where it doesn’t fit. It’s writing
down which parts of the setup that machine should have, in one file. The options system is what makes that list worth
reading: modules.profiles.wsl.enable = true says what it does, and the WSL profile is where the parts that only make
sense on Windows live.
What that actually buys
Back to the hand-made version for a second. Everything installed by hand is invisible to the repo, so the repo is a partial description of your machines and the missing part lives in your head. Now nothing is installed that the repo doesn’t describe, and a person reading the repo knows what the machine is, without asking me.
That is the documentation part, and it isn’t the comments (you can write perfect comments in a bashrc). It’s that the description and the machine are the same statement, so they can’t quietly disagree.
What it costs
Nix is confusing, and I’m not going to pretend otherwise. It’s a language, and a different idea of what a config file is, and the error messages assume you already know things. I started using it early in my dev journey, before I had years of hand-made config to drag along, and that was luck rather than judgement. If you’re later than that, the cost is real.
There is a second wall behind this one too, the pattern that makes “every module is loaded, hosts switch things on” work. It’s the part that took me the longest to understand, and it’s part 2.
Next
One flake for a laptop, a Raspberry Pi and a Hetzner VM. Why I ended up there, what gets easier, and the parts that are genuinely annoying.