Provisioning macOS Laptops With a Brewfile
A new engineer follows the wiki's "Mac setup" page and after two hours has zsh: command not found: brew because the Homebrew installer's final instruction — add eval "$(/opt/homebrew/bin/brew shellenv)" to ~/.zprofile — scrolled past; later, docker-credential-desktop: executable file not found in $PATH appears because a colleague's setup included Docker Desktop and theirs does not. A Brewfile and a short script replace the wiki page with something executable and checkable. This page provisions a macOS developer laptop that way, as part of workstation provisioning and team dotfiles.
The Brewfile covers the workstation baseline only — tools every project assumes. Project-specific versions stay in each repository's toolchain file.
Diagnostic
Check whether Homebrew is installed and on PATH for login shells, and how far the machine differs from the team manifest:
#!/usr/bin/env bash
set -euo pipefail
uname -m
test -x /opt/homebrew/bin/brew && echo "brew installed at /opt/homebrew" || echo "brew not installed"
zsh -lc 'command -v brew' || echo "brew not on PATH for login shells"
if command -v brew >/dev/null; then
brew bundle check --file=Brewfile --verbose 2>&1 | head -10
fi
/usr/bin/pgrep -q oahd && echo "rosetta installed" || echo "rosetta not installed"
Expected bad output on a half-configured laptop:
arm64
brew installed at /opt/homebrew
brew not on PATH for login shells
rosetta not installed
Homebrew exists but new terminals cannot find it, and Rosetta is missing, so the first cask that ships an Intel binary will stop the installation to prompt.
Root cause
Homebrew on Apple Silicon installs to /opt/homebrew, which is not on the default PATH, so its installer asks you to add a shellenv line to the login profile — an interactive instruction that is easy to miss and impossible to enforce. Casks that ship Intel-only binaries require Rosetta 2, and if it is absent macOS prompts to install it mid-run. Some casks (VPN clients, virtualisation tools) install privileged helpers and ask for an administrator password, which breaks unattended runs. None of this is visible in a wiki checklist, so each person hits a different subset in a different order, and the resulting machines differ in which tools were installed before the process stalled.
Resolution
- Write a small team Brewfile, split into required and optional parts so the required baseline stays short:
# Brewfile — required workstation baseline
brew "git"
brew "gh"
brew "jq"
brew "mise"
brew "chezmoi"
brew "direnv"
brew "mkcert"
brew "colima"
brew "docker"
brew "docker-compose"
brew "docker-buildx"
brew "docker-credential-helper"
cask "visual-studio-code"
cask "1password-cli"
# Brewfile.optional — installed only when asked for
cask "iterm2"
cask "rectangle"
cask "font-jetbrains-mono-nerd-font"
brew "ripgrep"
brew "fd"
- Write the install script so it handles Homebrew,
PATH, Rosetta and the manifests in the right order, and is safe to re-run:
#!/usr/bin/env bash
set -euo pipefail
if [ "$(uname -m)" = arm64 ] && ! /usr/bin/pgrep -q oahd; then
sudo softwareupdate --install-rosetta --agree-to-license
fi
if ! command -v brew >/dev/null && [ ! -x /opt/homebrew/bin/brew ]; then
NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi
brew_bin=/opt/homebrew/bin/brew; [ -x "$brew_bin" ] || brew_bin=/usr/local/bin/brew
grep -q 'brew shellenv' ~/.zprofile 2>/dev/null || echo "eval \"\$($brew_bin shellenv)\"" >> ~/.zprofile
eval "$($brew_bin shellenv)"
brew update --quiet
brew bundle --file=Brewfile --no-lock
[ "${WITH_OPTIONAL:-0}" = 1 ] && brew bundle --file=Brewfile.optional --no-lock
brew bundle check --file=Brewfile
NONINTERACTIVE=1 stops the Homebrew installer from waiting for Enter; Rosetta is installed first so no cask stops to prompt; the shellenv line is written idempotently.
Run it once with admin available, since the first run may install casks needing privileges:
sudo -v && ./install.sh. Later runs usually need no password.Schedule a weekly drift check so machines stay aligned with the manifest as it evolves:
#!/usr/bin/env bash
set -euo pipefail
cat > ~/Library/LaunchAgents/dev.acme.brewcheck.plist <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>dev.acme.brewcheck</string>
<key>ProgramArguments</key><array><string>/bin/zsh</string><string>-lc</string><string>brew bundle check --file=$HOME/src/dotfiles/Brewfile || osascript -e 'display notification "Run install.sh to update tools" with title "Workstation drift"'</string></array>
<key>StartCalendarInterval</key><dict><key>Weekday</key><integer>1</integer><key>Hour</key><integer>10</integer></dict>
</dict></plist>
EOF
launchctl load -w ~/Library/LaunchAgents/dev.acme.brewcheck.plist
Expected output
$ ./install.sh
==> Fetching git, gh, jq, mise, chezmoi, direnv, mkcert, colima, docker ...
Using visual-studio-code
Installing 1password-cli
Homebrew Bundle complete! 14 Brewfile dependencies now installed.
The Brewfile's dependencies are satisfied.
$ zsh -lc 'command -v brew mise docker'
/opt/homebrew/bin/brew
/opt/homebrew/bin/mise
/opt/homebrew/bin/docker
Every baseline tool is installed and on PATH in new login shells, and brew bundle check confirms the machine matches the manifest.
Re-running the script immediately afterwards is a useful check in itself: it should finish in seconds, report every dependency as already satisfied and change nothing. If a second run installs or upgrades something, a step is not idempotent — usually a cask with an auto-updater that reports a version mismatch — and it is better to find that on your own machine than on a new hire's.
Prevention
Test the script on a clean macOS VM (Tart or UTM) for each supported macOS version whenever the Brewfile changes. A failing provisioning script is only discovered on a new hire's first day otherwise.
Keep project tools out of the Brewfile. If a tool needs a specific version for a project, it belongs in that project's
mise.tomlordevbox.json, as covered in toolchain version management.Review Brewfile changes like code in the dotfiles repository, so additions are deliberate and removals are announced.
Platform caveats
Apple Silicon (ARM64): Homebrew lives in
/opt/homebrew; scripts and dotfiles must not assume/usr/local. Usebrew --prefixwherever a path is needed.
macOS (Intel): Homebrew lives in
/usr/localand Rosetta is irrelevant; the script's architecture checks handle both.
Managed Macs: MDM may block some casks or require them to come from the company software portal. Keep those out of the Brewfile and list them in the README's "from Self Service" section instead.
Mac App Store apps:
masentries in a Brewfile need the user to be signed in to the App Store; keep them in the optional file to avoid failing unattended runs.
Rollback
brew bundle cleanup lists packages installed outside the manifest, and individual packages can be uninstalled; the LaunchAgent is removed by unloading it:
#!/usr/bin/env bash
set -euo pipefail
launchctl unload -w ~/Library/LaunchAgents/dev.acme.brewcheck.plist
rm -f ~/Library/LaunchAgents/dev.acme.brewcheck.plist
brew bundle cleanup --file=Brewfile
Frequently Asked Questions
Why can new terminals not find brew after installation?
On Apple Silicon, Homebrew lives in /opt/homebrew, which is not on the default PATH. Add eval "$(/opt/homebrew/bin/brew shellenv)" to ~/.zprofile; the install script does this idempotently.
Can a Brewfile pin package versions?
Not in general; Homebrew installs the current version of each formula. That suits workstation tools, which should be current. Anything that must be pinned belongs in a project toolchain file managed by mise, asdf or Devbox.
Should the Brewfile include Docker Desktop?
Only if the organisation has chosen Docker Desktop as its runtime and holds the necessary licences. Otherwise install the Docker CLI and the chosen runtime, such as Colima, as in the example.
How do we handle tools some developers need and others do not?
Keep them in an optional Brewfile or per-role files (Brewfile.data, Brewfile.mobile) and install them with an environment variable or flag, so the required baseline stays small.