halkyon/deploy/sidecars/README.md
Tizian 「ティツィアーン」 d8c827eab6
Some checks failed
CI / check (push) Failing after 41m12s
CI / deploy (push) Has been skipped
feat(deploy): Jellyfin at home, published through WireGuard (ADR 0032)
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>
2026-10-30 23:17:57 +01:00

453 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`:
```sh
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` |
| 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) |
| Photon, which finds places for Train's route planner, beside Train | `compose.search.yaml` | none |
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.
1. ✱ **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
(`useradd` warns that the ID lies above the system range, which is meant: it must
not meet a container's IDs); the media folder is `deploy`'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't `cd` into it, but `sudo`
reaches it by its path, which the shell makes of `~deploy` without entering it.
```sh
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
```
2. **DNS** (the operator, at the domain's DNS): the Jellyfin domain, such as
`jellyfin.example.org`, pointing at the instance's server (`A`, and `AAAA` if it has
an IPv6 address).
3. **Switch it on** (the operator, on the instance's server, as `deploy`): in
`deploy/.env`,
```sh
COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml
JELLYFIN_DOMAIN=jellyfin.example.org
```
4. **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;
through `tar` and SSH, which every server has (rsync would have to be installed on
it too):
```sh
tar -C "<the folder's parent>" -cf - "<folder>" |
ssh <deploy's ssh alias> 'tar -C halkyon-learn/deploy/jellyfin/media/movies -xf -'
```
5. **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.
6. **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.2` and `fd4a:6c6b:7e80::2`
(Caddy's addresses on `edge`). 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.
7. **Check from outside** (anyone, anywhere): `https://<domain>/System/Info/Public`
answers, `/Users/Public` lists the demo account, a `POST` to `/Users/Password` gets
403, and the instance's `instance.info()` names the domain as `jellyfinUrl`.
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
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.
1. ✱ **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 (`useradd` warns 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.
```sh
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
```
2. **Switch it on** (the operator, on the instance's server, as `deploy`): in
`deploy/.env`, `COMPOSE_FILE=compose.yaml:compose.languagetool.yaml`.
3. **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
(its `LANGUAGETOOL_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's `dl.fbaipublicfiles.com`) into `deploy/languagetool/fasttext/`, which it
uses to tell which language a note is in; checks start once it is there.
4. **Check it**: on the instance's server, as `deploy`, `docker compose ps languagetool`
says `healthy` after a minute; then, from anywhere, the instance names the languages
it checks:
```sh
curl -s -X POST -H 'Content-Type: application/json' -d '{}' https://<domain>/instance/info
```
answers with `grammarLanguages`, `de-DE` and `en-US` among 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.
1. **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 `~/backups` with Learn's.
2. **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
`.env` as `TRAIN_POSTGRES_PASSWORD` (`openssl rand -hex 32`), and its database:
```sh
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;
SQL
```
`CREATE ROLE` makes 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 … OWNER`
gives it its own database, where its migrations make the tables. The two `REVOKE`s
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 to `halkyon_train`, and `halkyon_train` no 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.)
3. ✱ **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.
```sh
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
```
4. **DNS** (the operator, at the domain's DNS): Train's domain, `train.halkyon.eu`,
pointing at the instance's server (`A`, and `AAAA` if it has an IPv6 address).
5. **Switch it on** (the operator, as `deploy`): in `deploy/.env`, `compose.train.yaml`
added to `COMPOSE_FILE`, and Train's settings (`.env.example`, *With Halkyon Train*):
```sh
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_SECRET` must be set (Learn's register takes no member without it). Where
`INSTANCE_MEMBERS` is set, add `http://train-server:8082/` to it; unset, the compose
file names Train.
6. **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-segments`
says when); until then BRouter finds no route. Where brouter.de doesn't answer or
the folder isn't BRouter's, the log says so and it tries again an hour later.
7. **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.
1. **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.
2. ✱ **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.
```sh
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
```
3. **Switch it on** (the operator, as `deploy`): in `deploy/.env`, `compose.maps.yaml`
added to `COMPOSE_FILE` after Train's, and the secret the keys are made with:
```sh
COMPOSE_FILE=compose.yaml:compose.languagetool.yaml:compose.train.yaml:compose.maps.yaml
MAP_KEY_SECRET=<openssl rand -hex 32>
```
`MAP_STYLE_URL` is then not used. Changing `MAP_KEY_SECRET` refuses every key handed
out before, until the apps ask for the source again (as each opens).
4. **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-refresh` says when it is done); until then Train's
maps stay empty. A failed cut is tried again an hour later.
5. **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.mvt` is refused
with 401. Not yet tried on the server (29 Oct 2026): whether `pmtiles serve` reads a
new cut moved over the old one without a restart (else `docker compose restart
map-tiles` after each cut), and Caddy's `import` of an empty folder where the map is
off.
## Train's search
Photon beside Train (Halkyon Train's ADR 0030, D50; `compose.train.yaml` first), which
finds the places Train's route planner searches for, on GraphHopper's weekly database
of Europe (a 32.6 GB download on 29 Oct 2026, more unpacked, to be measured; Germany's
alone 7.5 GB, `PHOTON_REGION=europe/germany`). Only Train's server reaches it, for
people signed in; nothing searched is kept or logged. The database is renewed monthly:
downloaded and unpacked beside the one in use, checked against its MD5, then put in
its place, Photon away for a minute or two.
Steps, in order; ✱ marks a step as root.
1. **Room and memory** (the operator, on the instance's server): `df -h ~deploy` and
`free -h`. The database takes tens of GB, twice that while a new one comes; Photon
wants `PHOTON_HEAP` (6g by default) and room beside it (`PHOTON_MEMORY`, 8g).
2. ✱ **Photon's user and folder** (the operator, as a user with sudo): the user and
group ID 61179 are Photon's alone, as the compose file runs it.
```sh
sudo groupadd --system --gid 61179 photon
sudo useradd --system --uid 61179 --gid 61179 --no-create-home --shell /usr/sbin/nologin photon
sudo mkdir -p ~deploy/halkyon-learn/deploy/photon/data
sudo chown -R photon:photon ~deploy/halkyon-learn/deploy/photon/data
```
3. **Switch it on** (the operator, as `deploy`): in `deploy/.env`, `compose.search.yaml`
added to `COMPOSE_FILE` after Train's:
```sh
COMPOSE_FILE=compose.yaml:compose.languagetool.yaml:compose.train.yaml:compose.maps.yaml:compose.search.yaml
```
4. **Start it** (the operator, as `deploy`): `docker compose up -d --build`. Train's
server is recreated with it. The first database takes an hour or more
(`docker compose logs -f photon-data` says when it is ready); Photon starts on it
within a minute, and until then Train's planner offers no search.
5. **Check it**: in Train, signed in, the planner's search finds a town of the region.
Not yet tried on the server (29 Oct 2026): the database's size unpacked, Photon's
memory on Europe's, and that 1.3.0 reads GraphHopper's `1.0` build.