Bootstrapping My Dotfiles

dotfiles tooling

I’ve had a dotfiles repo since 2013. I got a new laptop, couldn’t remember how I’d configured my shell, and decided to put everything in Git. The usual story.

Four hundred and eighty commits later, the repo manages my shell, editor, terminal, window manager, Git config, macOS defaults, and about 126 Homebrew packages. This week I finally rewrote the bootstrap process, so here’s how the whole thing works.

The Structure

The repo is organized by topic. Each directory maps to one tool: zsh/ for my shell, nvim/ for Neovim, git/ for Git, tmux/ for tmux. Twenty directories, each mirroring the structure of the home directory.

GNU Stow handles symlinks. It takes a directory like git/ and creates symlinks from its contents into $HOME. git/.gitconfig becomes ~/.gitconfig. zsh/.zshrc becomes ~/.zshrc. No template engine, no runtime. Just symlinks pointing back into the repo.

The stow script is twelve directories in an array and a for loop:

stow_dirs=(
    aerospace bin ghostty git karabiner
    nvim osx ssh system tmux wezterm zsh
)

for dir in "${stow_dirs[@]}"; do
    stow "$dir" -t ~
done

Some directories are deliberately left out. bash/ is there for machines where I don’t have zsh. yabai/ is an older tiling window manager I replaced with AeroSpace. vim/ is the pre-Neovim config. They’re in the repo for reference but I don’t symlink them anymore.

The Old Bootstrap

For years, setting up a new Mac meant running a script called install_dotfiles.sh for the symlinks, then a separate .brew script that ran individual brew install commands with flags Homebrew deprecated years ago, then manually applying macOS defaults. Three scripts, right order, manual intervention.

The .brew script had enough bitrot that I couldn’t run it without editing it first. Deprecated flags, packages that changed names, casks that moved taps. Every new machine was an archaeology exercise.

The New Bootstrap

bootstrap.sh does everything: installs Xcode CLI Tools if missing, installs Homebrew if missing, runs brew bundle against a Brewfile, initializes git submodules, calls stow.sh, and optionally applies macOS defaults. One command from bare Mac to working environment.

stow.sh is the symlink-only script for when you’ve added a config file or changed the list. It doesn’t touch packages.

The real improvement is the Brewfile. Instead of imperative brew install commands:

brew "ripgrep"
brew "fd"
brew "fzf"
brew "zoxide"
cask "ghostty"
cask "docker-desktop"

brew bundle is idempotent. Run it on a fresh machine or one that already has half the packages and it does the right thing.

The Brewfile ended up at 345 lines organized into sections: core CLI, shell experience, editors, language toolchains for Go, Rust, Python, and Node, infrastructure, network, media, GUI apps, fonts. About half the lines are commented out. Packages I’ve used at some point but don’t need everywhere. They’re there so I remember they exist.

Lazy Loading

I open new terminal windows constantly. A 500ms startup delay on every one adds up. The worst offenders are nvm (sources a massive shell script), pyenv (needs shims in PATH), and Google Cloud SDK (its own init process).

I defer all of them until first use:

_lazy_load() {
    local init_callback=$1
    shift
    local cmds=("$@")

    for cmd in "${cmds[@]}"; do
        eval "
        $cmd() {
            unset -f ${(j: :)cmds}
            $init_callback
            $cmd \"\$@\"
        }
        "
    done
}

_lazy_load _init_nvm nvm node npm npx yarn

First time I type node, the wrapper initializes nvm, removes itself, and runs the real binary. Every call after that goes straight through. Same pattern for gcloud, gsutil, and anything else that’s slow to init.

PATH Management

PATH on macOS is a mess. /usr/libexec/path_helper runs early in shell startup and builds a PATH from /etc/paths and /etc/paths.d/*. Then Homebrew wants its prefix at the front. Then language version managers want their shims at the front. Then your personal scripts should probably be at the front too.

I wrote a priority-based path manager. Groups: user scripts at 1, language toolchains at 2, dev tools at 3, system paths at 4, OS defaults at 5. Each module registers its paths, and path_build sorts by priority and deduplicates.

path_add --user "$HOME/bin" "$HOME/.local/bin"
path_add --language "${GOBIN:-$HOME/workspace/go/bin}" "$HOME/.cargo/bin"
path_add --tools "$brew_prefix/bin" "$brew_prefix/sbin"
path_add_system  # preserve existing paths from path_helper
path_build

More machinery than most people need. But I never have to think about the order of my .zshrc or worry about a tool being shadowed by a system binary.

The Rest of It

aerospace/ configures my tiling window manager in a DWM-style master-stack layout. karabiner/ remaps my keyboard. osx/.osx sets about a hundred macOS defaults: key repeat speed, Finder behavior, Dock placement. git/.gitconfig has a decade of aliases and GPG signing through 1Password.

bin/ has personal scripts and pre-compiled binaries with platform variants for macOS and Linux. There’s a wifi geolocation tool I wrote in Go and various shell utilities I’ve accumulated.

tmux has its own plugin ecosystem through git submodules: tpm for plugin management, resurrect for session persistence, continuum for automatic saves. The layout is DWM-inspired, matching how I think about window management.

Why Bother

I’ve set up enough machines to know the pain of doing it manually. Consulting means regular rotations between client machines and personal machines. A one-command setup saves real time.

But honestly I just like having a system. Every tool has a place, every config is version-controlled. Change a setting, commit it. Something breaks, bisect it. Try a new tool and don’t like it, revert. Thirteen years of shell configuration, all diffable.

The bootstrap rewrite was overdue. The old setup worked but required institutional knowledge I kept in my head. The new version is something I could hand someone and say “clone this, run bootstrap.sh” without caveats.

Update: I’ve since migrated the whole setup to Nix. nix-darwin and home-manager replace the Brewfile, stow, and the macOS defaults script. The bootstrap is now just switch on either macOS or Linux.

Discussion