Homebrew in CI/CD: Best Practices for Fast Pipelines
Homebrew adds 30-60 seconds to every CI run. Here's how to minimize that — caching strategies, HOMEBREW_NO_AUTO_UPDATE, and why stout is built for CI.
If you run CI/CD pipelines on macOS — whether on GitHub Actions, CircleCI, Buildkite, or self-hosted runners — Homebrew is almost certainly part of your workflow. And it’s almost certainly one of the slowest parts. A single brew install in CI can add 30-60 seconds to your pipeline, and if you’re installing multiple packages, the overhead compounds quickly.
Here’s why Homebrew is slow in CI, how to optimize it within Homebrew’s constraints, and what changes when you switch the underlying tool.
Why Homebrew is especially slow in CI
Homebrew’s performance issues are amplified in CI environments for several reasons:
No persistent state. Most CI runners start with a fresh environment (or a base image) on every run. That means:
- No cached homebrew-core git repository — full clone required
- No cached bottles — every package downloads fresh
- Auto-update triggers on the first command because there’s no recent update timestamp
Sequential operations. When your CI job runs brew install pkg-a pkg-b pkg-c, Homebrew installs each package sequentially. With dependencies, this can mean downloading and extracting 20+ bottles one at a time.
Ruby startup per command. Each brew invocation in your CI script incurs the ~500ms Ruby startup cost. A typical CI script might run brew update, brew install, brew link, and brew services — that’s 2 seconds of pure interpreter overhead before any real work happens.
Rate limiting. GitHub Actions runners share IP ranges. Homebrew’s API requests to formulae.brew.sh (hosted on GitHub Pages) and bottle downloads from ghcr.io can hit rate limits, adding unpredictable delays.
Baseline: How slow is it?
Here’s a typical GitHub Actions workflow step that installs three packages:
- name: Install dependencies
run: brew install ffmpeg imagemagick redis
Without any optimization, this step takes:
| Phase | Time |
|---|---|
| Auto-update (git fetch) | 15-30s |
| Resolve dependencies | 5-15s |
| Download bottles (sequential) | 20-40s |
| Extract and link | 5-10s |
| Total | 45-95s |
That’s up to a minute and a half for three packages. If you’re running this on every push, it adds up fast.
Optimization 1: Disable auto-update
The single biggest win is preventing the automatic brew update:
- name: Install dependencies
env:
HOMEBREW_NO_AUTO_UPDATE: 1
run: brew install ffmpeg imagemagick redis
This skips the git fetch of homebrew-core, saving 15-30 seconds per run. The tradeoff is that you’re using whatever formula versions are baked into the runner image. On GitHub Actions, macOS runner images are refreshed weekly, so formulae are at most a week old.
For most CI use cases, this is fine. You rarely need the absolute latest version of a dependency — you need a consistent, working version.
Optimization 2: Cache the Homebrew prefix
GitHub Actions supports caching arbitrary directories. You can cache the Homebrew installation between runs:
- name: Cache Homebrew
uses: actions/cache@v4
with:
path: |
/opt/homebrew
~/Library/Caches/Homebrew
key: brew-${{ runner.os }}-${{ hashFiles('.brew-deps') }}
restore-keys: |
brew-${{ runner.os }}-
- name: Install dependencies
env:
HOMEBREW_NO_AUTO_UPDATE: 1
HOMEBREW_NO_INSTALL_CLEANUP: 1
run: brew install ffmpeg imagemagick redis
Create a .brew-deps file listing your dependencies (one per line) so the cache key changes when your dependencies change.
Caveats:
- The Homebrew prefix is large. Caching
/opt/homebrewcan mean uploading/downloading 2-5GB, which may be slower than a fresh install for small dependency sets. - Cache restore/save has its own overhead (30-60 seconds for large caches on GitHub Actions).
- Cached bottles may become stale, leading to subtle version mismatches.
Optimization 3: Use a Brewfile for deterministic installs
Instead of individual brew install commands, use a Brewfile:
# Brewfile
brew "ffmpeg"
brew "imagemagick"
brew "redis"
- name: Install dependencies
env:
HOMEBREW_NO_AUTO_UPDATE: 1
run: brew bundle --file=Brewfile
brew bundle installs all packages in one invocation, saving Ruby startup overhead for each individual install. It also skips packages that are already installed, which pairs well with caching.
Optimization 4: Pin specific versions
CI reproducibility often requires pinned versions. Homebrew makes this difficult because it doesn’t have a native lock file mechanism. Workarounds include:
# Pin a formula to prevent upgrades
brew pin node@20
# Install a specific version (if the formula supports it)
brew install [email protected]
For stricter pinning, you can extract a formula to a local tap:
brew extract --version=3.2.0 openssl homebrew/core
This is fragile and not well-supported in CI. It’s one of Homebrew’s biggest gaps for production use.
Optimization 5: Minimize what you install
Audit your CI dependencies. Common bloat sources include:
-
Installing packages available in the runner image. GitHub Actions macOS runners come with
git,curl,jq,python3,node, and many other tools pre-installed. Check the runner image docs before addingbrew installcommands. -
Installing build dependencies for packages that have bottles. If you’re installing a pre-built bottle, you don’t need the build dependencies. Use
--force-bottleto skip source-build fallbacks. -
Installing graphical applications.
brew install --cask dockerin CI is almost never what you want. Use Docker’s official installation method or a purpose-built action instead.
Optimization 6: Set environment variables
Several environment variables reduce Homebrew’s CI overhead:
env:
HOMEBREW_NO_AUTO_UPDATE: 1 # Skip auto-update
HOMEBREW_NO_INSTALL_CLEANUP: 1 # Skip cleanup after install
HOMEBREW_NO_ANALYTICS: 1 # Disable analytics reporting
HOMEBREW_NO_ENV_HINTS: 1 # Suppress environment hints
NONINTERACTIVE: 1 # Skip interactive prompts
Each of these eliminates a small overhead. Together, they can save 5-10 seconds per pipeline.
The fundamental problem with Homebrew in CI
Even with all these optimizations, Homebrew in CI is solving the wrong problem. CI environments need:
- Fast, deterministic installs from a known package set
- Parallel operations to minimize wall-clock time
- Minimal overhead — no git repos, no auto-updates, no Ruby startup
- Reproducibility — the same input should produce the same output
Homebrew was designed for interactive use on a developer’s personal machine. Its architecture — mutable git repos, Ruby evaluation, sequential downloads, implicit auto-updates — is the opposite of what CI needs.
How stout is built for CI
stout was designed with CI as a first-class use case:
5ms startup, not 500ms. No Ruby interpreter means every stout command runs instantly. A CI script with five stout invocations saves 2.5 seconds on startup alone.
3MB index, not 700MB git repo. stout’s SQLite index downloads in 1-2 seconds on CI runner networks. There’s no git clone, no fetch, no merge. The total metadata footprint is 3MB — trivial to cache or re-download.
Parallel downloads. stout downloads all bottles concurrently. Installing ffmpeg with 25 dependencies downloads all bottles at once, saturating the CI runner’s network connection:
- name: Install dependencies
run: |
curl -fsSL https://get.stout.dev | sh
stout install ffmpeg imagemagick redis
# Typical output:
# Resolving 43 packages... done (0.02s)
# Downloading 43 bottles... done (5.1s)
# Installing... done (3.2s)
# Total: 8.3s
Lock files for reproducibility. stout supports stout.lock files that pin exact versions and checksums:
# Generate a lock file from your current packages
stout lock > stout.lock
# Install from a lock file in CI
stout install --lockfile stout.lock
This gives you deterministic, reproducible installs without the workarounds required by Homebrew.
Minimal caching footprint. If you do want to cache stout’s state, the total size is the 3MB index plus whatever packages you’ve installed. There’s no 700MB git repository or multi-gigabyte download cache to manage.
A real-world comparison
Here’s the same three-package install on a GitHub Actions macOS runner:
| Metric | Homebrew (optimized) | stout |
|---|---|---|
| Setup time | 0s (pre-installed) | 2s (curl install) |
| Update/index | 0s (disabled) | 1s (download index) |
| Install ffmpeg + imagemagick + redis | 35-50s | 8-12s |
| Total | 35-50s | 11-15s |
stout is 3-4x faster even when Homebrew has auto-update disabled and is pre-installed on the runner. The difference grows with more packages because stout’s parallel downloads scale linearly with available bandwidth, while Homebrew’s sequential downloads scale linearly with the number of packages.
For teams running hundreds or thousands of CI jobs per day, switching the package installation step from Homebrew to stout can save hours of aggregate compute time — and reduce the flakiness that comes from Homebrew’s network-dependent sequential operations.
Need Rust performance engineering or AI agent expertise?
Neul Labs — the team behind stout — consults on Rust development, performance optimization, CLI tool design, and AI agent infrastructure. We build fast, reliable systems that ship.