Reaching the media server from anywhere: a WireGuard bridge to the VPS
The media server has no public IP and lives behind a segmented internal network. A WireGuard tunnel, reused rather than duplicated, made it reachable at a real domain. A stale Docker bind mount nearly undid it on the very first deploy.
The media server has always been internal-only: reachable on the local network, invisible to anything outside it. That was fine when the only thing that needed it was a treadmill. It stopped being fine the moment I wanted to watch something from anywhere that wasn't the building it lives in.
The constraint that shaped everything else: that network has no public IP. It sits behind NAT on a connection I don't control the routing for, which rules out the obvious answer: port forward the media server and point a domain at it.
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 of these are real addresses from any network I administer. The domain name is the only thing that's real.
Don't build a second VPN#
I already run a WireGuard tunnel for remote desktop access into that network, terminating at a small always-on box inside it. My first instinct was to stand up a completely separate tunnel just for this, and the media server's own history is full of the cost of picking the more complicated option out of habit.
There was no reason for a second one. WireGuard peers are just entries in a config file; the interface doesn't care whether a peer is a laptop, a phone, or a VPS. So the plan became: add the VPS as one more peer on the tunnel that already exists, with its allowed traffic scoped to exactly the one thing it needs to reach.
Flipping who dials whom#
The obvious shape (the VPS calls the media server) doesn't work, because the media server has nothing public to call. WireGuard doesn't care which side initiates, though. A peer with no Endpoint set just waits; a peer with one dials out. So the direction flips: the VPS sits and waits, and the internal box, which already has a working outbound path because that's how its existing remote-desktop peers reach it, dials out to the VPS instead.
Internal network (NAT, no public IP)
│
│ WireGuard peer with Endpoint = <VPS>:51820 - dials OUT
▼
VPS (203.0.113.10, public)
│
│ WireGuard peer with NO Endpoint - just listens
▼
Caddy → media server
That one flip means no port forward, no change to the firewall rule set on the internal side at all. Outbound UDP already worked; nothing had to be opened for it.
Scoping the tunnel to one box#
The existing tunnel's other peers (laptop, phone) are allowed to reach a handful of internal subnets, because a remote-desktop session is legitimately going anywhere the user needs to go. The VPS peer gets none of that.
# On the VPS
[Interface]
PrivateKey = <vps private key>
Address = 192.0.2.5/32
ListenPort = 51820
[Peer]
PublicKey = <internal box's public key>
AllowedIPs = 192.0.2.53/32 # the media server, and nothing else
# Appended to the existing peer list on the internal box
[Peer]
PublicKey = <vps public key>
Endpoint = 203.0.113.10:51820
AllowedIPs = 192.0.2.5/32
PersistentKeepalive = 25
AllowedIPs does two jobs at once here: it's the route WireGuard installs, and it's the firewall. A stolen VPS key gets exactly one host, on exactly one path, and nothing else on that network is reachable from it, not by policy but by the shape of the tunnel itself.
The reverse proxy, and the bug that almost hid it#
With the tunnel up, Caddy just needed one more site block pointed at the tunnel address:
jellyfin.example.com {
reverse_proxy 192.0.2.53:8096
}
Deployed it, and the certificate came back, and the page loaded a 302 to nowhere useful. The Caddy container's logs said the config it was running had no site block for that hostname at all, despite the file on disk being correct and caddy reload reporting success.
The cause turned out to be a Docker gotcha I'd read about before and never actually hit: a bind mount of a single file, not a directory, is pinned to the inode that existed when the container started. git pull doesn't edit a tracked file in place; it writes a new blob and renames it over the old path, which swaps the inode. The mount kept serving the old one, silently, no matter how many times the config inside it got reloaded.
caddy reload re-reads whatever the container currently sees, and the container was looking at a file that no longer existed on disk, through a mount that had no reason to notice. A plain restart fixed it immediately: Docker re-resolves bind mounts every time a container starts, and a container starts far more often than it gets created.
The fix that actually matters is the automated one: the deploy script now diffs what the running container sees against what's on disk, and only restarts the proxy when they genuinely differ. Every other deploy (the overwhelming majority, which never touches that one config file) stays exactly as cheap and connection-dropless as before. The lesson generalises past Caddy: a bind mount of a single file plus a tool that replaces files by rename, rather than editing them, is a silent staleness bug waiting to happen, and reload-style commands can't fix what they can't see.
What it actually costs#
Once it worked, the obvious next question was whether it was actually usable rather than merely reachable. A page load isn't a fair test for a media server: the real cost is sustained transfer, which a 300KB asset doesn't expose.
Fetching the app's own JS bundles in parallel (closer to how a browser actually behaves) landed at roughly 7-10 Mbit/s sustained through the full path. Enough for a typical 1080p stream, tight for 4K without transcoding down. That number is a property of the internal network's upload capacity, not of anything WireGuard or Caddy add. Both sit close enough to zero overhead that they weren't worth measuring separately.
Where it landed#
One tunnel, reused rather than duplicated. One peer, scoped to one host. One reverse proxy block. And one Docker mount lesson that will save a confused half hour the next time any bind-mounted config file gets edited by a tool that replaces rather than rewrites.
The media server is reachable at its own subdomain now, the same way the rest of this domain's services are, and the internal network it actually lives on is exactly as closed as it was before any of this started.