# ADR 0032: Jellyfin at home, published by the instance through WireGuard - **Status:** decided 30 Oct 2026, by the developer (D1, D9 and D10 their own choices, D2–D8 agreed as recommended). Built the same day (*As built*, below); not brought up yet, which takes the developer's steps as root on the instance's server. - **Builds on:** the Jellyfin sidecar (`deploy/compose.jellyfin.yaml`, `deploy/sidecars/README.md`, decided 14 Oct 2026) and Halkyon Learn's suggested server (`JellyfinSuggestion`, decided 13 Oct). Train's Jellyfin (ADR 0030, D47, D52, D54) reaches it the same way. ## Context The Jellyfin sidecar runs Jellyfin on the instance's server, with its media copied there (`deploy/jellyfin/media/`). The developer's media is at home, on a machine in the home network (192.168.178.0/24, behind a FRITZ!Box), and should be reachable as `jellyfin.halkyon.eu`, where learn.halkyon.eu's people would find it, under three conditions the developer set on 30 Oct: - the home network's address is never exposed: neither DNS nor anything Jellyfin answers names it; - nobody signs up: accounts are the admin's to make (Jellyfin has no self-registration, but a fresh server's setup wizard is open to whoever reaches it first, and a user's password can be set from outside, which `deploy/sidecars/jellyfin.caddy` refuses); - Halkyon's apps reach Jellyfin over HTTPS alone (Learn's `JellyfinRepository.find`, Train's D54), so the name needs a certificate every device trusts. Found on 30 Oct: halkyon.eu's DNS is at Infomaniak; `jellyfin.halkyon.eu` already points at learn.halkyon.eu's server (`A` 89.58.53.154 and an `AAAA`), but nothing answers there, as the sidecar isn't switched on. With `JELLYFIN_URL` unset, the instance suggests `https://jellyfin.halkyon.eu` to its people as soon as a Jellyfin server answers there. ## Decisions - **D1 A WireGuard tunnel from home to the instance's server, which publishes Jellyfin** (the developer's choice, 30 Oct, as recommended). Alternatives weighed: - *Cloudflare Tunnel*: hides the address too, but needs all of halkyon.eu's DNS moved to Cloudflare (a tunnel needs a zone on Cloudflare's nameservers on the free plan), lets Cloudflare read every password, token and video (it ends TLS), conflicts with Cloudflare's terms for serving mostly video outside its paid video products, and leaves `jellyfin.caddy`'s rules to be rebuilt as Cloudflare's. - *An SSH reverse tunnel* (`ssh -R` as `deploy`, kept up by autossh): no new software on the server, but TCP over TCP stalls a stream on a lossy line, and binding where a container reaches it needs sshd's `GatewayPorts` changed. - *The sidecar as built*, the media copied to the server: no tunnel at all, but the server's disk holds the library. The public sees only the instance's server, TLS ends on a machine the operator holds, `jellyfin.caddy` and the sidecar README's settings hold unchanged, and the DNS record is already right. Streams are bounded by the home line's upload speed and count towards the server's traffic. - **D2 The home end runs in Compose, Jellyfin inside the tunnel's network namespace** (recommended; over WireGuard on the home host with Jellyfin's port bound to the tunnel's address, which Docker can't bind before the interface is up, and over firewall rules on the home host). A `wireguard` container (`cap_add: NET_ADMIN`, the host's kernel module) holds the tunnel; Jellyfin joins it with `network_mode: service:wireguard`, so it listens on the tunnel and on that namespace's loopback alone, and is not on the home network at all (no DLNA, no discovery on UDP 7359). The admin port is published on the home host's loopback only (`127.0.0.1:8096`), where Learn on the same machine may reach it over plain HTTP (`isLoopback`). The home end dials out with `PersistentKeepalive = 25`, so the FRITZ!Box needs no open port, and the server's config holds no endpoint for it. The files live beside the deploy as `deploy/home-jellyfin/` (compose file, WireGuard config without its key; the steps in the sidecar README); the key stays out of the repository. - **D3 The tunnel's addresses are 10.61.96.1 (the server) and 10.61.96.2 (home), a /30, IPv4 alone** (recommended): outside Docker's address pools (172.17.0.0/16 to 172.31.0.0/16 and 192.168.0.0/16 in /20s, which a new network could take), the edge network (172.30.80.0/24), the home network and Multipass' bridges on the laptop (10.97.x.0/24). Caddy reaches Jellyfin over IPv4; visitors keep coming over either. - **D4 WireGuard on the server's host, as `wg-quick@wg-jellyfin`, on UDP 51820** (recommended, over a WireGuard container on `edge`, which would need `NET_ADMIN` beside the instance and routes into it from Caddy). The port is opened in the host's firewall; WireGuard answers nothing that isn't signed by a known key, so it shows no service to a scan. The home peer's `AllowedIPs` on the server is `10.61.96.2/32`, so nothing it sends under another source address gets in. The server's key is kept in `/etc/wireguard/` (root, 0600) and in the operator's password manager, as a new server needs it again. - **D5 The tunnel reaches Jellyfin from Caddy alone, and reaches nothing on the server** (recommended), by rules that `wg-quick`'s `PostUp` sets and `PreDown` removes, so they live exactly as long as the interface: - in `DOCKER-USER`: `172.30.80.2` (Caddy) to `10.61.96.2` on TCP 8096 accepted; every other new connection into or out of `wg-jellyfin` dropped, replies (`ESTABLISHED,RELATED`) passing; - in `INPUT`: nothing new from `wg-jellyfin` accepted, so home can't reach sshd, published ports or anything else listening on the host. The rules match Caddy's own address, as `DOCKER-USER` sees packets before Docker masquerades them; home then sees them from `10.61.96.1` (Docker's `MASQUERADE` for `edge`, which takes every packet leaving the network; found while building, the proposal had them arrive as `172.30.80.2`), so home's `AllowedIPs` is `10.61.96.1/32` alone. A compromised home machine thus gets replies to Caddy's requests and nothing else of the production server. - **D6 The CI runner can't reach the tunnel either** (recommended): `10.61.96.0/30` is added to `IPAddressDeny` in `deploy/runner/forgejo-runner.service`, beside Docker's ranges. Without it, a job could talk to the home Jellyfin through the host's route. - **D7 One site, with its upstream a setting** (recommended, over a second site file repeating the rules): `jellyfin.caddy` proxies to `{$JELLYFIN_UPSTREAM:jellyfin:8096}`. A new `deploy/compose.jellyfin-home.yaml` mounts the same site into Caddy, sets `JELLYFIN_DOMAIN` and `JELLYFIN_UPSTREAM=10.61.96.2:8096`, leaves the server's `JELLYFIN_URL` unset (D9) and runs no Jellyfin container; `COMPOSE_FILE` takes either it or `compose.jellyfin.yaml`, never both. `redeploy.sh` needs no change: WireGuard is the host's, set up once by root, as the sidecars' users are. - **D8 Jellyfin's own settings for a server seen through a proxy** (recommended; the sidecar README's step 6, with two additions): `JELLYFIN_PublishedServerUrl = https://jellyfin.halkyon.eu`, so Jellyfin never hands out a home address; **LAN networks** set to `172.29.96.1/32` alone (the gateway of the home end's Compose network, its subnet fixed for it, through which Docker hands on what reaches the machine's loopback; the proposal said `127.0.0.1/32`, which no request comes from), as Jellyfin counts every private range as local when it's left empty, which would count the tunnel too and let an admin without remote access sign in through it; **Known proxies** `10.61.96.1`, as which Caddy's requests arrive (D5). The admin works from the home machine's `http://localhost:8096`, not through an SSH tunnel; the wizard is finished there before the tunnel is first brought up. - **D9 learn.halkyon.eu suggests it to all its people** (the developer's, 30 Oct, over `JELLYFIN_URL=none`), while it answers: the compose file leaves `JELLYFIN_URL` unset, so the instance looks at `https://jellyfin.` on its own domain (`JellyfinSuggestion`) and suggests it only once it answers as a Jellyfin server. Set, the instance would suggest it whether it answers or not, which D10 makes the wrong default. Its login screen shows only the accounts meant for everyone; the admin and every personal account are hidden from it (D8, the sidecar README's step 6). - **D10 The home end is the developer's own machine, this laptop** (the developer's, 30 Oct, over an always-on machine at home): Jellyfin is reachable while it runs and away otherwise, which is accepted. It dials out, so it serves from wherever the laptop is online, not only at home, as long as outgoing UDP passes. Asleep, the tunnel stops; on waking, the next keepalive brings it back within 25 s. ## Consequences - People reaching `jellyfin.halkyon.eu` see the instance's server alone. The instance's server knows the home address (the tunnel's endpoint, in `wg show`), as its operator does anyway; nothing it serves names it. - Every byte streamed crosses the home line's upload and the server twice (in through the tunnel, out to the visitor). Learn streams as stored (`static=true`), so a video's bitrate must fit the upload; Train's smaller copies (D52) are transcoded at home. - The production server gains a listening UDP port, a kernel interface and firewall rules outside Compose; they belong in the sidecar README's steps, with how to switch them off (`systemctl disable --now wg-quick@wg-jellyfin`, the port closed). - With the laptop off, asleep or offline, Caddy answers 502 at `jellyfin.halkyon.eu`; Learn and Train show the server as unreachable, as for any Jellyfin that is away. The instance stops suggesting it at its first look after that: a look counts for an hour, and the first `instance.info()` after the hour starts the next one in the background, that answer still giving the old suggestion. Apps that took the suggestion already keep it (the app asks once), and devices signed in to it keep their token. - The instance's own look reaches `jellyfin.halkyon.eu` at its public address from inside its container, through the host back to Caddy. With the sidecar this was never used in production, as its compose file sets `JELLYFIN_URL`; the check after setup confirms that `instance.info()` names it. ## As built (30 Oct 2026, the agent's calls, each with its reason) - **Files**: `deploy/compose.jellyfin-home.yaml` and `jellyfin.caddy`'s upstream (`{$JELLYFIN_UPSTREAM:jellyfin:8096}`, D7); `deploy/home-jellyfin/` with the home end's `compose.yaml` and `wg0.conf.example`, and under `server/` the instance's `wg-jellyfin.conf`, its rules (`wg-jellyfin-rules.sh`, POSIX sh as the host's scripts are: the host has no Dart) and a systemd drop-in; the steps in `deploy/sidecars/README.md`, *Jellyfin at home*. `test/deploy/jellyfin_home_test.dart` holds each rule above to the files, refusals first: no `JELLYFIN_URL`, the home peer's one address, the drops, the runner's deny, Jellyfin inside the tunnel's namespace, its port on the loopback alone. - **The rules in chains of their own** (`halkyon-jellyfin-forward`, `-input`), jumped to from `DOCKER-USER` and `INPUT` for the tunnel's interface alone, so their order against Docker's rules is fixed and `down` removes all of them, a half-made set too. `RETURN` rather than `ACCEPT` for what passes, so Docker's own rules still apply. - **The tunnel starts after Docker and restarts with it** (`After`, `Requires`, `PartOf=docker.service`): its rules go into Docker's chain, which exists only once Docker runs. - **The server's private key is a file beside the config** (`PostUp = wg set %i private-key …`), so `wg-jellyfin.conf` is copied from the repository as it is, but for the home end's public key. - **The server looks at Jellyfin through Caddy on `edge`** (`extra_hosts`, the domain at 172.30.80.2), not at the public address from inside its container, which needs Docker's hairpin NAT and, by the `AAAA` record, IPv6 to work for it. The certificate is the domain's, so the look is as anyone's. - **CI's Sandbox step also fails if a job reaches 10.61.96.2:8096**, as it does for Docker's networks: the deny in the runner's unit is only on the server once installed again (step 3). - **The home end's image** is `linuxserver/wireguard` 1.0.20260223-r0-ls126, of 15 Oct 2026, pinned by its digest: two weeks old, rather than the build of the day before; its client mode brings up `wg0.conf` with the host's kernel module, and its `wg` makes the home end's keys, so nothing is installed on the machine. Jellyfin's image is the sidecar's, and so is its user ID, 61096. ## Open (31 Oct 2026, found setting the home end up; for the developer to decide) - **A failed tunnel takes Jellyfin's internet with it.** The image's client mode deletes the namespace's default route whenever `wg0.conf` fails to come up (`ip route del default` in its `svc-wireguard/run`, ls126: a placeholder or wrong key, an endpoint that doesn't resolve), so Jellyfin, in that namespace, reaches neither TMDb nor anything else, and a library scanned then gets frames of its videos for posters. A tunnel that comes up keeps the route, before its first handshake too. Noted in `deploy/sidecars/README.md`, step 4. Ways out: - keep it, documented (as now): a broken tunnel then also stops new metadata, which is loud rather than silent; - a custom service (the image's `/custom-services.d`; `/custom-cont-init.d` runs before `svc-wireguard`, too early) that puts the route back once the tunnel has failed, so Jellyfin keeps the internet whatever the tunnel does; - Jellyfin on `outside` itself rather than in WireGuard's namespace, which changes how Caddy's requests reach it (D8) and so reopens this record. A test in `test/deploy/jellyfin_home_test.dart` for whichever is chosen.