feat(deploy)!: Jellyfin on halkyon.li as the sidecar, ADR 0032's home end gone (ADR 0039, D15)

The Jellyfin sidecar takes its library from a folder on the host where .env
names one (JELLYFIN_MEDIA, read-only), else from deploy/jellyfin/media as
before. On halkyon.li that is /data/media: the library moves out of the
developer's home, which Jellyfin's own user can't enter. The sidecar README
says how the home end's configuration, accounts and libraries move in, with
its network settings corrected for Caddy before it starts.

BREAKING CHANGE: compose.jellyfin-home.yaml, deploy/home-jellyfin/ and
jellyfin.caddy's JELLYFIN_UPSTREAM are gone with ADR 0032, whose server end
retires with learn.halkyon.eu's server; it was never brought up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-08 19:15:25 +01:00
commit 2218695dcf
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 113 additions and 496 deletions

View file

@ -1,27 +0,0 @@
# 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.learn.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

@ -6,7 +6,9 @@
# JELLYFIN_DOMAIN=jellyfin.example.org
#
# Its files are the server's, in ./jellyfin (config, cache, media), owned by a user ID
# of its own, which no person, no other container and not the CI runner uses: processes
# of its own; or the media in a folder elsewhere on the host that .env names
# (JELLYFIN_MEDIA, readable by all: halkyon.li's /data/media, ADR 0039, D15). The user ID
# is one no person, no other container and not the CI runner uses: processes
# of one user ID can read each other's environment and files. Setting it up, and the
# demo account: sidecars/README.md.
@ -21,7 +23,7 @@ services:
volumes:
- ./jellyfin/config:/config
- ./jellyfin/cache:/cache
- ./jellyfin/media:/media:ro
- ${JELLYFIN_MEDIA:-./jellyfin/media}:/media:ro
# On `edge` alone, beside Caddy and the server's public API, never beside Postgres;
# no published port. A fixed address, for the admin's SSH tunnel (sidecars/README.md).
networks:

View file

@ -1,59 +0,0 @@
# 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

@ -1,52 +0,0 @@
#!/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

@ -1,17 +0,0 @@
# 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

@ -1,7 +0,0 @@
# 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

@ -1,21 +0,0 @@
# 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>
# learn.halkyon.eu's server by its IPv4 address, not its name (ADR 0032, D11): a name
# needs DNS to come up, which a machine starting or waking hasn't always got yet, and a
# tunnel that fails to come up takes Jellyfin's internet with it. A new address of the
# server's goes here too.
Endpoint = 89.58.53.154: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

@ -72,11 +72,12 @@ for the later.
```
4. **Docker's data on the HDD** (D12). Everything Docker holds now (the home Jellyfin's
images and containers) moves with it. Its configuration and library are in
`deploy/home-jellyfin/`, untouched:
images and containers) moves with it. ADR 0032's home Jellyfin stops here for good;
its configuration and library stay where they are and move into the instance later
(`../sidecars/README.md`, *Jellyfin on halkyon.li*):
```sh
(cd deploy/home-jellyfin && sudo docker compose down)
sudo docker compose -p home-jellyfin down
sudo systemctl stop docker docker.socket containerd
sudo mkdir -p /data/docker /data/containerd
sudo rsync -aHAX --numeric-ids /var/lib/docker/ /data/docker/

View file

@ -18,7 +18,6 @@ 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) |
@ -111,154 +110,57 @@ 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
## Jellyfin on halkyon.li, moved in from ADR 0032's home end
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.
On halkyon.li (ADR 0039, D15), the Jellyfin that ran as ADR 0032's home end becomes the
sidecar above, with its configuration, accounts and library kept, on the same machine.
Its library stays on the host rather than in `deploy/jellyfin/media`. Once, in place of
the *Jellyfin* steps 1 and 4 to 6, after `../host/README.md`, step 4 (which stopped it),
✱ as root:
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.
1. ✱ **Its user**, the sidecar's (ID 61096), as *Jellyfin*, step 1 has it.
2. ✱ **The library on the HDD, readable by Jellyfin**: the developer's home is closed to
other users, and Jellyfin runs as its own. `/home` is a bind mount of `/data/home`,
so this is a rename on one filesystem, instant whatever its size:
```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
sudo mv /home/raisondetredev/Media /data/media
sudo chmod -R a+rX /data/media
```
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.
`.env`: `JELLYFIN_MEDIA=/data/media`. Jellyfin's libraries keep their paths
(`/media/…`), as the folder is mounted where it was.
3. ✱ **Its configuration and cache into the deploy**, still Jellyfin's:
```sh
sudo apt-get install wireguard-tools
(umask 077 && wg genkey | sudo tee /etc/wireguard/wg-jellyfin.key | wg pubkey)
from=~deploy/halkyon/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
old=/home/raisondetredev/project-halkyon/halkyon/deploy/home-jellyfin
sudo install -d -o 61096 -g 61096 ~deploy/halkyon/deploy/jellyfin
sudo mv "$old/jellyfin/config" "$old/jellyfin/cache" ~deploy/halkyon/deploy/jellyfin/
```
The printed public key is the instance's, for step 4.
4. ✱ **Its network settings for Caddy**, before it starts: ADR 0032's would count the
admin's requests as remote and lock them out. In
`~deploy/halkyon/deploy/jellyfin/config/network.xml` (`sudoedit`), the local networks
emptied and Caddy named as the proxy, as *Jellyfin*, step 6 has them:
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.
```xml
<LocalNetworkSubnets />
<KnownProxies>
<string>172.30.80.2</string>
<string>fd4a:6c6b:7e80::2</string>
</KnownProxies>
```
5. ✱ **ADR 0032's tunnel removed**, its key with it (it never had a peer):
```sh
sudo install -m 0644 ~deploy/halkyon/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
sudo rm -rf "$old"
```
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.
Whenever `wg0.conf` fails to come up (a placeholder or a wrong key, an endpoint that
doesn't resolve: why it names the server by its address, ADR 0032, D11, and a new
address of the server's goes there too), the image's client mode deletes the namespace's default route
(`ip route del default` in its `svc-wireguard/run`, ls126), and Jellyfin with it loses
the internet: TMDb's metadata and images fail with *Network is unreachable*, and a
library scanned then gets frames of its videos for posters. A tunnel that comes up
keeps the route, even before its first handshake. Fixing `wg0.conf` alone isn't enough:
`docker compose up -d --force-recreate`, both containers, since Jellyfin lives in
WireGuard's namespace and a restart of WireGuard alone leaves it in the old one. The
folder `wireguard/` is the container's user's (61096) from its first start on, so
`wg0.conf` is edited with sudo.
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.learn.yaml:compose.jellyfin-home.yaml
JELLYFIN_DOMAIN=jellyfin.halkyon.eu
```
then `cd ~/halkyon/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): gives up after 5 s. The
# image's nc is OpenBSD's, whose own -w ends the try.
docker compose exec -T wireguard nc -w 5 -z 10.61.96.1 22 && 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.
6. **Switch it on** as *Jellyfin*, step 3 (`JELLYFIN_DOMAIN=jellyfin.halkyon.li`), and
start it (step 5). The admin then works through *Jellyfin*, step 6's tunnel from
melchior, and checks under Dashboard › Networking that the settings of step 4 hold.
Check from outside as step 7.
## LanguageTool

View file

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