Protomaps' basemap cut for Europe and served by pmtiles beside Train's server (compose.maps.yaml), drawn by Train's own style, light and dark, which the server serves. Only people signed in get tiles: map.source hands a key that Caddy has the server check before each tile; nothing logs a tile. Also fixes .dockerignore, which left apps/train/ out of the build context, so Train's server's and BRouter's images couldn't have been built. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
15 KiB
Sidecars
Services an instance may run beside itself (decided 2026-10-14), each switched on by
naming its compose file after the instance's own in deploy/.env:
COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml
A sidecar's compose file adds its service and, if it is reached from outside, mounts
its site from this folder into Caddy's /etc/caddy/sidecars/, which the Caddyfile
imports. It joins the edge network beside Caddy and the server's public API, never the
internal one where Postgres is, and publishes no port of its own. Its process runs as a
user ID of its own, which no person, no other container and not the CI runner uses.
redeploy.sh deploys whatever .env switches on, and recreates Caddy when a sidecar's
site changes.
| Sidecar | Compose file | Site |
|---|---|---|
| Jellyfin, which people watch and listen to learn from | compose.jellyfin.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) |
Both together: COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml:compose.languagetool.yaml.
Jellyfin
A Jellyfin server beside the instance, which the instance then suggests to its people
(JELLYFIN_URL, set by the compose file): the app's Jellyfin sign-in fills it in and
lists the people it shows on its login screen. Videos stream as stored, nothing is
transcoded. Caddy refuses every change to a user from outside, so an account without a
password (a demo's) stays everyone's; the admin signs in through an SSH tunnel only.
Steps, in order; ✱ marks a step as root.
-
✱ Its user and folders (the operator, on the instance's server, as a user with sudo): the user and group ID 61096 are Jellyfin's alone, as the compose file runs it (
useraddwarns that the ID lies above the system range, which is meant: it must not meet a container's IDs); the media folder isdeploy's, so it can copy files in, readable by all. The folders are named in full:deploy's home is closed to other users (Ubuntu's default), so a user with sudo can'tcdinto it, butsudoreaches it by its path, which the shell makes of~deploywithout entering it.sudo groupadd --system --gid 61096 jellyfin sudo useradd --system --uid 61096 --gid 61096 --no-create-home --shell /usr/sbin/nologin jellyfin sudo mkdir -p ~deploy/halkyon-learn/deploy/jellyfin/config \ ~deploy/halkyon-learn/deploy/jellyfin/cache ~deploy/halkyon-learn/deploy/jellyfin/media sudo chown -R jellyfin:jellyfin ~deploy/halkyon-learn/deploy/jellyfin/config \ ~deploy/halkyon-learn/deploy/jellyfin/cache sudo chown -R deploy:deploy ~deploy/halkyon-learn/deploy/jellyfin/media -
DNS (the operator, at the domain's DNS): the Jellyfin domain, such as
jellyfin.example.org, pointing at the instance's server (A, andAAAAif it has an IPv6 address). -
Switch it on (the operator, on the instance's server, as
deploy): indeploy/.env,COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml JELLYFIN_DOMAIN=jellyfin.example.org -
The media (the operator, on their own machine): a folder per film, show or album, as Jellyfin expects them, into
deploy/jellyfin/media/, readable by all; throughtarand SSH, which every server has (rsync would have to be installed on it too):tar -C "<the folder's parent>" -cf - "<folder>" | ssh <deploy's ssh alias> 'tar -C halkyon-learn/deploy/jellyfin/media/movies -xf -' -
Start it (the operator, on the instance's server, as
deploy):cd ~/halkyon-learn/deploy && docker compose up -d. Caddy and the server are recreated with it (the site, and the instance's suggestion); the instance is away for seconds. -
Set Jellyfin up through a tunnel (the operator, on their own machine):
ssh -N -L 8096:172.30.80.10:8096 <deploy's ssh alias>, then http://localhost:8096.- The wizard: an admin of a name nobody guesses, its password in a password
manager; a library of the type Movies (or Shows, Music) on
/media/…; remote connections allowed. - Dashboard › Networking › Known proxies:
172.30.80.2andfd4a:6c6b:7e80::2(Caddy's addresses onedge). Jellyfin then sees each visitor's own address, and the tunnel as local. - Dashboard › General: Quick Connect off, unless people sign in with it.
- Dashboard › Users › the admin: Hide this user from login screens; Allow remote connections to this server off.
- For a demo, Dashboard › Users › +: an account without a password, shown on
login screens, seeing the demo's library alone; off: playback and conversion that
need transcoding, content downloading, remote control of other users, deleting
media. On its Profile tab, Failed login tries before user is locked out
-1: with Jellyfin's default of 3, anyone could switch the demo off with three wrong passwords, and without a password the lockout guards nothing. People sign in to it by tapping it and leaving the password empty: Jellyfin doesn't say which accounts have none.
- The wizard: an admin of a name nobody guesses, its password in a password
manager; a library of the type Movies (or Shows, Music) on
-
Check from outside (anyone, anywhere):
https://<domain>/System/Info/Publicanswers,/Users/Publiclists the demo account, aPOSTto/Users/Passwordgets 403, and the instance'sinstance.info()names the domain asjellyfinUrl.
Switching it off: the compose file out of COMPOSE_FILE, then docker compose up -d --remove-orphans; deploy/jellyfin/ stays until removed by hand.
LanguageTool
LanguageTool's open-source server beside the instance (ADR 0024), which checks spelling
and grammar for Halkyon Learn: Check in the assistant's field, and the underlines while
writing (the app's Settings › Writing). Only the instance's server reaches it
(LANGUAGETOOL_URL, set by the compose file): LanguageTool has no sign-in of its own,
so it gets no site, and the server checks who asks and how often (signed in, 60 checks a
minute each). Nothing written is kept or logged, LanguageTool's own log included: Docker
keeps none for it. It checks without n-gram data (LanguageTool's statistical checks of
words like their and there, tens of GB per language), in about 1.5 GB of memory.
Steps, in order; ✱ marks a step as root.
-
✱ Its user and folder (the operator, on the instance's server, as a user with sudo): the user and group ID 61081 are LanguageTool's alone, as the compose file runs it (
useraddwarns that the ID lies above the system range, which is meant: it must not meet a container's IDs). The folder is named in full, as Jellyfin's.sudo groupadd --system --gid 61081 languagetool sudo useradd --system --uid 61081 --gid 61081 --no-create-home --shell /usr/sbin/nologin languagetool sudo mkdir -p ~deploy/halkyon-learn/deploy/languagetool/fasttext sudo chown -R languagetool:languagetool ~deploy/halkyon-learn/deploy/languagetool -
Switch it on (the operator, on the instance's server, as
deploy): indeploy/.env,COMPOSE_FILE=compose.yaml:compose.languagetool.yaml. -
Start it (the operator, on the instance's server, as
deploy):cd ~/halkyon-learn/deploy && docker compose up -d. The server is recreated with it (itsLANGUAGETOOL_URL); the instance is away for seconds. At its first start LanguageTool fetches fastText's model of languages (lid.176.bin, 126 MB, from Facebook'sdl.fbaipublicfiles.com) intodeploy/languagetool/fasttext/, which it uses to tell which language a note is in; checks start once it is there. -
Check it: on the instance's server, as
deploy,docker compose ps languagetoolsayshealthyafter a minute; then, from anywhere, the instance names the languages it checks:curl -s -X POST -H 'Content-Type: application/json' -d '{}' https://<domain>/instance/infoanswers with
grammarLanguages,de-DEanden-USamong them.
Switching it off: the compose file out of COMPOSE_FILE, then docker compose up -d --remove-orphans; the app's Writing settings then say the instance checks nothing, and
deploy/languagetool/ stays until removed by hand.
Train
Halkyon Train's server beside the instance (Halkyon Train's ADR 0030, D46), at its own
domain, a member of the register Learn's server holds (ADR 0016), its database
halkyon_train in the instance's PostgreSQL, and BRouter planning its routes (D11, D44)
from the segments of the operator's region (Europe's by default, about 3.3 GB on
28 Oct 2026), refreshed monthly. redeploy.sh builds, dumps, migrates and waits for it
as for Learn's server once .env names its compose file, and backup.sh backs its
database up beside Learn's. The map stays OpenFreeMap's until the instance serves its
own tiles (D22, D23).
Steps, in order; ✱ marks a step as root.
-
Room on the disk (the operator, on the instance's server):
df -h ~deploy. BRouter's segments take about 3.3 GB for Europe, and a refresh writes each one beside the old before replacing it; Train's dumps go to~/backupswith Learn's. -
Its database (the operator, on the instance's server, as
deploy, in~/halkyon-learn/deploy): a role of Train's own, with the password that goes into.envasTRAIN_POSTGRES_PASSWORD(openssl rand -hex 32), and its database:docker compose exec -T postgres psql -U halkyon_learn -d postgres -v ON_ERROR_STOP=1 \ -v password="<TRAIN_POSTGRES_PASSWORD>" <<'SQL' CREATE ROLE halkyon_train LOGIN PASSWORD :'password'; CREATE DATABASE halkyon_train OWNER halkyon_train; REVOKE CONNECT ON DATABASE halkyon_train FROM PUBLIC; REVOKE CONNECT ON DATABASE halkyon_learn FROM PUBLIC; SQLCREATE ROLEmakes the role Train's server signs in as, which may log in and is nothing more (no superuser, no creating roles or databases);CREATE DATABASE … OWNERgives it its own database, where its migrations make the tables. The twoREVOKEs take away the right every role has by default to connect to a database: only its owner (and the instance's superuser,halkyon_learn, which Learn's server signs in as) connects tohalkyon_train, andhalkyon_trainno longer connects to Learn's. Nothing of Learn's changes. (Learn's server, signing in as the superuser, could still read Train's; giving it a role of its own is a step of its own.) -
✱ BRouter's user and folder (the operator, as a user with sudo): the user and group ID 61177 are BRouter's alone, as the compose file runs it; the folder its segments go to is theirs.
sudo groupadd --system --gid 61177 brouter sudo useradd --system --uid 61177 --gid 61177 --no-create-home --shell /usr/sbin/nologin brouter sudo mkdir -p ~deploy/halkyon-learn/deploy/brouter/segments sudo chown -R brouter:brouter ~deploy/halkyon-learn/deploy/brouter/segments -
DNS (the operator, at the domain's DNS): Train's domain,
train.halkyon.eu, pointing at the instance's server (A, andAAAAif it has an IPv6 address). -
Switch it on (the operator, as
deploy): indeploy/.env,compose.train.yamladded toCOMPOSE_FILE, and Train's settings (.env.example, With Halkyon Train):COMPOSE_FILE=compose.yaml:compose.languagetool.yaml:compose.train.yaml TRAIN_DOMAIN=train.halkyon.eu TRAIN_POSTGRES_PASSWORD=<as in step 2> TRAIN_SESSION_KEY_HASH_PEPPER=<openssl rand -hex 32>INSTANCE_SECRETmust be set (Learn's register takes no member without it). WhereINSTANCE_MEMBERSis set, addhttp://train-server:8082/to it; unset, the compose file names Train. -
Start it (the operator, as
deploy):docker compose up -d --build. Caddy and Learn's server are recreated with it (Train's site, and the register's member); the instance is away for seconds. Train's server applies its migrations as it starts. The segments come in the first hour or so (docker compose logs brouter-segmentssays when); until then BRouter finds no route. -
Check it:
https://train.halkyon.eu/answers; in Train, signed in with a Learn account, a route plans between two points of the region.
Train's map
The instance's own map for Halkyon Train (Halkyon Train's ADR 0030, D50), beside Train
(compose.train.yaml first): Protomaps' basemap cut for Europe from Protomaps' daily
build (the planet's was 139 GB on 29 Oct 2026; Europe's cut is estimated at some 45 GB,
to be measured), served by pmtiles serve and drawn by Train's own style, which Train's
server serves. Only people signed in to Train get tiles: map.source hands the app a
key, which Caddy has Train's server check before each tile. No tile is logged. The cut
is renewed monthly where Protomaps has a newer build, written beside the old one and
moved over it. Off, Train's maps are OpenFreeMap's (or MAP_STYLE_URL's) again.
Steps, in order; ✱ marks a step as root.
-
Room on the disk (the operator, on the instance's server):
df -h ~deploy. A cut takes some 45 GB, and twice that while a new one is cut beside it. -
✱ The tiles' user and folder (the operator, as a user with sudo): the user and group ID 61178 are the tiles' alone, as the compose file runs them.
sudo groupadd --system --gid 61178 maptiles sudo useradd --system --uid 61178 --gid 61178 --no-create-home --shell /usr/sbin/nologin maptiles sudo mkdir -p ~deploy/halkyon-learn/deploy/maps/tiles sudo chown -R maptiles:maptiles ~deploy/halkyon-learn/deploy/maps/tiles -
Switch it on (the operator, as
deploy): indeploy/.env,compose.maps.yamladded toCOMPOSE_FILEafter Train's, and the secret the keys are made with:COMPOSE_FILE=compose.yaml:compose.languagetool.yaml:compose.train.yaml:compose.maps.yaml MAP_KEY_SECRET=<openssl rand -hex 32>MAP_STYLE_URLis then not used. ChangingMAP_KEY_SECRETrefuses every key handed out before, until the apps ask for the source again (as each opens). -
Start it (the operator, as
deploy):docker compose up -d --build. Caddy and Train's server are recreated with it. The first cut takes hours (docker compose logs -f map-tiles-refreshsays when it is done); until then Train's maps stay empty. -
Check it: in Train, signed in, a workout's map is drawn and names the instance as its source;
curl -I https://train.halkyon.eu/tiles/europe/0/0/0.mvtis refused with 401. Not yet tried on the server (29 Oct 2026): whetherpmtiles servereads a new cut moved over the old one without a restart (elsedocker compose restart map-tilesafter each cut), and Caddy'simportof an empty folder where the map is off.