stout
Article

Setting Up an Offline Mirror with stout

Create a local package mirror for air-gapped environments with stout mirror — including the built-in HTTP server and Ed25519 verification.

Neul Labs ·
#stout#offline#mirror#air-gapped#enterprise

Enterprise environments frequently operate networks that have no connection to the public internet. Government agencies, financial institutions, healthcare systems, and defense contractors all maintain air-gapped infrastructure where downloading packages from ghcr.io at install time is not an option. stout’s mirror command lets you create a complete local copy of any subset of the package ecosystem, serve it over your internal network, and verify every package with Ed25519 signatures — all without a single byte leaving your network perimeter.

The air-gapped problem

Traditional Homebrew has no built-in support for offline or air-gapped installations. The workarounds are painful: manually downloading .tar.gz bottles on an internet-connected machine, transferring them via sneakernet, and placing them in the right Cellar paths with correct permissions. There is no integrity verification beyond manually checking SHA256 hashes, no dependency resolution for the transferred packages, and no way to keep the mirror updated without repeating the entire process.

stout treats offline operation as a first-class use case, not an afterthought.

Creating a mirror

The stout mirror command creates a self-contained mirror directory that includes both the SQLite index and all bottle files for your selected packages.

Start by initializing a mirror:

stout mirror init --path /srv/stout-mirror
# Initialized empty mirror at /srv/stout-mirror
# Mirror signing key generated: /srv/stout-mirror/keys/mirror-ed25519.pub

This creates the mirror directory structure:

/srv/stout-mirror/
├── index.db              # SQLite index (will be populated)
├── bottles/              # Package bottle storage
├── keys/
│   ├── mirror-ed25519.pub    # Public key (distribute to clients)
│   └── mirror-ed25519.sec    # Private key (keep secure)
└── mirror.toml           # Mirror configuration

stout automatically generates an Ed25519 signing keypair for your mirror. Every index update and every bottle added to the mirror will be signed with this key. Client machines verify these signatures before trusting any package.

Selecting packages to mirror

You can add packages to your mirror individually or from a list:

# Add individual packages with all dependencies
stout mirror add --path /srv/stout-mirror git
stout mirror add --path /srv/stout-mirror [email protected]
stout mirror add --path /srv/stout-mirror node@20

# Add from a list file
stout mirror add --path /srv/stout-mirror --from-file packages.txt

# Add everything matching a pattern
stout mirror add --path /srv/stout-mirror "lib*"

When you add a package, stout resolves its complete transitive dependency tree and includes every dependency in the mirror. There is no risk of a partial dependency set causing install failures on the air-gapped side.

stout mirror add --path /srv/stout-mirror ffmpeg
# Resolving dependencies for ffmpeg...
# Adding 26 packages: aom, dav1d, fdk-aac, ffmpeg, fontconfig,
#   freetype, frei0r, gmp, gnutls, lame, libass, libbluray,
#   librist, libsoxr, libvidstab, libvmaf, libvorbis, libvpx,
#   libx264, libx265, opus, rav1e, rubberband, sdl2, snappy, xvid

Downloading bottles

After selecting packages, pull the actual bottle files into the mirror:

stout mirror sync --path /srv/stout-mirror
# Downloading bottles for 83 packages...
# [========================================] 83/83 (1.2 GB)
# Signing index... done
# Mirror sync complete: 83 packages, 1.2 GB

The sync command downloads bottles for your current platform by default. To mirror bottles for multiple platforms (for instance, both macOS ARM and macOS Intel), specify them explicitly:

stout mirror sync --path /srv/stout-mirror \
    --platforms arm64_sonoma,sonoma,arm64_ventura,ventura

After sync completes, the mirror is entirely self-contained. The /srv/stout-mirror directory can be copied to a USB drive, burned to optical media, or transferred via any mechanism appropriate for your air-gap policy.

Serving the mirror

On the air-gapped network, stout includes a built-in HTTP server to host the mirror. No separate web server installation is required:

stout mirror serve --path /srv/stout-mirror --bind 0.0.0.0:8080
# Serving mirror at http://0.0.0.0:8080
# 83 packages available (1.2 GB)
# Press Ctrl+C to stop

