Context
Today the only published artifact is a single Docker image built for linux/amd64 (ghcr.io/labstack/fanout:latest, produced by .github/workflows/release.yml). Native binaries do not exist on the GitHub Releases page, even though:
- The product is positioned everywhere as "single binary."
- The homepage and
docs/install.md both advertise curl -fsSL fanout.run/install.sh | sh — but no install.sh exists in site/public/ or anywhere in the repo.
- The "Pre-built binary" section of
docs/install.md points at github.com/labstack/fanout/releases — those releases currently have zero attached artifacts.
The gap between the marketing message and what someone actually gets when they follow the docs is the bug. This issue closes it.
CGO is required (duckdb-go/v2), but DuckDB ships precompiled native bindings for darwin-amd64, darwin-arm64, linux-amd64, linux-arm64, windows-amd64 — so we can produce binaries for those targets if we build on a matching native runner per target.
Who actually runs Fanout
- Production self-hosters (~70%): Linux VM or bare metal. amd64 dominant; arm64 growing fast (Graviton, Hetzner ARM, Ampere).
- Self-hosters / homelab (~20%): Single Linux box, often arm64 (Pi 5, Synology, mini-PC). Prefer native binary over container.
- Developers evaluating (~10%): macOS arm64 dominant, macOS amd64 fading. Install locally to kick the tires.
- Windows server: extremely rare for an OTel backend. Skip until asked.
Target platform matrix
| Tier |
Platform |
Distribution form |
| Must |
linux/amd64 |
tarball, multi-arch Docker, install.sh, Homebrew |
| Must |
linux/arm64 |
tarball, multi-arch Docker, install.sh, Homebrew |
| Should |
darwin/arm64 |
tarball, install.sh, Homebrew |
| Should |
darwin/amd64 |
tarball, install.sh, Homebrew |
| Defer |
windows/amd64 |
wait for demand |
Distribution channels
- GitHub Releases — tarballs (
.tar.gz), SHA256SUMS, auto-generated changelog.
- Docker (GHCR) — extend from amd64-only to
linux/amd64,linux/arm64 via docker buildx. Single multi-arch manifest.
- install.sh — makes the docs honest. Detects OS+arch, downloads the right tarball, verifies checksum, installs to
/usr/local/bin/fanout. Lives at site/public/install.sh so Astro serves it from https://fanout.run/install.sh.
- Homebrew tap (
labstack/homebrew-tap) — goreleaser auto-publishes a formula on each tag. brew install labstack/tap/fanout works on macOS + Linux.
Implementation steps
1. Add goreleaser config
Create .goreleaser.yaml with:
before.hooks: build web/dist via bun run build, copy into internal/ui/dist/.
- Four
builds entries (linux_amd64, linux_arm64, darwin_amd64, darwin_arm64) with env: ["CGO_ENABLED=1"], main: ./cmd/fanout, ldflags: ["-s", "-w", "-X main.version={{.Version}}"].
archives: .tar.gz with name_template: fanout_{{.Version}}_{{.Os}}_{{.Arch}}. Include LICENSE, README.md.
checksum: name_template: SHA256SUMS, sha256.
changelog: filter out chore: and docs: from public release notes.
release: github, draft: false, prerelease: auto.
brews: block pointing at labstack/homebrew-tap.
2. Rework .github/workflows/release.yml
Current workflow is single-job Docker on ubuntu-24.04. Replace with three parallel jobs, all gated on the same tag:
-
binaries: matrix over four native runners:
ubuntu-24.04 → linux/amd64
ubuntu-24.04-arm → linux/arm64
macos-14 (Apple Silicon) → darwin/arm64
macos-13 (Intel) → darwin/amd64
Each runner: install Go 1.26.1, install Bun, bun install in web/, goreleaser release --clean --split --single-target.
-
docker: single job using docker buildx with platforms: linux/amd64,linux/arm64. Pushes multi-arch manifest to ghcr.io/labstack/fanout:{tag,latest}.
-
finalise: needs [binaries, docker]. Runs goreleaser continue --merge to upload all partial archives + SHA256SUMS + Homebrew formula update as a single GitHub Release.
3. Wire version into the binary
- Add
var version = "dev" package var in cmd/fanout/main.go and a -version / --version flag that prints it and exits.
- goreleaser's
-X main.version={{.Version}} populates it on release builds.
justfile build target: -X main.version=$(git describe --tags --always --dirty) for local builds.
4. Write site/public/install.sh
Plain POSIX shell, runnable on macOS and Linux. Logic:
- Detect OS:
uname -s → darwin/linux; bail on anything else.
- Detect arch:
uname -m → x86_64 → amd64, aarch64/arm64 → arm64.
- Resolve latest release tag from
api.github.com/repos/labstack/fanout/releases/latest.
- Download
fanout_{tag}_{os}_{arch}.tar.gz and SHA256SUMS from that release.
- Verify checksum (
sha256sum -c on linux, shasum -a 256 -c on macOS).
- Extract
fanout, install to /usr/local/bin/fanout if writable else ~/.local/bin/fanout (warn on PATH).
- Print: "fanout {version} installed. Next: see https://fanout.run/docs/getting-started/".
Honour FANOUT_VERSION env var to pin a tag; FANOUT_PREFIX for custom install dir.
5. Update docs and site to match reality
| File |
Change |
site/src/content/docs/docs/install.md |
Lead with "Quick install (curl)", then Docker, then "Pre-built binary" with per-platform table linking GitHub Releases assets, then Homebrew, then "From source." |
site/src/content/docs/docs/getting-started.md |
One-line note that the binary serves on 127.0.0.1:4317 by default while the Docker image listens on all interfaces. |
site/src/components/site/HowItWorks.astro |
Confirm step 1 ("Install") is truthful after this lands. |
6. Resolve the fanout up discrepancy
cmd/fanout/main.go has no subcommand library — invoking the binary runs the server. But the marketing mockup shows fanout up and fanout 0.4.2 installed.
Two paths:
- A — match the marketing copy: add a tiny CLI router using stdlib
flag.NewFlagSet per subcommand (no cobra). fanout up runs the server; fanout version prints version. ~60 lines of change.
- B — match the binary: change marketing/install copy to just
fanout (no up), and the install transcript to "Listening on :7520, :4317".
Recommendation: A. The up/version/migrate shape is what users expect from a server binary in 2026, and we already wrote the marketing around it.
7. Homebrew tap
Create a separate public repo labstack/homebrew-tap (one-time). Add a brews: block to .goreleaser.yaml:
name: fanout
homepage: https://fanout.run
description: "Single-binary observability — OpenTelemetry ingest, fast UI, chat investigator."
repository: { owner: labstack, name: homebrew-tap }
test: | — run fanout --version as formula smoke test.
Add a fine-scoped PAT (TAP_GITHUB_TOKEN) as a repo secret so goreleaser can push formula updates to the tap on each release.
8. CHANGELOG + release notes hygiene
goreleaser auto-generates a changelog from commit subjects. To make it useful:
- Keep conventional commit prefixes (
feat:, fix:, chore:, docs:, refactor:) — most recent commits already follow this.
- Configure goreleaser to filter
chore: and docs: out of public release notes.
- No separate
CHANGELOG.md — GitHub release notes are the source of truth.
Files to create
.goreleaser.yaml
site/public/install.sh
Files to modify
.github/workflows/release.yml # 3-job split: binaries, docker, finalise
cmd/fanout/main.go # version var + minimal subcommand routing
justfile # local build embeds version via -X
site/src/content/docs/docs/install.md
site/src/content/docs/docs/getting-started.md
site/src/components/site/HowItWorks.astro
Separate repo to create
labstack/homebrew-tap (public, empty on creation)
Verification
End-to-end on a test tag (e.g. v0.0.0-test1):
git tag v0.0.0-test1 && git push --tags — workflow fires.
- Workflow completes; GitHub Releases page shows:
- 4 tarballs (
fanout_v0.0.0-test1_{linux,darwin}_{amd64,arm64}.tar.gz)
SHA256SUMS
- Auto-generated release notes
docker manifest inspect ghcr.io/labstack/fanout:v0.0.0-test1 shows both linux/amd64 and linux/arm64.
- From a Linux amd64 box:
curl -fsSL https://fanout.run/install.sh | sh, verify fanout --version prints v0.0.0-test1.
- Repeat from macOS arm64.
brew tap labstack/tap && brew install fanout, verify fanout --version.
fanout up starts the server, ingest works, UI loads.
- Delete the test tag and test release after verification.
Out of scope (intentionally)
.deb/.rpm packages — heavy lift, low payoff for v1; hand-rolled systemd unit in the binary tarball is good enough.
- Windows binaries — DuckDB binding exists but Windows-server installs of an OTel backend are vanishingly rare. Add when someone asks.
- Auto-update mechanism in the binary — ops norm is "redownload, restart."
- Code signing (macOS notarization, Windows Authenticode) — defer until someone hits a Gatekeeper warning.
Context
Today the only published artifact is a single Docker image built for
linux/amd64(ghcr.io/labstack/fanout:latest, produced by.github/workflows/release.yml). Native binaries do not exist on the GitHub Releases page, even though:docs/install.mdboth advertisecurl -fsSL fanout.run/install.sh | sh— but noinstall.shexists insite/public/or anywhere in the repo.docs/install.mdpoints atgithub.com/labstack/fanout/releases— those releases currently have zero attached artifacts.The gap between the marketing message and what someone actually gets when they follow the docs is the bug. This issue closes it.
CGO is required (
duckdb-go/v2), but DuckDB ships precompiled native bindings fordarwin-amd64,darwin-arm64,linux-amd64,linux-arm64,windows-amd64— so we can produce binaries for those targets if we build on a matching native runner per target.Who actually runs Fanout
Target platform matrix
linux/amd64linux/arm64darwin/arm64darwin/amd64windows/amd64Distribution channels
.tar.gz),SHA256SUMS, auto-generated changelog.linux/amd64,linux/arm64viadocker buildx. Single multi-arch manifest./usr/local/bin/fanout. Lives atsite/public/install.shso Astro serves it fromhttps://fanout.run/install.sh.labstack/homebrew-tap) — goreleaser auto-publishes a formula on each tag.brew install labstack/tap/fanoutworks on macOS + Linux.Implementation steps
1. Add goreleaser config
Create
.goreleaser.yamlwith:before.hooks: buildweb/distviabun run build, copy intointernal/ui/dist/.buildsentries (linux_amd64,linux_arm64,darwin_amd64,darwin_arm64) withenv: ["CGO_ENABLED=1"],main: ./cmd/fanout,ldflags: ["-s", "-w", "-X main.version={{.Version}}"].archives:.tar.gzwithname_template: fanout_{{.Version}}_{{.Os}}_{{.Arch}}. IncludeLICENSE,README.md.checksum:name_template: SHA256SUMS,sha256.changelog: filter outchore:anddocs:from public release notes.release:github,draft: false,prerelease: auto.brews:block pointing atlabstack/homebrew-tap.2. Rework
.github/workflows/release.ymlCurrent workflow is single-job Docker on
ubuntu-24.04. Replace with three parallel jobs, all gated on the same tag:binaries: matrix over four native runners:ubuntu-24.04→linux/amd64ubuntu-24.04-arm→linux/arm64macos-14(Apple Silicon) →darwin/arm64macos-13(Intel) →darwin/amd64Each runner: install Go 1.26.1, install Bun,
bun installinweb/,goreleaser release --clean --split --single-target.docker: single job usingdocker buildxwithplatforms: linux/amd64,linux/arm64. Pushes multi-arch manifest toghcr.io/labstack/fanout:{tag,latest}.finalise: needs[binaries, docker]. Runsgoreleaser continue --mergeto upload all partial archives +SHA256SUMS+ Homebrew formula update as a single GitHub Release.3. Wire version into the binary
var version = "dev"package var incmd/fanout/main.goand a-version/--versionflag that prints it and exits.-X main.version={{.Version}}populates it on release builds.justfilebuildtarget:-X main.version=$(git describe --tags --always --dirty)for local builds.4. Write
site/public/install.shPlain POSIX shell, runnable on macOS and Linux. Logic:
uname -s→darwin/linux; bail on anything else.uname -m→x86_64→amd64,aarch64/arm64→arm64.api.github.com/repos/labstack/fanout/releases/latest.fanout_{tag}_{os}_{arch}.tar.gzandSHA256SUMSfrom that release.sha256sum -con linux,shasum -a 256 -con macOS).fanout, install to/usr/local/bin/fanoutif writable else~/.local/bin/fanout(warn on PATH).Honour
FANOUT_VERSIONenv var to pin a tag;FANOUT_PREFIXfor custom install dir.5. Update docs and site to match reality
site/src/content/docs/docs/install.mdsite/src/content/docs/docs/getting-started.md127.0.0.1:4317by default while the Docker image listens on all interfaces.site/src/components/site/HowItWorks.astro6. Resolve the
fanout updiscrepancycmd/fanout/main.gohas no subcommand library — invoking the binary runs the server. But the marketing mockup showsfanout upandfanout 0.4.2 installed.Two paths:
flag.NewFlagSetper subcommand (no cobra).fanout upruns the server;fanout versionprints version. ~60 lines of change.fanout(noup), and the install transcript to "Listening on :7520, :4317".Recommendation: A. The
up/version/migrateshape is what users expect from a server binary in 2026, and we already wrote the marketing around it.7. Homebrew tap
Create a separate public repo
labstack/homebrew-tap(one-time). Add abrews:block to.goreleaser.yaml:name: fanouthomepage: https://fanout.rundescription: "Single-binary observability — OpenTelemetry ingest, fast UI, chat investigator."repository: { owner: labstack, name: homebrew-tap }test: |— runfanout --versionas formula smoke test.Add a fine-scoped PAT (
TAP_GITHUB_TOKEN) as a repo secret so goreleaser can push formula updates to the tap on each release.8. CHANGELOG + release notes hygiene
goreleaser auto-generates a changelog from commit subjects. To make it useful:
feat:,fix:,chore:,docs:,refactor:) — most recent commits already follow this.chore:anddocs:out of public release notes.CHANGELOG.md— GitHub release notes are the source of truth.Files to create
Files to modify
Separate repo to create
Verification
End-to-end on a test tag (e.g.
v0.0.0-test1):git tag v0.0.0-test1 && git push --tags— workflow fires.fanout_v0.0.0-test1_{linux,darwin}_{amd64,arm64}.tar.gz)SHA256SUMSdocker manifest inspect ghcr.io/labstack/fanout:v0.0.0-test1shows bothlinux/amd64andlinux/arm64.curl -fsSL https://fanout.run/install.sh | sh, verifyfanout --versionprintsv0.0.0-test1.brew tap labstack/tap && brew install fanout, verifyfanout --version.fanout upstarts the server, ingest works, UI loads.Out of scope (intentionally)
.deb/.rpmpackages — heavy lift, low payoff for v1; hand-rolled systemd unit in the binary tarball is good enough.