Skip to content

ADR-013 — macOS Provisioning and Recovery

Date: 2026-04-16 Status: Research / Proposed Context: If the Mac breaks, gets lost, or is replaced, the goal is to get back to a fully working setup as fast as possible. Current state: nothing is automated — apps are manually installed, .zshrc is near-empty, configs are scattered.


The Problem

Right now, setting up a new Mac means: - Remembering what was installed (brew list shows 118 packages, 13 casks) - Manually recreating dotfiles (.zshrc, .ssh/config, Ghostty config, Starship config) - Re-configuring macOS preferences (Dock, Finder, keyboard settings) - Re-installing VS Code extensions and settings - Re-setting up MCP server configs, Vault tokens, SSH keys

None of this is captured anywhere reproducible.


Approaches Compared

The Jeff Geerling approach. A single repo with an Ansible playbook that provisions everything, plus dotfiles managed alongside it.

How it works: - config.yml defines what to install (brew packages, casks, Mac App Store apps) - Ansible roles handle: Homebrew, dotfiles, macOS defaults, Dock layout - Dotfiles are stored in the repo and symlinked into place - One command to go from fresh macOS to fully configured: ansible-playbook main.yml

Pros: - You already know Ansible (your entire homelab is IaC with it) - Proven pattern — Geerling's mac-dev-playbook has 6k+ stars, actively maintained - Tags let you run just parts: --tags homebrew, --tags dotfiles, --tags osx - community.general.osx_defaults module handles macOS system preferences - Can manage remote Macs over SSH too - Fits your existing mental model: "everything is IaC"

Cons: - Ansible on localhost feels slightly odd at first - Some macOS settings require Full Disk Access for Terminal - Mac App Store installs via mas require being signed in first

Reference: geerlingguy/mac-dev-playbook

2. Nix-Darwin + Home Manager

Fully declarative macOS configuration using the Nix package manager.

How it works: - flake.nix defines the entire system state declaratively - nix-darwin manages system-level config (like NixOS but for macOS) - home-manager manages user dotfiles and per-user packages - nix-homebrew manages Homebrew casks declaratively through Nix - Run darwin-rebuild switch to apply changes

Pros: - Truly declarative — the config IS the system, not a script that modifies it - Reproducible down to exact package versions (pinned via flake.lock) - Rollback: darwin-rebuild switch --rollback instantly reverts - Large package ecosystem (80k+ packages in nixpkgs)

Cons: - Steep learning curve — Nix language is its own thing - You don't use Nix anywhere else in your stack (homelab is all Debian/Alpine + Docker) - Debugging Nix errors is painful - Some macOS GUI apps still need Homebrew casks (Nix can't manage .app bundles well) - Overkill for a single Mac

Reference: nix-darwin/nix-darwin

3. Chezmoi (Dotfiles Only)

Purpose-built dotfile manager. Doesn't handle package installation.

How it works: - chezmoi init creates a source directory (~/.local/share/chezmoi/) - chezmoi add ~/.zshrc tracks a file - Templates support machine-specific config (e.g., different paths on work vs personal Mac) - chezmoi apply deploys all dotfiles - Backed by a git repo (push to Forgejo)

Pros: - Best-in-class dotfile management — handles secrets, templates, OS differences - Uses real files (not symlinks) — you can stop using it anytime with no cleanup - Built-in encryption for sensitive dotfiles (age or gpg) - Single binary, no dependencies

Cons: - Only manages dotfiles — you still need something else for packages and macOS settings - Another tool to learn when Ansible can also manage dotfiles (via symlinks or copy)

Reference: chezmoi.io

4. Brewfile Only (Minimal Approach)

Just a Brewfile in a git repo. No automation framework.

How it works:

# Export current state
brew bundle dump --file=~/git/mac-setup/Brewfile

# Restore on new Mac
brew bundle install --file=~/git/mac-setup/Brewfile

Pros: - Zero dependencies beyond Homebrew - Dead simple — one file, two commands - Easy to maintain (brew bundle dump regenerates it)

Cons: - Only handles packages — not dotfiles, not macOS settings, not app configs - Covers maybe 30% of the full setup - No idempotent system preferences or Dock configuration

5. Shell Script (DIY)

A bash script that runs brew install, copies dotfiles, runs defaults write.

Pros: No framework dependency, easy to understand. Cons: Not idempotent, fragile, hard to maintain, reinvents what Ansible already does. You'd end up building a bad version of Ansible.


Recommendation: Ansible + Forgejo Repo

