You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
deploy/compose: mobile pairing broken out of the box — bundle ships no buzz-pair-relay and relay 404s the legacy /pair fallback #2734
On a self-hosted relay deployed from the official deploy/compose bundle, mobile pairing fails for every user: the desktop's Settings → mobile-pairing card generates a QR, the phone scans it, and the connection errors out.
Root cause
The pairing flow depends on a dedicated pairing relay (buzz-pair-relay), and the self-hosted bundle neither runs one nor tells the desktop where to find one:
start_pairing (desktop) probes the main relay's NIP-11 for pairing_relay_url — the compose bundle sets no BUZZ_PAIRING_RELAY_URL, so the field is absent.
Fallback: because the relay advertises NIP-43, pairing_relay_from_nip11 resolves to the legacy /pair path on the main relay.
buzz-relay does not serve /pair (no such route in router.rs). Live check on a fresh bundle deploy:
The QR therefore encodes a dead endpoint, and the phone fails to connect.
Net: every deployment from deploy/compose has mobile pairing broken out of the box — no error on the relay side, nothing in the docs, and the operator's only signal is a generic connection error on the phone.
The kicker: the relay image already ships the fix — /usr/local/bin/buzz-pair-relay is present in ghcr.io/block/buzz:main; it just isn't wired into the bundle.
Verified fix (running in production tonight)
On our deployment we added a pair service to the compose stack using the same image:
…exposed it at wss://pair.<domain>, and set BUZZ_PAIRING_RELAY_URL=wss://pair.<domain> on the relay. Result: NIP-11 advertises pairing_relay_url, the desktop QR encodes the working endpoint, and pairing proceeds.
Proposed upstream change (PR to follow — I'm claiming this)
Add the pair service to deploy/compose/compose.yml.
Route it in the Caddy TLS overlay — likely as a /pair path proxy on the main domain, which also makes the existing NIP-43 legacy fallback Just Work with zero extra DNS, plus document BUZZ_PAIRING_RELAY_URL for split-domain setups.
Document both in deploy/compose/.env.example and the bundle README.
Related, for context: #1953 (closed — pairing relay auth optionality), #2324 (pairing how-to docs request), #2595 (QR version validation). None cover the missing sidecar in the self-hosted bundle.
Environment
ghcr.io/block/buzz:main (2026-07-24), single-node deploy/compose derivative on Docker 29.x, desktop 0.4.24, iOS app current TestFlight.
Confirmation from the deployment described above: with the pair sidecar running and BUZZ_PAIRING_RELAY_URL advertised in NIP-11, a real desktop → iOS pairing just completed end-to-end (QR scan, SAS confirmation, 'Mobile device paired successfully'). Same relay, same members-only config — the only delta was the sidecar + advertisement. PR incoming.
+1, hit this independently on a Railway self-host (closed relay, NIP-43). Confirming the workaround for anyone else: run buzz-pair-relay from the same image as a second service with BUZZ_PAIR_RELAY_BIND_ADDR=[::]:5000, expose it, and set BUZZ_PAIRING_RELAY_URL=wss://<pair-host> on the relay. The NIP-11 pairing_relay_url advertisement path works and desktop pairing succeeds end to end.
Confirmed on a digest-pinned v0.5.2 compose deployment (sha-3e48f1b) behind Caddy with membership auth (NIP-43): running buzz-pair-relay from the same image as a second service, routing /pair to it, and setting BUZZ_PAIRING_RELAY_URL so NIP-11 advertises it takes pairing from an instant 404 to 101 Switching Protocols.
Two operational traps we hit applying this to an already-running stack, for anyone landing here:
Single-file bind-mount + caddy reload = stale routes. If your Caddyfile is bind-mounted as a file and you replace it with install/cp, the new file gets a new inode — the running container stays bound to the old one, so caddy reload re-reads the OLD config and /pair keeps 404ing even though the file on disk is correct (verified: host file had the route, the container's copy didn't, inodes differed). docker compose up -d --force-recreate caddy is the only correct refresh.
The sidecar's module docs ask the proxy to route only/pair to it (it enforces no path restrictions itself), so scope the matcher:
Suggestion for whichever PR fixes the bundle: add a post-deploy assertion that /pair answers a WebSocket handshake with 101 over HTTP/1.1 and that the NIP-11 document contains pairing_relay_url. Either check catches this whole class at deploy time instead of at first phone scan.
Confirmed on a fresh self-hosted deploy (2026-07-30, relay 0.2.0, deploy/compose bundle behind an external TLS proxy) — and there's a one-line workaround that makes pairing work today:
BUZZ_PAIRING_RELAY_URL=wss://pairing.buzz.xyz
added to the relay's environment. After a restart the NIP-11 document advertises pairing_relay_url, the desktop app takes the PairingRelay::Configured branch instead of the dead /pair legacy path, and mobile pairing completes normally (QR → SAS → done).
What made this hard to diagnose as a self-hoster:
BUZZ_PAIRING_RELAY_URL appears nowhere in deploy/compose/.env.example or the compose README — we only found it by reading crates/buzz-relay/src/config.rs.
The failure mode is misleading: the desktop probe (pairing_relay_from_nip11) sees NIP-43 in supported_nips, falls back to LegacyPath, appends /pair — and the router has no such route anymore, so the QR panel just shows "WebSocket connection failed: HTTP error: 404 Not Found".
Suggested minimal fix until the bundled pair-relay lands: ship BUZZ_PAIRING_RELAY_URL=wss://pairing.buzz.xyz as a documented default in .env.example, and have the desktop app surface a clearer error when it ends up on the legacy path.
Thanks for the detailed writeup, this matches the root cause exactly. PR #2736 fixes it properly: the compose bundle now runs the pair-relay sidecar, Caddy routes /pair so the legacy fallback works out of the box, and BUZZ_PAIRING_RELAY_URL is documented in .env.example and the README with all three deploy shapes (Caddy TLS, split domain behind your own proxy, direct non-TLS). Your external proxy setup is the split domain case and the env var is the right lever there, exactly as you found.
One note on shipping it as a default: the value is operator specific so there's no sane default to ship, which is why the PR documents it as a commented example instead.
The clearer desktop error when the legacy path 404s is a good idea and out of scope for #2736, which is compose and docs only. That deserves its own issue so it doesn't get lost.
Reproduced on Desktop v0.5.3 against a closed NIP-43 Compose deployment. NIP-11 advertised NIP-43 without pairing_relay_url, Desktop selected wss://<relay>/pair, and the WebSocket upgrade returned HTTP 404.
Confirmed the existing relay image already contains /usr/local/bin/buzz-pair-relay. Wiring that binary as a separate Compose service with BUZZ_PAIR_RELAY_BIND_ADDR=0.0.0.0:5000, setting BUZZ_PAIRING_RELAY_URL=wss://${BUZZ_DOMAIN}/pair on the main relay, and routing only /pair through Caddy resolved it. After deployment, NIP-11 advertised the pairing URL, /pair returned 101 Switching Protocols, and desktop-to-mobile pairing completed successfully.
Current upstream/main still lacks this Compose wiring. Existing PR #2736 appears to cover the required deployment change.
Confirmed end-to-end on Buzz Desktop v0.5.6 (Windows x64) against a private self-hosted Compose deployment.
The current deploy/compose/compose.yml still contains no pairing-relay service or pairing URL wiring. Before remediation, the main relay and HTTPS/WSS worked but mobile pairing had no valid dedicated endpoint.
Working deployment shape:
run /usr/local/bin/buzz-pair-relay as a bounded sidecar;
bind its host port to loopback only;
set BUZZ_PAIRING_RELAY_URL=wss://<private-relay>/pair on the main relay;
route only /pair through the private TLS reverse proxy to the sidecar;
leave the main relay on /.
Verification performed:
main relay NIP-11 advertised the configured pairing URL;
WebSocket upgrades on both / and /pair returned 101 Switching Protocols;
direct access to the pairing host port remained denied;
Desktop-to-mobile QR/SAS pairing completed successfully with the same identity;
all relay-side events completed without errors.
This is still a real v0.5.6 installation blocker. PR #2736 appears to implement the appropriate bundle-level fix but remains open.
Self-hosted compose deployment here — hit the same 404, and there is a zero-infrastructure fix that may help others in this thread while the bundled sidecar story lands:
Set BUZZ_PAIRING_RELAY_URL to any public NIP-01 relay in the relay's .env and recreate the relay container:
BUZZ_PAIRING_RELAY_URL=wss://relay.damus.io
The relay then advertises pairing_relay_url in its NIP-11 document, the desktop takes the Configured path instead of the legacy /pair guess, and QR pairing completes normally (measured today at relay rev 96ae141: QR renders, phone pairs, SAS confirms).
This is safe by design: per NIP-AB the pairing relay is just a rendezvous — the payload is end-to-end encrypted with SAS visual confirmation, and the relay in the middle learns nothing. No sidecar, no extra service.
Two small asks that would have saved the debugging: (1) ship the key (commented out, with the "any NIP-01 relay works" note) in deploy/compose's example env; (2) #3779's error-message fix — the bare 404 gives no hint that a config key exists.
I have a self-hosted Tailscale-only self-hosted deployment and the sidecar fix works, with one difference from the Caddy setups. I hope this is helps and if there is issue or improvements with the suggestion it's always welcome 🙏
@madisonicus's public-relay suggestion works. For a tailscale deployment it adds an external dependency at the point where credential material is moving. The sidecar is a few lines, so here's the self-contained version.
Serving the sidecar at a path on the main host (tailscale serve --set-path /pair 5000) produced a QR that rendered, then The pairing relay rejected authentication on the phone — the #4932 failure. Giving it its own origin fixed it:
Pairing then completed end to end, including mobile credential import. Since #4198 reports mobile rejecting private relay URLs, note that a .ts.net name over wss:// passes that validator where an RFC1918 address doesn't — the check may be narrower than it looks.
One mechanism in this story deserves a spotlight, because it means the bundle gap can't even theoretically degrade gracefully on a members-only relay — and it has a sharp implication for the "just stop advertising NIP-43" half of the fix.
The MainRelay fallback is structurally dead on membership relays. Pairing signs everything with ephemeral throwaway keys (PairingSession::new_source), and membership relays enforce membership at NIP-42 AUTH itself. Raw transcript from a digest-pinned 0.2.0 compose deployment (fresh ephemeral key, correctly signed kind:22242 AUTH response):
<< ["AUTH","0be4472967fe70fa2f01aad600b3b79d9815fdab01b3e9979a704fb11c74c3f1"]
<< ["NOTICE","auth-required: authenticate before subscribing"]
<< ["CLOSED","trigger","auth-required: not authenticated"]
>> ["AUTH",{signed kind:22242 with relay + challenge tags}]
<< ["OK","5a35ee7b4ed0bfa39ad1fa68c0a539911f7b7a26ef51d6e677b348e4bb39d571",false,"restricted: not a relay member"]
(connection closed by relay)
Why this matters for the fix: current main's RelayInfo::build already gates the NIP-43 advertisement (the nip11.rs tests literally say advertising NIP-43 "misroutes pairing peers to a non-existent /pair sidecar"). But un-advertising alone just moves discovery from LegacyPath (404) to MainRelay — which then fails as above. On a closed relay there is no configuration-free path: pairing needs a real pairing relay reachable somewhere, advertised via pairing_relay_url (or bundled à la PR #2736).
Confirmations from our deployment (compose, membership auth, no reverse proxy):
The relay-side BUZZ_PAIRING_RELAY_URL env var works on the 0.2.0-era image as others reported; the binary in the pinned image contains the knob.
buzz-pair-relay runs fine bound directly to a public port for a small deployment, with one caveat worth knowing: hyper's default 30s header_read_timeout is silently disabled in the sidecar because run_server installs no timer (http1::Builder without .timer(...) → Time::Empty → the default timeout is dropped with only a debug-level warning). Proxyless deployments therefore have no pre-upgrade read timeout at all — trickled headers hold sockets forever, invisible to MAX_CONNS (which only counts upgraded connections). One-line fix: .timer(TokioTimer::new()) in run_server (hyper-util's tokio feature is already a dependency). We're running with that patch + a per-IP new-connection rate limit; happy to PR it.
Desktop-side, we also patched our fork with a client-side BUZZ_PAIRING_RELAY_URL env override consulted before the NIP-11 probe (validated to ws(s):// + host, loud failure when set-but-invalid, QR carries it to mobile automatically) — useful when the relay's config is out of reach. Also happy to PR if there's interest, though the relay-side advertisement makes it unnecessary for most setups.
For anyone whose transport now works but the phone still fails at "Failed to import credentials": that's #4198 — release mobile builds require an https:// relay URL at credential import, so plain-HTTP compose deployments dead-end there even with a perfect pairing relay.
Another confirmation, plus one trap that hasn't come up yet in this thread because everyone above is on Caddy, Railway or Tailscale. We run Traefik v3 (file provider) in front of a members-only relay, and the sidecar + /pair route fixed pairing exactly as described. It then silently broke again a week later.
Traefik router priority defaults to rule length. The pair router was pinned at priority: 100:
The main relay router had no explicit priority, so Traefik computed it from the rule length. Adding tenants grew that router's Host(...) || Host(...) || ... rule past 100 characters, the main router outranked the pair router, /pair went back to the relay, and pairing returned to a bare 404 with nothing in the deploy diff that looks related. Fix is to pin the pair router well above any rule length:
priority: 1000
Exact Path(), not PathPrefix() — the sidecar does not enforce paths itself, so the proxy is the only thing keeping an unauthenticated rendezvous endpoint scoped to one path.
One more note on the compose spelling, since a command: on the sidecar is an easy thing to copy over from the Helm chart. The image sets ENTRYPOINT ["/usr/local/bin/buzz-relay"] (Dockerfile:171) and deploy/charts/buzz/templates/pairing-relay.yaml uses command: ["/usr/local/bin/buzz-pair-relay"], which is correct for Kubernetes because k8s command maps to Docker ENTRYPOINT. In compose, command: is appended as an argument to the existing entrypoint, so the container boots a second buzz-relay with no .env, dies on DB connection failed: pool timed out, and crashloops behind a 502 that reads like a bad route. Compose needs entrypoint:, as in the snippet above from @Sonictrio.
Verification we use on every hostname after a routing change, expecting 101:
Adding another self-hosted verification report here rather than opening a duplicate, because #2734 is the best canonical home for the pairing-sidecar/self-hosted compose path. This run also ties together a few adjacent self-hosted failure modes: NIP-98 authority, /pair routing, and private-network client access.
Summary
A self-hosted Buzz desktop deployment was already usable from desktop, but mobile pairing failed through several distinct stages until the externally presented relay authority, the pairing sidecar route, and private tailnet client access were all aligned.
Environment
Self-hosted Buzz deployment.
Desktop client on Windows.
Mobile client on Android.
Containerized relay plus dedicated buzz-pair-relay sidecar.
Private Tailscale networking.
Tailnet-only HTTPS/WSS exposure on port 443.
No public Funnel/public exposure.
Existing community, channels, agents, and memberships were preserved.
Hostnames, IP addresses, pubkeys, community IDs, channel names, user names, and filesystem paths are intentionally omitted or generalized.
Symptoms observed
After moving desktop clients to a dedicated private HTTPS/WSS endpoint, the community was reachable but HTTP bridge queries failed with a NIP-98 URL mismatch. The event was signed for the new public authority, while the relay verifier still expected the old physical-host authority.
After aligning the canonical/NIP-98 authority, the existing community loaded successfully: channels and agents were visible and normal desktop messaging worked.
Mobile pairing then reached QR scan and six-digit SAS/code confirmation, but failed during completion with errors equivalent to:
WebSocket protocol/read error
connection reset without closing handshake
mobile: lost connection to the pairing relay
After correcting the pairing-sidecar runtime/route so /pair reached the sidecar consistently, a fresh retry regressed to an earlier-looking stage: QR scan succeeded on the phone, but the desktop did not acknowledge the scan and no SAS/code confirmation appeared.
Correlated logs showed the mobile device never reached the pairing relay during that retry. Tailscale Serve was active and /pair was still mapped to the sidecar, but the private-network ACL/grant only allowed the desktop client to reach the dedicated Buzz endpoint. Mobile traffic to the tagged Buzz node on tcp:443 was dropped before it could reach /pair.
After granting both desktop and mobile clients access to the private Buzz endpoint on tcp:443, pairing completed successfully end to end.
Root causes / contributing factors
A. NIP-98 canonical authority mismatch
The externally presented relay authority differed from the authority expected by the relay’s NIP-98 verifier. Once the canonical/base/public authority used for the relay verifier matched the private HTTPS/WSS endpoint, ordinary desktop bridge requests resumed working.
This is adjacent to #6441 and #4952: operators need clearer guidance that the host/authority used for ingress, tenant/community binding, and signed HTTP/WebSocket auth must be aligned carefully when a self-hosted deployment is moved behind a private reverse proxy or service-host endpoint.
B. Pairing sidecar routing
The working shape required /pair to be routed to the dedicated pairing sidecar, not to the main relay. A basic WSS connection/open check alone was not enough to prove a full pairing session would survive QR scan, SAS/code confirmation, and credential/community transfer.
This matches the core issue in #2734 and the related self-hosting reports such as #6521.
C. Private-network client authorization
Desktop access alone was not enough. The mobile client also needed explicit private-network access to the same dedicated Buzz endpoint on tcp:443. Without that grant, the phone appeared to scan the QR locally, but no pairing session reached the sidecar and the desktop never advanced to code confirmation.
This was not obvious from the mobile or desktop pairing UI. The decisive evidence came only from correlating pairing-sidecar logs with private-network service-host drops.
D. Discoverability / operator-facing diagnostics
Each failure looked like “mobile pairing is broken,” but the required fixes lived at different layers:
canonical/NIP-98 authority;
reverse-proxy path routing to /pair;
pairing-sidecar liveness across the full session;
per-client private-network grants.
The UI and logs did not make those prerequisites obvious.
Preserve the existing relay backend, existing community, channels, agents, memberships, and pairing sidecar.
Present Buzz through a dedicated private HTTPS/WSS authority on the tailnet.
Align the relay’s canonical/NIP-98/public authority with that dedicated endpoint.
Route / to the main Buzz relay.
Route /pair to the existing buzz-pair-relay sidecar.
Grant both the desktop client and the mobile client private tcp:443 access to the dedicated Buzz endpoint.
Verify pairing end to end, not just WebSocket-open readiness.
Verification after the working configuration
Existing community retained.
Channels loaded on desktop.
Agents loaded on desktop.
Ordinary desktop message/agent smoke test passed.
Pairing launched from the desktop mobile-invite flow.
QR scan completed.
SAS/confirmation codes matched.
Desktop pairing stages completed.
Mobile imported the existing community and channels.
Mobile opened an existing channel.
Mobile sent a message.
Agent received/responded.
Activity/readback was visible on both mobile and desktop.
Mobile read/write behavior passed.
Suggested upstream improvements
Concrete improvements that would have made this much easier to diagnose:
Document the self-hosted reverse-proxy authority model explicitly: the canonical/NIP-98 authority must match the HTTPS/WSS authority clients actually use.
In compose/self-host docs, treat the pairing sidecar and /pair route as required for mobile pairing, not optional operator folklore.
Add a /pair health/readiness check that tests more than a socket open where practical, or at least documents that 101 Switching Protocols is only a transport preflight.
During mobile pairing, surface a clearer distinction between:
pairing relay unreachable;
pairing sidecar reached but protocol/session rejected;
credential import failed;
private-network access denied before the relay.
Add a pairing diagnostic checklist for private deployments: NIP-11 pairing_relay_url, /pair route, canonical authority, and both desktop/mobile access grants.
Consider an end-to-end self-hosted mobile-pairing integration test for the topology above.
Activity
webdevtodayjason commentedon Jul 24, 2026
Confirmation from the deployment described above: with the
pairsidecar running andBUZZ_PAIRING_RELAY_URLadvertised in NIP-11, a real desktop → iOS pairing just completed end-to-end (QR scan, SAS confirmation, 'Mobile device paired successfully'). Same relay, same members-only config — the only delta was the sidecar + advertisement. PR incoming.fix(deploy): bundle buzz-pair-relay sidecar so mobile pairing works o…
jsilets commentedon Jul 28, 2026
+1, hit this independently on a Railway self-host (closed relay, NIP-43). Confirming the workaround for anyone else: run
buzz-pair-relayfrom the same image as a second service withBUZZ_PAIR_RELAY_BIND_ADDR=[::]:5000, expose it, and setBUZZ_PAIRING_RELAY_URL=wss://<pair-host>on the relay. The NIP-11pairing_relay_urladvertisement path works and desktop pairing succeeds end to end.JGroutsis commentedon Jul 30, 2026
Confirmed on a digest-pinned v0.5.2 compose deployment (
sha-3e48f1b) behind Caddy with membership auth (NIP-43): runningbuzz-pair-relayfrom the same image as a second service, routing/pairto it, and settingBUZZ_PAIRING_RELAY_URLso NIP-11 advertises it takes pairing from an instant 404 to101 Switching Protocols.Two operational traps we hit applying this to an already-running stack, for anyone landing here:
Single-file bind-mount +
caddy reload= stale routes. If your Caddyfile is bind-mounted as a file and you replace it withinstall/cp, the new file gets a new inode — the running container stays bound to the old one, socaddy reloadre-reads the OLD config and/pairkeeps 404ing even though the file on disk is correct (verified: host file had the route, the container's copy didn't, inodes differed).docker compose up -d --force-recreate caddyis the only correct refresh.The sidecar's module docs ask the proxy to route only
/pairto it (it enforces no path restrictions itself), so scope the matcher:Suggestion for whichever PR fixes the bundle: add a post-deploy assertion that
/pairanswers a WebSocket handshake with 101 over HTTP/1.1 and that the NIP-11 document containspairing_relay_url. Either check catches this whole class at deploy time instead of at first phone scan.DrDoomi commentedon Jul 30, 2026
Confirmed on a fresh self-hosted deploy (2026-07-30, relay 0.2.0, deploy/compose bundle behind an external TLS proxy) — and there's a one-line workaround that makes pairing work today:
BUZZ_PAIRING_RELAY_URL=wss://pairing.buzz.xyz
added to the relay's environment. After a restart the NIP-11 document advertises pairing_relay_url, the desktop app takes the PairingRelay::Configured branch instead of the dead /pair legacy path, and mobile pairing completes normally (QR → SAS → done).
What made this hard to diagnose as a self-hoster:
BUZZ_PAIRING_RELAY_URL appears nowhere in deploy/compose/.env.example or the compose README — we only found it by reading crates/buzz-relay/src/config.rs.
The failure mode is misleading: the desktop probe (pairing_relay_from_nip11) sees NIP-43 in supported_nips, falls back to LegacyPath, appends /pair — and the router has no such route anymore, so the QR panel just shows "WebSocket connection failed: HTTP error: 404 Not Found".
Suggested minimal fix until the bundled pair-relay lands: ship BUZZ_PAIRING_RELAY_URL=wss://pairing.buzz.xyz as a documented default in .env.example, and have the desktop app surface a clearer error when it ends up on the legacy path.
webdevtodayjason commentedon Jul 30, 2026
Thanks for the detailed writeup, this matches the root cause exactly. PR #2736 fixes it properly: the compose bundle now runs the pair-relay sidecar, Caddy routes /pair so the legacy fallback works out of the box, and BUZZ_PAIRING_RELAY_URL is documented in .env.example and the README with all three deploy shapes (Caddy TLS, split domain behind your own proxy, direct non-TLS). Your external proxy setup is the split domain case and the env var is the right lever there, exactly as you found.
One note on shipping it as a default: the value is operator specific so there's no sane default to ship, which is why the PR documents it as a commented example instead.
The clearer desktop error when the legacy path 404s is a good idea and out of scope for #2736, which is compose and docs only. That deserves its own issue so it doesn't get lost.
nedos commentedon Aug 1, 2026
Reproduced on Desktop v0.5.3 against a closed NIP-43 Compose deployment. NIP-11 advertised NIP-43 without
pairing_relay_url, Desktop selectedwss://<relay>/pair, and the WebSocket upgrade returned HTTP 404.Confirmed the existing relay image already contains
/usr/local/bin/buzz-pair-relay. Wiring that binary as a separate Compose service withBUZZ_PAIR_RELAY_BIND_ADDR=0.0.0.0:5000, settingBUZZ_PAIRING_RELAY_URL=wss://${BUZZ_DOMAIN}/pairon the main relay, and routing only/pairthrough Caddy resolved it. After deployment, NIP-11 advertised the pairing URL,/pairreturned101 Switching Protocols, and desktop-to-mobile pairing completed successfully.Current
upstream/mainstill lacks this Compose wiring. Existing PR #2736 appears to cover the required deployment change.morkalg commentedon Aug 7, 2026
Confirmed end-to-end on Buzz Desktop v0.5.6 (Windows x64) against a private self-hosted Compose deployment.
The current
deploy/compose/compose.ymlstill contains no pairing-relay service or pairing URL wiring. Before remediation, the main relay and HTTPS/WSS worked but mobile pairing had no valid dedicated endpoint.Working deployment shape:
/usr/local/bin/buzz-pair-relayas a bounded sidecar;BUZZ_PAIRING_RELAY_URL=wss://<private-relay>/pairon the main relay;/pairthrough the private TLS reverse proxy to the sidecar;/.Verification performed:
/and/pairreturned101 Switching Protocols;This is still a real v0.5.6 installation blocker. PR #2736 appears to implement the appropriate bundle-level fix but remains open.
madisonicus commentedon Aug 10, 2026
Self-hosted compose deployment here — hit the same 404, and there is a zero-infrastructure fix that may help others in this thread while the bundled sidecar story lands:
Set
BUZZ_PAIRING_RELAY_URLto any public NIP-01 relay in the relay's.envand recreate the relay container:The relay then advertises
pairing_relay_urlin its NIP-11 document, the desktop takes the Configured path instead of the legacy/pairguess, and QR pairing completes normally (measured today at relay rev 96ae141: QR renders, phone pairs, SAS confirms).This is safe by design: per NIP-AB the pairing relay is just a rendezvous — the payload is end-to-end encrypted with SAS visual confirmation, and the relay in the middle learns nothing. No sidecar, no extra service.
Two small asks that would have saved the debugging: (1) ship the key (commented out, with the "any NIP-01 relay works" note) in
deploy/compose's example env; (2) #3779's error-message fix — the bare404gives no hint that a config key exists.Sonictrio commentedon Aug 10, 2026
I have a self-hosted Tailscale-only self-hosted deployment and the sidecar fix works, with one difference from the Caddy setups. I hope this is helps and if there is issue or improvements with the suggestion it's always welcome 🙏
@madisonicus's public-relay suggestion works. For a tailscale deployment it adds an external dependency at the point where credential material is moving. The sidecar is a few lines, so here's the self-contained version.
In
compose.yml:Serving the sidecar at a path on the main host (
tailscale serve --set-path /pair 5000) produced a QR that rendered, thenThe pairing relay rejected authenticationon the phone — the #4932 failure. Giving it its own origin fixed it:Pairing then completed end to end, including mobile credential import. Since #4198 reports mobile rejecting private relay URLs, note that a
.ts.netname overwss://passes that validator where an RFC1918 address doesn't — the check may be narrower than it looks.Env: relay 0.2.1 (
ghcr.io/block/buzz:main), Desktop 0.5.8, iOS current, Debian 13 LXC on Proxmox, Docker 29.7.1.feat(desktop): BUZZ_PAIRING_RELAY_URL pairing override + pair-relay p…
idk-its-unknown commentedon Aug 19, 2026
One mechanism in this story deserves a spotlight, because it means the bundle gap can't even theoretically degrade gracefully on a members-only relay — and it has a sharp implication for the "just stop advertising NIP-43" half of the fix.
The
MainRelayfallback is structurally dead on membership relays. Pairing signs everything with ephemeral throwaway keys (PairingSession::new_source), and membership relays enforce membership at NIP-42 AUTH itself. Raw transcript from a digest-pinned 0.2.0 compose deployment (fresh ephemeral key, correctly signed kind:22242 AUTH response):Why this matters for the fix: current
main'sRelayInfo::buildalready gates the NIP-43 advertisement (thenip11.rstests literally say advertising NIP-43 "misroutes pairing peers to a non-existent /pair sidecar"). But un-advertising alone just moves discovery fromLegacyPath(404) toMainRelay— which then fails as above. On a closed relay there is no configuration-free path: pairing needs a real pairing relay reachable somewhere, advertised viapairing_relay_url(or bundled à la PR #2736).Confirmations from our deployment (compose, membership auth, no reverse proxy):
BUZZ_PAIRING_RELAY_URLenv var works on the 0.2.0-era image as others reported; the binary in the pinned image contains the knob.buzz-pair-relayruns fine bound directly to a public port for a small deployment, with one caveat worth knowing: hyper's default 30sheader_read_timeoutis silently disabled in the sidecar becauserun_serverinstalls no timer (http1::Builderwithout.timer(...)→Time::Empty→ the default timeout is dropped with only a debug-level warning). Proxyless deployments therefore have no pre-upgrade read timeout at all — trickled headers hold sockets forever, invisible toMAX_CONNS(which only counts upgraded connections). One-line fix:.timer(TokioTimer::new())inrun_server(hyper-util'stokiofeature is already a dependency). We're running with that patch + a per-IP new-connection rate limit; happy to PR it.BUZZ_PAIRING_RELAY_URLenv override consulted before the NIP-11 probe (validated tows(s)://+ host, loud failure when set-but-invalid, QR carries it to mobile automatically) — useful when the relay's config is out of reach. Also happy to PR if there's interest, though the relay-side advertisement makes it unnecessary for most setups.For anyone whose transport now works but the phone still fails at "Failed to import credentials": that's #4198 — release mobile builds require an
https://relay URL at credential import, so plain-HTTP compose deployments dead-end there even with a perfect pairing relay.andyg5000-agent commentedon Sep 24, 2026
Another confirmation, plus one trap that hasn't come up yet in this thread because everyone above is on Caddy, Railway or Tailscale. We run Traefik v3 (file provider) in front of a members-only relay, and the sidecar +
/pairroute fixed pairing exactly as described. It then silently broke again a week later.Traefik router priority defaults to rule length. The pair router was pinned at
priority: 100:The main relay router had no explicit priority, so Traefik computed it from the rule length. Adding tenants grew that router's
Host(...) || Host(...) || ...rule past 100 characters, the main router outranked the pair router,/pairwent back to the relay, and pairing returned to a bare 404 with nothing in the deploy diff that looks related. Fix is to pin the pair router well above any rule length:Exact
Path(), notPathPrefix()— the sidecar does not enforce paths itself, so the proxy is the only thing keeping an unauthenticated rendezvous endpoint scoped to one path.One more note on the compose spelling, since a
command:on the sidecar is an easy thing to copy over from the Helm chart. The image setsENTRYPOINT ["/usr/local/bin/buzz-relay"](Dockerfile:171) anddeploy/charts/buzz/templates/pairing-relay.yamlusescommand: ["/usr/local/bin/buzz-pair-relay"], which is correct for Kubernetes because k8scommandmaps to Docker ENTRYPOINT. In compose,command:is appended as an argument to the existing entrypoint, so the container boots a secondbuzz-relaywith no.env, dies onDB connection failed: pool timed out, and crashloops behind a 502 that reads like a bad route. Compose needsentrypoint:, as in the snippet above from @Sonictrio.Verification we use on every hostname after a routing change, expecting 101:
If a bundled reference deployment lands from #4082 / #5715, a Traefik variant with the priority pinned would save the next operator the regression.
fgdghooperator commentedon Oct 5, 2026
Adding another self-hosted verification report here rather than opening a duplicate, because #2734 is the best canonical home for the pairing-sidecar/self-hosted compose path. This run also ties together a few adjacent self-hosted failure modes: NIP-98 authority,
/pairrouting, and private-network client access.Summary
A self-hosted Buzz desktop deployment was already usable from desktop, but mobile pairing failed through several distinct stages until the externally presented relay authority, the pairing sidecar route, and private tailnet client access were all aligned.
Environment
buzz-pair-relaysidecar.Hostnames, IP addresses, pubkeys, community IDs, channel names, user names, and filesystem paths are intentionally omitted or generalized.
Symptoms observed
After moving desktop clients to a dedicated private HTTPS/WSS endpoint, the community was reachable but HTTP bridge queries failed with a NIP-98 URL mismatch. The event was signed for the new public authority, while the relay verifier still expected the old physical-host authority.
After aligning the canonical/NIP-98 authority, the existing community loaded successfully: channels and agents were visible and normal desktop messaging worked.
Mobile pairing then reached QR scan and six-digit SAS/code confirmation, but failed during completion with errors equivalent to:
After correcting the pairing-sidecar runtime/route so
/pairreached the sidecar consistently, a fresh retry regressed to an earlier-looking stage: QR scan succeeded on the phone, but the desktop did not acknowledge the scan and no SAS/code confirmation appeared.Correlated logs showed the mobile device never reached the pairing relay during that retry. Tailscale Serve was active and
/pairwas still mapped to the sidecar, but the private-network ACL/grant only allowed the desktop client to reach the dedicated Buzz endpoint. Mobile traffic to the tagged Buzz node ontcp:443was dropped before it could reach/pair.After granting both desktop and mobile clients access to the private Buzz endpoint on
tcp:443, pairing completed successfully end to end.Root causes / contributing factors
A. NIP-98 canonical authority mismatch
The externally presented relay authority differed from the authority expected by the relay’s NIP-98 verifier. Once the canonical/base/public authority used for the relay verifier matched the private HTTPS/WSS endpoint, ordinary desktop bridge requests resumed working.
This is adjacent to #6441 and #4952: operators need clearer guidance that the host/authority used for ingress, tenant/community binding, and signed HTTP/WebSocket auth must be aligned carefully when a self-hosted deployment is moved behind a private reverse proxy or service-host endpoint.
B. Pairing sidecar routing
The working shape required
/pairto be routed to the dedicated pairing sidecar, not to the main relay. A basic WSS connection/open check alone was not enough to prove a full pairing session would survive QR scan, SAS/code confirmation, and credential/community transfer.This matches the core issue in #2734 and the related self-hosting reports such as #6521.
C. Private-network client authorization
Desktop access alone was not enough. The mobile client also needed explicit private-network access to the same dedicated Buzz endpoint on
tcp:443. Without that grant, the phone appeared to scan the QR locally, but no pairing session reached the sidecar and the desktop never advanced to code confirmation.This was not obvious from the mobile or desktop pairing UI. The decisive evidence came only from correlating pairing-sidecar logs with private-network service-host drops.
D. Discoverability / operator-facing diagnostics
Each failure looked like “mobile pairing is broken,” but the required fixes lived at different layers:
/pair;The UI and logs did not make those prerequisites obvious.
Working topology
Sanitized topology that completed pairing:
No public Funnel/public exposure was required.
Successful workaround / resolution
The minimal working sequence was:
/to the main Buzz relay./pairto the existingbuzz-pair-relaysidecar.tcp:443access to the dedicated Buzz endpoint.Verification after the working configuration
Suggested upstream improvements
Concrete improvements that would have made this much easier to diagnose:
/pairroute as required for mobile pairing, not optional operator folklore./pairhealth/readiness check that tests more than a socket open where practical, or at least documents that101 Switching Protocolsis only a transport preflight.pairing_relay_url,/pairroute, canonical authority, and both desktop/mobile access grants.Related issues I found while searching before commenting: #2734, #6521, #4198, #6441, #4952.