Brewfiles and brew bundle: Version-Control Your Entire macOS Toolset
A Brewfile plus brew bundle turns ad-hoc brew install commands into one version-controlled, idempotent provisioning step. Here's the format, the workflow, and the traps.
10 Nov 2025, 19:54 UTC

If you provision new Macs, onboard teammates, or maintain dotfiles, the practical answer to "how do I keep my tools consistent" is a Brewfile: a single declarative file that lists every tap, formula, cask, and Mac App Store app you want, processed in one run by brew bundle. Instead of a README full of brew install commands that drifts out of date, you commit one file to a repo and run one command on a fresh machine.
What a Brewfile looks like
A Brewfile is plain Ruby, but most files are purely declarative. Each line type maps to a different kind of install:
# Brewfile
tap "homebrew/cask-fonts"
brew "git"
brew "ripgrep"
brew "jq"
cask "docker"
cask "visual-studio-code"
cask "font-jetbrains-mono"
mas "Xcode", id: 497799835
The line types: tap adds a third-party formula repository, brew installs a command-line formula, cask installs a GUI application or font, and mas installs a Mac App Store app by its numeric ID (via the separate mas tool). You find an app's ID with mas search xcode after installing mas itself.
The core workflow
Run these from the directory containing your Brewfile (or point at one with --file). No special permissions beyond a normal Homebrew install are needed; casks that install to /Applications may prompt for your password.
brew bundle install— installs everything listed. Already-installed entries are skipped, so the command is idempotent and safe to re-run in a dotfiles setup script.brew bundle check— exits zero if everything is present, non-zero with a list of what's missing otherwise. This makes it a useful onboarding or CI gate:brew bundle check || brew bundle install.brew bundle dump --force— snapshots what's currently installed into a Brewfile (the--forceoverwrites an existing file). Add--describeto annotate each entry with its description.brew bundle cleanup— lists installed items that are not in the Brewfile. With--forceit actually uninstalls them.
A typical bootstrap for a new machine is then just:
git clone https://github.com/you/dotfiles.git ~/.dotfiles
cd ~/.dotfiles
brew bundle check || brew bundle install
Expected result: check prints the missing entries and exits non-zero on a fresh machine; after install completes, re-running check prints something like "The Brewfile's dependencies are satisfied" and exits zero. Verify the exit code yourself with echo $? rather than trusting the message.
Generating a Brewfile from an existing machine
brew bundle dump captures what is installed, not why. The raw dump includes dependencies you never chose directly and casks you tried once and forgot. Treat the dump as a draft: compare it against brew leaves (formulae you explicitly installed, not pulled in as dependencies) and brew list --cask, then delete anything you wouldn't deliberately install on a new machine. A curated 20-line Brewfile ages far better than a verbatim 120-line dump.
Isolating builds with brew bundle exec
A less-known subcommand, brew bundle exec <cmd>, runs a command in an environment containing only the tools declared in the Brewfile, hiding other globally installed formulae from PATH. This is useful for reproducing a "works on my machine" failure: if a build script secretly depends on a tool you installed ad-hoc, it will fail under bundle exec until you add that tool to the Brewfile — which is exactly what you want to discover before a teammate does.
Limits and common mistakes
masentries are fragile. They require being signed into the App Store, only work on macOS, and can fail in headless or CI contexts. Keep them in a separate section or a second Brewfile (brew bundle --file=Brewfile.mas) so amasfailure doesn't block the rest of provisioning.cleanup --forceis destructive. It uninstalls anything installed but not listed — including tools a colleague installed intentionally outside the Brewfile. Always run plainbrew bundle cleanupfirst and read the list before adding--force.- Names drift. Cask tokens and tap paths change over time (notably, Homebrew deprecated the
homebrew/casktap prefix because casks are now in the core API). An old Brewfile may reference moved or removed entries;brew bundle checkwill surface these as errors on a current Homebrew. - Resist clever Ruby. Because Brewfiles are Ruby, you can branch on
OS.mac?and similar. Occasional conditionals are fine, but heavy logic breaksbrew bundle dumpround-trips and makes the file harder to review. Prefer two small files over one clever one. - It manages Homebrew things only. A Brewfile won't pin formula versions, manage language runtimes (use a version manager for that), or configure the apps it installs — pair it with your dotfiles for settings.
Verifying before you commit
Subcommands and flags do evolve, so confirm against your installed version with brew bundle --help. Then sanity-check the round trip on a spare machine or VM: create a minimal Brewfile with one brew entry, run brew bundle check (expect non-zero), run brew bundle install, and run check again (expect zero). That two-minute exercise validates the exact workflow your onboarding docs will depend on.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.