Self-hosting the iroh relay
The CLI↔app bridge (thunderbolt acp|mcp --transport iroh) uses
iroh. Without an override, iroh uses relay servers operated by n0, its
authors. This guide explains the relay’s role and how contributors or self-hosters can run one.
What a relay does (and doesn’t)
An iroh connection is QUIC between two endpoints identified by ed25519 keys (NodeIds). Peers behind NATs often cannot open a direct UDP path immediately, so every endpoint maintains a long-lived connection to a home relay, an HTTPS server that:
- Forwards encrypted packets when no direct path exists. This covers connection startup,
permanent NAT traversal failures, and the browser client in
crates/thunderbolt-acp-client, which cannot use UDP and therefore remains relay-only. - Assists hole-punching by carrying candidate addresses between peers. Native peers then try a direct QUIC path and migrate to it when possible.
- Names the meeting point because a connection ticket embeds the NodeId and home-relay URL. Ticket recipients can dial the peer without separate discovery.
A relay cannot read forwarded traffic. QUIC encrypts traffic end to end for the peer’s NodeId. A hostile relay can drop or delay packets and observe connection metadata, including participants, timing, and volume, but cannot read payloads. Self-hosting controls availability and metadata exposure; payload confidentiality does not depend on relay trust.
Why self-host
- Availability: operate capacity and rate limits appropriate for your deployment instead of relying on n0’s free, best-effort public relays.
- Metadata privacy: keep user connection graphs and traffic timing within infrastructure you control.
- Control: configure access tokens, rate limits, logging, and metrics.
Relay traffic moves fully to the configured self-hosted relay. DNS discovery for bare-NodeId dials
still queries n0’s DNS service because the transport retains presetN0. Ticket-based dials, the
normal Thunderbolt flow, never use DNS discovery because each ticket contains its relay URL. A
custom discovery service can remove the remaining n0 DNS dependency.
Client configuration
Both clients accept a relay override. Leaving it unset keeps the n0 default.
| Client | Variable | Configuration path |
|---|---|---|
| CLI (Bun) | THUNDERBOLT_IROH_RELAY_URL (runtime) |
cli/src/iroh/endpoint.ts configureTransport, replacing only the relay in the n0 preset |
| Web app (wasm) | VITE_IROH_RELAY_URL (build time) |
src/acp/iroh/iroh-transport.ts → crates/thunderbolt-acp-client relay-only endpoint |
Local development
Start the official relay in development mode:
docker compose -f deploy/iroh-relay/docker-compose.yml up -dThe compose service runs n0computer/iroh-relay:v1.0.2 --dev over plain HTTP on port 3340.
Configure both clients with http://localhost:3340:
THUNDERBOLT_IROH_RELAY_URL=http://localhost:3340 thunderbolt iroh idVITE_IROH_RELAY_URL=http://localhost:3340 bun run devDevelopment mode has no TLS or QUIC address discovery. Use it only on localhost.
Verifying your relay
First, decode a generated endpoint ticket and confirm its embedded relay URL:
cd cliTICKET='<ticket from thunderbolt iroh id>' bun -e \ 'import { EndpointTicket } from "@number0/iroh"; console.log(EndpointTicket.fromString(process.env.TICKET!).endpointAddr().relayUrl())'The command must print http://localhost:3340/ and no n0 relay URL.
Then run a round-trip with two state directories so each process has a distinct identity:
- Set
THUNDERBOLT_HOME=/tmp/thunderbolt-afor identity A andTHUNDERBOLT_HOME=/tmp/thunderbolt-bfor identity B. SetTHUNDERBOLT_IROH_RELAY_URL=http://localhost:3340for both. - Run
thunderbolt iroh idas identity B and copy its NodeId. - Run
thunderbolt iroh allow <B_NODE_ID>as identity A. - Start
thunderbolt acp --transport iroh -- catas identity A and copy its ticket. - Pipe one JSON-RPC line through
thunderbolt acp connect <A_TICKET>as identity B. Confirm output is byte-identical.
Same-host native peers can migrate to a direct path after the relay-mediated handshake. Embedded relay URL and successful authenticated round-trip verify relay configuration even when migration occurs.
Operational notes
- Version coupling: keep the server minor version aligned with the
irohand@number0/iroh1.x clients when upgrading either side. - Default logging omits per-connection details. Set
RUST_LOG=infoor enable metrics when debugging. - Access modes include
everyone,allowlist,denylist,shared_token, and an HTTP callback. Seedeploy/iroh-relay/config.example.tomlfor field examples.