Earlier this year I set up a new Mac mini and decided I was done doing it by hand. Every machine I’ve ever owned has been configured by memory, old blog posts (some of them mine) and vibes. This time I built a proper dotfiles repo: clone it, run one script, go make a brew, come back to a fully working development machine.

This post covers the setup itself - the Ruby and Node side is handled by mise, which got its own post.

The layout

The whole repo is six files to start with:

~/dotfiles
├── install.sh
├── Brewfile
├── zsh/.zshrc
├── git/.gitconfig
└── mise/.config/mise/config.toml

Those folders aren’t random - they’re GNU Stow packages. Stow symlinks a folder’s contents into your home directory, mirroring the structure. So stow zsh links zsh/.zshrc to ~/.zshrc, and stow mise links the config into ~/.config/mise/. The magic is that the real files live in the git repo - edit ~/.zshrc and you’re editing the repo, ready to commit.

The Brewfile

Every package the machine needs, declared in one file:

brew "stow"
brew "git"
brew "zsh"
brew "starship"
brew "tree"
brew "htop"
brew "fzf"
brew "libyaml"
brew "postgresql@17"
brew "redis"
cask "iterm2"
cask "zed"
cask "font-fira-code-nerd-font"

Install the lot with brew bundle. When I add something new by hand, brew bundle dump --force regenerates the file so the repo stays honest.

The install script

install.sh does the rest, roughly in this order: install Homebrew if missing, brew bundle, install Zinit (my zsh plugin manager, with plugins lazy-loaded in turbo mode so new shells stay instant), stow all the packages, install mise and the tool versions from config.toml, install the gems I always want (rails jekyll bundler rubocop), start Postgres and Redis with brew services, and finally sort out a GitHub SSH key.

The prompt is Starship, which I’d recommend to anyone - one binary, one config file, works in any shell.

Lessons learned the hard way

Because of course it didn’t work first time.

Old tap references rot. My Brewfile had tap "homebrew/cask-fonts" in it, which Homebrew has since deprecated - the whole brew bundle reported failure over two lines that no longer do anything. Casks and fonts are built in now; delete the taps.

The chicken-and-egg SSH key. My first version generated the GitHub SSH key inside install.sh… which you can’t run until you’ve cloned the repo… which needs the key. The key setup now lives at the top of the README as a manual pre-step, with an HTTPS-clone fallback.

Don’t trust shell activation in scripts. The gem install step silently used the ancient system Ruby because mise wasn’t activated in the script’s shell. The fix is exporting the shims path explicitly at the top of the script - full detail in the mise post.

Day to day

This is the bit that makes it worth it. Change a config? It’s already in the repo, so:

cd ~/dotfiles
git add . && git commit -m "Update zshrc" && git push

On any other machine: git pull, and if tool versions changed, mise install. Adding a new config to manage is just as quick:

mkdir -p newthing
mv ~/.somerc newthing/.somerc
stow newthing

Next fresh machine - or next time this one needs nuking from orbit - it’s git clone, ./install.sh, kettle on. That’s the whole disaster recovery plan, and I’m quite pleased with it.