build(todo): Todo deployable beside Learn's server, and released as an APK, on F-Droid and as a snap (ADR 0033, step 5)

deploy/compose.todo.yaml adds Todo's server at todo.halkyon.eu, a member
of Learn's register, its database halkyon_todo in the instance's
PostgreSQL, its image built as Train's. redeploy.sh and backup.sh go over
every app server .env switches on (train, todo). Each compose file names
its server to the register in a variable of its own
(INSTANCE_MEMBER_TRAIN, INSTANCE_MEMBER_TODO), which halkyon_base adds to
INSTANCE_MEMBERS, each address once, as a second compose file's default
would have replaced Train's.

apps/todo/release.dart configures the release tool for Todo's APK and
snap: a key of its own (pinned once made; until then nothing is
released), the F-Droid listing (Time, NonFreeNet), and the snap from
apps/todo/snap/, which the tool builds in a clone of the repository at the
tag with that folder in place of snap/, as snapcraft builds one recipe a
folder and the root's is Learn's. What's new for 0.1.0 in English and
German, now shown. Android's backup and transfer are off, as Train's
(D53): the reminders scheduled keep their tasks' titles unsealed.

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-02 15:35:40 +01:00
commit e61d3abb4f
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 963 additions and 107 deletions

View file

@ -28,7 +28,8 @@ DOMAIN=http://localhost
# data too, and told again hourly until each answers. Each of them sets, in its own
# environment, the same INSTANCE_SECRET and ACCOUNT_REGISTER_URL, this server's web
# server as it reaches it (`http://server:8082`). Learn never takes ACCOUNT_REGISTER_URL:
# it doesn't start with one, as it holds the register.
# it doesn't start with one, as it holds the register. Train's and Todo's compose files
# name their own servers (INSTANCE_MEMBER_TRAIN, INSTANCE_MEMBER_TODO): this is for others.
#INSTANCE_MEMBERS=
# The instance's assistants, which an operator grants to people (Halkyon Learn's
@ -38,7 +39,8 @@ DOMAIN=http://localhost
# Sidecars switched on beside the instance (sidecars/README.md), each by its compose file
# after the instance's own: compose.jellyfin.yaml, a Jellyfin people watch and listen to
# learn from; compose.languagetool.yaml, spelling and grammar checked; compose.train.yaml,
# Halkyon Train's server with its BRouter. Unset: the instance alone.
# Halkyon Train's server with its BRouter; compose.todo.yaml, Halkyon Todo's server.
# Unset: the instance alone.
#COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml
# With the Jellyfin sidecar: the domain it answers at, which the instance then suggests
@ -50,8 +52,7 @@ DOMAIN=http://localhost
# pepper (never change it once people sign in), INSTANCE_SECRET above set, and the
# region of BRouter's segments, `west,south,east,north` in whole degrees, multiples of
# 5 (empty: Europe's, -25,35,40,70, about 3.3 GB). Generate each secret:
# openssl rand -hex 32. Its server is told of accounts gone without INSTANCE_MEMBERS;
# setting it, name Train's too (`http://train-server:8082/`).
# openssl rand -hex 32. Its compose file names its server to Learn's register.
#TRAIN_DOMAIN=train.example.org
#TRAIN_POSTGRES_PASSWORD=
#TRAIN_SESSION_KEY_HASH_PEPPER=
@ -84,6 +85,14 @@ DOMAIN=http://localhost
#PHOTON_HEAP=
#PHOTON_MEMORY=
# With Halkyon Todo beside the instance (compose.todo.yaml, sidecars/README.md, Todo):
# the domain its apps reach it at, its database's password and its own session pepper
# (never change it once people sign in), INSTANCE_SECRET above set. Generate each
# secret: openssl rand -hex 32. Its compose file names its server to Learn's register.
#TODO_DOMAIN=todo.example.org
#TODO_POSTGRES_PASSWORD=
#TODO_SESSION_KEY_HASH_PEPPER=
# Postgres password, shared by the postgres and server services.
# Generate: openssl rand -base64 32
POSTGRES_PASSWORD=

View file

