The second time I reinstalled macOS from scratch, it cost me a full Saturday clicking through App Store installs and re-typing shell aliases from memory. The third time, it cost 40 minutes and one command. The difference wasn’t a faster internet connection — it was refusing to let the second Saturday happen a third time.

Most people treat a clean install as a one-off disaster to survive. I started treating it as a test: if I can’t rebuild this machine from a script, my setup isn’t actually backed up, it’s just sitting on a disk I haven’t lost yet.


Why This Exists

A factory-reset Mac and a five-year-old Mac with the OS reinstalled are, on paper, identical hardware. What separates a 40-minute recovery from a lost weekend is whether your configuration lives in files you can hand to a fresh machine, or only in your own memory of what you clicked in 2023.

The failure mode I kept hitting: I’d remember to back up data — Documents, Photos, projects — and completely fail to back up configuration — which 40 command-line tools I actually use daily, what my shell aliases were, which VS Code extensions I’d installed one at a time over two years and never listed anywhere. Data restores from Time Machine in an afternoon. Configuration you never wrote down doesn’t restore; it gets slowly re-discovered, one “oh right, I need that too” at a time, for the next three weeks.

The fix is boring on purpose: two files, checked into a Git repo, that describe the machine instead of relying on memory of it.


Step 1: A Brewfile That Describes Every App You Actually Use

Homebrew Bundle reads a Brewfile and installs everything listed — command-line tools, GUI apps via Homebrew Cask, even Mac App Store apps if you have mas installed. Generate one from your current machine before you ever need it:

brew bundle dump --file=~/Brewfile --force

That single command produces a plain-text list of every formula, cask, and tap currently installed. Here’s what mine actually looks like, trimmed down:

tap "homebrew/bundle"

# Command-line tools
brew "git"
brew "ffmpeg"
brew "jq"
brew "wget"
brew "gh"

# Applications
cask "visual-studio-code"
cask "docker"
cask "rectangle"
cask "iterm2"

On a fresh Mac, after installing Homebrew itself, one command reinstalls everything on that list:

brew bundle install --file=~/Brewfile

No App Store hunting, no “wait, what was that menu bar app I used to organize windows” — the answer is sitting in a text file, and the install runs unattended while you do something else.

Approach Time to fully reinstalled dev environment What it captures
Manual App Store + memory A full day, spread across a week of “oh, I forgot X” Whatever you happen to remember
Brewfile + dotfiles script ~40 minutes, mostly unattended Everything installed at the moment you last ran dump

Step 2: Dotfiles in a Repo, Symlinked Instead of Copied

Your .zshrc, .gitconfig, and editor settings are configuration in the same sense the Brewfile is — they just live as dotfiles instead of a package list. The pattern that scales is symlinking them from a Git repo into your home directory, rather than copying, because a symlink means editing the file anywhere edits it everywhere, including the repo you’ll commit later.

#!/usr/bin/env bash
# setup_dotfiles.sh — symlink dotfiles from ~/dotfiles into $HOME
set -euo pipefail

DOTFILES_DIR="$HOME/dotfiles"
FILES=(.zshrc .gitconfig .vimrc .aliases)

for file in "${FILES[@]}"; do
  target="$HOME/$file"
  source="$DOTFILES_DIR/$file"

  if [ -e "$target" ] && [ ! -L "$target" ]; then
    mv "$target" "$target.backup.$(date +%s)"
    echo "Backed up existing $file"
  fi

  ln -sfn "$source" "$target"
  echo "Linked $file"
done

The backup-before-overwrite line matters more than it looks: the first time I wrote this script, I skipped it, ran the script on a machine that still had a real .zshrc, and silently lost three custom functions I’d never gotten around to committing. Now every run keeps a timestamped copy of whatever it’s about to replace, so a mistake costs a diff, not a rewrite from memory.


Step 3: One Script That Runs Both, in Order

Stitch the two together and a fresh macOS install becomes one command instead of two separate ones you have to remember the order of:

#!/usr/bin/env bash
# bootstrap.sh — run once on a freshly installed Mac
set -euo pipefail

echo "Installing Homebrew..."
if ! command -v brew &> /dev/null; then
  /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi

echo "Installing packages from Brewfile..."
brew bundle install --file="$HOME/dotfiles/Brewfile"

echo "Cloning dotfiles..."
if [ ! -d "$HOME/dotfiles" ]; then
  git clone https://github.com/YOUR_USERNAME/dotfiles.git "$HOME/dotfiles"
fi

echo "Symlinking dotfiles..."
bash "$HOME/dotfiles/setup_dotfiles.sh"

echo "Setup complete. Restart your terminal."

That third echo — “Setup complete” — used to be the point where I’d start manually reconfiguring the fifteen things the script doesn’t cover: macOS System Settings toggles (trackpad speed, key repeat rate, Dock auto-hide), app-specific license activations, SSH key restoration from the backup you made before the wipe. Those are real gaps, not oversights — some of it genuinely needs a human decision each time (which SSH key goes where), and scripting a decision that changes per-machine just moves the judgment call, it doesn’t remove it.

Tip: defaults write can script most of those System Settings toggles too (defaults write com.apple.dock autohide -bool true, for instance) — worth adding to bootstrap.sh once you’ve confirmed the exact keys for the handful of settings you always change. I keep mine in a separate macos_defaults.sh rather than bundling it into the main script, since a wrong defaults write key is silent and harder to notice mid-run than a failed brew install.


What Actually Changed

The measurable difference isn’t the 40 minutes versus a day — it’s that the 40-minute version is boring enough that I actually do it, instead of limping along on a half-configured machine for a week because a full manual setup felt like too much to start. A script you trust removes the excuse to defer.

The real test of whether this setup works isn’t the first run — it’s forgetting you have it, reinstalling macOS eight months from now for an unrelated reason, and being mildly annoyed that it only takes 40 minutes instead of feeling relieved that it worked at all. That’s what “automated” is supposed to feel like: not exciting, just no longer a decision you have to make from scratch.