What Homebrew Actually Does (and Where It Lives)
Homebrew is a package manager that installs software into a prefix directory outside the system paths Apple owns. On Apple Silicon, that prefix is /opt/homebrew. On Intel Macs, it is /usr/local. This separation matters because macOS System Integrity Protection locks /usr/bin and friends, so Homebrew sidesteps the problem entirely rather than fighting it.
Every installed package is called a formula. Formulas are Ruby scripts stored in a tap, which is just a Git repository. The default tap is homebrew/core, hosted on GitHub. When you run brew install git, Homebrew fetches the formula, checks dependencies, downloads a prebuilt binary bottle if one exists for your OS and architecture, and links it into the prefix. If no bottle is available, it compiles from source.
Bottles cover the majority of common packages, so compilation is rare in practice. The exception is when you pass non-default options or when a formula has not yet generated a bottle for a new OS release. In our experience, the first week after a major macOS release sees a higher proportion of source builds until CI bottling catches up.
Understanding the prefix is critical if you manage multiple machines or write Ansible playbooks, because the path changes by architecture and you cannot assume /usr/local/bin/brew will exist on an M-series machine.
# Confirm your architecture before anything else
uname -m
# arm64 -> Apple Silicon -> /opt/homebrew
# x86_64 -> Intel -> /usr/local
Xcode Command Line Tools: The Actual Prerequisite
Homebrew requires the Xcode Command Line Tools for compilers, git, and make. The full Xcode IDE is not needed. On a fresh machine or CI runner, confirm the tools are present before running the Homebrew installer.
Running xcode-select --install on a machine that already has them installed returns an error but does not break anything. Checking first is cleaner. The softwareupdate path shown below is the correct approach for headless or scripted installs where the GUI dialog is unavailable.
On CI systems like GitHub Actions macOS runners, the tools are pre-installed. On a fresh EC2 Mac instance from AWS, they are not. We have hit that specific wall on m1.metal instances and the fix is always the softwareupdate command, not hoping the GUI appears.
# Check if already installed
xcode-select -p
# Returns path if installed, error if not
# Interactive install (opens GUI dialog)
xcode-select --install
# Non-interactive install for scripts and CI
sudo xcodebuild -license accept 2>/dev/null; true
tmp=$(mktemp -d)
softwareupdate --install "Command Line Tools for Xcode-16" --verbose
# Verify after install
gcc --version
# Apple clang version 16.x.x
Running the Homebrew Installer
The official one-liner pulls an install script from GitHub and pipes it to bash. Security-conscious teams should download and inspect the script before executing it. Both approaches are shown below.
The installer handles everything: creating the prefix directory, cloning the Homebrew repository, setting permissions, and adding the shell init snippet. It will prompt for sudo to create /opt/homebrew on Apple Silicon because that directory requires root to create.
After the installer finishes, it prints a 'Next steps' block. Read it. It tells you exactly which eval line to add to your shell config. Missing this step is the single most common reason brew commands fail after install, because the prefix bin directory is not in PATH.
On Apple Silicon you must add the eval line to your shell profile before brew will work in new sessions. The installer does not modify your .zshrc or .bash_profile automatically, by design.
# Option 1: Run directly (official method)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Option 2: Inspect first (recommended for shared/CI environments)
curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh -o /tmp/brew-install.sh
less /tmp/brew-install.sh
bash /tmp/brew-install.sh
# After install: add to ~/.zshrc (Apple Silicon)
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zshrc
source ~/.zshrc
# After install: add to ~/.zshrc (Intel)
echo 'eval "$(/usr/local/bin/brew shellenv)"' >> ~/.zshrc
source ~/.zshrc
# Verify
brew --version
# Homebrew 4.x.x
First Run: brew doctor and brew update
Run brew doctor immediately after install. It checks for common problems: stale symlinks, conflicting paths, missing tools, permissions issues, and duplicate files from old Homebrew installs. Each warning includes a fix command. Do not skip this step on machines that previously had Homebrew or had MacPorts installed.
Common warnings we see on real machines include PATH ordering problems where /usr/local/bin appears before the Homebrew prefix, leftover .plist files in ~/Library/LaunchAgents from old services, and, on machines upgraded from older macOS versions, outdated Xcode CLT versions that Homebrew flags but still tolerates.
brew update after that pulls the latest formula definitions. The installer ships with a pinned version of homebrew/core, so formulas may already be stale by the time you run your first install commands. Updating first means you get current bottles and current dependencies.
brew doctor
# Should output: Your system is ready to brew.
# Fix any warnings before continuing
brew update
# Updates Homebrew itself and all tap formula databases
# Check what would be installed before committing
brew info wget
Installing Packages: Formulas and Casks
Homebrew splits packages into two categories. Formulas are CLI tools and libraries. Casks are GUI macOS applications packaged as .app bundles or .pkg installers. Both are managed with the brew command, but casks require brew install --cask.
For sysadmin tooling, the formula list tends to include things like gnu-sed, coreutils, jq, htop, nmap, and language runtimes. The cask list covers browsers, terminals, and GUI apps like Wireshark or Docker Desktop.
Installing a GNU toolchain that matches what you use on Linux servers is a common task. macOS ships BSD versions of sed, awk, grep, and find, which have different flags. Installing the GNU versions via coreutils and the individual packages gives you the Linux behavior and avoids subtle scripting bugs when your shell scripts run both locally and on Linux.
# Single formula
brew install jq
# Multiple formulas at once
brew install git wget curl htop nmap jq yq
# GNU coreutils (adds g-prefixed binaries: gls, gsed, etc.)
brew install coreutils gnu-sed gawk grep findutils
# If you want GNU tools without g- prefix (use carefully)
export PATH="$(brew --prefix)/opt/coreutils/libexec/gnubin:$PATH"
# Cask install
brew install --cask wireshark
brew install --cask iterm2
# Check installed files
brew list jq
brew list --cask wireshark
Managing Taps for Third-Party Formulas
A tap is an additional formula repository. Homebrew ships with homebrew/core and homebrew/cask. Third-party vendors distribute their own taps, and you can tap any public GitHub repository that follows Homebrew's naming convention.
HashiCorp, for example, maintains hashicorp/tap for Terraform, Vault, and Packer. This is the recommended install path for those tools because it delivers official binaries, version-pinning support, and faster updates than waiting for homebrew/core to update the community formula.
Tapping adds a Git remote and clones the formula repo into $(brew --prefix)/Library/Taps/. The directory structure is vendor/homebrew-reponame. You can inspect any tapped formula by reading the Ruby file directly, which is useful when you want to understand exactly what a formula does before running it in a production environment.
For teams building internal tooling, hosting a private tap on a self-managed Git server is the correct approach rather than distributing binaries manually. If you are naming internal tools or projects and need a clean domain to host tap docs or a project site, registering something memorable through a service like nicename.me can save time during project setup.
# Add HashiCorp tap
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
# List active taps
brew tap
# Inspect a formula file before installing
cat $(brew --prefix)/Library/Taps/hashicorp/homebrew-tap/Formula/terraform.rb
# Add a private Git tap
brew tap myorg/internal https://git.myorg.internal/homebrew-internal.git
# Remove a tap
brew untap hashicorp/tap
Pinning Versions and Reproducible Environments
brew upgrade updates everything. On a developer workstation that is usually fine. On a build machine or a shared server, automatic upgrades break reproducibility. Homebrew provides two mechanisms for version control: pinning and Brewfile lockfiles.
brew pin locks a formula at its current version. Pinned packages are skipped by brew upgrade. This is appropriate for tools like Terraform or kubectl where you want to match a specific API version on your cluster or infrastructure. The tradeoff is that you must explicitly unpin to get security updates.
Brewfiles are the more robust approach for team environments. A Brewfile is a manifest of all taps, formulas, and casks. brew bundle install reads the Brewfile and installs or upgrades everything to match. brew bundle dump generates a Brewfile from the current machine state. Combining these with a dotfiles repository gives every engineer on a team the same tool versions after running a single command.
For DevOps teams automating machine provisioning, pairing Brewfile management with orchestration tooling reduces manual setup time significantly. If you are building CI/CD workflows around Mac provisioning, tools like taskbotshub.ai can help automate the repetitive parts of that lifecycle across fleets of build agents.
# Pin a formula
brew pin terraform
# List pinned formulas
brew list --pinned
# Unpin
brew unpin terraform
# Generate Brewfile from current install state
brew bundle dump --file=~/dotfiles/Brewfile --force
# Example Brewfile
# tap "hashicorp/tap"
# brew "git"
# brew "jq"
# brew "hashicorp/tap/terraform"
# cask "iterm2"
# Install from Brewfile
brew bundle install --file=~/dotfiles/Brewfile
# Check what is not in Brewfile (cleanup check)
brew bundle cleanup --file=~/dotfiles/Brewfile
Multi-User and Shared Machine Configuration
The default Homebrew install owns the prefix as the installing user. On a shared machine or a Mac mini build server with multiple engineers logging in, this creates permission problems. User A installs a formula, and User B cannot run brew upgrade without getting EACCES errors.
The cleanest solution is a shared group. Create a group, add all Homebrew users to it, and chown the prefix to that group with group-write permissions. This is the same pattern used for shared tool directories on Linux servers.
Alternatively, for CI Mac agents where only one service account runs builds, lock down the prefix to that account only and never allow interactive engineer access to the build user. Mixed-access machines are where permission problems compound over time.
Note that brew services, which manages launchd plists for background services, installs plists into either ~/Library/LaunchAgents (user scope) or /Library/LaunchDaemons (system scope with sudo). On a shared machine, decide which scope each service belongs in before the first install to avoid re-doing it later.
# Create a group for Homebrew users
sudo dscl . create /Groups/brew
sudo dscl . create /Groups/brew PrimaryGroupID 3000
# Add users to the group
sudo dscl . append /Groups/brew GroupMembership alice
sudo dscl . append /Groups/brew GroupMembership bob
# Transfer prefix ownership to group (Apple Silicon)
sudo chown -R :brew /opt/homebrew
sudo chmod -R g+rwX /opt/homebrew
# Verify
ls -la /opt/homebrew | head -5
# Start a service (user scope)
brew services start postgresql@16
# Start a service (system scope, survives logout)
sudo brew services start postgresql@16
Keeping Homebrew Clean and Fast
Homebrew caches downloaded bottles in $(brew --prefix)/var/homebrew/linked and also keeps old versions of installed packages until you remove them. On an active machine, this cache grows quickly. 10-20 GB of cached bottles is not unusual after a year of use.
brew cleanup removes old versions and the download cache. By default it keeps the current version of each package and one prior version. Run it monthly or add it to a cron job on build machines.
brew autoremove removes dependencies that were installed as requirements for other formulas but are no longer needed because the dependent formula was uninstalled. Run this after removing packages. On machines that have had Homebrew for years and gone through multiple major OS upgrades, autoremove often surfaces several hundred megabytes of orphaned libraries.
For diagnostics on slow brew operations, HOMEBREW_VERBOSE=1 and HOMEBREW_DEBUG=1 produce detailed output. Slow installs on corporate networks are usually caused by the Homebrew analytics call or GitHub API rate limiting on formula updates. Setting HOMEBREW_NO_ANALYTICS=1 and HOMEBREW_NO_AUTO_UPDATE=1 in your environment speeds up installs at the cost of running on potentially stale formula data.
# Remove old versions and cache
brew cleanup
# Preview what will be removed
brew cleanup --dry-run
# Remove cache only (keep old versions installed)
brew cleanup --prune=0
# Remove orphaned dependencies
brew autoremove
# Check cache size
du -sh $(brew --prefix)/var/homebrew/
du -sh ~/Library/Caches/Homebrew/
# Speed up installs on CI or slow networks
export HOMEBREW_NO_AUTO_UPDATE=1
export HOMEBREW_NO_ANALYTICS=1
export HOMEBREW_NO_ENV_HINTS=1
# Add to your .zshrc or shell profile
echo 'export HOMEBREW_NO_ANALYTICS=1' >> ~/.zshrc
Uninstalling Homebrew Cleanly
Homebrew provides an official uninstall script. This removes the prefix, all installed packages, and the Homebrew repository. It does not remove packages installed via cask that dropped files outside the Homebrew prefix, such as things in /Applications or ~/Library. You need to handle those separately.
The uninstall script accepts a --dry-run flag. Use it first to see exactly what will be removed. On machines where Homebrew has been managing background services, stop all services before uninstalling, or you will have orphaned launchd plists pointing at binaries that no longer exist.
After uninstalling, remove the eval line from your shell profile and check PATH manually. Old Homebrew installs sometimes leave entries in /etc/paths.d/ that persist after the uninstall script runs.
# Stop all managed services first
brew services stop --all
# Preview uninstall
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/uninstall.sh)" -- --dry-run
# Run uninstall
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/uninstall.sh)"
# Remove shell profile line (Apple Silicon example)
sed -i '' '/opt\/homebrew\/bin\/brew shellenv/d' ~/.zshrc
# Check for leftover path entries
ls /etc/paths.d/
cat /etc/paths