Before submitting
Area
apps/web (the change is in packages/client-runtime, shared by web and mobile; the template has no option for it)
Problem or use case
I self-host a headless t3 serve backend that is reachable at two addresses:
http://192.168.178.36:3773 on my LAN
https://machine.tailnet.ts.net over Tailscale Serve
Which one works depends on where the client is: a laptop docked at home can use the LAN address, the same laptop on another network can only use the tailnet address, and a phone alternates between the two several times a day.
A saved environment stores exactly one endpoint, so I have to edit the environment's URL by hand every time I change networks. When the stored endpoint is unreachable, EnvironmentSupervisor retries that single URL forever with backoff capped at 16s and never tries the other address I know is up.
Saving the backend twice is not a workaround: the catalog is keyed by environmentId (packages/client-runtime/src/connection/registry.ts), and connectionId is derived as bearer:${environmentId} (connection/onboarding.ts), so pairing the same server at a second URL overwrites the first entry instead of adding one.
Proposed solution
Let a bearer environment hold an ordered list of endpoints instead of a single httpBaseUrl / wsBaseUrl pair, and have the connection runtime try them in order.
BearerConnectionProfile (packages/client-runtime/src/connection/catalog.ts) gains an ordered endpoint list. Existing single-URL profiles migrate to a one-element list.
ConnectionResolver.prepare (connection/resolver.ts) walks the candidates, and EnvironmentSupervisor (connection/supervisor.ts) advances to the next candidate on transient reasons (network, timeout, transport, endpoint-unavailable) instead of re-attempting the same URL.
- Remember the last endpoint that connected and start from it next time, so the common case stays a single attempt.
- Settings → Connections gains add / remove / reorder for endpoints on an existing environment, extending
updateBearerConnection (connection/onboarding.ts).
Bearer credentials are scoped per environment rather than per URL, so the pairing flow should not need to change.
This would also give the existing AdvertisedEndpoint concept somewhere to land. Today the server advertises candidate endpoints, but per docs/internals/remote.md they are a pairing-time UI hint that the connection runtime never consumes.
Why this matters
Reaching one backend from more than one network is the normal case for a self-hosted setup, not an edge case. Right now the options are to pick the single URL that is correct on one network and hand-edit it elsewhere, or to route everything through one overlay and accept that the environment is unreachable whenever that overlay is down.
The same mechanism generalizes past my case: LAN + Tailscale, Tailscale + relay, direct + SSH-forwarded, or a stable hostname plus a raw IP as a DNS-outage fallback. It turns "my laptop moved" from a manual settings edit into a reconnect.
Smallest useful scope
Two endpoints per bearer environment (primary + fallback), tried in order, with the last working one remembered and tried first.
Not needed for a first pass: reordering UI beyond add/remove, automatic endpoint discovery, parallel/happy-eyeballs racing, or any change to relay, SSH, or primary targets.
Alternatives considered
- Use only the Tailscale MagicDNS URL. This is what I do today and it works well: Tailscale connects same-LAN peers directly (
tailscale status shows direct 192.168.178.21:33593 for my phone), so home traffic stays on the local wire and I get HTTPS for the hosted web app. The cost is that every client must have Tailscale running and logged in; if it is down, the LAN path is not reachable even when the machine is three meters away.
- Save the backend twice, once per URL. Not possible today, per the
environmentId keying described above.
- Split-horizon DNS, resolving one hostname to the LAN address at home and the tailnet address elsewhere. Breaks HTTPS, because the Tailscale Serve certificate is only valid for the
*.ts.net name.
Risks or tradeoffs
- Failover must distinguish transient failures from auth/config failures. An expired credential on the primary should block rather than silently shift traffic to the fallback.
- Serial candidate attempts add latency when the first endpoint is black-holed rather than refusing. This probably needs a short per-candidate connect timeout.
- Endpoint identity matters: after failing over, the client should confirm the
environmentId from /.well-known/t3/environment still matches before treating the candidate as the same environment. A stale LAN IP can be reassigned to a different machine by DHCP.
- Changing a persisted profile schema needs a migration for existing saved environments.
Examples or references
docs/internals/remote.md — "resolves one access endpoint"; advertised endpoints are hints the connection attempt does not use.
docs/internals/connection-runtime.md — supervisor retry ladder, backoff capped at 16s.
- Observed on server 0.0.31, Linux, paired from the web app and the Android app.
Contribution
Before submitting
Area
apps/web (the change is in
packages/client-runtime, shared by web and mobile; the template has no option for it)Problem or use case
I self-host a headless
t3 servebackend that is reachable at two addresses:http://192.168.178.36:3773on my LANhttps://machine.tailnet.ts.netover Tailscale ServeWhich one works depends on where the client is: a laptop docked at home can use the LAN address, the same laptop on another network can only use the tailnet address, and a phone alternates between the two several times a day.
A saved environment stores exactly one endpoint, so I have to edit the environment's URL by hand every time I change networks. When the stored endpoint is unreachable,
EnvironmentSupervisorretries that single URL forever with backoff capped at 16s and never tries the other address I know is up.Saving the backend twice is not a workaround: the catalog is keyed by
environmentId(packages/client-runtime/src/connection/registry.ts), andconnectionIdis derived asbearer:${environmentId}(connection/onboarding.ts), so pairing the same server at a second URL overwrites the first entry instead of adding one.Proposed solution
Let a bearer environment hold an ordered list of endpoints instead of a single
httpBaseUrl/wsBaseUrlpair, and have the connection runtime try them in order.BearerConnectionProfile(packages/client-runtime/src/connection/catalog.ts) gains an ordered endpoint list. Existing single-URL profiles migrate to a one-element list.ConnectionResolver.prepare(connection/resolver.ts) walks the candidates, andEnvironmentSupervisor(connection/supervisor.ts) advances to the next candidate on transient reasons (network,timeout,transport,endpoint-unavailable) instead of re-attempting the same URL.updateBearerConnection(connection/onboarding.ts).Bearer credentials are scoped per environment rather than per URL, so the pairing flow should not need to change.
This would also give the existing
AdvertisedEndpointconcept somewhere to land. Today the server advertises candidate endpoints, but perdocs/internals/remote.mdthey are a pairing-time UI hint that the connection runtime never consumes.Why this matters
Reaching one backend from more than one network is the normal case for a self-hosted setup, not an edge case. Right now the options are to pick the single URL that is correct on one network and hand-edit it elsewhere, or to route everything through one overlay and accept that the environment is unreachable whenever that overlay is down.
The same mechanism generalizes past my case: LAN + Tailscale, Tailscale + relay, direct + SSH-forwarded, or a stable hostname plus a raw IP as a DNS-outage fallback. It turns "my laptop moved" from a manual settings edit into a reconnect.
Smallest useful scope
Two endpoints per bearer environment (primary + fallback), tried in order, with the last working one remembered and tried first.
Not needed for a first pass: reordering UI beyond add/remove, automatic endpoint discovery, parallel/happy-eyeballs racing, or any change to relay, SSH, or primary targets.
Alternatives considered
tailscale statusshowsdirect 192.168.178.21:33593for my phone), so home traffic stays on the local wire and I get HTTPS for the hosted web app. The cost is that every client must have Tailscale running and logged in; if it is down, the LAN path is not reachable even when the machine is three meters away.environmentIdkeying described above.*.ts.netname.Risks or tradeoffs
environmentIdfrom/.well-known/t3/environmentstill matches before treating the candidate as the same environment. A stale LAN IP can be reassigned to a different machine by DHCP.Examples or references
docs/internals/remote.md— "resolves one access endpoint"; advertised endpoints are hints the connection attempt does not use.docs/internals/connection-runtime.md— supervisor retry ladder, backoff capped at 16s.Contribution