Given that your entire homelab is Ansible-based, the natural choice is an Ansible playbook in a Forgejo repo. You already think in playbooks and roles.

Proposed Repo Structure

mac-setup/                          # Forgejo repo: holo/mac-setup
├── main.yml                        # Main playbook
├── inventory.yml                   # localhost
├── config.yml                      # Your personal config (overrides defaults)
├── default.config.yml              # Default values
├── requirements.yml                # Ansible Galaxy roles
│
├── dotfiles/                       # All dotfiles stored here
│   ├── zshrc
│   ├── ssh_config
│   ├── gitconfig
│   ├── starship.toml
│   └── ghostty/
│       └── config
│
├── tasks/
│   ├── homebrew.yml                # Brew packages and casks
│   ├── dotfiles.yml                # Symlink dotfiles into place
│   ├── macos-defaults.yml          # System preferences via defaults write
│   ├── dock.yml                    # Dock layout
│   ├── vscode.yml                  # VS Code extensions and settings
│   ├── ssh.yml                     # SSH key setup
│   └── mcp.yml                     # MCP server config (.mcp.json, settings.json)
│
└── files/
    ├── vscode-extensions.txt       # VS Code extension list
    └── launchd/                    # LaunchAgent plists
        └── backup-to-cajita.plist

What Each Task File Handles

homebrew.yml — packages, casks, and taps:

# config.yml
homebrew_taps:
  - homebrew/cask-fonts

homebrew_packages:
  - ansible
  - bat
  - eza
  - fd
  - fzf
  - git
  - rclone
  - ripgrep
  - rsync
  - starship
  - tldr
  - zoxide
  - atuin
  # ... all 118+ packages

homebrew_casks:
  - ghostty
  - visual-studio-code
  - iterm2
  - discord
  - rectangle
  - rsyncui
  # ... all casks

dotfiles.yml — symlink from repo into home:

- name: Symlink dotfiles
  ansible.builtin.file:
    src: "{{ playbook_dir }}/dotfiles/{{ item.src }}"
    dest: "{{ item.dest }}"
    state: link
    force: true
  loop:
    - { src: zshrc, dest: "~/.zshrc" }
    - { src: ssh_config, dest: "~/.ssh/config" }
    - { src: gitconfig, dest: "~/.gitconfig" }
    - { src: starship.toml, dest: "~/.config/starship.toml" }
    - { src: "ghostty/config", dest: "~/.config/ghostty/config" }

macos-defaults.yml — system preferences:

- name: Finder — show file extensions
  community.general.osx_defaults:
    domain: NSGlobalDomain
    key: AppleShowAllExtensions
    type: bool
    value: true

- name: Finder — show hidden files
  community.general.osx_defaults:
    domain: com.apple.finder
    key: AppleShowAllFiles
    type: bool
    value: true

- name: Dock — auto-hide
  community.general.osx_defaults:
    domain: com.apple.dock
    key: autohide
    type: bool
    value: true

- name: Keyboard — fast key repeat
  community.general.osx_defaults:
    domain: NSGlobalDomain
    key: KeyRepeat
    type: int
    value: 2

- name: Disable personalized ads
  community.general.osx_defaults:
    domain: com.apple.AdLib
    key: allowApplePersonalizedAdvertising
    type: bool
    value: false

vscode.yml — extensions and settings:

- name: Export current extensions (for reference)
  # Run manually: code --list-extensions > files/vscode-extensions.txt

- name: Install VS Code extensions
  ansible.builtin.command:
    cmd: "code --install-extension {{ item }}"
  loop: "{{ lookup('file', 'files/vscode-extensions.txt').splitlines() }}"
  changed_when: false

Bootstrap on a Fresh Mac

# 1. Install Xcode CLI tools (needed for git and Ansible)
xcode-select --install

# 2. Install Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# 3. Install Ansible
brew install ansible

# 4. Clone the repo (HTTPS first — SSH keys don't exist yet)
git clone https://git.eva-00.network/holo/mac-setup.git ~/git/mac-setup

# 5. Install Ansible Galaxy roles
cd ~/git/mac-setup
ansible-galaxy install -r requirements.yml

# 6. Run it
ansible-playbook main.yml --ask-become-pass

# 7. Post-setup: restore SSH keys from backup, re-auth Vault, sign into MAS

What Can't Be Automated

Some things require manual steps on a fresh Mac: - SSH private keys — restore from encrypted backup (senku USB, restic, or 1Password/Keychain) - Vault token — re-run vault-bootstrap-claude workflow after Forgejo access is restored - Mac App Store sign-in — required before mas install can run - iCloud/Apple ID — manual sign-in - Browser profiles/logins — sync via browser account or export/import - Full Disk Access — Terminal needs FDA permission for some defaults write commands

