I rewrote my dotfiles around a single Makefile
My dotfiles repo turned eight years old last month, which is older than two of the jobs I've had and most of the espresso gear in my kitchen. For most of those eight years the entry point was a file called install.sh. It started as twelve honest lines. By last winter it was 340 lines of if [ "$(uname)" = "Darwin" ] branches, a function named do_the_thing, and a comment that just said # don't.
I finally rewrote the whole thing around a single Makefile. I've been living on it for about three weeks now, across my work laptop and a fresh Linux box I set up specifically to test it, and I think it's the first time the bootstrap story has actually felt good instead of merely surviving.
Why the shell script had to go
The problem with my install.sh wasn't bash. Bash is fine. The problem was that the script was a single linear run, and bootstrapping a machine is not a single linear run. Sometimes I just want to relink my zsh config because I edited it. Sometimes I want to reinstall my Neovim plugins without touching anything else. Sometimes I'm on a borrowed machine and I only want the shell stuff, none of the GUI app nonsense.
With the script, all I had was the whole thing or nothing. So I'd end up commenting out chunks, running it, and uncommenting them. That is a deeply stupid way to live and I did it for years.
What I actually wanted was a menu of small, named, idempotent operations. And it turns out there's a 40-year-old tool sitting on every machine I own that does exactly that.
The shape of it
The core idea is that every meaningful action is a target, and the targets are small. Here's roughly the top of mine:
.DEFAULT_GOAL := help
DOTFILES := $(shell pwd)
help: ## Show this help
@grep -E '^[a-z-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " %-14s %s\n", $$1, $$2}'
all: brew shell nvim git macos ## Everything
shell: ## Symlink zsh + starship config
ln -sf $(DOTFILES)/zsh/.zshrc ~/.zshrc
ln -sf $(DOTFILES)/starship.toml ~/.config/starship.toml
nvim: ## Link Neovim config + sync plugins
ln -sf $(DOTFILES)/nvim ~/.config/nvim
nvim --headless "+Lazy! sync" +qa
The help target is the part I'm unreasonably proud of, and I stole it from some gist years ago like everyone else. Every target gets a ## comment, and running make with no arguments prints a clean list of what's available. My dotfiles are now self-documenting. I open the repo on a machine I haven't touched in months, type make, and it tells me what it can do. No README archaeology.
Idempotency is the whole game
Here's the thing nobody tells you: the reason a Makefile beats a shell script for this isn't the syntax. It's that writing targets pushes you toward making each step safe to run a hundred times.
When everything was one linear script, re-running it felt dangerous, so I rarely did. Now each target is so small that I just run it whenever. Edited my zsh aliases? make shell. It re-links, it's a no-op if the link already exists, done. The friction dropped to basically zero, and low friction is the entire point of dotfiles. If maintaining your setup is annoying, you stop maintaining your setup, and then your setup rots.
The best tool isn't the one with the most features. It's the one you'll actually keep running six months from now when you can't remember how any of it works.
The part I got wrong first
My first version leaned hard on Make's dependency graph. I had nvim depend on brew depend on xcode-tools, the whole chain, very clever, very tidy on paper.
It was miserable. Make's dependency model is built around files and timestamps, and almost nothing in a dotfiles setup is a file with a meaningful mtime. "Is Homebrew installed" is not a question about a timestamp. I was fighting the tool, writing fake sentinel files in ~/.cache just to make Make's brain happy.
So I backed off. Now my targets are mostly flat and independent. The dependencies that exist are only the obvious composition ones — all calls the others in order, and that's about it. Each target checks its own preconditions with plain shell:
brew: ## Install Homebrew + Brewfile
@command -v brew >/dev/null || \
/bin/bash -c "$$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew bundle --file=$(DOTFILES)/Brewfile
Treating Make as a clean task runner with a great built-in help system, rather than as a build system pretending to manage my laptop, was the unlock. Once I stopped being precious about "doing Make properly," it got simple.
Three weeks in
Things I genuinely like now: bringing up the fresh Linux box took one make all and about four minutes of watching scroll. The macOS defaults live in their own make macos target so I can skip them on Linux without a single OS check polluting everything else. And when I tweak something, the change-test loop is just rerun-one-target, which means I actually keep the repo current instead of letting it drift for a year.
Things I'm still unsure about: I haven't found a clean way to handle secrets and machine-specific values, so right now there's an awkward make local that copies a template and tells me to go fill it in by hand. That's not automation, that's a sticky note. I'll figure it out eventually, or I won't and it'll join do_the_thing in the graveyard of clever ideas.
But the core bet paid off. One file, a list of small named verbs, a help screen that explains itself. If you've got an install.sh that's quietly metastasizing the way mine was, try it. Worst case you spend a Sunday afternoon and learn that tabs-not-spaces in a Makefile is still, in this year of our lord, the most aggravating error message in computing.