Using brew bundle and a Brewfile to Declare Your Mac's Packages
How brew bundle and a Brewfile give you declarative package setup on macOS and Linux — a worked example, check/cleanup workflows, and the version-pinning limits to know first.
03 May 2026, 02:21 UTC

The short answer
If you want a new Mac (or a teammate's) to end up with the same command-line tools, GUI apps, and fonts you rely on, the supported Homebrew way is brew bundle. You write a Ruby file called a Brewfile listing what should be installed, and brew bundle install converges the machine to that list. It is idempotent, it can check rather than install, and it can even remove things that are not in the file. What it does not do is pin versions — more on that below, because it changes how you should use it.
What a Brewfile actually is
A Brewfile is plain Ruby evaluated by Homebrew. Each line is a directive naming a package source and a package:
# Brewfile
tap "homebrew/cask-fonts"
brew "git"
brew "jq"
brew "ripgrep"
cask "iterm2"
cask "font-jetbrains-mono"
mas "Xcode", id: 497799835
vscode "golang.go"
The four main directives cover different installation channels:
brew— formulae, i.e. command-line tools and libraries installed by Homebrew itself.cask— GUI applications, fonts, and other binary distributions installed via Homebrew Cask.mas— Mac App Store apps, driven by the separatemasCLI (install it withbrew install mas). The numericidis the App Store product ID.vscode— Visual Studio Code extensions, using the publisher.extension identifier.
Because the file is Ruby, you can add logic. A common pattern is installing a tool only on macOS:
brew "mas" if OS.mac?
cask "docker" if OS.mac?
Running it: the workflow that matters
Run these from the directory containing your Brewfile (or pass --file=/path/to/Brewfile). No elevated privileges are needed for formulae; some casks may prompt for your password when they install to /Applications.
Install everything:
brew bundle install
Anything already installed is skipped, so re-running is safe. Run it twice and the second pass should report everything satisfied — that is your idempotency check.
Check without changing anything:
brew bundle check
This exits non-zero if anything is missing. That makes it useful in an onboarding script or CI job: assert the environment is correct instead of blindly reinstalling. You can get a list of what's missing with brew bundle list variants or the verbose check output.
Bootstrapping from an existing machine:
brew bundle dump --force --describe
This writes a Brewfile describing what you currently have installed (--force overwrites an existing file; --describe adds comments). The dump includes everything, including dependencies you never asked for, so expect to edit it down. Cross-check against brew leaves (formulae you installed explicitly) and brew list --cask to decide what belongs in the curated file.
Going fully declarative with cleanup
By default brew bundle is additive: it installs what is listed but never removes anything. If you want the Brewfile to be the complete description of the machine, use:
brew bundle cleanup
Without flags this only lists installed packages that are absent from the Brewfile. Adding --force actually uninstalls them. This is destructive — a package you use daily but forgot to add to the Brewfile will be removed. Always run the dry-run first, and only then:
brew bundle cleanup --force
Rollback here is simply reinstalling whatever was wrongly removed (brew install or brew install --cask the item), but the cleaner fix is adding it to the Brewfile so cleanup stops flagging it.
The limits that bite people
No version pinning. A Brewfile records that you want jq, not which jq. brew bundle install always fetches the latest available formula or cask. Two machines provisioned a month apart can end up with different versions. If you need reproducible versions, you need a lockfile-based tool (Nix, asdf, devcontainers) — a Brewfile is a convenience layer, not a lockfile.
mas entries are fragile. They require an Apple ID signed into the App Store, and only apps already associated with that account can be installed. On fresh machines, VMs, and CI runners this commonly fails. Many teams comment out mas lines or guard them behind a manual step.
Names drift. Formulae and casks get renamed, moved between taps, or deprecated. A Brewfile that worked last year can fail today with a "No available formula" error even though you changed nothing. When that happens, search for the replacement (brew search <name>) and update the file.
Flags evolve. Subcommand options change between Homebrew releases. Before scripting around a flag, confirm it exists on your version with brew bundle --help.
A minimal verification
To sanity-check the feature before committing to it, create a throwaway Brewfile with a single tiny formula:
brew "hello"
Run brew bundle install, then brew bundle check — it should report success and exit zero. Delete the Brewfile's line, run brew bundle check again, and it should now exit non-zero. That two-minute exercise confirms the install/check cycle works on your setup before you hand the file to your team.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.