Distributing a Private CLI via Homebrew with Nix Cross-Compilation
5 min readYou have a private CLI tool. It’s built with Nix, produces static binaries for four platforms, and works beautifully — on your machine. Now your teammates want it, and they don’t have Nix.
What you want is brew install my-cli. One command, any Mac or Linux box, no Nix required. But the source repo is private. Homebrew can’t authenticate against it. You can’t just point a formula at your GitHub Releases and call it a day.
This post covers the pattern: a private Homebrew tap repo, Nix cross-compilation, and a release script that builds, uploads, and generates the formula in one shot.
The auth problem
Homebrew formulas download tarballs via HTTPS. When you brew install something, Homebrew fetches the URL in the formula’s url field — no authentication, no SSH keys, just a plain HTTP GET. If your source repo is private, that GET returns a 404.
This is the fundamental constraint. The formula needs to download binaries from somewhere Homebrew can reach.
The tap repo trick
The solution is a separate repo — a tap — that’s also private but becomes accessible once a user runs brew tap. Here’s how it works:
- You create a private repo named
homebrew-tap(orhomebrew-tools,homebrew-internal— thehomebrew-prefix is what matters) - Your teammates run
brew tap myorg/tap, which clonesgithub.com/myorg/homebrew-tapvia SSH - Once tapped, Homebrew can access that repo’s GitHub Releases via authenticated HTTPS
- Your formula points to release assets on the tap repo, not the source repo
The naming convention is load-bearing. Homebrew automatically prepends homebrew- to the short name and assumes GitHub:
brew tap myorg/tap
# → clones github.com/myorg/homebrew-tap via SSH
No full URL needed. If your teammates have SSH keys configured for GitHub (they do — they’re developers), this just works. For HTTPS-only setups, HOMEBREW_GITHUB_API_TOKEN handles auth.
Tap repo structure
Minimal. One directory, one file:
homebrew-tap/
├── Formula/
│ └── my-cli.rb
That’s it. No Brewfile, no Casks, no scripts. The release assets live in GitHub Releases on this same repo.
The formula
A Homebrew formula is a Ruby class that tells brew where to download binaries and how to install them. For a multi-platform CLI:
class MyCli < Formula
desc "Description of your CLI tool"
homepage "https://github.com/myorg/homebrew-tap"
version "0.1.0"
license "Proprietary"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/myorg/homebrew-tap/releases/download/my-cli-v0.1.0/my-cli-0.1.0-darwin-arm64.tar.gz"
sha256 "DARWIN_ARM64_SHA256"
else
url "https://github.com/myorg/homebrew-tap/releases/download/my-cli-v0.1.0/my-cli-0.1.0-darwin-amd64.tar.gz"
sha256 "DARWIN_AMD64_SHA256"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/myorg/homebrew-tap/releases/download/my-cli-v0.1.0/my-cli-0.1.0-linux-arm64.tar.gz"
sha256 "LINUX_ARM64_SHA256"
else
url "https://github.com/myorg/homebrew-tap/releases/download/my-cli-v0.1.0/my-cli-0.1.0-linux-amd64.tar.gz"
sha256 "LINUX_AMD64_SHA256"
end
end
def install
bin.install "my-cli"
end
test do
assert_match "my-cli", shell_output("#{bin}/my-cli --version")
end
end
Four platform blocks, four sha256 hashes, one bin.install. The URLs all point to the tap repo’s releases — not the source repo.
The test block is optional but good practice. brew test my-cli will run it after install.
The release script
You don’t want to build four tarballs, compute four hashes, upload them, edit the formula, commit, and push — by hand. That’s the kind of process you do correctly twice and then fumble on the third.
Instead, add a homebrew-release package to your flake. It’s a shell script with Nix-provided dependencies:
homebrew-release = let
runtimeDeps = with pkgs; [ gh coreutils gnutar gzip git openssh jq ];
in pkgs.writeShellScriptBin "homebrew-release" ''
export PATH="${pkgs.lib.makeBinPath runtimeDeps}:$PATH"
set -euo pipefail
GH_OWNER="myorg"
GH_REPO="my-cli"
GH_TAP_REPO="homebrew-tap"
PACKAGE="my-cli"
TAP_REPO="git@github.com:$GH_OWNER/$GH_TAP_REPO.git"
if ! gh auth status &>/dev/null; then
echo "Error: gh CLI is not authenticated. Run: gh auth login" >&2
exit 1
fi
VERSION=$(grep '^version' cli/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
TAG="$PACKAGE-v$VERSION"
echo "Version: $VERSION (tag: $TAG)"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
# Clone tap repo for existing sha256 values
TAP_DIR="$WORK/tap"
git clone "$TAP_REPO" "$TAP_DIR" 2>&1 | grep -v "^warning:" || true
mkdir -p "$TAP_DIR/Formula"
FORMULA="$TAP_DIR/Formula/my-cli.rb"
existing_sha() {
local plat=$1
if [ -f "$FORMULA" ]; then
sed -n "/$plat\.tar\.gz/{n;s/.*sha256 \"\([^\"]*\)\".*/\1/p;}" "$FORMULA"
fi
}
# Create or reuse GitHub release on the tap repo
if gh release view "$TAG" --repo "$GH_OWNER/$GH_TAP_REPO" &>/dev/null; then
echo "Release $TAG already exists."
else
gh release create "$TAG" \
--repo "$GH_OWNER/$GH_TAP_REPO" \
--title "$PACKAGE $TAG" \
--notes "$PACKAGE release $VERSION"
fi
EXISTING_ASSETS=$(gh release view "$TAG" \
--repo "$GH_OWNER/$GH_TAP_REPO" \
--json assets -q '.assets[].name' 2>/dev/null || true)
DARWIN_ARM64_SHA="PLACEHOLDER"
DARWIN_AMD64_SHA="PLACEHOLDER"
LINUX_ARM64_SHA="PLACEHOLDER"
LINUX_AMD64_SHA="PLACEHOLDER"
PLATFORMS="darwin-arm64:aarch64-darwin darwin-amd64:x86_64-darwin linux-arm64:aarch64-linux linux-amd64:x86_64-linux"
for ENTRY in $PLATFORMS; do
PLAT="''${ENTRY%%:*}"
NIX_SYS="''${ENTRY##*:}"
FILENAME="$PACKAGE-$VERSION-$PLAT.tar.gz"
if echo "$EXISTING_ASSETS" | grep -qF "$FILENAME"; then
SHA=$(existing_sha "$PLAT")
if [ -n "$SHA" ] && [ "$SHA" != "PLACEHOLDER" ]; then
echo "[$PLAT] Already published (sha256: ''${SHA:0:16}...)"
else
echo "[$PLAT] Already published, fetching sha256..."
gh release download "$TAG" \
--repo "$GH_OWNER/$GH_TAP_REPO" \
--pattern "$FILENAME" \
--dir "$WORK"
SHA=$(sha256sum "$WORK/$FILENAME" | cut -d' ' -f1)
fi
else
echo "[$PLAT] Building .#packages.$NIX_SYS.my-cli-static..."
OUT=$(nix build ".#packages.$NIX_SYS.my-cli-static" --no-link --print-out-paths)
BDIR="$WORK/build-$PLAT"
mkdir -p "$BDIR"
cp "$OUT/bin/my-cli" "$BDIR/"
TARBALL="$WORK/$FILENAME"
tar czf "$TARBALL" -C "$BDIR" my-cli
SHA=$(sha256sum "$TARBALL" | cut -d' ' -f1)
echo "[$PLAT] sha256: ''${SHA:0:16}..."
echo "[$PLAT] Uploading to release..."
gh release upload "$TAG" "$TARBALL" \
--repo "$GH_OWNER/$GH_TAP_REPO" \
--clobber
fi
case "$PLAT" in
darwin-arm64) DARWIN_ARM64_SHA="$SHA" ;;
darwin-amd64) DARWIN_AMD64_SHA="$SHA" ;;
linux-arm64) LINUX_ARM64_SHA="$SHA" ;;
linux-amd64) LINUX_AMD64_SHA="$SHA" ;;
esac
done
# Generate the formula with real sha256 values
# (template omitted for brevity — same structure as above,
# with $DARWIN_ARM64_SHA etc. interpolated)
cd "$TAP_DIR"
git add Formula/my-cli.rb
if git diff --cached --quiet; then
echo "Formula already up to date."
else
git commit -m "Update my-cli to $VERSION"
git push -u origin HEAD
echo "Tap updated."
fi
'';
Then expose it in your flake outputs:
packages.homebrew-release = homebrew-release;
A few things worth calling out:
- Incremental by design. If a platform’s tarball is already uploaded, the script reads its sha256 from the existing formula and skips the build. You can resume after a partial failure — say, if the linux-amd64 remote builder was down — without re-uploading what’s already done.
ghfor everything. Creates releases, uploads assets, downloads for sha256 recovery. Must be authenticated viagh auth login.- Clones the tap to a temp dir. Reads existing sha256 values, writes the updated formula, commits, pushes. The temp dir gets cleaned up on exit.
The release workflow
# 1. Bump version in cli/Cargo.toml
# 2. Run the release script
nix run .#homebrew-release
That’s it. The script:
- Reads the version from
Cargo.toml - Creates a GitHub Release tagged
my-cli-v0.1.0on the tap repo - Builds static binaries for all four platforms via
nix build .#packages.$system.my-cli-static - Tars them up, computes sha256 hashes, uploads as release assets
- Generates the formula with real hashes, commits, and pushes to the tap repo
Your teammates run brew upgrade my-cli and get the new version. No Nix, no Docker, no “download this tarball and put it in your PATH.”
User installation
From the user’s perspective — the person who just wants the CLI:
# One-time setup
brew tap myorg/tap
# Install
brew install my-cli
# Later
brew upgrade my-cli
Three commands total, one of which they do once. If they have SSH access to the tap repo, it just works. That’s the whole point.
Public vs. private source repos
If your source repo is public, you don’t need the tap repo trick at all. Point the formula directly at the source repo’s GitHub Releases:
url "https://github.com/myorg/my-cli/releases/download/v0.1.0/my-cli-0.1.0-darwin-arm64.tar.gz"
Homebrew can fetch from public repos without auth. You still need a tap repo for the formula itself (unless you’re submitting to homebrew-core, which requires open source), but the binaries can live on the source repo.
The whole re-hosting dance — uploading binaries to the tap repo’s releases — only matters when the source repo is private and Homebrew can’t reach it. If that constraint goes away, simplify.
Prerequisites
- A Nix flake that produces static binaries for four platforms — see Building Portable Rust CLI Binaries with Nix for the full pattern
ghCLI installed and authenticated- Remote builders configured for cross-platform builds (Linux from Mac, or vice versa)
- A
homebrew-taprepo created on GitHub (private, with thehomebrew-prefix) - SSH access to the tap repo (or
HOMEBREW_GITHUB_API_TOKENfor HTTPS)
The Nix cross-compilation and Homebrew tap are independent problems that happen to compose well. Nix gives you reproducible, hermetic builds across four targets. Homebrew gives you a distribution channel that meets developers where they already are. The release script is the glue.