The most surprising thing about caching Homebrew installations is that it’s often slower than just installing Homebrew from scratch on every CI run.

Let’s see it in action. Imagine you’re building a C++ project that depends on boost. Without caching, your GitHub Actions workflow might look like this:

name: Build C++ Project

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - name: Install dependencies
      run: |
        brew update
        brew install boost
    - name: Build
      run: |
        make

This works fine, but brew update and brew install boost can take a significant chunk of time on every single run. Now, let’s add caching. The common approach is to cache the Homebrew installation directory.

name: Build C++ Project with Cache

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - name: Cache Homebrew
      uses: actions/cache@v3
      with:
        path: |
          ~/Library/Caches/Homebrew
          /home/runner/.cache/Homebrew # This path can vary!

        key: ${{ runner.os }}-homebrew-${{ hashFiles('**/Brewfile') }} # Or other identifier

    - name: Install dependencies
      run: |
        brew update
        brew install boost
    - name: Build
      run: |
        make

The idea here is that actions/cache will save the ~/Library/Caches/Homebrew (or the equivalent path on Linux, which is usually /home/runner/.cache/Homebrew) directory. On subsequent runs, if the key matches, it will restore this directory, and brew update/brew install should be nearly instant.

However, there’s a catch. Homebrew’s cache isn’t just a static collection of downloaded .tar.gz files. It also includes compiled components, metadata, and crucial symlinks. When you restore this cache, Homebrew often needs to re-verify, re-link, or even recompile parts of the cached installation to ensure integrity and correct linking for the current environment. This re-verification process can be surprisingly time-consuming, sometimes even exceeding the time it would take to just download and install everything fresh.

The problem you’re trying to solve with caching is the latency introduced by brew update and brew install. brew update fetches the latest formulae definitions from remote repositories. brew install <package> downloads the package archive, unpacks it, compiles it (if necessary), and then links it into the Homebrew prefix (/usr/local on macOS, /home/linuxbrew/.linuxbrew on Linux).

The actions/cache action is designed to restore files and directories. When you cache the Homebrew installation directory, you’re essentially restoring a snapshot of Homebrew’s internal state. If this state is slightly out of sync with the current runner environment (e.g., different OS version, different architecture, or even just a slight drift in metadata), Homebrew’s internal checks will kick in. These checks might involve running brew doctor, verifying checksums, and re-establishing symbolic links. These operations, while intended to ensure correctness, can negate the performance benefits of caching.

The key to making Homebrew caching actually work in CI is to be more selective about what you cache and to understand what Homebrew really needs to be fast. Instead of caching the entire Homebrew installation directory, consider caching only the downloaded formula archives. These are typically stored within the Homebrew cache, but not the entire thing.

A more effective strategy involves caching the downloaded formula archives and letting Homebrew manage the installation and linking on the runner. This often means targeting specific directories within ~/Library/Caches/Homebrew or /home/runner/.cache/Homebrew that contain the actual downloaded .tar.gz or .zip files for formulae.

Here’s a more refined approach focusing on the downloaded archives:

name: Build C++ Project with Targeted Cache

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - name: Cache Homebrew formula archives
      uses: actions/cache@v3
      with:
        path: |
          ~/.cache/Homebrew/formula # This path can vary by OS and Homebrew version

        key: ${{ runner.os }}-homebrew-formula-${{ hashFiles('**/Brewfile') }}

    - name: Install dependencies
      run: |
        brew update --quiet # Update quietly to reduce noise if cache hits
        brew install boost
    - name: Build
      run: |
        make

The path in the cache action is crucial. On Linux, Homebrew often stores downloaded archives in ~/.cache/Homebrew/formula. On macOS, it might be ~/Library/Caches/Homebrew/formula. You’ll need to inspect your runner’s Homebrew installation to find the exact location. The key should be deterministic, often incorporating the OS and a hash of your Brewfile or any other file that dictates your dependencies.

Even with this targeted caching, you might still encounter issues if Homebrew needs to recompile. For truly effective caching, you often need to ensure that the environment (OS, compiler versions, etc.) is as consistent as possible between runs. If the underlying system libraries or compiler change, Homebrew might be forced to recompile, bypassing the benefits of cached archives.

The real performance gain in CI often comes not from caching the Homebrew installation itself, but from caching the build artifacts of your own project. If your project takes a long time to compile, caching the compiled binaries and intermediate object files is usually a much bigger win than caching Homebrew. Homebrew’s strength is providing a consistent, reproducible environment for installing tools, not necessarily for speeding up the installation process itself in a mutable CI environment.

The next hurdle you’ll likely face is when Homebrew encounters a formula that has been updated on the remote repository, but your cached Brewfile hasn’t been updated to match.

Want structured learning?

Take the full Homebrew course →