@ -4,8 +4,9 @@
# how it was set up and how to restore.
#
# Everything people keep is in the database, their files too (Serverpod's database
# cloud storage), and in Train's where .env switches Train on (compose.train.yaml,
# Halkyon Train's ADR 0030, D46), but for the releases of published studysets, which are git
# cloud storage), and in Train's and Todo's where .env switches each on (compose.train.yaml,
# Halkyon Train's ADR 0030, D46; compose.todo.yaml, Halkyon Todo's ADR 0033), but for the
# releases of published studysets, which are git
# repositories in the server's volume: a dump and a bundle of each repository cover
# the instance; the secrets in deploy/.env are kept in Proton Pass instead. restic stores the dump encrypted in a Hetzner Storage Box
# and keeps 24 hourly, 7 daily, 4 weekly and 3 monthly snapshots (decided 2026-10-03).
@ -36,11 +37,15 @@ cd "$(dirname "$0")"
# the last hour and stores it only once; restic compresses what it stores.
mkdir -p -m 700 "$HOME/backups"
dump="$HOME/backups/latest.dump"
train_dump="$HOME/backups/latest-train.dump"
train=
if docker compose config --services | grep -qx train-server; then
train=yes
fi
# The apps' servers .env switches on beside Learn's, each with its database
# (halkyon_<app>) dumped to latest-<app>.dump.
services=$(docker compose config --services)
apps=
for app in train todo; do
if printf '%s\n' "$services" | grep -qx "$app-server"; then
apps="$apps $app"
fi
done
(
umask 077
# Never during a deploy (redeploy.sh holds this lock): its migrations would wait
@ -48,14 +53,17 @@ fi
exec 9>"$HOME/.redeploy.lock"
flock 9
docker compose exec -T postgres pg_dump -U halkyon_learn -Fc -Z 0 halkyon_learn >"$dump.partial"
if [ -n "$train" ]; then
docker compose exec -T postgres pg_dump -U halkyon_learn -Fc -Z 0 halkyon_train >"$train_dump.partial"
fi
for app in $apps; do
docker compose exec -T postgres pg_dump -U halkyon_learn -Fc -Z 0 "halkyon_$app" \
>"$HOME/backups/latest-$app.dump.partial"
done
)
mv "$dump.partial" "$dump"
if [ -n "$train" ]; then
mv "$train_dump.partial" "$train_dump"
fi
set -- "$dump"
for app in $apps; do
mv "$HOME/backups/latest-$app.dump.partial" "$HOME/backups/latest-$app.dump"
set -- "$@" "$HOME/backups/latest-$app.dump"
done
# Each repository as a git bundle, after the dump: a release the dump lacks is never
# numbered again (the server numbers past the highest tag), while one the bundles
@ -79,13 +87,9 @@ releases="$HOME/backups/repositories.tar"
)
mv "$releases.partial" "$releases"
if [ -n "$train" ]; then
restic backup --quiet "$dump" "$train_dump" "$releases"
else
restic backup --quiet "$dump" "$releases"
fi
restic backup --quiet "$@" "$releases"
# By host alone: restic groups by the paths backed up too, so a path added (Train's
# dump) would leave the snapshots before it a group of their own, never forgotten, and
# dump, Todo's) would leave the snapshots before it a group of their own, never forgotten, and
# a deleted account in them for good.
restic forget --quiet --group-by host --keep-hourly 24 --keep-daily 7 --keep-weekly 4 --keep-monthly 3

View file

@ -211,6 +211,11 @@ docker compose exec -T postgres \
whose role is the cluster's and so survives the restore (on a new server, make it first:
`sidecars/README.md`, *Train*, step 2).
Todo's database, where `.env` switches Todo on (`compose.todo.yaml`, Halkyon Todo's
ADR 0033), is `latest-todo.dump` in the same snapshots, restored as Train's, with
`todo` for `train` (its role made first on a new server: `sidecars/README.md`, *Todo*,
step 1).
To check a backup without touching the instance, restore it into a database of its own
next to the real one, look, and drop it:

55
deploy/compose.todo.yaml Normal file
View file