The built-in server is a lightweight Tokio-based HTTP server. It serves the SQLite index and bottle files with proper caching headers and supports range requests for resumable downloads. For production use, you can also place the mirror directory behind nginx, Apache, or any static file server — the directory layout is designed to be served as-is.

For persistent operation, run the server as a systemd service:

# /etc/systemd/system/stout-mirror.service
[Unit]
Description=stout package mirror
After=network.target

[Service]
ExecStart=/usr/local/bin/stout mirror serve --path /srv/stout-mirror --bind 0.0.0.0:8080
Restart=always
User=stout-mirror

[Install]
WantedBy=multi-user.target

Configuring clients

Client machines on the air-gapped network need two things: the mirror URL and the mirror’s public signing key.

# Add the mirror as a package source
stout config set mirror_url "http://mirror.internal:8080"

# Trust the mirror's signing key
stout key trust /path/to/mirror-ed25519.pub --name "internal-mirror"

Once configured, all stout operations use the local mirror instead of public endpoints:

stout update
# Fetching index from http://mirror.internal:8080... done
# Index updated: 83 packages available

stout install ffmpeg
# Installing ffmpeg and 25 dependencies from internal-mirror...
# All bottles verified against key: internal-mirror
# Installation complete

Ed25519 signature verification

Every component of the mirror is signed. When stout mirror sync runs, it signs the SQLite index file with the mirror’s Ed25519 private key. The signature is stored alongside the index:

/srv/stout-mirror/
├── index.db
├── index.db.sig          # Ed25519 signature of index.db
├── bottles/
│   ├── jq-1.7.1.arm64_sonoma.bottle.tar.gz
│   ├── jq-1.7.1.arm64_sonoma.bottle.tar.gz.sig
│   ...

Each bottle also gets an individual signature. On the client side, stout verifies both the index signature (before trusting any package metadata) and each bottle signature (before extraction). A failed verification halts the operation immediately:

stout install jq
# Fetching jq-1.7.1.arm64_sonoma.bottle.tar.gz... done
# Verifying signature... OK (signed by: internal-mirror)
# Extracting... done

If someone tampers with a bottle file on the mirror server, clients will reject it:

stout install jq
# Fetching jq-1.7.1.arm64_sonoma.bottle.tar.gz... done
# Verifying signature... FAILED
# Error: Signature verification failed for jq-1.7.1
#   Expected signer: internal-mirror
#   This may indicate the package has been tampered with.
#   Aborting installation.

Keeping the mirror updated

When new package versions are released, update the mirror on an internet-connected machine and transfer the changes:

# On the internet-connected machine
stout mirror sync --path /srv/stout-mirror --update-only
# Checking for updates...
# 12 packages have newer versions
# Downloading updated bottles (340 MB)...
# Signing index... done

The --update-only flag downloads only bottles that have newer versions than what the mirror already contains, minimizing transfer size. You can then transfer only the changed files to the air-gapped mirror using rsync, a USB drive, or your organization’s approved transfer mechanism.

For organizations with a data diode or one-way transfer mechanism, stout can export changes as a single archive:

stout mirror export-delta --path /srv/stout-mirror --since 2026-03-15
# Exporting changes since 2026-03-15...
# Created: mirror-delta-20260321.tar.zst (340 MB, 12 packages)

On the air-gapped side, import the delta:

stout mirror import-delta --path /srv/stout-mirror mirror-delta-20260321.tar.zst
# Importing 12 updated packages...
# Verifying signatures... OK
# Mirror updated: 83 packages (12 updated)

Mirror status and auditing

Check the current state of your mirror at any time:

stout mirror status --path /srv/stout-mirror
# Mirror: /srv/stout-mirror
# Packages: 83 (26 with multiple versions)
# Total size: 1.2 GB
# Last sync: 2026-03-21 14:32:00 UTC
# Signing key: internal-mirror (ed25519:a3f2...9c71)
# Platforms: arm64_sonoma, sonoma
# Stale packages: 3 (updates available upstream)

For compliance and auditing purposes, the mirror maintains a log of all sync operations, including which packages were added, updated, or removed, and the signing key used for each operation.

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.