feat(deploy): Jellyfin at home, published through WireGuard (ADR 0032)
Some checks failed
CI / check (push) Failing after 41m12s
CI / deploy (push) Has been skipped

The instance serves jellyfin.halkyon.eu and its Caddy proxies through a
WireGuard tunnel to a Jellyfin on the operator's own machine, which dials
out, so nothing names the home network's address and its router opens no
port. compose.jellyfin-home.yaml takes the sidecar's place, with its
site's rules unchanged (jellyfin.caddy's upstream is now a setting), and
leaves JELLYFIN_URL unset, so the instance suggests it only while it
answers.

On the instance's server the tunnel reaches Jellyfin from Caddy alone and
nothing of the host (wg-jellyfin-rules.sh, set and removed with the
tunnel); the CI runner's unit denies it, and CI's Sandbox step fails if a
job reaches it. The home end keeps Jellyfin in WireGuard's network
namespace, its port on the loopback alone. The steps are in
deploy/sidecars/README.md, the rules held by test/deploy/jellyfin_home_test.dart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-30 22:49:47 +01:00
commit d8c827eab6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 675 additions and 3 deletions

View file

@ -18,6 +18,7 @@ site changes.
| Sidecar | Compose file | Site |
|---|---|---|
| Jellyfin, which people watch and listen to learn from | `compose.jellyfin.yaml` | `jellyfin.caddy` |
| Jellyfin at home, published through a WireGuard tunnel, in its place | `compose.jellyfin-home.yaml` | `jellyfin.caddy` |
| LanguageTool, which checks spelling and grammar | `compose.languagetool.yaml` | none |
| Halkyon Train's server, with BRouter for its routes | `compose.train.yaml` | `train.caddy` |
| Train's own map tiles, beside Train | `compose.maps.yaml` | `train-maps.caddy` (in Train's site) |
@ -106,6 +107,142 @@ Steps, in order; ✱ marks a step as root.
Switching it off: the compose file out of `COMPOSE_FILE`, then `docker compose up -d
--remove-orphans`; `deploy/jellyfin/` stays until removed by hand.
## Jellyfin at home
The Jellyfin sidecar's site, served by the instance, with Jellyfin on the operator's own
machine rather than beside the instance (ADR 0032): `compose.jellyfin-home.yaml` on the
instance's server, `home-jellyfin/` on that machine, a WireGuard tunnel between them
that the machine dials out on. People reach the instance's server alone; nothing names
the home network's address, and its router opens no port. Caddy's rules are the
sidecar's (`jellyfin.caddy`), and the tunnel reaches Jellyfin from Caddy alone and
nothing of the instance's server (`home-jellyfin/server/wg-jellyfin-rules.sh`; nor can
the CI runner reach it, `IPAddressDeny`). The instance suggests it only while it
answers: `JELLYFIN_URL` stays unset, so the instance looks at `jellyfin.` on its own
domain, which `JELLYFIN_DOMAIN` must therefore be. With the machine off, asleep or
offline, Caddy answers 502, and the instance stops suggesting it at its first look
after that, within about an hour. It takes the place of the sidecar, never runs
beside it.
The tunnel's addresses: 10.61.96.1 the instance's end, 10.61.96.2 the home end, UDP
51820 on the instance's server. Steps, in order; ✱ marks a step as root, 🏠 one on the
home machine.
1. 🏠 **The home end's key** (the operator, in `deploy/home-jellyfin/` of their
checkout): WireGuard's own tool from the image, so nothing is installed; the
private key goes into the git-ignored config, which only its owner reads, the
public key is printed for step 3.
```sh
cd deploy/home-jellyfin
image=$(sed -n 's/^ *image: \(lscr.io\/linuxserver\/wireguard@.*\)/\1/p' compose.yaml)
(umask 077 && mkdir -p wireguard/wg_confs &&
docker run --rm --entrypoint wg "$image" genkey > wireguard/home.key)
docker run --rm -i --entrypoint wg "$image" pubkey < wireguard/home.key
```
2. ✱ **The instance's end** (the operator, on the instance's server, as a user with
sudo): WireGuard's tools, the key, and the files from the checkout. The key is in
the password manager too: a new server needs it again, or the home end a new
public key.
```sh
sudo apt-get install wireguard-tools
(umask 077 && wg genkey | sudo tee /etc/wireguard/wg-jellyfin.key | wg pubkey)
from=~deploy/halkyon-learn/deploy/home-jellyfin/server
sudo install -m 0600 "$from/wg-jellyfin.conf" /etc/wireguard/wg-jellyfin.conf
sudo install -m 0700 "$from/wg-jellyfin-rules.sh" /etc/wireguard/wg-jellyfin-rules.sh
sudo install -D -m 0644 "$from/wg-quick-after-docker.conf" \
/etc/systemd/system/wg-quick@wg-jellyfin.service.d/after-docker.conf
```
The printed public key is the instance's, for step 4.
3. ✱ **Introduce the home end, open the port, start it** (as in step 2):
`sudo nano /etc/wireguard/wg-jellyfin.conf`, the home end's public key from step 1
in place of `<the home end's public key>`; where the host has a firewall, UDP 51820
opened (`sudo ufw allow 51820/udp`); the runner's unit as the checkout has it, so it
can't reach the tunnel; then the tunnel.
```sh
sudo install -m 0644 ~deploy/halkyon-learn/deploy/runner/forgejo-runner.service \
/etc/systemd/system/forgejo-runner.service
sudo systemctl daemon-reload
sudo systemctl restart forgejo-runner
sudo systemctl enable --now wg-quick@wg-jellyfin
```
4. 🏠 **The home end's config, folders and media** (in `deploy/home-jellyfin/`):
`wg0.conf.example` as `wireguard/wg_confs/wg0.conf`, with the key from step 1 and
the instance's public key from step 2; Jellyfin's folders owned by its user ID
(61096, as on the instance's server: no person and no other container uses it); the
media folder named in `.env` and readable by all, a folder per film, show or album
as Jellyfin expects them.
```sh
(umask 077 && sed "s|<the home end's private key>|$(cat wireguard/home.key)|" \
wg0.conf.example > wireguard/wg_confs/wg0.conf)
nano wireguard/wg_confs/wg0.conf # the instance's public key
sudo install -d -o 61096 -g 61096 jellyfin/config jellyfin/cache
echo "JELLYFIN_MEDIA=$HOME/Videos" > .env
chmod -R o+rX "$HOME/Videos"
docker compose up -d
```
`sudo wg show wg-jellyfin` on the instance's server now shows a handshake.
5. 🏠 **Set Jellyfin up** at http://localhost:8096 on the home machine, before the site is
switched on: the sidecar's step 6, but for its networking, which is the tunnel's:
- Dashboard › Networking › **LAN networks**: `172.29.96.1/32` alone, the gateway
through which this machine's loopback reaches it (`home-jellyfin/compose.yaml`).
Left empty, Jellyfin counts every private range as local, the tunnel's too, and an
admin without remote access could sign in through it.
- Dashboard › Networking › **Known proxies**: `10.61.96.1`, as which Docker on the
instance's server masquerades Caddy's requests. Jellyfin then sees each visitor's
own address.
- **Allow remote connections** on; the admin hidden from login screens, its remote
connections off; Quick Connect, the demo account and its lockout as in the
sidecar's step 6. Every account but those meant for everyone hidden from login
screens: the instance suggests this server to all its people.
6. **Switch the site on** (the operator, on the instance's server, as `deploy`): in
`deploy/.env`, `JELLYFIN_URL` left empty,
```sh
COMPOSE_FILE=compose.yaml:compose.jellyfin-home.yaml
JELLYFIN_DOMAIN=jellyfin.halkyon.eu
```
then `cd ~/halkyon-learn/deploy && docker compose up -d`. Caddy and the server are
recreated with it; the instance is away for seconds.
7. **Check from outside** (anyone, anywhere): the sidecar's step 7; and on the instance's
server, as `deploy`, that nothing reaches the home end but Caddy, and the home end
nothing of the server:
```sh
# Refused (on edge, but not at Caddy's address): times out.
docker run --rm --network halkyon-learn-edge caddy:2 \
wget -T 5 -q -O /dev/null http://10.61.96.2:8096/System/Info/Public && echo LEAK
# Caddy itself gets through.
docker compose exec -T caddy wget -T 5 -q -O - http://10.61.96.2:8096/System/Info/Public
```
and on the home machine, in `deploy/home-jellyfin/`:
```sh
# Refused (nothing new from the tunnel reaches the host): times out.
docker compose exec -T wireguard sh -c 'timeout 5 nc 10.61.96.1 22 </dev/null' && echo LEAK
```
Stopping the home end (`docker compose stop`) makes the site answer 502, and
`instance.info()` names no `jellyfinUrl` after the instance's next look, within about
an hour.
Switching it off: on the instance's server the compose file out of `COMPOSE_FILE`, `docker
compose up -d --remove-orphans`, and ✱ `sudo systemctl disable --now
wg-quick@wg-jellyfin`, the port closed again; at home `docker compose down`, with
`deploy/home-jellyfin/jellyfin/` kept until removed by hand.
## LanguageTool
LanguageTool's open-source server beside the instance (ADR 0024), which checks spelling

View file

@ -13,5 +13,7 @@
respond @userChanges 403
respond /Startup/* 403
reverse_proxy jellyfin:8096
# The sidecar's Jellyfin, or Jellyfin at home through WireGuard
# (compose.jellyfin-home.yaml, ADR 0032).
reverse_proxy {$JELLYFIN_UPSTREAM:jellyfin:8096}
}