@ -0,0 +1,55 @@
# Halkyon Todo's server beside the instance (ADR 0033, step 5). Switched on in .env,
# with Todo's domain and secrets:
#
# COMPOSE_FILE=compose.yaml:compose.todo.yaml
# TODO_DOMAIN=todo.halkyon.eu
#
# Todo is a member of the instance's register, which Learn's server holds (ADR 0016),
# as Train is: one account a person, signing in through the register, the shared
# INSTANCE_SECRET on every call between them, which reach each other on the instance's
# own networks alone. Its database is `halkyon_todo` in the instance's PostgreSQL, under
# a role of its own that can't connect to Learn's or Train's. Its server reads no task:
# every row is sealed on the person's devices (ADR 0033, D5). Setting it up:
# sidecars/README.md, Todo.
services:
todo-server:
build:
context: ..
dockerfile: apps/todo/halkyon_todo_server/Dockerfile
image: halkyon-todo-server
restart: unless-stopped
# As Learn's: migrations at every start (redeploy.sh has applied them before).
command: ["--apply-migrations"]
environment:
SERVERPOD_DATABASE_PASSWORD: ${TODO_POSTGRES_PASSWORD:?set TODO_POSTGRES_PASSWORD in deploy/.env}
SERVERPOD_PASSWORD_serverSideSessionKeyHashPepper: ${TODO_SESSION_KEY_HASH_PEPPER:?set TODO_SESSION_KEY_HASH_PEPPER in deploy/.env}
# The register (ADR 0016): Learn's web server, on the instance's own network,
# and the secret every call between them carries.
ACCOUNT_REGISTER_URL: http://server:8082/
SERVERPOD_PASSWORD_instanceSecret: ${INSTANCE_SECRET:?set INSTANCE_SECRET in deploy/.env}
# Caddy, as for Learn's server (compose.yaml).
TRUSTED_PROXIES: 172.30.80.2 fd4a:6c6b:7e80::2
SOURCE_URL: ${SOURCE_URL:-}
DATA_LOCATION: ${DATA_LOCATION:-}
BACKUP_LOCATION: ${BACKUP_LOCATION:-}
networks:
- default
- edge
depends_on:
postgres:
condition: service_healthy
logging:
driver: journald
caddy:
environment:
TODO_DOMAIN: ${TODO_DOMAIN:?set TODO_DOMAIN in deploy/.env}
volumes:
- ./sidecars/todo.caddy:/etc/caddy/sidecars/todo.caddy:ro
server:
environment:
# Todo's web server, told when an account goes (ADR 0016), beside the members
# the other compose files and INSTANCE_MEMBERS name.
INSTANCE_MEMBER_TODO: http://todo-server:8082/

View file

