Setting Up a Private Package Index
Host your own stout package index with Ed25519-signed updates for internal tools and curated package lists.
Why a private index?
Organizations often need to distribute internal tools, enforce approved package versions, or air-gap their package management from the public internet. stout’s private index feature lets you host your own package registry with Ed25519-signed metadata, fine-grained access control, and delta-compressed updates that keep bandwidth costs low even at scale.
Common use cases include:
- Distributing proprietary CLI tools to employees
- Curating an approved subset of packages that meet compliance requirements
- Hosting patched versions of upstream formulas with organization-specific build flags
- Operating in air-gapped or restricted network environments
Architecture overview
A stout private index consists of three components:
- Index server: Serves the package metadata (formula definitions, version lists, dependency graphs). This can be any static file host, S3 bucket, or the included
stout-index-serverbinary for dynamic operation. - Bottle mirror (optional): Hosts precompiled binary packages (bottles) so clients do not need to build from source.
- Signing keypair: An Ed25519 keypair used to sign index updates. Clients verify signatures before applying any index data.
Generating signing keys
Start by generating an Ed25519 keypair for your index:
stout index keygen --output ~/.stout/private-index-keys/
# This creates two files:
# ~/.stout/private-index-keys/index-signing.key (private key - keep secret)
# ~/.stout/private-index-keys/index-signing.pub (public key - distribute to clients)
Store the private key securely. You will need it every time you publish index updates. The public key gets distributed to every client that consumes your index.
Creating the index
Initialize a new index directory:
stout index init --name "acme-internal" --path ./acme-index
This creates the following structure:
acme-index/
index.toml # Index metadata (name, URL, signing public key)
formulas/ # Formula definitions
casks/ # Cask definitions
bottles/ # Precompiled binaries (optional)
signatures/ # Ed25519 signatures for each update
Edit index.toml to configure your index:
[index]
name = "acme-internal"
description = "Internal tools and approved packages for Acme Corp"
url = "https://stout-index.internal.acme.com"
signing_key = "ed25519:pk:abc123..." # Contents of index-signing.pub
[mirror]
upstream = "https://index.stout.dev" # Mirror upstream packages (optional)
filter = "allowlist" # "allowlist", "blocklist", or "all"
[bottles]
enabled = true
url = "https://bottles.internal.acme.com"
platforms = ["arm64_sonoma", "x86_64_linux"]
Adding formulas to your index
Mirroring upstream packages
To include specific upstream packages in your index:
# Add individual packages from the upstream stout index
stout index add --from-upstream git curl wget jq ripgrep
# Add packages matching a pattern
stout index add --from-upstream --pattern "python@3.*"
# Mirror all upstream packages (large, usually not recommended)
stout index add --from-upstream --all
Writing custom formulas
Create a formula for an internal tool. stout formulas use a TOML format that compiles to the same internal representation as Homebrew’s Ruby formulas:
# formulas/acme-cli.toml
[formula]
name = "acme-cli"
version = "2.8.0"
description = "Acme Corp internal command-line tool"
homepage = "https://wiki.internal.acme.com/acme-cli"
license = "proprietary"
[source]
url = "https://artifacts.internal.acme.com/acme-cli/v2.8.0/acme-cli-2.8.0.tar.gz"
sha256 = "a1b2c3d4e5f6..."
[dependencies]
build = ["[email protected]", "make"]
runtime = ["curl", "jq"]
[install]
steps = [
"make build RELEASE=1",
"install -m 755 bin/acme-cli ${prefix}/bin/acme-cli",
"install -m 644 share/man/acme-cli.1 ${prefix}/share/man/man1/",
]
[bottles.arm64_sonoma]
url = "https://bottles.internal.acme.com/acme-cli-2.8.0.arm64_sonoma.tar.gz"
sha256 = "f6e5d4c3b2a1..."
[bottles.x86_64_linux]
url = "https://bottles.internal.acme.com/acme-cli-2.8.0.x86_64_linux.tar.gz"
sha256 = "1a2b3c4d5e6f..."
You can also import existing Homebrew Ruby formulas:
stout index import-formula ./my-tool.rb --output ./acme-index/formulas/
Building bottles
Pre-build binary bottles so clients do not have to compile from source:
# Build a bottle for the current platform
stout index build-bottle acme-cli --index ./acme-index
# Build bottles for multiple platforms using cross-compilation or CI
stout index build-bottle acme-cli \
--index ./acme-index \
--platforms arm64_sonoma,x86_64_linux \
--output ./acme-index/bottles/
For large organizations, set up a CI job that builds bottles automatically when formulas change. Here is a GitHub Actions example:
name: Build Bottles
on:
push:
paths: ["formulas/**"]
jobs:
build:
strategy:
matrix:
os: [macos-14, ubuntu-22.04]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: neullabs/setup-stout@v1
- name: Build changed bottles
run: |
changed=$(git diff --name-only HEAD~1 -- formulas/ | sed 's|formulas/||;s|\.toml||')
for formula in $changed; do
stout index build-bottle "$formula" --index . --output ./bottles/
done
- name: Upload bottles
run: |
aws s3 sync ./bottles/ s3://acme-stout-bottles/ --acl private
Signing and publishing
Every index update must be signed with your private key:
# Sign the current index state
stout index sign \
--index ./acme-index \
--key ~/.stout/private-index-keys/index-signing.key
# Publish to your hosting infrastructure
stout index publish --index ./acme-index --target s3://acme-stout-index/
The sign command generates a new signed manifest that includes SHA-256 hashes of every file in the index. Clients verify this manifest before applying updates, ensuring that no package metadata has been tampered with in transit or at rest.
Configuring clients
On each developer machine, add your private index:
stout config add-index acme-internal \
--url https://stout-index.internal.acme.com \
--signing-key "ed25519:pk:abc123..." \
--priority 100
The --priority flag determines resolution order. Higher numbers take precedence, so setting your private index to 100 (default upstream is 50) means that when a package exists in both indexes, the private version wins.
Verify the configuration:
stout config list-indexes
# NAME URL PRIORITY VERIFIED
# upstream https://index.stout.dev 50 yes
# acme-internal https://stout-index.internal.acme.com 100 yes
Now stout install acme-cli resolves against your private index first:
stout install acme-cli
# ==> Resolving [email protected] from acme-internal...
# ==> Downloading acme-cli-2.8.0.arm64_sonoma.tar.gz [4.2 MB]
# ==> Installed [email protected] (0.6s)
Access control
For indexes served by the stout-index-server binary, you can configure token-based authentication:
# Server side: generate access tokens
stout-index-server token create --name "engineering-team" --scope "read"
# Token: stout_idx_a1b2c3d4e5f6...
# Client side: configure the token
stout config add-index acme-internal \
--url https://stout-index.internal.acme.com \
--signing-key "ed25519:pk:abc123..." \
--token "stout_idx_a1b2c3d4e5f6..."
For static hosting (S3, GCS, nginx), rely on your infrastructure’s access controls (IAM roles, VPN restrictions, mTLS) rather than stout-level tokens.
Air-gapped environments
For fully air-gapped networks, export the complete index and bottles as a portable archive:
# On the connected machine
stout index export --index ./acme-index --include-bottles --output ./acme-index-export.tar.zst
# Transfer the archive to the air-gapped network, then:
stout index import --archive ./acme-index-export.tar.zst --serve-at /opt/stout-index
# Point clients at the local path
stout config add-index acme-internal --url file:///opt/stout-index --signing-key "ed25519:pk:abc123..."
Keeping your index up to date
Set up a cron job or CI pipeline to regularly sync upstream packages and rebuild bottles:
#!/bin/bash
# sync-index.sh - run daily via cron
set -euo pipefail
cd /var/lib/stout-index/acme-index
stout index sync-upstream --filter allowlist
stout index build-bottles --changed-only --output ./bottles/
stout index sign --key /etc/stout/index-signing.key
stout index publish --target s3://acme-stout-index/
echo "Index sync completed at $(date)"
Clients pick up updates automatically on their next stout update call, which transfers only the delta since their last sync.
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.