Skip to main content

I Couldn't Describe My Own Laptop

TL;DR: I bought a new MacBook, sat down to write out what was on the old one, and could not. 73 Homebrew formulae, most of which I could not justify, roughly 35 apps matching no installer, six Pythons. Nix fixed the part I expected to be hard - the machine now has a file that describes it - and did nothing at all about the part that actually was hard: finding out what belonged in the file.

If you only want the working setup, it is The Smallest Thing That Helped. It takes five commands and does not need sudo.


I Had Been Hearing About This for Years #

Nix came up periodically, the way tools do. Someone would mention it in a thread, or a colleague would say their whole machine was in a git repo, and I would nod. Declarative. Reproducible. A single file that describes your environment. I understood the pitch every single time.

And every single time, nothing happened.

It is worth being precise about why, because I do not think I am unusual here. The idea was never unclear. I could have explained it to you. The problem was that it was unattached: there was no specific thing in my week that it made better. “Your machine would be described by a file” is a statement about a property, and properties do not motivate anyone. Problems do.

So I kept not adopting it, for years, with full understanding of what I was not adopting.

The New Laptop Was Not the Problem #

Eventually I bought a new machine.

I want to be clear that this part went fine, because the version of this story where the new machine is a disaster would be a better story and it would also be a lie. Apple’s migration works. I could have pointed the assistant at the old laptop, gone to make coffee, and come back to a working machine.

That is exactly what I decided not to do.

I had been using that machine for a long time, and I knew that somewhere in it was a layer of sediment: things installed for one afternoon and never opened again, things installed twice, things installed by a tutorial I no longer remembered reading. A migration assistant does not distinguish between your tools and your sediment. It moves both, faithfully, forever.

So I decided to write down what was actually on the laptop, and bring across only the parts I could justify.

The List That Does Not End #

This is where it fell apart.

I started with Homebrew, because that felt like the easy one. brew leaves prints the formulae you asked for, as opposed to the ones dragged in as dependencies. Mine printed 73 packages.

I went through them one at a time and tried to answer a simple question for each: what is this for? For some of them I had an immediate answer. For most I had a vague feeling, or nothing at all. There was a Common Lisp compiler. There were three separate Terraform-adjacent linters. There was a tool for decompiling Android APKs.

Then I opened /Applications, and found roughly 35 apps that matched no Homebrew cask and no App Store receipt, meaning they had been downloaded from somewhere and dragged in by hand. Including, and I want to be honest about this one, MacPorts. A second package manager. Sitting quietly next to the package manager I knew about, installed by a version of me who had a reason.

And it kept going:

  • 73 Homebrew formulae, and no memory of why most of them were there
  • roughly 35 hand-installed apps matching no cask and no App Store entry
  • six Python installs
  • npm globals belonging to a Node install I no longer used
  • a full Rust toolchain, including the tutorial exercises
  • the dock order
  • the terminal config
  • the shell aliases I would notice the absence of on day one and could not have listed on day zero

You can finish that list yourself. That is the point. Everyone reading this has a machine that would produce one.

This inventory was not an afternoon of work. It was the single biggest task in the entire migration, and it was not engineering. It was archaeology.

What Was Actually Wrong #

Somewhere in the middle of that list, the problem changed shape.

I had assumed my machine was messy, and that the fix was to tidy it. But messy is fine. Messy is normal, and a tidy machine becomes a messy one within about six months of real work.

The actual problem was different, and worse: my laptop was the only copy of its own configuration, and that copy could not be read.

Every decision about that machine - every install, every preference, every path entry - existed in exactly one place, encoded as the state of the machine itself. There was no version of it I could open. Nothing could be reviewed, diffed, searched, or explained to another person. I could not answer “what is on this laptop?” for the same reason you cannot answer “what is in this soup?” by looking at it.

The word for what I was missing is legibility. Not reproducibility, not automation, not tidiness. I needed the machine to be described somewhere other than itself, in something I could read.

That is a much smaller ask than “automate my entire setup”. And it is the thing Nix turned out to be genuinely good at.

The Smallest Thing That Helped #

Here is what I would suggest you try, and it is deliberately not “set up your whole machine with Nix”. It is one repository, no sudo, and you can delete it afterwards.

The idea is a dev shell: an environment that exists only inside one directory. You install a tool “into” a project rather than into your computer.

You need Nix itself first. I would use the Determinate Systems installer, which ships an uninstaller, which matters for a thing you are trying out on the strength of a blog post.

You also need direnv, and its shell hook, which is the part everyone forgets:

nix profile add nixpkgs#direnv
echo 'eval "$(direnv hook zsh)"' >> ~/.zshrc   # or bash, or fish

Without the hook, direnv is installed and does nothing.

Then, in any project directory, a file called flake.nix:

{
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
  outputs =
    { nixpkgs, ... }:
    let
      pkgs = nixpkgs.legacyPackages.aarch64-darwin;
    in
    {
      devShells.aarch64-darwin.default = pkgs.mkShell {
        packages = [ pkgs.jq ];
      };
    };
}

