Brewfile and Bundle Management with stout
Use stout bundle to manage your development environment declaratively with Brewfiles — install, check, and snapshot your entire setup.
A reproducible development environment is the foundation of reliable software engineering. When every developer, CI runner, and staging server uses the same packages at the same versions, an entire class of “works on my machine” bugs disappears. stout’s bundle system lets you declare your complete package environment in a Brewfile, install it in one command, verify it hasn’t drifted, and snapshot it for exact reproduction later. It is fully compatible with Homebrew’s Brewfile format while adding capabilities that Homebrew’s brew bundle lacks.
Brewfile format
stout reads standard Brewfiles — the same format Homebrew uses. If you already have a Brewfile, it works without modification:
# Brewfile
brew "git"
brew "jq"
brew "ripgrep"
brew "fd"
brew "[email protected]"
brew "node@20"
brew "postgresql@16"
brew "redis"
cask "firefox"
cask "visual-studio-code"
cask "docker"
tap "homebrew/services"
Each line declares a package dependency. brew entries are CLI packages (formulae), cask entries are GUI applications, and tap entries are third-party package repositories.
Installing from a Brewfile
stout bundle install
# Reading Brewfile...
# Resolving dependencies for 8 formulae and 3 casks...
# 14 packages to install (6 already satisfied)
#
# Downloading 8 bottles in parallel... done (4.2s)
# Extracting and linking... done (2.1s)
#
# Downloading 3 casks in parallel... done (8.3s)
# Installing casks... done
#
# Bundle complete: 14 packages installed
stout reads the Brewfile from the current directory by default, or you can specify a path with --file. The key difference from Homebrew’s brew bundle install is performance: stout resolves all dependencies upfront, downloads everything in parallel (both formulae and casks), and then installs. Homebrew processes each line sequentially, downloading and installing one package at a time.
For a Brewfile with 30 packages, the timing difference is stark:
| Operation | brew bundle | stout bundle |
|---|---|---|
| 30 packages, cold install | 4-6 minutes | 20-35 seconds |
| 30 packages, all satisfied | 15-20 seconds | 0.3 seconds |
The “all satisfied” case is especially important — this is what happens on every CI run after the cache is warm, or when a developer runs stout bundle install to confirm their environment is up to date. Homebrew spends 15+ seconds checking each package in Ruby; stout checks all 30 against its SQLite database in under a second.
Checking for drift
Over time, installed packages drift from what the Brewfile specifies. A colleague upgrades a library globally, a CI runner has an extra package preinstalled, or someone installs something ad-hoc and forgets to add it to the Brewfile. stout bundle check detects this drift:
stout bundle check
# Checking Brewfile against installed packages...
#
# ✓ git 2.44.0 (matches Brewfile)
# ✓ jq 1.7.1 (matches Brewfile)
# ✗ [email protected] — not installed
# ✗ postgresql@16 — installed 16.1, Brewfile requires 16.2
# ✓ firefox (matches Brewfile)
#
# 2 issues found. Run 'stout bundle install' to fix.
The exit code is non-zero when drift is detected, making it suitable for CI pipeline gates:
# .github/workflows/env-check.yml
- name: Verify environment
run: stout bundle check --file Brewfile
# Fails the build if any package is missing or wrong version
Version pinning
Standard Brewfiles do not specify versions — brew "[email protected]" means “install the latest version of the [email protected] formula.” stout extends the Brewfile format with optional version pinning:
# Brewfile with version pins
brew "git", version: "2.44.0"
brew "jq", version: "1.7.1"
brew "[email protected]", version: "3.12.2"
brew "postgresql@16", version: "16.2"
brew "redis" # No pin — use latest
When a version is pinned, stout bundle install installs exactly that version, even if a newer one is available. This is essential for reproducibility across developer machines and CI environments.
Homebrew does not support version pinning in Brewfiles. To achieve the same effect with Homebrew, teams resort to maintaining custom taps with formula files locked to specific commits — a fragile and high-maintenance approach.
Snapshots: capturing the exact state
Version pins in the Brewfile are one level of reproducibility. Snapshots go further — they capture the exact installed state including versions, checksums, and dependency graph:
stout bundle snapshot --output snapshot-2026-03-26.toml
# Captured snapshot of 14 packages
# Saved to snapshot-2026-03-26.toml
The snapshot file records precise version and integrity information:
# snapshot-2026-03-26.toml
[metadata]
created = "2026-03-26T14:30:00Z"
platform = "arm64_sonoma"
stout_version = "0.8.0"
[[packages]]
name = "git"
version = "2.44.0"
revision = 0
bottle_sha256 = "a1b2c3d4e5f6..."
[[packages]]
name = "jq"
version = "1.7.1"
revision = 0
bottle_sha256 = "f6e5d4c3b2a1..."
[[packages]]
name = "[email protected]"
version = "3.12.2"
revision = 1
bottle_sha256 = "1a2b3c4d5e6f..."
# ... all packages with exact versions and checksums
Restore a snapshot on another machine:
stout bundle restore --snapshot snapshot-2026-03-26.toml
# Restoring 14 packages from snapshot...
# Verifying bottle checksums against snapshot...
# All packages match snapshot exactly
# Restore complete
The restore verifies that each installed bottle matches the SHA-256 recorded in the snapshot. If an upstream bottle has been rebuilt (same version, different binary), stout detects the mismatch and warns you. This guarantees bit-for-bit reproducibility across machines.
Generating a Brewfile from installed packages
If you don’t have a Brewfile yet, generate one from your current installation:
stout bundle dump --file Brewfile
# Wrote Brewfile with 47 formulae, 12 casks, 3 taps
The generated Brewfile includes all currently installed packages. You can then edit it to remove packages you don’t want to track, add version pins, and commit it to your repository.
# Generate with version pins included
stout bundle dump --file Brewfile --with-versions
Brewfile groups
stout extends the Brewfile format with groups — logical collections of packages that can be installed selectively:
# Brewfile with groups
brew "git"
brew "jq"
group :backend do
brew "postgresql@16"
brew "redis"
brew "[email protected]"
end
group :frontend do
brew "node@20"
brew "yarn"
end
group :devops do
brew "terraform"
brew "kubectl"
brew "helm"
end
cask "visual-studio-code"
Install only the groups you need:
# Backend developer
stout bundle install --groups backend
# Frontend developer
stout bundle install --groups frontend
# Full-stack developer
stout bundle install --groups backend,frontend
# Everything (default — installs all groups)
stout bundle install
Ungrouped entries (like git and jq above) are always installed. This lets teams share a single Brewfile while allowing individual developers to install only the packages relevant to their role.
CI/CD optimization
stout bundle is designed for CI pipeline performance. Several features specifically target CI use cases.
Parallel installation. All formulae and casks are downloaded concurrently, reducing a 30-package install from minutes to seconds.
Cache-friendly. stout’s entire state lives in well-defined directories that can be cached between CI runs:
# GitHub Actions with caching
- name: Cache stout packages
uses: actions/cache@v4
with:
path: |
~/.stout/cellar
~/.stout/downloads
~/.stout/index.db
key: stout-${{ hashFiles('Brewfile.lock') }}-${{ runner.os }}
- name: Install dependencies
run: stout bundle install --file Brewfile
Lockfile generation. Similar to package-lock.json or Gemfile.lock, stout can generate a lockfile that records the resolved dependency graph with exact versions:
stout bundle lock --file Brewfile
# Generated Brewfile.lock with 42 packages (14 direct, 28 transitive)
The lockfile ensures that stout bundle install on CI uses the same versions that were tested locally, even if upstream releases happen between your last local install and the CI run.
# Brewfile.lock (auto-generated, do not edit)
git: 2.44.0 (sha256:a1b2c3...)
jq: 1.7.1 (sha256:f6e5d4...)
python@3.12: 3.12.2+1 (sha256:1a2b3c...)
depends: mpdecimal:2.5.1, openssl@3:3.2.1, sqlite:3.45.1, xz:5.4.6
postgresql@16: 16.2 (sha256:9c8d7e...)
depends: icu4c:74.2, krb5:1.21.2, lz4:1.9.4, openssl@3:3.2.1, readline:8.2.10
# ...
Cleaning up unlisted packages
Over time, packages accumulate that are not in your Brewfile — experiments, one-off tools, or dependencies of packages you have since removed. stout bundle cleanup removes anything not declared:
stout bundle cleanup --dry-run
# The following packages are installed but not in Brewfile:
# wget, htop, tree, nmap
#
# Run without --dry-run to remove them
stout bundle cleanup
# Removing 4 packages not in Brewfile...
# Removed: wget, htop, tree, nmap
# Freed 12 MB
The cleanup respects dependency chains — if a package not in your Brewfile is a dependency of one that is, it will not be removed.
Comparing with Homebrew’s brew bundle
| Feature | brew bundle | stout bundle |
|---|---|---|
| Brewfile format | Supported | Supported (compatible) |
| Version pinning | Not supported | Supported |
| Snapshots | Not available | Full snapshot/restore |
| Groups | Not supported | Supported |
| Lockfile | Not available | Supported |
| Parallel install | No (sequential) | Yes (Tokio) |
| Check speed (30 pkgs) | ~18s | ~0.3s |
| Install speed (30 pkgs) | 4-6 min | 20-35s |
stout’s bundle system is a superset of Homebrew’s. Existing Brewfiles work without changes, while the additional features — version pins, snapshots, groups, and lockfiles — provide the reproducibility guarantees that professional software teams require.
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.