Declarative package management with a Brewfile

I’m a big fan of macOS, and I’m a big fan of the most popular package manager on macOS, Homebrew. It’s one of the first things I install on a new Mac, and I use it for managing both casks (applications) as well as normal packages.

However, my Brew workflow looks very different from most people. In this post, I’m going to describe my workflow and then give examples of what benefits this gives me.

My package management workflow revolves around a Brewfile. This is a text file that lets me declaratively list what packages I want to be installed. Instead of imperatively issuing commands like install and uninstall, I edit this text file to include the package that I want, then run a single command that syncs my system with the text file (installing missing packages and uninstalling extra packages), as well as upgrading any out-of-date packages.

That magic command is:

alias bbic="brew update &&\
    brew bundle install --cleanup --file=~/.config/Brewfile --no-lock &&\
    brew upgrade"

An example excerpt from my Brewfile:

# Moreutils provides, for example, vipe
brew "moreutils"

# macOS ships with a slightly out-of-date `less`
brew "less"

# jq, a command line JSON editor
brew "jq"

# Zig programming language (install latest master)
brew "zig", args: [ "HEAD" ]

# iTerm2 beta
tap "homebrew/cask-versions"
cask "iterm2-beta"

# Lagrange, Gemini client
cask "lagrange"

# Sublime editor
cask "Sublime Text"

As you can see from this example, the syntax is very simple. Other declarative package management systems (Nix, for example) require learning a new syntax and a new system. But a Brewfile is just an unordered list of Homebrew packages.

Advantage 1: Package tracking

Other than the simplicity, the other thing to notice in that example excerpt is the comments. A Brewfile allows me to sort and comment on the packages inline, instead of having to keep in my head why I installed or uninstalled something. (To uninstall, I’ll frequently just comment out the line with the package.)

This keeps my system organized, and ensures that I can keep track of every installed package and what its purpose is.

Advantage 2: Easy Software Trials

I like to be able to quickly and easily try out software, and uninstall it when I’m done. I don’t think installing software should be a commitment. If I'm trying out a software, I can just run brew install example-test-software, which installs the software like normal. But the next time I run bbic, it will cleanup by uninstalling example-test-software, since it's not listed in my Brewfile. Since I like to "trial-run" software without hassle, this "unsaved-by-default" behavior is a plus for me.

Advantage 3: Everything always up-to-date

If I want to permanently install a piece of software, I just edit ~/.config/Brewfile, add the package (and a comment explaining why I added it), and then run bbic. This installs the software, but it also updates Homebrew and upgrades any out-of-date software at the same time. This ensures that every package on my system is always up to date. (This command invokes feelings of the famous pacman -Syu that I'd grown fond of when using Arch.)

I can imagine many other workflows similar to this one, like having a Brewfile per-project and taking advantage of the ability to run a Brewfile-specific sandbox, or having "install" and "uninstall" aliases that edit the Brewfile for you. I'm just describing what works for me. Hopefully this post gets you thinking about other ways of managing packages (that don't require switching operating system).

I originally started using this method because Brew didn't provide a way to automatically remove packages that were originally installed as dependencies, which made temporarily installing software difficult. Brew now, however, offers brew autoremove.

Background from Hero Patterns; CC BY 4.0