pieterpel
← All writing
Dotfiles on Nix · 1 of 4

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.

Published 2026-09-30 5 min read #nix #dotfiles

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:

source · modules/terminal/aliases.nix:9-12 view on GitHub ↗
    let
      eza = lib.getExe pkgs.eza;
      nh = lib.getExe pkgs.nh;
    in

And then the aliases themselves, from a bit further down the same file:

source · modules/terminal/aliases.nix:48-49 view on GitHub ↗
        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:

source · modules/terminal/starship.nix:8-14 view on GitHub ↗
      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:

source · hosts/surface/_users/pieterpel/default.nix:21-28 view on GitHub ↗
    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.