Widening the same tunnel: a directory, a hypervisor console, and a redirect loop that wasn't mine to fix
The WireGuard bridge that reached Jellyfin got two more stops added to it, a hypervisor console and a download manager, plus a small directory page to find them from. Along the way, a routing tool that updates one table but not the other, a bind mount forgotten twice in two days, and a redirect loop that turned out to be somebody else's nginx.
The WireGuard bridge that made a home media server reachable through the VPS was built to carry one host. Today it carries three: the media server, unchanged, plus a Proxmox hypervisor console and a download manager, both reached through a new directory page rather than linked straight off the homepage.
Every IP address below is an example, from 192.0.2.0/24 and 203.0.113.0/24, which RFC 5737 reserves for documentation. None are real addresses from any network I administer.
Why a directory page, and why a second lock on two of the three#
A hypervisor console is not a media library. Getting into it means full control over every VM on the host, so reusing the exact pattern from yesterday, a reverse proxy with nothing else in front of it, wasn't good enough. Both the hypervisor console and the download manager sit behind a second gate at the proxy layer, HTTP basic auth, on top of whatever login each service has of its own. An internet-wide scanner never even reaches either login form.
The directory page itself carries the same gate. It has no data of its own, but it is a map of what internal tools exist and that's not something worth handing to an unauthenticated crawler either.
Widening AllowedIPs the wrong way, then the right way#
Adding two more hosts to the tunnel meant widening the AllowedIPs line on the VPS side of the existing peer, from one /32 to three:
AllowedIPs = 192.0.2.52/32, 192.0.2.53/32, 192.0.2.56/32
Applying that without dropping the tunnel is a solved problem: edit the file, then push the change into the running interface with wg syncconf instead of a full restart. That part worked exactly as expected. What didn't: syncconf updates WireGuard's own peer table, the thing that decides which packets get encrypted to which peer, but it does not touch the kernel's routing table. wg-quick up adds a matching ip route entry for every AllowedIPs prefix when the interface first comes up; syncconf has no equivalent step, because it's a narrower tool that was never meant to.
The result was a peer that would happily encrypt and decrypt traffic for the two new addresses, sitting behind a kernel that had no route sending anything there in the first place. Proxying to either new host failed with a connection error, not a timeout, which is the tell: ip route get on one of the new addresses showed it going out the default internet route instead of the tunnel, while the original address still correctly showed dev wg0.
The interface was managed by a systemd unit already, so the fix was a restart of that unit rather than hand-adding routes that would only last until the next reboot:
systemctl restart wg-quick@wg0
That re-runs the full wg-quick up sequence, including the routing table step syncconf skips, and it re-reads the config file, which by then had all three addresses in it. The trade-off is a brief drop of the tunnel while it re-establishes, seconds rather than minutes, and worth checking for before doing it, not after.
A comment that turned out to be wrong#
Yesterday's Caddyfile comment claimed the tunnel's scoping applied "on both ends." Widening it today was a reason to actually check that, rather than carry the claim forward unexamined into a bigger config. It wasn't true. The internal server's own firewall rules for forwarding tunnel traffic are broad accepts, not scoped to any particular destination, because that server has other peers on the same tunnel that legitimately need wider access than a new VPS peer should get.
The scoping was real, just one-sided: the VPS's own AllowedIPs is what it's willing to route anywhere via the tunnel at all, so it can't reach further even though nothing stops it on the other end. Functionally identical from the VPS's point of view, structurally a different claim, and worth fixing the comment rather than leaving a wrong explanation for whoever reads it next. That's usually me.
The same bind-mount bug, in a new spot#
The new directory page is a static site, published to its own folder the same way the homepage and blog already are, which meant it also needed its own read-only bind mount into the Caddy container. It didn't get one on the first pass. Every other static site on the domain has had this exact bind mount since the beginning; adding a fourth one and forgetting to add the fourth mount is the kind of mistake that's obvious in hindsight and easy to miss in the moment, because the Caddy config itself, the part that actually gets reviewed, was correct. root * /srv/hub resolves inside the container, and without the mount that path is simply empty, four hundred and four, no error that points anywhere useful. Caught by testing the page after deploying rather than assuming a green deploy meant a working page.
Certificates and the retry that doesn't happen on its own#
The new subdomains were live in Caddy's config before DNS existed for them, because deploying the code and adding the DNS records were two separate steps done in that order. Caddy tried to get certificates immediately, Let's Encrypt and its fallback issuer both refused with an NXDOMAIN, and Caddy backed off. Once DNS was actually in place, a plain caddy reload against the same config file did nothing: the config hadn't changed since the last load, and an unchanged config doesn't re-trigger certificate provisioning, retry or otherwise. It sat there, correctly configured, permanently failing to serve HTTPS for hosts that would now resolve fine.
A container restart, not a reload, forced Caddy back through its full startup sequence, which checks every configured host for a valid certificate regardless of whether the config changed. This time DNS resolved, the challenges passed, and all three certificates came back within seconds of each other.
A redirect loop that belonged to someone else#
The download manager's page loaded through Caddy, through the tunnel, and then redirected to itself. Forever. The obvious read is that something in the new reverse proxy block was wrong, since that's the newest piece in the chain, but the response headers said otherwise: Server: nginx, not whatever the download manager's own interface reports.
Confirming that meant going around Caddy entirely and hitting the internal address directly from inside the tunnel, bypassing the domain, the certificate and the basic auth layer all at once, to see what the origin server actually does on its own. It did the same thing when asked over plain HTTP and over HTTPS: an unconditional redirect back to itself, regardless of which scheme the request already arrived on. That's not a proxy correctly enforcing HTTPS and being asked over the wrong scheme, which is the usual cause of a loop like this; it's a fixed rule that fires no matter what, on an nginx instance bundled in front of the download manager by its own container image, doing something the reverse proxy in front of it had no way to see or route around.
The fix lived entirely on that container: a "secure connection" setting on the download manager's own web interface, turned on independently of anything this tunnel does, generating a redirect Caddy could proxy but never resolve. Turning it off, along with the download manager's own login (the proxy-layer basic auth already covers that gate), fixed it immediately.
The lesson that generalises: a redirect loop through a reverse proxy is not necessarily the reverse proxy's problem. Response headers say which software actually answered the request, and hitting the origin directly, skipping every layer in front of it, is the fastest way to find out whether a loop originates before the proxy, at the proxy, or after it. In this case it was already looping before Caddy ever saw a scheme to get right or wrong.
Where it landed#
One tunnel now carries three hosts instead of one. Two of them sit behind a second lock that the first one, the media server, deliberately doesn't need. A directory page ties them together without listing raw addresses anywhere a visitor would see them. And three separate failures, a routing table that needed a different tool to update it, a bind mount forgotten in a new place, a redirect loop that started one hop further away than it looked, all turned out to be the kind of thing that's fast to fix once correctly located and slow to find by assuming the newest change is automatically the guilty one.