@ -108,9 +108,9 @@ services:
server:
environment:
# Train's web server, told when an account goes (ADR 0016); set
# INSTANCE_MEMBERS in .env to name more members, Train's among them.
INSTANCE_MEMBERS: ${INSTANCE_MEMBERS:-http://train-server:8082/}
# Train's web server, told when an account goes (ADR 0016), beside the members
# the other compose files and INSTANCE_MEMBERS name.
INSTANCE_MEMBER_TRAIN: http://train-server:8082/
networks:
# Train's server and BRouter, without a way out.

View file

@ -106,8 +106,9 @@ services:
ACCOUNT_DOMAIN: ${ACCOUNT_DOMAIN:-}
# The instance's other services (ADR 0016), whose accounts this server's
# register keeps: the secret every call between them carries, and their web
# servers, told when an account goes; empty: this is the instance's only
# service.
# servers, told when an account goes (beside those the services' own compose
# files name, INSTANCE_MEMBER_TRAIN and INSTANCE_MEMBER_TODO); empty: this is
# the instance's only service.
SERVERPOD_PASSWORD_instanceSecret: ${INSTANCE_SECRET:-}
INSTANCE_MEMBERS: ${INSTANCE_MEMBERS:-}
# The Jellyfin server suggested to people; empty: jellyfin. on the

View file

@ -10,8 +10,8 @@
# - as a fast-forward of the deployed commit,
# - if every new commit is signed by a key in deploy/allowed-signers, as the deployed
# checkout lists them (so no commit can add its own key),
# dumps the database first (the last 3 dumps stay in ~/backups; Train's too where .env
# switches Train on, compose.train.yaml), applies the migrations
# dumps the database first (the last 3 dumps stay in ~/backups; Train's and Todo's too where
# .env switches each on, compose.train.yaml, compose.todo.yaml), applies the migrations
# before switching to the new server, starts it once more after a migration (see below),
# and fails unless that server answers afterwards. A failure leaves the checkout at the deployed commit, so
# running the CI job again retries. By hand, with your own key, which passes the commit as CI does:
@ -60,17 +60,22 @@ main() {
git merge -q --ff-only "$commit"
trap back_out EXIT
cd deploy
# Train's server, where .env switches it on (compose.train.yaml): built, dumped,
# migrated and waited for as Learn's.
train=
if docker compose config --services | grep -qx train-server; then
train=yes
fi
# The apps' servers beside Learn's, where .env switches each on (compose.train.yaml,
# compose.todo.yaml): built, dumped, migrated and waited for as Learn's.
services=$(docker compose config --services)
apps=
for app in train todo; do
if printf '%s\n' "$services" | grep -qx "$app-server"; then
apps="$apps $app"
fi
done
# Build while the running server keeps serving; then dump, migrate, switch.
docker compose build -q server dictionaries
if [ -n "$train" ]; then
docker compose build -q train-server brouter
fi
for app in $apps; do
docker compose build -q "$app-server"
done
# Train's routes, with its server.
case " $apps " in *' train '*) docker compose build -q brouter ;; esac
# Train's own map, where .env switches it on (compose.maps.yaml).
if docker compose config --services | grep -qx map-tiles; then
docker compose build -q map-tiles
@ -80,18 +85,18 @@ main() {
docker compose build -q photon
fi
dump halkyon_learn
if [ -n "$train" ]; then
dump halkyon_train
fi
for app in $apps; do
dump "halkyon_$app"
done
# Migrations as a run of their own, before the switch: Serverpod's maintenance role
# applies them and exits with 0 only if that worked. Each migration is one
# transaction, so a failing one stops the deploy here and the running server keeps
# serving the schema it knows. (Until the switch, the old server serves the new
# schema; migrations that only add, as they should, keep that harmless.)
docker compose run --rm --no-deps -e role=maintenance server --apply-migrations
if [ -n "$train" ]; then
docker compose run --rm --no-deps -e role=maintenance train-server --apply-migrations
fi
for app in $apps; do
docker compose run --rm --no-deps -e role=maintenance "$app-server" --apply-migrations
done
switched=yes
docker compose up -d --remove-orphans
# Caddy reads its Caddyfile when it starts, and `up` doesn't restart it for a
@ -104,9 +109,9 @@ main() {
docker compose up -d --force-recreate --no-deps caddy
fi
wait_until_answering server
if [ -n "$train" ]; then
wait_until_answering train-server
fi
for app in $apps; do
wait_until_answering "$app-server"
done
# After a migration the new server is started once more. The sync engine
# (serverpod_offline_sync v0.0.10) reconciles its registry of synced tables as a
# server first uses the database; when a migration changed a synced table, that
@ -119,18 +124,19 @@ main() {
docker compose restart server
wait_until_answering server
fi
# Train's server too: it syncs through the same engine.
if [ -n "$train" ] &&
! git diff --quiet "$deployed" "$commit" -- ../apps/train/halkyon_train_server/migrations; then
docker compose restart train-server
wait_until_answering train-server
fi
# The apps' servers too: they sync through the same engine.
for app in $apps; do
if ! git diff --quiet "$deployed" "$commit" -- "../apps/$app/halkyon_${app}_server/migrations"; then
docker compose restart "$app-server"
wait_until_answering "$app-server"
fi
done
trap - EXIT
docker image prune -f >/dev/null
echo "Deployed $commit."
}
# Dumps the database $1 (halkyon_learn, halkyon_train), as it is before the new server
# Dumps the database $1 (halkyon_learn, halkyon_train, halkyon_todo), as it is before the new server
# applies its migrations; as the instance's superuser, halkyon_learn.
dump() {
mkdir -p "$HOME/backups"
@ -147,7 +153,7 @@ dump() {
ls -1t "$HOME"/backups/"$1"-*.dump | tail -n +4 | xargs -r rm --
}
# Waits up to a minute for the new server $1 (server, train-server) to answer inside the
# Waits up to a minute for the new server $1 (server, train-server, todo-server) to answer inside the
# stack's network.
wait_until_answering() {
tries=0

View file

@ -23,6 +23,7 @@ site changes.
| 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` |
Both together: `COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml:compose.languagetool.yaml`.
@ -366,9 +367,9 @@ Steps, in order; ✱ marks a step as root.
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.
`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.
@ -469,3 +470,52 @@ Steps, in order; ✱ marks a step as root.
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-learn/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):
```sh
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 `REVOKE`s 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*):
```sh
COMPOSE_FILE=compose.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.

View file

@ -0,0 +1,12 @@
# Halkyon Todo's site (compose.todo.yaml, ADR 0033, step 5): its API server alone. Its
# web server (8082) takes the register's calls between the instance's services
# (`/halkyon-base/…`, ADR 0016) on the instance's own network, never through here.
{$TODO_DOMAIN} {
encode zstd gzip
# Serverpod's streaming methods use WebSockets; reverse_proxy upgrades them as-is.
# The visitor's address comes last in X-Forwarded-For, as for Learn's server.
handle {
reverse_proxy todo-server:8080
}
}