Maintenance Workflow

# After installing a new brew package manually:
brew bundle dump --file=~/git/mac-setup/Brewfile --force
# Then update config.yml to match

# After changing a dotfile:
# Edit the dotfile in ~/git/mac-setup/dotfiles/ (the symlink means changes are immediate)
cd ~/git/mac-setup && git add -A && git commit -m "update zshrc" && git push

# Periodic full sync:
ansible-playbook main.yml --ask-become-pass

Adopting an Existing Mac (Current State)

The Mac already has 27 brew packages, 13 casks, 21 VS Code extensions, 3 custom LaunchAgents, dotfiles, and macOS preferences configured. Here's how each approach handles adopting what's already there.

Current inventory (captured 2026-04-16)

Homebrew (27 packages, 13 casks):

# Packages
ansible, autoconf, automake, coreutils, git, kompose, librsvg, libtool,
mcp-grafana, media-info, mkvtoolnix, node, [email protected], pkgconf, pwgen,
restic, rsync, ruby, sshpass, testdisk, wget, wireguard-tools, zlib,
terraform, netbird, libyaml, libksba

# Casks
aegisub, calibre, commander-one, discord, forklift, iterm2, macfuse,
parallels, reader, rectangle, rsyncui, visual-studio-code, wifiman

# Taps
gromgit/fuse, hashicorp/tap, netbirdio/tap

VS Code extensions (21):

anthropic.claude-code, github.github-vscode-theme, hashicorp.hcl,
hashicorp.terraform, ms-kubernetes-tools.vscode-kubernetes-tools,
ms-python.*, ms-toolsai.jupyter*, ms-vscode.makefile-tools,
qwtel.sqlite-viewer, redhat.ansible, redhat.vscode-yaml,
willasm.obsidian-md-vsc, kiranshah.chatgpt-helper

Dotfiles:

~/.zshrc          — near-empty (just PATH exports)
~/.bashrc         — near-empty (just RVM PATH)
~/.gitconfig      — name: miltongz, email: [email protected]
~/.ssh/config     — 2 hosts (github wildcard, forgejo)

LaunchAgents (custom):

com.holo.backup-to-cajita.plist     — daily restic backup at 10:00
com.homelab.mount-nfs-wake.plist    — auto-mount NFS on wake
com.homelab.mount-smb-wake.plist    — auto-mount SMB on wake

macOS defaults (current values):

Dock autohide: true
Show all extensions: true
Show hidden files: not set (default)
Key repeat: 5 (not fast)

Approach 1: Ansible (smooth adoption)

Ansible is naturally idempotent, so adopting an existing system is painless:

Step 1 — Capture everything into the repo:

# Dump current brew state (this is your starting point)
brew bundle dump --file=~/git/mac-setup/Brewfile --force

# Copy dotfiles into repo (not move — we'll symlink later)
cp ~/.zshrc ~/git/mac-setup/dotfiles/zshrc
cp ~/.ssh/config ~/git/mac-setup/dotfiles/ssh_config
cp ~/.gitconfig ~/git/mac-setup/dotfiles/gitconfig

# Copy LaunchAgents
cp ~/Library/LaunchAgents/com.holo.* ~/git/mac-setup/files/launchd/
cp ~/Library/LaunchAgents/com.homelab.* ~/git/mac-setup/files/launchd/

