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
9 changed files with 675 additions and 3 deletions

View file

@ -0,0 +1,27 @@
# Jellyfin at home, published by the instance (ADR 0032): the Jellyfin sidecar's site,
# with Caddy proxying through a WireGuard tunnel to a Jellyfin on the operator's own
# machine (home-jellyfin/), rather than to one beside the instance. Nothing of the home
# network shows: people reach the instance's server alone. Switched on in .env, in place
# of compose.jellyfin.yaml, never beside it:
#
# COMPOSE_FILE=compose.yaml:compose.jellyfin-home.yaml
# JELLYFIN_DOMAIN=jellyfin.halkyon.eu
#
# The tunnel is the host's (wg-quick@wg-jellyfin, with the rules letting Caddy alone
# through it), set up once as root: sidecars/README.md, *Jellyfin at home*.
services:
caddy:
environment:
JELLYFIN_DOMAIN: ${JELLYFIN_DOMAIN:?set JELLYFIN_DOMAIN in deploy/.env}
# The home end's address in the tunnel (D3).
JELLYFIN_UPSTREAM: 10.61.96.2:8096
volumes:
- ./sidecars/jellyfin.caddy:/etc/caddy/sidecars/jellyfin.caddy:ro
server:
# No JELLYFIN_URL (D9): the instance looks at jellyfin. on its own domain and
# suggests it only while it answers, as the home end is away whenever its machine
# is. It looks through Caddy on edge, not by the public address back in.
extra_hosts:
- ${JELLYFIN_DOMAIN:?set JELLYFIN_DOMAIN in deploy/.env}:172.30.80.2

View file

@ -0,0 +1,59 @@
# Jellyfin at home (ADR 0032): the home end, on the operator's own machine, which
# learn.halkyon.eu's server publishes as jellyfin.halkyon.eu (compose.jellyfin-home.yaml).
# WireGuard dials out to the instance's server, so the router opens nothing; Jellyfin
# lives in WireGuard's network namespace, so it listens on the tunnel and on this
# machine's loopback alone, and is not on the home network (no DLNA, no discovery).
# Run here, as the operator: `docker compose up -d`. Setting it up:
# ../sidecars/README.md, *Jellyfin at home*. Its folders are git-ignored: the tunnel's
# key (wireguard/), Jellyfin's configuration and cache (jellyfin/), and .env.
name: halkyon-home-jellyfin
services:
wireguard:
# linuxserver/wireguard 1.0.20260223-r0-ls126 (15 Oct 2026), its client mode: it
# brings up wireguard/wg_confs/wg0.conf with the host's kernel module.
image: lscr.io/linuxserver/wireguard@sha256:6bcf5d2f5a265c7e9835eaadd989938e681aabe8bf8bcd4556b61d463da6555c
restart: unless-stopped
cap_add:
- NET_ADMIN
sysctls:
- net.ipv4.conf.all.src_valid_mark=1
environment:
PUID: "61096"
PGID: "61096"
volumes:
- ./wireguard:/config
# Jellyfin's port, which lives here: on this machine's loopback alone, for its
# admin and for Halkyon Learn on this machine over plain HTTP (D2).
ports:
- 127.0.0.1:8096:8096
networks:
- outside
jellyfin:
# As the sidecar's (../compose.jellyfin.yaml): v12.2, the version Halkyon Learn's
# Jellyfin feature is checked with.
image: jellyfin/jellyfin@sha256:357724bf0ae27a672c7cbaa899db2d9abeb13dbd8657ccce750258a4c059d037
restart: unless-stopped
# As on the instance's server: a user ID no person and no other container uses.
user: "61096:61096"
network_mode: service:wireguard
depends_on:
- wireguard
environment:
# Never a home address in what Jellyfin hands out (D8).
JELLYFIN_PublishedServerUrl: https://jellyfin.halkyon.eu
volumes:
- ./jellyfin/config:/config
- ./jellyfin/cache:/cache
- ${JELLYFIN_MEDIA:?set JELLYFIN_MEDIA in deploy/home-jellyfin/.env}:/media:ro
networks:
# Out to the internet, for the tunnel and Jellyfin's metadata. A fixed subnet, so
# Jellyfin's LAN networks can name its gateway alone, through which this machine's
# loopback reaches it (D8).
outside:
ipam:
config:
- subnet: 172.29.96.0/24

View file

