Make Your Workstation Reproducible with brew bundle and a Brewfile
A Brewfile turns your macOS or Linux toolchain into an inspectable manifest. Learn how brew bundle check, install, and pin work—and where the reproducibility limits are.
25 Aug 2026, 13:52 UTC

Setting up a new Mac or Linux box usually means a day of clicking through installers and reconstructing the exact set of tools you had last time. You remember the big ones—git, your editor, a database—but you forget the font cask, the App Store utility, or the fact that you wanted PostgreSQL to restart on boot. A notes-app checklist goes stale the moment you install something ad hoc.
The useful takeaway: Homebrew ships a built-in brew bundle command that reads a Brewfile, a small Ruby DSL declaring taps, formulae, casks, Mac App Store apps, and editor extensions. You can inspect exactly what a machine should have before anything is installed, and you can version that manifest alongside your dotfiles.
The thesis: a Brewfile is not a hermetic lockfile, but it is the most practical workstation manifest available on macOS and Linux—provided you treat checking, installing, and pinning as separate decisions.
What brew bundle actually does
In current Homebrew releases, brew bundle is built in; the old external homebrew/bundle tap is obsolete. The command has three everyday subcommands:
brew bundle checkreports what is missing without touching the system.brew bundle install(the default when no subcommand is given) installs only what is absent.brew bundle dumpwrites a Brewfile from the current machine state.
Entries resolve through the same dependency solver as brew install, so ordering and conflicts behave consistently with manual installs. One sharp edge: brew bundle honors --file and the HOMEBREW_BUNDLE_FILE environment variable. A leftover ~/.Brewfile can silently take precedence over the project-local manifest you intended to use.
A worked example
Suppose you want a machine with the cask-fonts tap, git, PostgreSQL 16 configured to restart as a service, VS Code, and one Mac App Store app. A minimal Brewfile looks like this:
tap "homebrew/cask-fonts"
brew "git"
brew "postgresql@16", restart_service: true
cask "visual-studio-code"
mas "SomeApp", id: 123456789
Run the following in your terminal on the target machine. No sudo is required for the brew commands themselves, though casks may prompt for a password to write into /Applications, and mas entries require a signed-in Apple ID.
brew bundle check --file=./Brewfile
brew bundle install --file=./Brewfile
check is read-only: it lists the taps, formulae, casks, and App Store entries that are absent. install then adds only those missing items. If you skip --file, be aware that ~/.Brewfile may be picked up instead.
Pin versus float: the deliberate trade-off
Once the manifest is installed, the next question is how aggressively to upgrade. brew pin <formula> holds a formula back from brew upgrade until you run brew unpin <formula>. Pinned names appear in brew list --pinned, and you can preview the effect with brew upgrade --dry-run.
The trade-off is real. A pinned database server or language runtime stays stable, but it also misses upstream security patches. Floating gets you fixes immediately, but a minor bump can break a local workflow. A common compromise is to pin stateful services (databases, queues) and float lightweight CLI tools.
Limitations and platform realities
A Brewfile is not a lockfile. Casks are third-party binaries, mas entries depend on a signed-in Apple ID, and the file itself is executable Ruby—so supply-chain and reproducibility guarantees are weaker than in language ecosystems with hash-pinned lockfiles.
Install prefixes differ by platform: /opt/homebrew on Apple Silicon, /usr/local on Intel macOS, and /home/linuxbrew/.linuxbrew on Linux. Brewfiles that hardcode paths or assume a prefix break when shared across machines. Homebrew 4.x also made brew upgrade act on casks as well as formulae, so older tutorials that separate brew upgrade and brew upgrade --cask may not match current installs.
Service behavior is not portable. brew services uses launchd on macOS and systemd on Linux, so a restart_service: true option does not guarantee identical boot behavior across platforms. Versioned formula names such as postgresql@16 also age and eventually disappear from the core tap, so examples need periodic review.
Finally, brew bundle cleanup --force deletes installed formulae and casks that are not listed in the Brewfile. That makes the manifest authoritative, but it is destructive when the file is stale, partial, or generated on a different machine. Treat it as a one-way operation: run it only after you have diffed the manifest against brew list --versions and confirmed nothing ad hoc is about to vanish.
Where to start
Begin with brew bundle dump to capture your current machine, then prune the file until every line is intentional. Commit it to your dotfiles repo. On the next machine, run brew bundle check first, then brew bundle install. Record the validation environment with brew config and brew doctor, and rehearse the whole flow in a VM or scratch prefix before trusting it on a production laptop.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.