Deploying beyond the LAN
A node on a trusted LAN needs nothing beyond the tutorial. Leaving the LAN — a VPS, a reachable home server, tickets that travel — adds four concerns: the address you pin into tickets, TLS, the relay your mesh rides, and the binary you actually run.
Pin the public URL
lofi-node init --public-url https://sync.example.net
publicUrl is the base embedded in every issued ticket. It must be the address enrolled devices
will reach — not localhost, not an internal IP that stops resolving off-network. It can be changed
per-issuance (ticket issue --url …), but pinning it at init keeps tickets consistent.
Front the gate with TLS
The gate speaks plain HTTP; beyond a trusted LAN, put TLS in front of it (a reverse proxy such as
Caddy or nginx terminating https:// and forwarding to the gate's port) and issue tickets with the
https:// public URL. Two reasons, one hard and one practical: the ticket URL is a bearer
credential and should not transit networks unencrypted; and installed PWAs generally require a
secure origin, so an app enrolled over plain http may work in a tab and fail as an installed app.
Note what TLS does not change: the ticket still gates access, revocation still works the same, and the WebSocket upgrade still rides the same URL — the browser client derives its endpoints from the http(s) base.
Bring your own relay
Paired nodes hole-punch direct connections wherever possible; an iroh relay assists that hole-punching and carries the traffic that cannot go direct. Out of the box a node uses n0-computer's public relays: shared by every iroh developer, rate-limited, no uptime guarantees. That default exists so the tutorial works in thirty seconds; n0 blesses it for development and testing, and a production mesh should not lean on it.
lofi-node init --relay https://relay.example.net
The relay is part of what you self-host, and it fits the machine you already have: iroh-relay is a
single open-source binary with built-in Let's Encrypt support, cheap to run because it only carries
the non-direct residue of your own mesh's traffic. The publicly reachable machine hosting your root
node is its natural home. Each node elects its own relay (relay in
config.json), and the election travels inside that node's pairing tickets, so
nothing has to be coordinated across the mesh — no lofi-run infrastructure, no shared choke point.
Your mesh's availability depends only on machines you operate.
--relay accepts several URLs (comma-separated or repeated) for failover. --no-relay opts out
entirely; defensible for nodes that are all mutually reachable (static IPs, a VPN), but it also
forgoes relay-assisted address discovery, so expect hole-punching to suffer across NATs.
One binary
deno task compile # → dist/lofi-node
The compiled binary embeds the prebuilt native transport layer and extracts it to a version-keyed OS cache on first run. Artifact digests are pinned in the source, so an extraction that doesn't match its pin fails loudly. Release binaries cover macOS arm64 and Linux x86_64.
Windows is a documented gap: the native layer's build path needs a libnode.dll import library
the upstream toolchain doesn't ship for this configuration. On Windows the node runs LAN-only — the
Jazz server works, status().mesh reports { state: "unavailable", reason: … }, and
ticket()/pair() throw typed errors rather than degrade silently.
Linux aarch64 is a second documented gap: the Jazz engine (jazz-napi) publishes no linux-arm64-gnu build, so neither a compiled binary nor a native container image can boot on arm64 Linux. The iroh transport layer is built for the platform already; the target returns when the upstream package appears.
Run it in Docker
The container image wraps the same compiled binary in a minimal glibc base:
ghcr.io/felinestatemachine/lofi-node, published per release, linux/amd64 (per the platform note
above; Docker Desktop and qemu run it on arm64 hosts under emulation).
docker run --rm -v lofi-node-data:/data ghcr.io/felinestatemachine/lofi-node \
init --dir /data --port 4802 --public-url https://sync.example.net
docker run -d --name lofi-node --network host \
-v lofi-node-data:/data ghcr.io/felinestatemachine/lofi-node
Everything durable lives in the /data volume: config.json, the node identity key,
tickets.json, and the SQLite store. CLI verbs run through docker exec against the same directory
and take effect without a restart, exactly as outside a container:
docker exec lofi-node lofi-node ticket issue --dir /data --label phone
Two container-specific choices matter:
- Networking. Host networking keeps Docker's bridge NAT out of iroh's hole-punching path. A
bridge network with a published port (
-p 4802:4802) serves apps through the gate without issue, but paired nodes behind it are more likely to fall back to relayed traffic (the relay's role). Host networking is a Linux feature; Docker Desktop supports it from version 4.34. - The health check probes
http://127.0.0.1:4802/health. A node initialized with a different--portneeds the check overridden in compose.
The image runs as uid 1000. Named volumes inherit that ownership; a bind mount must be writable by
uid 1000 (chown 1000:1000 <dir>) or the storage probe fails at boot, naming the path. The
repository's compose.yaml
is the reference deployment.
The version invariant
A node pins the exact Jazz alpha of the apps it serves; version bumps are coordinated changes, never drive-by. Before pointing the apps you rely on at your node, confirm the pins match — a mismatched pair fails in protocol-level ways no amount of network configuration fixes. See troubleshooting for what the mismatches look like.