That is the whole thing. A flake here is just a file that says what this project needs. The line that matters is packages = [ pkgs.jq ];, and you can put anything in nixpkgs in that list. aarch64-darwin means an Apple Silicon Mac - swap it for x86_64-darwin on an Intel one, or x86_64-linux on Linux.

Then five commands:

git init
echo 'use flake' > .envrc
echo '/.direnv/' > .gitignore
git add flake.nix .envrc .gitignore
direnv allow

Now cd into that directory and run jq --version. You get jq-1.8.2. cd out of it and run the same command, and you get your system’s version, or nothing at all.

Nothing was installed globally. Nothing was added to a PATH you maintain by hand. The tool exists inside that directory and nowhere else, and the file that says so is sitting right there, four lines of actual content, readable by anyone who opens the repo.

That is the entire idea. Everything else Nix does is this, at larger scale.

Your Git Index Is the Filesystem Nix Sees #

There is one rule that will trip you up, and it is worth understanding rather than memorising, because it explains a whole class of confusing behaviour.

Notice that the commands above included git add, before anything was built, in a repo with no commits. That was not ceremony.

A flake in a git repository is built from git’s view of your files, not from the directory’s. More precisely: the git index decides which files exist, and the working tree decides what is in them.

Leave flake.nix untracked and you get this:

error: Path 'flake.nix' in the repository "..." is not tracked by Git.

       To make it visible to Nix, run:

       git -C "..." add "flake.nix"

Which is, credit where it is due, an excellent error message. It names the problem and hands you the exact command. Modern Nix is much better at this than its reputation suggests.

Three cases follow from the rule, and they behave differently:

  • A file Nix looks up by path - flake.nix itself, or something you import - is loud if untracked, with the error above.
  • A file you have already staged, then edited just works. You do not re-add it. Nix reads the working tree for files the index already knows about, which is why git add without git commit is enough.
  • A file Nix finds by listing a directory is silently invisible.

That last one is the one to know about. whatNixSees here is a throwaway output I added to the flake, which does nothing but list its own directory:

$ echo *
flake.lock flake.nix ghost.txt packages.nix

$ nix eval .#whatNixSees
[ "flake.lock" "flake.nix" "packages.nix" ]

Your shell sees four files. Nix sees three. There is no error, no hint, and not even the “Git tree is dirty” warning that shows up in other situations, because an untracked file does not make a tree dirty - dirty means tracked content has changed.

Annoying, but defensible. The rule exists so that a build which works on your machine cannot depend on a file that only exists on your machine. The alternative is a project that builds for you and fails for the next person at the identical commit, which is the exact failure flakes were designed to prevent. The cost is that you have to tell git about a file before Nix will believe in it.

What It Cost #

I have told you what worked. Here is the other side, and I think it is the more important half.

The archaeology is still yours. This is the big one. Nix did not tell me what was on my laptop. It could not. It has no idea which of those 73 formulae I actually use, and neither does any other tool, because that information does not exist anywhere except in your head and your habits. What Nix did was make ignoring the question impossible: you cannot write a file describing your machine without deciding what belongs in it. That is a real service, and it is not the same service as making the job easy. If you are hoping a tool will hand you the inventory, none of this will help you.

Hundreds of megabytes to run a 1 MB program. The first time you run that dev shell, Nix fetches a nixpkgs checkout, which is where nearly all of that goes. jq itself is about 1 MB. This is disk and bandwidth rather than difficulty; it is a one-time cost shared by every later project, and I would rather you knew before you started the download than during it.

I am not finished. My configuration covers maybe 90% of the machine, and the remaining 10% is the boring 10%: the fiddly per-app state, the things I keep deferring. I am not going to pretend there was a clean cutover, because there was not.

And when you want it gone:

direnv deny                      # approval is stored outside the project
rm -rf the-project-directory     # takes .direnv with it
nix store gc --dry-run           # see what would be deleted before deleting it

That first line is the one people miss, and it has to come first. direnv records your approval in ~/.local/share/direnv/allow/, not in the project, so deleting the directory alone leaves a little state behind - and once the directory is gone there is nothing left to point direnv deny at. If you have already deleted it, direnv prune clears the orphaned approvals.

What I Would Tell You #

Write the inventory before you choose the tool. The list is the work. Whatever you adopt afterwards is downstream of it, and if you are not willing to write the list, you do not have a tooling problem yet.

Start with one repo, not one machine. Zero to Nix is the gentlest on-ramp if you want one. A dev shell is reversible, and a machine rebuild is not. The idea lands identically either way, and only one of them can ruin your afternoon.

Stage before you build. git add is not a ritual. It is how you tell Nix which files exist.

A configuration you cannot read is not a configuration. It is a backup of a decision you have already forgotten.


Nix never told me what was on my laptop. It just made it impossible to carry on not knowing.

Mateusz Soltysik
Author
Mateusz Soltysik
Senior Software Engineer & Tech Lead | Python • Go • TS | Certified AWS & GCP Architect