# Copy scripts
cp ~/bin/*.sh ~/git/mac-setup/files/bin/

# Export VS Code extensions
code --list-extensions > ~/git/mac-setup/files/vscode-extensions.txt

Step 2 — Replace originals with symlinks:

# Ansible does this for you, but the first run replaces real files with symlinks
# The dotfiles.yml task uses force: true, so it overwrites the existing file with a link
# From then on, editing ~/.zshrc actually edits the repo copy

Step 3 — Run the playbook:

ansible-playbook main.yml --ask-become-pass
# Homebrew tasks: already-installed packages are skipped (idempotent)
# Dotfile tasks: real files replaced with symlinks to repo
# macOS defaults: already-correct values are skipped
# Everything is a no-op on an already-configured machine

Why this works well: Ansible's homebrew module checks if a package is already installed before doing anything. osx_defaults checks the current value before writing. The whole playbook is safe to run repeatedly — on your current Mac it'll mostly no-op, on a fresh Mac it'll install everything.

Approach 2: Nix-Darwin (disruptive adoption)

Nix-Darwin is more invasive because it wants to own the system:

The problem: Nix installs packages into /nix/store/ and creates symlinks in ~/.nix-profile/. Your existing Homebrew packages stay in /opt/homebrew/. You end up with two package managers fighting over $PATH priority.

Adoption path: 1. Install Nix: curl -L https://nixos.org/nix/install | sh 2. Install nix-darwin and define your flake.nix 3. Move packages from Homebrew to Nix one by one (some casks must stay in Homebrew) 4. Use nix-homebrew to manage remaining Homebrew casks declaratively 5. Run darwin-rebuild switch

The friction: - You can't just dump current state into a Nix config — you have to translate everything manually into Nix language - GUI apps (casks) mostly stay in Homebrew anyway, managed via nix-homebrew - Existing dotfiles need to be rewritten as Home Manager config (Nix syntax, not the original format) - Two package managers running simultaneously until you fully migrate - If something breaks, debugging Nix on macOS is harder than Ansible

Honest assessment: Adopting Nix on an existing Mac is a multi-day project. The payoff (exact reproducibility, rollback) is real but the cost is high, especially since you don't use Nix elsewhere.

Approach 3: Chezmoi (dotfiles only, non-disruptive)

Chezmoi is the gentlest adoption — it only touches dotfiles, not packages:

Step 1 — Initialize and add existing files:

brew install chezmoi
chezmoi init

# Add files one at a time (chezmoi copies them to its source dir)
chezmoi add ~/.zshrc
chezmoi add ~/.gitconfig
chezmoi add ~/.ssh/config
chezmoi add ~/.config/ghostty/config

# Push source to Forgejo
chezmoi cd  # enters ~/.local/share/chezmoi/
git remote add origin [email protected]:holo/dotfiles.git
git push -u origin main

Step 2 — On a new Mac:

chezmoi init [email protected]:holo/dotfiles.git
chezmoi apply  # deploys all dotfiles

The catch: This only handles dotfiles. You still need Ansible (or a Brewfile, or a shell script) for packages, macOS defaults, VS Code extensions, and LaunchAgents. So chezmoi alone doesn't solve the problem — but it could complement Ansible.

Comparison for adoption

Factor Ansible Nix-Darwin Chezmoi
Captures current state brew bundle dump + copy files Manual translation to Nix chezmoi add per file
Disruption to running system None (symlinks dotfiles, rest is idempotent) Significant (new package manager, PATH changes) None (copies files)
Time to adopt 1-2 hours 1-2 days 30 minutes (dotfiles only)
Covers packages Yes Yes No
Covers macOS defaults Yes (osx_defaults module) Yes (defaults in nix-darwin) No
Covers LaunchAgents Yes (copy task) Yes (launchd in nix-darwin) No
Covers VS Code Yes (extension install task) Partial (via home-manager) No
Ongoing maintenance Edit files in repo, re-run playbook Edit flake.nix, darwin-rebuild switch Edit source, chezmoi apply
Fits existing workflow Perfectly (you already use Ansible) New paradigm to learn Simple but incomplete

Verdict

Ansible is the clear winner for adoption. The existing Mac becomes managed in an afternoon: 1. Dump current state (brew bundle dump, copy dotfiles) 2. Commit to Forgejo 3. Run the playbook — it no-ops on everything already correct 4. From now on, the repo is the source of truth

Nix-Darwin — technically superior but adoption cost doesn't justify it for a single Mac with no Nix elsewhere in the stack.

Chezmoi — revisit only if a second machine (work laptop, Linux desktop) enters the picture and dotfiles need per-machine templating. For a single Mac with 4 dotfiles, Ansible's symlink approach is equivalent with zero added tooling.


Decision

Phase 1 — Capture current state

  1. Create holo/mac-setup repo on Forgejo
  2. Run brew bundle dump to generate initial Brewfile
  3. Move dotfiles (.zshrc, .ssh/config, .gitconfig) into the repo
  4. Create basic main.yml that installs Homebrew packages and symlinks dotfiles

Phase 2 — Add macOS preferences

  1. Audit current defaults read settings worth preserving
  2. Add macos-defaults.yml task file
  3. Add Dock configuration
  4. Add VS Code extension list

Phase 3 — Polish

  1. Add LaunchAgent plists (backup-to-cajita, etc.)
  2. Add MCP config deployment (.mcp.json, settings.json)
  3. Document bootstrap steps in README
  4. Test on a fresh macOS VM (Parallels) to verify end-to-end

Sources