How I Keep My Mac Up to Date With Dotfiles and Ansible
The commit log of my dotfiles repo reads like a diary of a bad week. "Fix git under Rosetta." "Target native Apple Silicon Homebrew." "Drop Rosetta leftovers." "Ansible: stop forcing cask installs over hand-installed apps."
None of those commits are about adding a feature. They are all about the same thing: my Mac had drifted away from what I thought it was. Intel Homebrew in one place, native Homebrew in another, an app I installed by hand that Homebrew did not know about, a font the old install left behind that made the new one fail.
That is the real job of a dotfiles repo. Not "back up my .zshrc." The job is keeping the machine and the repo telling the same story, so that when something drifts, you notice and fix it in one place.
This article is how I do that on macOS with an Ansible-driven dotfiles setup. What the repo looks like, how a bare Mac becomes my Mac with one command, how I update it afterward, and the specific things that broke along the way. If you want to skip to the code, the whole thing is public at alexcloudstar/.dotfiles.
What "Up to Date" Actually Means for a Mac
When people say they keep their Mac up to date, they usually mean one of three different things. Mixing them up is why most dotfiles setups quietly rot.
Config drift. Your .zshrc, .tmux.conf, and Neovim config on the machine no longer match the repo. You tweaked something in a hurry and never committed it.
Software drift. You ran brew install something to follow a tutorial. Now the machine has a tool the repo has never heard of. Rebuild the Mac from the repo and that tool is gone.
Toolchain drift. Node, Rust, Go, and Python versions move. Global packages pile up. The thing that works on this machine fails on the next one because it was set up in a different order.
A symlink manager solves the first one. A Brewfile solves most of the second. Neither touches the third.
My rule is simple. The repo is the only place installs get recorded. If I want a tool, it goes into a file first, then the machine catches up. If it is on the machine and not in the file, it is drift, and I either add it or remove it.
Where Stow Stopped Being Enough
My dotfiles repo started in January 2024 with a Neovim config and a stow-compatible layout. I wrote about that approach in Dotfiles: The Secret Weapon for Effortless Configuration Management, and the advice there still holds for the first step. Put configs in a repo. Link them into place.
GNU Stow is good at exactly that. It is small, it has no config of its own, and it does one thing. The trouble is that "one thing" is only the first of the three drifts.
Stow manages files. It does not know about Homebrew. It does not install Node. It does not set your login shell. On a fresh Mac you still run a pile of commands from memory, or from a setup.sh that you stopped updating a year ago.
Other people hit the same wall. In a write-up on migrating from Stow to chezmoi, the author describes Stow writing every edit straight into each machine's repo clone, refusing to link over existing files like ~/.zprofile on fresh machines, and leaving packages and macOS settings in separate scripts. Different fix than mine, same diagnosis: the symlinks were the easy part.
So in April 2026 I added an Ansible playbook to the repo. Stow is still installed on my machine and nothing uses it. That is how I know the migration is done.
Why Ansible for macOS Dotfiles
Ansible is not the obvious choice for a personal laptop. It is built for fleets of servers, it is written in YAML, and it feels like overkill for one machine.
I picked it for three reasons that matter more on a laptop than people expect.
It is idempotent by design. Every task describes a state, not a command. "This symlink exists." "This package is installed." Run the playbook ten times and the tenth run changes nothing. That is the property that turns "setup script I run once" into "command I can run any time."
It does everything in one language. Symlinks, Homebrew taps, formulae, casks, /etc/shells, rustup, nvm, global npm packages. One tool, one command, one place to read what the machine looks like.
It has a dry-run mode. ansible-playbook site.yml --check tells you what would change without changing it. For a repo whose job is to reveal drift, that is the feature.
The cost is verbosity. Ansible wants twelve lines for what a shell one-liner does in one. I accept that because the twelve lines are still readable six months later.
For a well-known example of the same idea, look at Jeff Geerling's mac-dev-playbook. It covers Homebrew, Mac App Store apps, dotfiles, and macOS settings, with tags like dotfiles and homebrew for running a subset. As far as I can tell from its README, it is about installing. What to do a month later is the problem I actually have.
The Layout: One Command, Six Roles
The repo has one entry point. On a fresh Mac, the whole install is:
git clone --recurse-submodules https://github.com/alexcloudstar/.dotfiles ~/.dotfiles
cd ~/.dotfiles
./bootstrap.sh
bootstrap.sh is a plain bash script. It checks that you are on macOS and not running under Rosetta, installs Xcode Command Line Tools if they are missing, installs Homebrew, installs Ansible with Homebrew, installs the one Ansible collection the playbook needs, and runs the playbook:
ansible-galaxy collection install -r requirements.yml
ansible-playbook site.yml --ask-become-pass "$@"
That "$@" matters. Any argument you pass to bootstrap.sh goes straight to ansible-playbook, which is how ./bootstrap.sh --skip-tags claude works later.
The playbook applies six roles in order, and each role has a tag with the same name:
roles:
- { role: homebrew, tags: [homebrew] }
- { role: packages, tags: [packages] }
- { role: shell, tags: [shell] }
- { role: languages, tags: [languages] }
- { role: symlinks, tags: [symlinks] }
- { role: claude, tags: [claude] }
The order is not arbitrary. Homebrew has to exist before packages. Packages have to exist before the shell role, because the shell role wants Homebrew's zsh. The languages role wants go from Homebrew. Symlinks come last so a half-finished install never leaves you with broken config pointing at tools that are not there.
The repo itself is boring on purpose. One folder per tool (nvim/, zsh/, tmux/, yabai/, skhd/, gitconfig/), a local/ folder for scripts, and ansible/ for the playbook. No templating, no per-machine variables. One Mac, one config.
Config Drift: Plain Symlinks Beat Clever Tools
The symlinks role is the part Stow used to do. It is about thirty lines:
- name: Symlink home-level dotfiles
ansible.builtin.file:
src: "{{ dotfiles_root }}/{{ item.src }}"
dest: "{{ ansible_facts['env']['HOME'] }}/{{ item.dest }}"
state: link
force: true
loop:
- { src: "gitconfig/.gitconfig", dest: ".gitconfig" }
- { src: "tmux/.tmux.conf", dest: ".tmux.conf" }
- { src: "zsh/.zshrc", dest: ".zshrc" }
state: link with force: true means the link is created if missing, replaced if wrong, and left alone if right. That also fixes the Stow complaint above: a stale file at the destination does not block the run.
The reason I like symlinks over copies is the update path. When I edit ~/.zshrc, I am editing the file in the repo. git status tells me immediately that the machine and the repo disagree. There is no separate "sync" step to forget.
You do not need a dedicated tool for this. Ansible's file module is enough, and the link lives next to the rest of your provisioning.
Software Drift: Package Lists in One File
Every package, tap, and cask lives in a single variables file, ansible/roles/packages/vars/main.yml. Today that is about 35 formulae and 15 casks across four third-party taps.
homebrew_casks:
- claude
- docker-desktop
- ghostty
- google-chrome
- iterm2
- raycast
- visual-studio-code
The task that uses it is short:
- name: Install Homebrew casks
community.general.homebrew_cask:
name: "{{ homebrew_casks }}"
path: "{{ brew_bin | dirname }}"
state: present
accept_external_apps: true
If you are new to Homebrew, the split is simple. Formulae are command-line tools and libraries, built or poured into Homebrew's own prefix. Casks are macOS apps and fonts, installed with the vendor's installer into /Applications. Ansible has a separate module for each, so my vars file has two lists.
Adding a tool is a one-line pull request to myself. Removing one is the same. And because the file is the source of truth, "what is on my machine" is a question I can answer by reading it.
There is one wrinkle with third-party taps. Homebrew ignores formulae from taps it has not been told to trust, so yabai and skhd from asmvik/formulae need a brew trust first. The playbook runs it for those two formulae before the install step. Worth knowing if you pull from taps.
Toolchain Drift: Check First, Then Install
Languages are where "idempotent" gets tested, because their installers are not.
nvm, bun, and rustup all install by piping a script from the internet into a shell. Run that twice and you get either a duplicate, an error, or a surprise. So the languages role asks before it acts:
- name: Check installed go tools
ansible.builtin.stat:
path: "{{ ansible_facts['env']['HOME'] }}/go/bin/{{ item | basename }}"
loop: "{{ go_tools }}"
register: go_tool_stats
- name: Install missing go tools
ansible.builtin.command: "{{ brew_bin | dirname }}/go install {{ item.item }}@latest"
loop: "{{ go_tool_stats.results }}"
when: not item.stat.exists
First task checks whether the binary exists. Second task installs only the ones that do not. Same pattern for nvm, bun, rustup, Node, and the global npm packages. The playbook installs nvm at a pinned version, Node at the latest LTS, vercel as the one global npm package, a stable Rust toolchain, and five Go tools.
There is a tradeoff in that go-tools task, and I wrote it into a comment in the file: it only installs when the binary is missing. It never updates them. If I want newer gopls, I run go install by hand. I would rather have a predictable playbook than one that quietly changes versions on every run.
That tradeoff is the whole design question for toolchains. A re-runnable playbook and an auto-updater are two different tools, and I keep them separate.
How I Actually Update the Mac: The Upkeep Tag
This is the part most dotfiles guides skip, and the reason I wrote this article.
A normal run of my playbook installs what is missing and fixes what has drifted. It does not upgrade anything. Upgrading is slow, occasionally breaks things, and I do not want it happening every time I re-link a config file.
So upgrades live behind their own tag:
- name: Upgrade Homebrew formulae and casks
community.general.homebrew:
upgrade_all: true
path: "{{ brew_bin | dirname }}"
tags: [never, upkeep]
- name: Remove old Homebrew downloads and versions
ansible.builtin.command: "{{ brew_bin }} cleanup"
changed_when: true
tags: [never, upkeep]
never is a special Ansible tag. A task tagged never is skipped unless you ask for another tag it carries. Add upkeep to the same list and the task runs only when I run:
cd ~/.dotfiles/ansible
ansible-playbook site.yml --tags upkeep
That one command runs brew upgrade for formulae and casks, then brew cleanup to clear old versions. A plain ./bootstrap.sh never touches either.
Two things to know. First, the homebrew role updates Homebrew's own index on every run, so you always install against fresh package data even when you do not upgrade. Second, never is not as safe as it looks. Ansible applies a role's tag to every task inside it, and that includes tasks tagged never. Run --tags packages and the upgrade tasks come along for the ride. I caught this in my own repo with --list-tasks, and I fixed it by adding a guard to both tasks:
when: "'upkeep' in ansible_run_tags"
Now the upgrade only runs when upkeep is one of the tags you asked for. ansible-playbook site.yml --tags <tag> --list-tasks is a good habit before you trust any tag: it shows exactly what will run.
The mental model I settled on has two commands:
- Converge:
./bootstrap.sh. Make the machine match the repo. Safe, fast, run it whenever. - Upkeep:
--tags upkeep. Move versions forward. Run it on purpose.
Separating them is the single best decision in the repo.
The Gotchas That Cost Me an Afternoon Each
Most of the value in an Ansible setup is in the problems you only find by running it on a real, messy machine. Here are the ones that left a mark in the commit history.
Rosetta Pretending to Be Apple Silicon
For a while my Apple Silicon Mac was running an Intel-era setup. Leftovers from the old Intel Homebrew, x86_64 toolchains, git needing a workaround under Rosetta. Mostly it worked, which is exactly the problem.
The fix was to stop assuming and start checking. The playbook now fails fast if it detects an x86_64 shell on arm64 hardware:
- name: Fail if running under Rosetta on Apple Silicon
ansible.builtin.fail:
msg: "Running under Rosetta (x86_64). Open a native arm64 terminal and rerun."
when:
- ansible_facts['os_family'] == 'Darwin'
- ansible_facts['architecture'] != 'arm64'
bootstrap.sh has the same guard in shell, because Ansible itself is not installed yet when the first check needs to run. The languages role goes further and reinstalls rustup natively if it finds an x86_64 binary.
The lesson is general. If your setup has an assumption, encode it as a check that fails loudly. A guard that costs two lines is cheaper than a month of slow, subtly wrong builds.
Docker Desktop Needs sudo, and Ansible Has No Terminal
The docker-desktop cask links docker-compose into /usr/local/cli-plugins and runs sudo mkdir to create the folder. Inside a playbook there is no terminal to type a password into, so Homebrew died with sudo: a password is required and rolled the install back.
The fix is to create the folder first, as a separate task that Ansible can escalate properly:
- name: Create Docker CLI plugins directory
ansible.builtin.file:
path: /usr/local/cli-plugins
state: directory
mode: "0755"
become: true
The pattern applies to any cask that wants sudo. Find what it is trying to create and create it yourself, ahead of time, with become.
A Real Folder Where a Symlink Should Be
My Claude Code config lives in a private submodule and gets linked into ~/.claude. On one run a real ~/.claude/skills directory was already sitting where the link should go. A symlink cannot replace a directory with files in it, so the task would fail.
The role now looks for real directories at link destinations and moves them aside instead of deleting them:
- name: Move aside real directories blocking Claude Code symlinks
ansible.builtin.command:
argv: [mv, "{{ item.item }}", "{{ item.item }}.pre-dotfiles-bak"]
creates: "{{ item.item }}.pre-dotfiles-bak"
when: item.stat.exists and item.stat.isdir and not item.stat.islnk
I found out later that one of those backed-up folders held a skill I still needed. Moving aside instead of deleting is the only reason I got it back. Never let automation destroy something it did not create.
Dry Runs Cannot Always Dry-Run
--check is the best feature in Ansible for this use case, with one caveat. Tasks that need sudo, like adding Homebrew's zsh to /etc/shells, fail in check mode when there is no password available. My dry run reports one failure on that task when I run it without a terminal, and that is expected. If you want a fully clean dry run, run it from a real terminal with --ask-become-pass.
Hand-Installed Apps
The last one is about apps you installed by dragging a .dmg into /Applications. Homebrew refuses to install a cask over an app that is already there.
You have three options:
- Leave them alone.
accept_external_apps: true, which my casks task uses, tells Ansible to skip apps that already exist. - Adopt them.
brew install --cask --adopt <name>tells Homebrew to take over an existing app. Its help text says it adopts existing artifacts that are identical to the ones being installed, so it only works when the app on disk matches the version Homebrew would install. The Ansiblehomebrew_caskmodule has no option for it, so it is a manual step outside the playbook. - Trash and reinstall. Move the app to the Trash, run
brew install --cask <name>, and Homebrew owns it from then on. Your settings live in~/Library, so they survive.
I looked at adopting first, decided it did not fit the playbook, and ended up trashing and reinstalling nine apps. Docker Desktop and OBS needed a second attempt. If you are going to do it, do it once, early, and then let Ansible own them.
Stow vs Chezmoi vs Ansible vs Nix: Which Should You Pick?
There is no single right answer, and anyone who tells you otherwise is selling something. Here is how I think about it after living with a few of them.
| Tool | What it manages | Good at | Where it hurts |
|---|---|---|---|
| GNU Stow | Symlinks | Tiny, no config, easy to understand | Files only. Nothing for packages or settings |
| chezmoi | Dotfiles, with templates | Per-machine differences, encryption, one-command bootstrap | Copies files instead of linking, so a separate apply step |
| Brewfile | Homebrew packages | The lightest way to pin what is installed | Packages only |
| Ansible | Everything | One tool and one command for the whole machine | Verbose YAML, needs sudo, overkill for one config file |
| nix-darwin | Everything, declaratively | The most reproducible option | Steep learning curve, and GUI apps often still come from Homebrew |
My own rough rules:
- One machine, simple config, no packages to track: Stow or plain symlinks. Do not over-build.
- Several machines that differ a little: chezmoi. Its templating handles per-machine differences better than anything else on this list.
- One or two Macs and you want the whole machine described: Ansible, if you are comfortable with YAML.
- You like going deep and want reproducibility above all: look at nix-darwin. People who use it commonly pair it with Homebrew for GUI apps, as in this write-up on using Nix as a Homebrew user.
The split I see most often in real setups is the same one: a tool for the user's files, and a tool for the system. Mine just happens to be one tool for both.
If you need a refresher on keeping your own machine's tool sprawl in check before you even pick one, I wrote about auditing a dev machine with SnapPoint.
Should You Copy This Setup?
Probably not line for line. A dotfiles repo is personal, and mine has opinions: yabai and skhd for tiling, Neovim with lazy.nvim, iTerm2 and Ghostty side by side, Homebrew Python and no version manager.
What I would copy is the shape: one entry point, one role per concern with tags, check before you install, converge and upkeep as separate commands, guards that fail loudly, and move things aside instead of deleting them.
If you want a working starting point, fork the repo. The README lists every language, formula, cask, and Neovim plugin it installs, explains each role, and documents the flags for skipping parts you do not want:
./bootstrap.sh --skip-tags claude # no access to the private submodule
./bootstrap.sh --skip-tags packages # keep your own brew setup
The claude/ folder is a private submodule, so skip that role or point .gitmodules at your own repo. Everything else is public. The first tagged release is v1.0.0. If it saves you an afternoon, a star on alexcloudstar/.dotfiles helps other people find it.
Once the base is in place, the same repo is a good home for your AI tooling config too. I covered how to organize that in writing a Claude Code skill, and a dotfiles repo is exactly where those folders belong.
Frequently Asked
Is Ansible good for managing dotfiles? It is good for provisioning a whole machine and fine for linking files. It is not the best tool if all you need is symlinks. Use it when you also want packages, shell setup, and language toolchains handled by the same command.
How do I set up a new Mac with Ansible? Install Apple's command line tools, Homebrew, and Ansible, then run a playbook from your dotfiles repo with --ask-become-pass. A small bootstrap script that does all of that, like the one in my repo, turns it into a single command.
How do I keep Homebrew packages up to date from a dotfiles repo? Keep the package list in a file in the repo, install from it, and run brew upgrade as a separate step. In my setup that step is an Ansible tag, upkeep, that never runs during a normal install.
Can I use brew install --cask --adopt in Ansible? Not through the homebrew_cask module, which has no adopt option. Run it manually once per app, or use accept_external_apps: true to leave hand-installed apps alone.
What about secrets in a public dotfiles repo? Keep them out. Use environment variables, ignored files, or a private submodule for anything sensitive. My Claude Code config is a private submodule for that reason.
My Take
I used to think of dotfiles as a backup. A place to put files so I would not lose them. That framing is why my first version was a pile of configs and a script I stopped updating.
The better framing is that a dotfiles repo is a contract. It says what my machine is supposed to be. The playbook enforces that contract, and the commit history records every time reality and the contract disagreed.
That is also why the boring parts matter most. A guard that rejects the wrong architecture. A task that creates a folder before a cask needs it. A flag that moves a stale directory aside instead of deleting it. None of it is clever. All of it is the difference between a repo that works on the day you write it and one that works the day you actually need it.
I do not think everyone needs Ansible for this. If Stow plus a Brewfile covers you, stay there, and be happy. But if you have ever rebuilt a Mac and spent the evening remembering what you had installed, write the contract down. Pick the smallest tool that can enforce it. Then run it often enough that the repo and the machine never get a chance to drift far apart.
If you want to see what that looks like in practice, the whole thing is at alexcloudstar/.dotfiles. Clone it, break it, and tell me what I missed.