halkyon/deploy/sidecars
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Tizian 「ティツィアーン」 c9d3eb4dd1
feat(todo): the server finds places through the instance's Photon (ADR 0033, D32)
Todo's locations search through the person's own instance, as Train's
planner does, not a public service: `SearchEndpoint` on `PlaceSearch`, for
those signed in, 60 searches a minute, nothing kept or logged. Its `Place`
carries the address's parts and the object in OpenStreetMap, and a shop's
car park or charger is left out, which came first for `Lindau EDEKA`.

`compose.todo-search.yaml` puts Todo's server on compose.search.yaml's
Photon. A file of its own: without it Todo starts as it did, and offers no
search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-07 21:49:35 +01:00
..
jellyfin.caddy feat(deploy): Jellyfin at home, published through WireGuard (ADR 0032) 2026-10-30 23:17:57 +01:00
README.md feat(todo): the server finds places through the instance's Photon (ADR 0033, D32) 2026-11-07 21:49:35 +01:00
todo.caddy build(todo): Todo deployable beside Learn's server, and released as an APK, on F-Droid and as a snap (ADR 0033, step 5) 2026-11-02 16:04:00 +01:00
train-maps.caddy feat(train): the instance's own map tiles, drawn by Train's style (ADR 0030, D50) 2026-10-30 15:53:07 +01:00
train.caddy feat(train): the instance's own map tiles, drawn by Train's style (ADR 0030, D50) 2026-10-30 15:53:07 +01:00

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.learn.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
Halkyon Todo's server compose.todo.yaml todo.caddy
Todo's place search, on Train's Photon compose.todo-search.yaml none

Both together: COMPOSE_FILE=compose.learn.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.

    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/deploy/jellyfin/config \
      ~deploy/halkyon/deploy/jellyfin/cache ~deploy/halkyon/deploy/jellyfin/media
    sudo chown -R jellyfin:jellyfin ~deploy/halkyon/deploy/jellyfin/config \
      ~deploy/halkyon/deploy/jellyfin/cache
    sudo chown -R deploy:deploy ~deploy/halkyon/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,

    COMPOSE_FILE=compose.learn.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):

    tar -C "<the folder's parent>" -cf - "<folder>" |
      ssh <deploy's ssh alias> 'tar -C halkyon/deploy/jellyfin/media/movies -xf -'
    
  5. Start it (the operator, on the instance's server, as deploy): cd ~/halkyon/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.

    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.

    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
    

    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.

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

    (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,

    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:

    # 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/:

    # 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.

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.

    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/deploy/languagetool/fasttext
    sudo chown -R languagetool:languagetool ~deploy/halkyon/deploy/languagetool
    
  2. Switch it on (the operator, on the instance's server, as deploy): in deploy/.env, COMPOSE_FILE=compose.learn.yaml:compose.languagetool.yaml.

  3. Start it (the operator, on the instance's server, as deploy): cd ~/halkyon/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:

    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/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:

    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 REVOKEs 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.

    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/deploy/brouter/segments
    sudo chown -R brouter:brouter ~deploy/halkyon/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):

    COMPOSE_FILE=compose.learn.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). The compose file names Train to Learn's register (INSTANCE_MEMBER_TRAIN), whatever INSTANCE_MEMBERS holds.

  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.

Workouts' weather (Halkyon Train's ADR 0036) comes from Open-Meteo through Train's server, on by default, the free API from the server's address (non-commercial use alone, 10,000 requests a day). WEATHER=off in deploy/.env stops it; an instance with ads or subscriptions needs one of Open-Meteo's paid plans, its key in OPEN_METEO_API_KEY.

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.

    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/deploy/maps/tiles
    sudo chown -R maptiles:maptiles ~deploy/halkyon/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:

    COMPOSE_FILE=compose.learn.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.

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.

    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/deploy/photon/data
    sudo chown -R photon:photon ~deploy/halkyon/deploy/photon/data
    
  3. Switch it on (the operator, as deploy): in deploy/.env, compose.search.yaml added to COMPOSE_FILE after Train's:

    COMPOSE_FILE=compose.learn.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.

Todo

Halkyon Todo's server beside the instance (Halkyon Todo's ADR 0033, step 5), at its own domain, a member of the register Learn's server holds (ADR 0016), its database halkyon_todo in the instance's PostgreSQL. It reads no task: every row is sealed on the person's devices (ADR 0033, D5), so it needs no sidecar of its own. 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.

Steps, in order:

  1. Its database (the operator, on the instance's server, as deploy, in ~/halkyon/deploy): a role of Todo's own, with the password that goes into .env as TODO_POSTGRES_PASSWORD (openssl rand -hex 32), and its database, as Train's (Train, step 2, says what each line does):

    docker compose exec -T postgres psql -U halkyon_learn -d postgres -v ON_ERROR_STOP=1 \
      -v password="<TODO_POSTGRES_PASSWORD>" <<'SQL'
    CREATE ROLE halkyon_todo LOGIN PASSWORD :'password';
    CREATE DATABASE halkyon_todo OWNER halkyon_todo;
    REVOKE CONNECT ON DATABASE halkyon_todo FROM PUBLIC;
    SQL
    

    The REVOKEs of Learn's and Train's databases were made with Train's, so halkyon_todo connects to neither; on an instance without Train, also REVOKE CONNECT ON DATABASE halkyon_learn FROM PUBLIC;.

  2. DNS (the operator, at the domain's DNS): Todo's domain, todo.halkyon.eu, pointing at the instance's server (A, and AAAA if it has an IPv6 address).

  3. Switch it on (the operator, as deploy): in deploy/.env, compose.todo.yaml added to COMPOSE_FILE, and Todo's settings (.env.example, With Halkyon Todo):

    COMPOSE_FILE=compose.learn.yaml:compose.languagetool.yaml:compose.train.yaml:compose.todo.yaml
    TODO_DOMAIN=todo.halkyon.eu
    TODO_POSTGRES_PASSWORD=<as in step 1>
    TODO_SESSION_KEY_HASH_PEPPER=<openssl rand -hex 32>
    

    INSTANCE_SECRET must be set. The compose file names Todo to Learn's register (INSTANCE_MEMBER_TODO).

  4. Start it (the operator, as deploy): docker compose up -d --build. Caddy and Learn's server are recreated with it (Todo's site, and the register's member); the instance is away for seconds. Todo's server applies its migrations as it starts.

  5. Check it: https://todo.halkyon.eu/ answers; in Todo, signed in with a Learn account, a task added on one device shows on another.

  6. Place search (optional; ADR 0033, D32): Todo's locations find places through the Photon of Train's search, set up first. In deploy/.env, compose.todo-search.yaml added to COMPOSE_FILE after compose.todo.yaml and compose.search.yaml, then docker compose up -d, which recreates Todo's server alone. Without it Todo starts as it did, and its app offers no search. Nothing searched is kept or logged, and a task's location stays sealed on the person's devices.