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
1. Ansible Playbook + Dotfiles Repo (Recommended)
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
- Create
holo/mac-setuprepo on Forgejo - Run
brew bundle dumpto generate initial Brewfile - Move dotfiles (
.zshrc,.ssh/config,.gitconfig) into the repo - Create basic
main.ymlthat installs Homebrew packages and symlinks dotfiles
Phase 2 — Add macOS preferences
- Audit current
defaults readsettings worth preserving - Add
macos-defaults.ymltask file - Add Dock configuration
- Add VS Code extension list
Phase 3 — Polish
- Add LaunchAgent plists (backup-to-cajita, etc.)
- Add MCP config deployment (
.mcp.json,settings.json) - Document bootstrap steps in README
- Test on a fresh macOS VM (Parallels) to verify end-to-end
Sources
- geerlingguy/mac-dev-playbook — The gold standard Ansible Mac playbook
- geerlingguy/ansible-collection-mac — Ansible Galaxy collection for macOS
- Ansible osx_defaults module docs
- nix-darwin/nix-darwin — Declarative macOS with Nix
- How I Built a Reproducible Mac Setup with Nix
- Declarative macOS with nix-darwin and home-manager
- chezmoi comparison table — Dotfile managers compared
- Dotfile Management Tools: YADM vs Chezmoi vs Nix
- Every way to automate a Mac setup, ranked
- ansible-mac-security — Security/privacy baseline playbook
- Automating macOS with Ansible — Nick Charlton
- Full Mac setup guide — Geerling