@ -0,0 +1,52 @@
#!/bin/sh
# The rules around the tunnel to Jellyfin at home (ADR 0032, D5), set by wg-quick as
# the tunnel comes up and taken away as it goes (wg-jellyfin.conf), so they live
# exactly as long as it: `wg-jellyfin-rules.sh up|down <interface>`. As
# /etc/wireguard/wg-jellyfin-rules.sh on the instance's server (root, 0700).
#
# - Forwarded (Docker's DOCKER-USER, before Docker's own rules): into the tunnel only
# Caddy (172.30.80.2, its fixed address on edge) to Jellyfin (10.61.96.2:8096), out
# of it only the replies; everything else dropped. Docker then masquerades Caddy's
# requests as the tunnel's 10.61.96.1, which the home end sees as its proxy.
# - To the host itself (INPUT): only replies from the tunnel, so the home end reaches
# neither sshd nor anything else listening here.
#
# IPv4 alone: the tunnel carries no IPv6 (D3). Needs Docker's chains, so the tunnel
# starts after Docker (wg-quick-after-docker.conf).
set -eu
action=$1
interface=$2
forward=halkyon-jellyfin-forward
input=halkyon-jellyfin-input
case "$action" in
up)
iptables -N "$forward"
iptables -A "$forward" -i "$interface" -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
iptables -A "$forward" -o "$interface" -s 172.30.80.2 -d 10.61.96.2 -p tcp --dport 8096 -j RETURN
iptables -A "$forward" -j DROP
iptables -I DOCKER-USER -i "$interface" -j "$forward"
iptables -I DOCKER-USER -o "$interface" -j "$forward"
iptables -N "$input"
iptables -A "$input" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A "$input" -j DROP
iptables -I INPUT -i "$interface" -j "$input"
;;
down)
# Whatever is there of it, so a half-made set goes too.
iptables -D DOCKER-USER -i "$interface" -j "$forward" 2>/dev/null || true
iptables -D DOCKER-USER -o "$interface" -j "$forward" 2>/dev/null || true
iptables -F "$forward" 2>/dev/null || true
iptables -X "$forward" 2>/dev/null || true
iptables -D INPUT -i "$interface" -j "$input" 2>/dev/null || true
iptables -F "$input" 2>/dev/null || true
iptables -X "$input" 2>/dev/null || true
;;
*)
echo "usage: $0 up|down <interface>" >&2
exit 2
;;
esac

View file

@ -0,0 +1,17 @@
# The instance's end of the tunnel to Jellyfin at home (ADR 0032, D4), as
# /etc/wireguard/wg-jellyfin.conf on the instance's server (root, 0600), brought up by
# wg-quick@wg-jellyfin. Its private key is a file of its own beside it, never here.
# Setting it up: sidecars/README.md, *Jellyfin at home*.
[Interface]
Address = 10.61.96.1/30
ListenPort = 51820
PostUp = wg set %i private-key /etc/wireguard/wg-jellyfin.key
# Caddy alone through the tunnel, nothing of the host from it (D5).
PostUp = /etc/wireguard/wg-jellyfin-rules.sh up %i
PreDown = /etc/wireguard/wg-jellyfin-rules.sh down %i
# The home end, which dials in: no endpoint, so this server never names its address.
[Peer]
PublicKey = <the home end's public key>
AllowedIPs = 10.61.96.2/32

View file

@ -0,0 +1,7 @@
# The tunnel to Jellyfin at home starts after Docker, whose DOCKER-USER chain its rules
# go into, and restarts with it (ADR 0032, D5). As
# /etc/systemd/system/wg-quick@wg-jellyfin.service.d/after-docker.conf.
[Unit]
After=docker.service
Requires=docker.service
PartOf=docker.service

View file

@ -0,0 +1,17 @@
# The home end of the tunnel to the instance's server (ADR 0032), copied to
# wireguard/wg_confs/wg0.conf (git-ignored) with its two keys filled in. Setting it up:
# ../sidecars/README.md, *Jellyfin at home*.
[Interface]
Address = 10.61.96.2/32
PrivateKey = <the home end's private key>
[Peer]
PublicKey = <the instance's public key>
Endpoint = learn.halkyon.eu:51820
# The instance's end alone, through which Caddy's requests come (masqueraded by Docker
# on the instance's server): nothing else of the internet goes through the tunnel.
AllowedIPs = 10.61.96.1/32
# Dialling out keeps the way open through the router and brings the tunnel back within
# 25 s after the machine wakes (D10).
PersistentKeepalive = 25

View file

@ -36,14 +36,14 @@ TimeoutStopSec=11min
# Away from production: /home (deploy's .env, the checkout, the database dumps) is
# invisible, the rest of the system read-only except the runner's own directory, other
# users' processes hidden, and Docker's container networks, where the database listens,
# unreachable.
# unreachable, and so is the tunnel to Jellyfin at home (10.61.96.0/30, ADR 0032, D6).
ProtectHome=yes
ProtectSystem=strict
ReadWritePaths=/var/lib/forgejo-runner
PrivateTmp=yes
ProtectProc=invisible
InaccessiblePaths=-/run/docker.sock -/run/containerd -/var/lib/docker
IPAddressDeny=172.16.0.0/12 192.168.0.0/16 fd00::/8
IPAddressDeny=172.16.0.0/12 192.168.0.0/16 fd00::/8 10.61.96.0/30
NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=

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}
}