Define the district server clearly enough that a new district can be stood up from the
runbook, and so that scales running on hydraskin nodes have a public path in.
Six Hydra services are now published as multi-arch OCI images (hydranps, hydravenues,
hydraorganization, hydrapipeline, hydraexperiencelibrary, hydradistrict) and one runs as a
scale on pi-node-001, byte-for-byte identical to its Hetzner instance. None of them can
serve traffic, because nothing routes into a scale. That is the gap this closes.
The model is one district server per ~100 km, serving nearby venues and bodies at low
latency. Brussels is the working example — OVH, EU-WEST-LZ-BRU-A:
| Instance | brussels-district-v2, 141.227.136.199 |
| Spec | b3-8 · 2 vCPU · 16 GB · 92 GB free · x86_64 · Ubuntu 24.04 |
| hydracluster node | node-2d5fba78 |
| Runs | hydraguard (wg0, 24 peers), hydraneckwebrtc controller (:443) + worker (:47990), coturn (:3478), hydranode |
Crucially, it already routes into the Pi network: a WireGuard peer carries
allowed-ips 10.0.0.0/24, and from the district box 10.0.0.4 and 10.0.0.5
(pi-node-003/004) are reachable over wg0. So the private path to the scales exists and
works today — only the public entry point is missing.
hydrareverseproxy already does exactly this job — TLS termination with autocert and
domain-based routing — and is already in production on the dashboard server
(78.47.174.83, v0.1.2) fronting six domains. Its own config.example.yaml even routes
hydraorganization.experiencenet.com and hydravenues.experiencenet.com, so this pattern
was anticipated.
Target shape on a district machine:
:443 hydrareverseproxy (autocert, host-routed)
├─ hydraneckwebrtc-brussels.experiencenet.com -> 127.0.0.1:8443 (moved off :443)
├─ hydranps.experiencenet.com -> 10.0.0.4:8080 (scale, via wg0)
└─ hydravenues.experiencenet.com -> 10.0.0.5:8081 (scale, via wg0)
untouched: :3478 TURN | :49152-65535 relay | :47990 worker | :51820/udp wg
Each scale exposes its port on its hydraskin host with an Incus proxy device; the
district proxy routes to <pi-lan-ip>:<port> over the mesh.
Putting ingress on the district server is better than a central one: the box already
terminates the tunnel to its own Pis, so traffic reaches a scale in one local hop instead
of hairpinning through Hetzner. The public face and the private mesh endpoint are the same
machine, which is where ingress belongs.
hydraneckwebrtc's controller currently owns :443 and would move behind the proxy.
The media path never touches the proxy — TURN (:3478), the relay range
(:49152-65535) and the worker (:47990) all bypass it. Only HTTP/WebSocket signalling is
proxied, and httputil.ReverseProxy handles WebSocket upgrades natively.
Header forwarding is no longer a concern: #63 (Authorization stripped) is fixed in
v0.1.2 — verified empirically, since the issue tracker's own authenticated API is served
through that proxy and returns 200 with a Bearer token. #63 can be closed.
Brussels is a Local Zone with no Floating IP. Verified: /ip/failover is empty and the
region advertises instance and network but no floating-IP service. The precise
behaviour is the IP is fixed for the life of the instance but not portable — it does not
drift on its own, but a delete/recreate draws a new address from the pool. That is what
produced 141.227.136.12 -> 141.227.136.199 when the box was rebuilt as -v2.
The good news: almost nothing depends on that IP directly.
The district box is also the WireGuard hub for the district mesh — 24 peers (distinct
from the Hetzner hydraguard hub, which has 9 and matches the nodes carrying a
wireguard_ip in hydracluster). A hardcoded endpoint IP across 24 peers would make a
recreate genuinely painful.
It is not hardcoded. pkg/mesh/mesh.go:150 HubEndpoint() prefers endpoint_hostname
over endpoint, and the live mesh.yaml sets both:
hub:
endpoint: 141.227.136.199
endpoint_hostname: hydraguard.experiencenet.com
so peers are issued hydraguard.experiencenet.com:51820. Recreate recovery is therefore
one DNS update, not re-keying 24 peers.
hcloud zone rrset canwireguard-tools ships reresolve-dns.sh; aDaily OVH snapshots exist — autobackup-brussels-district-v2, ~05:20 UTC, 7 days
retained. A restore recovers hub.key, mesh.yaml and wg0.conf together, so the mesh is
recoverable.
Three caveats:
/root/.hydraguard/backups/ holds timestampedmesh-*.yaml copies on the very instance they would protect.hub.key is the single irreplaceable secret (45 bytes, /etc/wireguard/hub.key).dpkg-db-backup, which is Debian'sEU-WEST-LZ-BRU-A). That covers instance loss, corruptionsize: 0 (2026-07-25) where every other shows 5, suggesting aA restore should be tested rather than assumed. An untested backup is a hypothesis,
and this one guards the credential that the whole district mesh depends on.
hydracloudproviders/docs/runbooks/ovhcloud.md — documents the dead .12 instance, thehydrareverseproxy/CLAUDE.md — claims it runs on "hydra-services cx23 (colocationhydrareverseproxy on brussels-district-v2; move hydraneckwebrtc's controllerproxy device per scale on the hydraskin nodes.The previous comment argued the district server was optional and proposed running the proxy from hydrastreamingmonitor instead. That understated the cost of the alternative. Correcting it here rather than leaving a wrong recommendation standing.
# on brussels-district-v2
FORWARD -i wg0 -o wg0 -j ACCEPT # hub explicitly relays peer<->peer
AllowedIPs = 10.10.0.0/16, 10.0.0.0/8 # every peer routes everything to the hub
There is no direct peer-to-peer path. pkg/config/{air,venue,neckair,headipad}.go all issue the same full-mesh AllowedIPs pointing at the hub, so any peer-to-peer traffic is relayed by brussels-district-v2.
Running the proxy on hydrastreamingmonitor (nbg1) would make every request:
client -> nbg1 (TLS) -> [WAN] -> Brussels hub -> Pi -> back the same way
Measured nbg1 -> Brussels: 16.2 ms RTT (vs 0.8 ms for a colocated Hetzner hop). That leg is added to every request and response.
Worse than the latency: it makes the district hub inline for production traffic. Today a hub outage costs management and mesh connectivity. Under that proposal it would also take down every cut-over service. On the box whose backup is unproven (#422/#424) and whose snapshots fail roughly 1 run in 5.
The hub is on the path either way — pi-node-001 is LAN-private behind NAT with no public endpoint, and WireGuard does no hole-punching, so the mesh is the only inbound route. "Traffic runs over the district" is inherent to LAN-private scales, not a choice.
What is a choice is whether the district is a transit hop or the termination point:
| Option | Path | Cost |
|---|---|---|
| Proxy on hydrastreamingmonitor | client -> nbg1 -> Brussels -> Pi | +16 ms, hub inline anyway, extra WAN dependency |
| Proxy on district server | client -> Brussels -> Pi | requires freeing :80/:443 |
So hosting ingress on the district server does not add district dependence — it makes that dependence local rather than transcontinental. Which is the "district server per ~100 km" model this issue set out to define in the first place.
serve.go parses backends with url.Parse into NewSingleHostReverseProxy, so http://10.10.x.y:8080 works today. It is deployed and proven on hydrastreamingmonitor (6 domains, autocert, :443).wireguard_ip — no hydraskin node is on the mesh. Enrolling the scale hosts is a prerequisite for either option.It currently binds both :80 and :443 directly on brussels-district-v2. Options: move it behind hydrareverseproxy as another route (cleanest if it is plain HTTPS), or move it to different ports if it needs raw TLS/WebRTC signalling on 443. Needs someone who knows whether its clients can tolerate a proxy in front.
Re-scoping: the proxy already exists, the gap is connectivity (2026-08-01)
This issue was framed as defining an ingress component. That framing was wrong, and it made the work look bigger and more disruptive than it is.
hydrareverseproxy v0.1.2 is already deployed and working — on
hydrastreamingmonitor(78.47.174.83), terminating TLS on :443 with autocert and routing six domains today:And it does not need modifying to front a scale.
internal/cli/serve.goparses each backend withurl.Parseand wraps it inhttputil.NewSingleHostReverseProxy, so a remote backend such ashttp://10.10.x.y:8080works exactly like a loopback one. Every route today happens to be127.0.0.1— that is a deployment fact, not a constraint.The real blocker
Neither end is on the WireGuard mesh:
wireguard_ipSo there is no network path from the proxy to a scale. That is the whole problem. It is connectivity, not ingress software.
Revised plan — the district server is not required
pi-node-001as a mesh peer (hydraguard air addor venue, depending on whether the LAN should be routed)hydrastreamingmonitoras a mesh peerhydranps.experiencenet.com -> http://10.10.x.y:8080This touches neither
brussels-district-v2nor hydraneckwebrtc. The earlier plan required moving hydraneckwebrtc off :80/:443 on a live WebRTC gateway — confirmed still holding both ports — and that turns out to be avoidable entirely for the initial cutovers.What remains true about the district server
Running hydrareverseproxy on
brussels-district-v2is still attractive later: it is the WireGuard hub, so it reaches every peer natively without adding hops, and terminating close to the scales matters once they are spread across districts. But it is an optimisation, not a prerequisite. Keeping it here as a follow-up rather than a blocker.Trade-off of the revised plan, stated plainly: traffic goes Hetzner -> OVH hub -> Pi rather than terminating at the district. Fine for hydranps (post-session survey collection). Worth revisiting for anything latency-sensitive.
Also corrected
hydrareverseproxy/CLAUDE.mdclaimed it "Runs on hydra-services cx23 (colocation server)". No such server exists in thehydraexperiencenetproject. It runs on hydrastreamingmonitor. Fixed.