build(deploy)!: the instance's compose file is compose.learn.yaml, beside compose.train.yaml and compose.todo.yaml

No file has Compose's default name any more, so .env's COMPOSE_FILE is always set,
naming compose.learn.yaml first (.env.example sets it). compose.yaml stays as a link to
compose.learn.yaml for the one deploy that switches learn.halkyon.eu's .env over: the
deploy's merge runs with the COMPOSE_FILE from before. A later commit removes it.

BREAKING CHANGE: an instance's deploy/.env must set COMPOSE_FILE, starting with
compose.learn.yaml, before the link goes.

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 22:06:18 +01:00
commit 3ac1880ac8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
22 changed files with 267 additions and 260 deletions

File diff suppressed because one or more lines are too long

View file

@ -5,7 +5,7 @@
# #
# docker build -f apps/learn/halkyon_learn_curator/Dockerfile . # docker build -f apps/learn/halkyon_learn_curator/Dockerfile .
# #
# A container of its own beside the server (deploy/compose.yaml, decided 2026-10-09): # A container of its own beside the server (deploy/compose.learn.yaml, decided 2026-10-09):
# the curator (AGPL) never enters the server's image or process, and a conversion # the curator (AGPL) never enters the server's image or process, and a conversion
# that fails can't touch the server. They share only the folder of files. # that fails can't touch the server. They share only the folder of files.

View file

@ -26,12 +26,12 @@ EDRDG's licence asks a server showing its dictionaries to update them at least
once a month: once a month:
- **An instance** keeps them current by itself: the `dictionaries` service in - **An instance** keeps them current by itself: the `dictionaries` service in
`deploy/compose.yaml` (the curator, in a container of its own) downloads `deploy/compose.learn.yaml` (the curator, in a container of its own) downloads
EDRDG's files of the day once its files are a week old, converts them, and EDRDG's files of the day once its files are a week old, converts them, and
replaces them in a volume the server reads (`curate update`, the curator's replaces them in a volume the server reads (`curate update`, the curator's
README); the server takes them up within the hour. To supply files yourself README); the server takes them up within the hour. To supply files yourself
instead, remove the service and mount `deploy/dictionaries/` into the server instead, remove the service and mount `deploy/dictionaries/` into the server
(`compose.yaml` says how), and update them monthly. (`compose.learn.yaml` says how), and update them monthly.
- **Development:** `dart run halkyon_learn_curator:curate update --into - **Development:** `dart run halkyon_learn_curator:curate update --into
../halkyon_learn_server/dictionaries` in the curator's folder (about a ../halkyon_learn_server/dictionaries` in the curator's folder (about a
minute); the server takes them up within the hour. minute); the server takes them up within the hour.

View file

@ -25,7 +25,7 @@ webServer:
publicPort: 443 publicPort: 443
publicScheme: https publicScheme: https
# The database: the `postgres` service of deploy/compose.yaml, reachable only inside # The database: the `postgres` service of deploy/compose.learn.yaml, reachable only inside
# the Compose network (it publishes no port), so no TLS between server and database. # the Compose network (it publishes no port), so no TLS between server and database.
# The password comes from the environment (SERVERPOD_DATABASE_PASSWORD, set in # The password comes from the environment (SERVERPOD_DATABASE_PASSWORD, set in
# deploy/.env); there is no production passwords.yaml. # deploy/.env); there is no production passwords.yaml.

View file

@ -3,7 +3,7 @@ import 'package:halkyon_learn_server/src/dictionary/dictionary_loader.dart';
import 'package:serverpod/serverpod.dart'; import 'package:serverpod/serverpod.dart';
/// Rereads the dictionary folder and loads the files that changed: the /// Rereads the dictionary folder and loads the files that changed: the
/// dictionaries service replaces them weekly (`deploy/compose.yaml`); the /// dictionaries service replaces them weekly (`deploy/compose.learn.yaml`); the
/// server schedules it hourly when it starts (`server.dart`): an hour /// server schedules it hourly when it starts (`server.dart`): an hour
/// with nothing changed costs it a hash of each file. /// with nothing changed costs it a hash of each file.
class DictionaryReloadFutureCall extends FutureCall { class DictionaryReloadFutureCall extends FutureCall {

View file

@ -38,12 +38,14 @@ LEARN_DOMAIN=http://localhost
# ADR 0021): in a file of their own, deploy/assistants.env, which goes to the server # ADR 0021): in a file of their own, deploy/assistants.env, which goes to the server
# alone (assistants.env.example says how). Without it, no assistant. # alone (assistants.env.example says how). Without it, no assistant.
# Sidecars switched on beside the instance (sidecars/README.md), each by its compose file # The compose files of what runs, the instance's own first (compose.learn.yaml: Caddy,
# after the instance's own: compose.jellyfin.yaml, a Jellyfin people watch and listen to # PostgreSQL, Learn's server with the register), then the sidecars switched on beside it
# (sidecars/README.md): compose.jellyfin.yaml, a Jellyfin people watch and listen to
# learn from; compose.languagetool.yaml, spelling and grammar checked; compose.train.yaml, # learn from; compose.languagetool.yaml, spelling and grammar checked; compose.train.yaml,
# Halkyon Train's server with its BRouter; compose.todo.yaml, Halkyon Todo's server. # Halkyon Train's server with its BRouter; compose.todo.yaml, Halkyon Todo's server.
# Unset: the instance alone. # Always set: no file has Compose's default name (compose.yaml until 2 Nov 2026), so
#COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml # without it `docker compose` finds none (e.g. compose.learn.yaml:compose.jellyfin.yaml).
COMPOSE_FILE=compose.learn.yaml
# With the Jellyfin sidecar: the domain it answers at, which the instance then suggests # With the Jellyfin sidecar: the domain it answers at, which the instance then suggests
# to its people (the app's Jellyfin sign-in fills it in). # to its people (the app's Jellyfin sign-in fills it in).

View file

@ -64,7 +64,7 @@
# Serverpod's streaming methods use WebSockets; reverse_proxy upgrades them as-is. # Serverpod's streaming methods use WebSockets; reverse_proxy upgrades them as-is.
# Caddy puts the visitor's address last in X-Forwarded-For (what a visitor sends # Caddy puts the visitor's address last in X-Forwarded-For (what a visitor sends
# there it replaces), which the server's per-network limits count by, as # there it replaces), which the server's per-network limits count by, as
# TRUSTED_PROXIES names Caddy (compose.yaml); the server never reads the Forwarded # TRUSTED_PROXIES names Caddy (compose.learn.yaml); the server never reads the Forwarded
# header, which Caddy passes on as sent. # header, which Caddy passes on as sent.
handle { handle {
reverse_proxy server:8080 reverse_proxy server:8080

View file

@ -1,6 +1,6 @@
# The instance's assistants (Halkyon Learn's ADR 0021, points 4 and 8, D37, D38, D63): # The instance's assistants (Halkyon Learn's ADR 0021, points 4 and 8, D37, D38, D63):
# copy to deploy/assistants.env, restrict it (chmod 600), and fill in. It is git-ignored, # copy to deploy/assistants.env, restrict it (chmod 600), and fill in. It is git-ignored,
# goes to Halkyon Learn's server alone (compose.yaml, `env_file`), never into an image, # goes to Halkyon Learn's server alone (compose.learn.yaml, `env_file`), never into an image,
# the database or its backups; keep it with deploy/.env's secrets (in Proton Pass). # the database or its backups; keep it with deploy/.env's secrets (in Proton Pass).
# Without the file, the instance offers no assistant, as before. A change takes a # Without the file, the instance offers no assistant, as before. A change takes a
# restart: docker compose up -d server. Anything the server can't use stops it as it # restart: docker compose up -d server. Anything the server can't use stops it as it

View file

@ -4,7 +4,7 @@
# network shows: people reach the instance's server alone. Switched on in .env, in place # network shows: people reach the instance's server alone. Switched on in .env, in place
# of compose.jellyfin.yaml, never beside it: # of compose.jellyfin.yaml, never beside it:
# #
# COMPOSE_FILE=compose.yaml:compose.jellyfin-home.yaml # COMPOSE_FILE=compose.learn.yaml:compose.jellyfin-home.yaml
# JELLYFIN_DOMAIN=jellyfin.halkyon.eu # JELLYFIN_DOMAIN=jellyfin.halkyon.eu
# #
# The tunnel is the host's (wg-quick@wg-jellyfin, with the rules letting Caddy alone # The tunnel is the host's (wg-quick@wg-jellyfin, with the rules letting Caddy alone

View file

@ -2,7 +2,7 @@
# people watch and listen to learn from (Halkyon Learn's Watch screen), suggested to them # people watch and listen to learn from (Halkyon Learn's Watch screen), suggested to them
# by the instance. Switched on in .env, with the domain it answers at: # by the instance. Switched on in .env, with the domain it answers at:
# #
# COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml # COMPOSE_FILE=compose.learn.yaml:compose.jellyfin.yaml
# JELLYFIN_DOMAIN=jellyfin.example.org # JELLYFIN_DOMAIN=jellyfin.example.org
# #
# Its files are the server's, in ./jellyfin (config, cache, media), owned by a user ID # Its files are the server's, in ./jellyfin (config, cache, media), owned by a user ID

View file

@ -2,7 +2,7 @@
# server beside the instance, which checks spelling and grammar for Halkyon Learn (Check # server beside the instance, which checks spelling and grammar for Halkyon Learn (Check
# in the assistant's field, and underlines while writing). Switched on in .env: # in the assistant's field, and underlines while writing). Switched on in .env:
# #
# COMPOSE_FILE=compose.yaml:compose.languagetool.yaml # COMPOSE_FILE=compose.learn.yaml:compose.languagetool.yaml
# #
# It has no site, unlike Jellyfin: LanguageTool has no sign-in of its own, so only the # It has no site, unlike Jellyfin: LanguageTool has no sign-in of its own, so only the
# server reaches it, which checks who asks and how often, and keeps nothing. Its one # server reaches it, which checks who asks and how often, and keeps nothing. Its one

228
deploy/compose.learn.yaml Normal file
View file

@ -0,0 +1,228 @@
# The Halkyon Learn instance: Caddy in front, the Serverpod server, PostgreSQL, and
# the dictionaries kept current beside them.
#
# cd deploy
# cp .env.example .env && chmod 600 .env # then fill in the secrets
# docker compose up -d --build
#
# This file was compose.yaml until 2 Nov 2026, so .env's COMPOSE_FILE names it (Compose
# finds no file by itself). compose.yaml is a link to it for the one deploy that
# switches learn.halkyon.eu's .env over, and goes with the next commit touching deploy/.
#
# Only Caddy publishes ports. Docker opens published ports directly in the kernel's
# packet filter, past ufw, and the Serverpod server listens on every interface, so the
# server and Postgres publish nothing: they're reachable only inside this project's
# networks, where Caddy finds the server by its service name.
#
# Two networks (decided 2026-10-14): `edge`, Caddy's, which the server shares with it,
# and the internal default one, where the server reaches Postgres. Caddy never reaches
# Postgres, nor does a sidecar behind Caddy.
#
# Sidecars (decided 2026-10-14) are each a compose file of their own beside this one,
# switched on in .env (COMPOSE_FILE=compose.learn.yaml:compose.jellyfin.yaml): each adds its
# service and mounts its site into Caddy's sidecars/ folder (sidecars/README.md).
# Never renamed, though the checkout's folder is ~deploy/halkyon since 2 Nov 2026: Docker
# names the volumes (the database, people's files, the releases' repositories) and the
# edge network after the project, and under a new name would start them empty.
name: halkyon-learn
# Logs go to the host's journal, whose size and age limits apply (journald.conf).
x-logging: &logging
driver: journald
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
environment:
# `learn.halkyon.eu` on its server (automatic Let's Encrypt certificate);
# `http://localhost` for the local rehearsal (plain HTTP, no certificate).
LEARN_DOMAIN: ${LEARN_DOMAIN:?set LEARN_DOMAIN in deploy/.env}
# The account domain whose profile pages this instance serves, where it isn't
# LEARN_DOMAIN; empty for none.
ACCOUNT_SITE: ${ACCOUNT_DOMAIN:-}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data # certificates: must survive restarts (rate limits)
- caddy_config:/config
networks:
edge:
# Fixed, so containers behind Caddy can trust it as their proxy (the
# visitor's own address comes in X-Forwarded-For): the server's
# TRUSTED_PROXIES, Jellyfin's known proxies. Both, as Caddy may reach
# the server by either.
ipv4_address: 172.30.80.2
ipv6_address: fd4a:6c6b:7e80::2
depends_on:
- server
logging: *logging
server:
build:
context: ..
dockerfile: apps/learn/halkyon_learn_server/Dockerfile
image: halkyon-learn-server
restart: unless-stopped
# Applies pending migrations at every start, then serves. One instance only, so
# no two servers ever migrate at once. (On learn.halkyon.eu, deploy/redeploy.sh has
# applied them in a run of their own before, so nothing is left to do here.)
command: ["--apply-migrations"]
# The instance's assistants with their keys (Halkyon Learn's ADR 0021, D37), passed
# whole, as their variables' names are the IDs the operator chooses; optional, so an
# instance without assistants starts as before (assistants.env.example).
env_file:
- path: assistants.env
required: false
environment:
SERVERPOD_DATABASE_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env}
SERVERPOD_PASSWORD_serverSideSessionKeyHashPepper: ${LEARN_SESSION_KEY_HASH_PEPPER:?set LEARN_SESSION_KEY_HASH_PEPPER in deploy/.env}
# Email sign-in (ADR 0010), only with the pepper and an SMTP service; empty: off.
SERVERPOD_PASSWORD_emailSignInPepper: ${EMAIL_SIGN_IN_PEPPER:-}
SERVERPOD_PASSWORD_emailSmtpHost: ${EMAIL_SMTP_HOST:-}
SERVERPOD_PASSWORD_emailSmtpPort: ${EMAIL_SMTP_PORT:-}
SERVERPOD_PASSWORD_emailSmtpUser: ${EMAIL_SMTP_USER:-}
SERVERPOD_PASSWORD_emailSmtpPassword: ${EMAIL_SMTP_PASSWORD:-}
SERVERPOD_PASSWORD_emailFrom: ${EMAIL_FROM:-}
# Password sign-in with TOTP (ADR 0017), only with these keys and email
# sign-in; empty: off. Each at least 32 characters, or the server stops.
SERVERPOD_PASSWORD_totpKey: ${TOTP_KEY:-}
SERVERPOD_PASSWORD_recoveryCodeKey: ${RECOVERY_CODE_KEY:-}
SERVERPOD_PASSWORD_signInDecoyKey: ${SIGN_IN_DECOY_KEY:-}
# SimpleLogin (ADR 0020), only with the instance's SimpleLogin app; empty: off.
# Its callback; empty: this server's own page, on its web server's public address
# (https://learn.halkyon.eu/simplelogin-callback).
SERVERPOD_PASSWORD_simpleLoginClientId: ${SIMPLELOGIN_CLIENT_ID:-}
SERVERPOD_PASSWORD_simpleLoginClientSecret: ${SIMPLELOGIN_CLIENT_SECRET:-}
SIMPLELOGIN_CALLBACK_URL: ${SIMPLELOGIN_CALLBACK_URL:-}
# The ways in offered, of those set up (`simplelogin password`); empty: all.
SIGN_IN_WAYS: ${SIGN_IN_WAYS:-}
# Who may make an account: open, approval or closed; empty: open.
SIGN_UP: ${SIGN_UP:-}
# Caddy, whose X-Forwarded-For names the visitor the per-network limits count
# (codes by mail, password and SimpleLogin sign-ins): without it, every
# visitor would share each limit with everyone. Its address on `edge`, below.
TRUSTED_PROXIES: 172.30.80.2 fd4a:6c6b:7e80::2
# Where the source of what this instance runs is; empty: the project's.
SOURCE_URL: ${SOURCE_URL:-}
# The domain people's handles are on; empty: LEARN_DOMAIN's host.
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 (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
# instance's own domain, if one answers there; `none`: none.
JELLYFIN_URL: ${JELLYFIN_URL:-}
# The LanguageTool server checking spelling and grammar, reached by the server
# alone; empty: none (compose.languagetool.yaml sets its own).
LANGUAGETOOL_URL: ${LANGUAGETOOL_URL:-}
# Where people's data and the backups are, which the app shows: `EU`, a
# country (`DE`) or a subdivision (`US-CA`); empty: it says nothing.
DATA_LOCATION: ${DATA_LOCATION:-}
BACKUP_LOCATION: ${BACKUP_LOCATION:-}
# How much room people's files may take, in MiB; empty: the defaults.
FILE_LIMIT_IDENTIFIED_MB: ${FILE_LIMIT_IDENTIFIED_MB:-}
FILE_LIMIT_INSTANCE_MB: ${FILE_LIMIT_INSTANCE_MB:-}
# The git repositories of published studysets (ADR 0005, rule 3).
REPOSITORIES_DIR: /app/repositories
# The dictionary files it loads at start and rereads hourly, those that changed
# (see the server's README): those the dictionaries service keeps current, read
# only. To supply files yourself instead, remove that service and mount
# ./dictionaries here (`- ./dictionaries:/app/dictionaries:ro`).
volumes:
- dictionaries:/app/dictionaries:ro
# Every release of every published studyset: backed up beside the database
# (backup.sh), never deleted with `down -v`.
- repositories:/app/repositories
networks:
- default
- edge
depends_on:
postgres:
condition: service_healthy
logging: *logging
# Keeps the dictionary files current from EDRDG's files of the day, once they are a
# week old (EDRDG's licence asks it of servers showing its dictionaries at least
# monthly; the curator's README), and the languages of DICTIONARY_LANGUAGES: Chinese
# from MDBG's CC-CEDICT as often, the rest from Wiktionary's dump whenever kaikki.org
# makes a new one (about monthly, some 3 GB, converted as it downloads): in a
# container of its own, the curator never in the server's image or process. It only
# downloads and writes into the volume; the files are never backed up, as they can be
# had again.
dictionaries:
build:
context: ..
dockerfile: apps/learn/halkyon_learn_curator/Dockerfile
image: halkyon-learn-dictionaries
restart: unless-stopped
# Docker's stop signal reaches the curator through a small init.
init: true
# JMdict's conversion takes some 400 MB for a minute a week, Wiktionary's some
# 300 MB for ten minutes a month, KRDict's some 600 MB for half a minute.
mem_limit: 1g
# Unset: German, Greek, English, Korean and Chinese; empty: none.
command: ["--every", "7", "--languages", "${DICTIONARY_LANGUAGES-de,el,en,ko,zh}"]
volumes:
- dictionaries:/dictionaries
logging: *logging
postgres:
image: postgres:16
restart: unless-stopped
# The server's session is UTC anyway (the driver sets it); this makes the
# database's own default UTC too, for psql, COPY loads and future sidecars.
command: ["postgres", "-c", "timezone=UTC", "-c", "log_timezone=UTC"]
environment:
POSTGRES_USER: halkyon_learn
POSTGRES_DB: halkyon_learn
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U halkyon_learn -d halkyon_learn"]
interval: 5s
timeout: 5s
retries: 12
logging: *logging
networks:
edge:
name: halkyon-learn-edge
# IPv6 too (the second reading's M5), so Docker forwards a visitor's IPv6
# connection to Caddy as it is, rather than through its userland proxy,
# which would show every IPv6 visitor as the network's gateway and so make
# them share every per-network limit. Needs Docker 27 or later with ip6tables
# (its default; not switched off in /etc/docker/daemon.json), and the network
# made anew: `docker compose down` (never -v) and `up -d` once, as Compose
# refuses a network whose settings changed. A unique local /64 (RFC 4193), the
# host's own; the CI runner's unit denies fd00::/8.
enable_ipv6: true
ipam:
config:
- subnet: 172.30.80.0/24
gateway: 172.30.80.1
# Containers without an address of their own (the server) get one from the
# upper half, so the fixed ones below it (Caddy .2, sidecars from .10) are
# never taken: the server started first once and took Caddy's (2026-10-14).
# (With a range alone, Docker would move the gateway to its first address.)
ip_range: 172.30.80.128/25
- subnet: fd4a:6c6b:7e80::/64
gateway: fd4a:6c6b:7e80::1
# As above: Caddy's ::2 and the sidecars' below it stay free.
ip_range: fd4a:6c6b:7e80::8000:0/97
volumes:
caddy_data:
caddy_config:
dictionaries:
postgres_data:
repositories:

View file

@ -2,7 +2,7 @@
# which it needs: Protomaps' basemap cut for Europe, served by pmtiles as ordinary # which it needs: Protomaps' basemap cut for Europe, served by pmtiles as ordinary
# tiles, which Train's style (served by Train's server) draws. Switched on in .env: # tiles, which Train's style (served by Train's server) draws. Switched on in .env:
# #
# COMPOSE_FILE=compose.yaml:compose.train.yaml:compose.maps.yaml # COMPOSE_FILE=compose.learn.yaml:compose.train.yaml:compose.maps.yaml
# MAP_KEY_SECRET=<openssl rand -hex 32> # MAP_KEY_SECRET=<openssl rand -hex 32>
# #
# Only people signed in to Train get tiles: Caddy asks Train's server about the key # Only people signed in to Train get tiles: Caddy asks Train's server about the key

View file

@ -2,7 +2,7 @@
# needs: Photon on GraphHopper's weekly database of Europe, asked by Train's server # needs: Photon on GraphHopper's weekly database of Europe, asked by Train's server
# alone. Switched on in .env: # alone. Switched on in .env:
# #
# COMPOSE_FILE=compose.yaml:compose.train.yaml:compose.search.yaml # COMPOSE_FILE=compose.learn.yaml:compose.train.yaml:compose.search.yaml
# #
# Photon has no site and no sign-in: only Train's server reaches it, on a network of # Photon has no site and no sign-in: only Train's server reaches it, on a network of
# their own without a way out, for people signed in, and nothing it is asked is kept # their own without a way out, for people signed in, and nothing it is asked is kept

View file

@ -1,7 +1,7 @@
# Halkyon Todo's server beside the instance (ADR 0033, step 5). Switched on in .env, # Halkyon Todo's server beside the instance (ADR 0033, step 5). Switched on in .env,
# with Todo's domain and secrets: # with Todo's domain and secrets:
# #
# COMPOSE_FILE=compose.yaml:compose.todo.yaml # COMPOSE_FILE=compose.learn.yaml:compose.todo.yaml
# TODO_DOMAIN=todo.halkyon.eu # TODO_DOMAIN=todo.halkyon.eu
# #
# Todo is a member of the instance's register, which Learn's server holds (ADR 0016), # Todo is a member of the instance's register, which Learn's server holds (ADR 0016),
@ -28,7 +28,7 @@ services:
# and the secret every call between them carries. # and the secret every call between them carries.
ACCOUNT_REGISTER_URL: http://server:8082/ ACCOUNT_REGISTER_URL: http://server:8082/
SERVERPOD_PASSWORD_instanceSecret: ${INSTANCE_SECRET:?set INSTANCE_SECRET in deploy/.env} SERVERPOD_PASSWORD_instanceSecret: ${INSTANCE_SECRET:?set INSTANCE_SECRET in deploy/.env}
# Caddy, as for Learn's server (compose.yaml). # Caddy, as for Learn's server (compose.learn.yaml).
TRUSTED_PROXIES: 172.30.80.2 fd4a:6c6b:7e80::2 TRUSTED_PROXIES: 172.30.80.2 fd4a:6c6b:7e80::2
SOURCE_URL: ${SOURCE_URL:-} SOURCE_URL: ${SOURCE_URL:-}
DATA_LOCATION: ${DATA_LOCATION:-} DATA_LOCATION: ${DATA_LOCATION:-}

View file

@ -1,7 +1,7 @@
# Halkyon Train's server beside the instance (ADR 0030, D46), with the BRouter that # Halkyon Train's server beside the instance (ADR 0030, D46), with the BRouter that
# plans its routes (D11, D44). Switched on in .env, with Train's domain and secrets: # plans its routes (D11, D44). Switched on in .env, with Train's domain and secrets:
# #
# COMPOSE_FILE=compose.yaml:compose.train.yaml # COMPOSE_FILE=compose.learn.yaml:compose.train.yaml
# TRAIN_DOMAIN=train.halkyon.eu # TRAIN_DOMAIN=train.halkyon.eu
# #
# Train is a member of the instance's register, which Learn's server holds (ADR 0016): # Train is a member of the instance's register, which Learn's server holds (ADR 0016):
@ -30,7 +30,7 @@ services:
# and the secret every call between them carries. # and the secret every call between them carries.
ACCOUNT_REGISTER_URL: http://server:8082/ ACCOUNT_REGISTER_URL: http://server:8082/
SERVERPOD_PASSWORD_instanceSecret: ${INSTANCE_SECRET:?set INSTANCE_SECRET in deploy/.env} SERVERPOD_PASSWORD_instanceSecret: ${INSTANCE_SECRET:?set INSTANCE_SECRET in deploy/.env}
# Caddy, as for Learn's server (compose.yaml). # Caddy, as for Learn's server (compose.learn.yaml).
TRUSTED_PROXIES: 172.30.80.2 fd4a:6c6b:7e80::2 TRUSTED_PROXIES: 172.30.80.2 fd4a:6c6b:7e80::2
SOURCE_URL: ${SOURCE_URL:-} SOURCE_URL: ${SOURCE_URL:-}
DATA_LOCATION: ${DATA_LOCATION:-} DATA_LOCATION: ${DATA_LOCATION:-}

View file

@ -1,224 +0,0 @@
# The Halkyon Learn instance: Caddy in front, the Serverpod server, PostgreSQL, and
# the dictionaries kept current beside them.
#
# cd deploy
# cp .env.example .env && chmod 600 .env # then fill in the secrets
# docker compose up -d --build
#
# Only Caddy publishes ports. Docker opens published ports directly in the kernel's
# packet filter, past ufw, and the Serverpod server listens on every interface, so the
# server and Postgres publish nothing: they're reachable only inside this project's
# networks, where Caddy finds the server by its service name.
#
# Two networks (decided 2026-10-14): `edge`, Caddy's, which the server shares with it,
# and the internal default one, where the server reaches Postgres. Caddy never reaches
# Postgres, nor does a sidecar behind Caddy.
#
# Sidecars (decided 2026-10-14) are each a compose file of their own beside this one,
# switched on in .env (COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml): each adds its
# service and mounts its site into Caddy's sidecars/ folder (sidecars/README.md).
# Never renamed, though the checkout's folder is ~deploy/halkyon since 2 Nov 2026: Docker
# names the volumes (the database, people's files, the releases' repositories) and the
# edge network after the project, and under a new name would start them empty.
name: halkyon-learn
# Logs go to the host's journal, whose size and age limits apply (journald.conf).
x-logging: &logging
driver: journald
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
environment:
# `learn.halkyon.eu` on its server (automatic Let's Encrypt certificate);
# `http://localhost` for the local rehearsal (plain HTTP, no certificate).
LEARN_DOMAIN: ${LEARN_DOMAIN:?set LEARN_DOMAIN in deploy/.env}
# The account domain whose profile pages this instance serves, where it isn't
# LEARN_DOMAIN; empty for none.
ACCOUNT_SITE: ${ACCOUNT_DOMAIN:-}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data # certificates: must survive restarts (rate limits)
- caddy_config:/config
networks:
edge:
# Fixed, so containers behind Caddy can trust it as their proxy (the
# visitor's own address comes in X-Forwarded-For): the server's
# TRUSTED_PROXIES, Jellyfin's known proxies. Both, as Caddy may reach
# the server by either.
ipv4_address: 172.30.80.2
ipv6_address: fd4a:6c6b:7e80::2
depends_on:
- server
logging: *logging
server:
build:
context: ..
dockerfile: apps/learn/halkyon_learn_server/Dockerfile
image: halkyon-learn-server
restart: unless-stopped
# Applies pending migrations at every start, then serves. One instance only, so
# no two servers ever migrate at once. (On learn.halkyon.eu, deploy/redeploy.sh has
# applied them in a run of their own before, so nothing is left to do here.)
command: ["--apply-migrations"]
# The instance's assistants with their keys (Halkyon Learn's ADR 0021, D37), passed
# whole, as their variables' names are the IDs the operator chooses; optional, so an
# instance without assistants starts as before (assistants.env.example).
env_file:
- path: assistants.env
required: false
environment:
SERVERPOD_DATABASE_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env}
SERVERPOD_PASSWORD_serverSideSessionKeyHashPepper: ${LEARN_SESSION_KEY_HASH_PEPPER:?set LEARN_SESSION_KEY_HASH_PEPPER in deploy/.env}
# Email sign-in (ADR 0010), only with the pepper and an SMTP service; empty: off.
SERVERPOD_PASSWORD_emailSignInPepper: ${EMAIL_SIGN_IN_PEPPER:-}
SERVERPOD_PASSWORD_emailSmtpHost: ${EMAIL_SMTP_HOST:-}
SERVERPOD_PASSWORD_emailSmtpPort: ${EMAIL_SMTP_PORT:-}
SERVERPOD_PASSWORD_emailSmtpUser: ${EMAIL_SMTP_USER:-}
SERVERPOD_PASSWORD_emailSmtpPassword: ${EMAIL_SMTP_PASSWORD:-}
SERVERPOD_PASSWORD_emailFrom: ${EMAIL_FROM:-}
# Password sign-in with TOTP (ADR 0017), only with these keys and email
# sign-in; empty: off. Each at least 32 characters, or the server stops.
SERVERPOD_PASSWORD_totpKey: ${TOTP_KEY:-}
SERVERPOD_PASSWORD_recoveryCodeKey: ${RECOVERY_CODE_KEY:-}
SERVERPOD_PASSWORD_signInDecoyKey: ${SIGN_IN_DECOY_KEY:-}
# SimpleLogin (ADR 0020), only with the instance's SimpleLogin app; empty: off.
# Its callback; empty: this server's own page, on its web server's public address
# (https://learn.halkyon.eu/simplelogin-callback).
SERVERPOD_PASSWORD_simpleLoginClientId: ${SIMPLELOGIN_CLIENT_ID:-}
SERVERPOD_PASSWORD_simpleLoginClientSecret: ${SIMPLELOGIN_CLIENT_SECRET:-}
SIMPLELOGIN_CALLBACK_URL: ${SIMPLELOGIN_CALLBACK_URL:-}
# The ways in offered, of those set up (`simplelogin password`); empty: all.
SIGN_IN_WAYS: ${SIGN_IN_WAYS:-}
# Who may make an account: open, approval or closed; empty: open.
SIGN_UP: ${SIGN_UP:-}
# Caddy, whose X-Forwarded-For names the visitor the per-network limits count
# (codes by mail, password and SimpleLogin sign-ins): without it, every
# visitor would share each limit with everyone. Its address on `edge`, below.
TRUSTED_PROXIES: 172.30.80.2 fd4a:6c6b:7e80::2
# Where the source of what this instance runs is; empty: the project's.
SOURCE_URL: ${SOURCE_URL:-}
# The domain people's handles are on; empty: LEARN_DOMAIN's host.
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 (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
# instance's own domain, if one answers there; `none`: none.
JELLYFIN_URL: ${JELLYFIN_URL:-}
# The LanguageTool server checking spelling and grammar, reached by the server
# alone; empty: none (compose.languagetool.yaml sets its own).
LANGUAGETOOL_URL: ${LANGUAGETOOL_URL:-}
# Where people's data and the backups are, which the app shows: `EU`, a
# country (`DE`) or a subdivision (`US-CA`); empty: it says nothing.
DATA_LOCATION: ${DATA_LOCATION:-}
BACKUP_LOCATION: ${BACKUP_LOCATION:-}
# How much room people's files may take, in MiB; empty: the defaults.
FILE_LIMIT_IDENTIFIED_MB: ${FILE_LIMIT_IDENTIFIED_MB:-}
FILE_LIMIT_INSTANCE_MB: ${FILE_LIMIT_INSTANCE_MB:-}
# The git repositories of published studysets (ADR 0005, rule 3).
REPOSITORIES_DIR: /app/repositories
# The dictionary files it loads at start and rereads hourly, those that changed
# (see the server's README): those the dictionaries service keeps current, read
# only. To supply files yourself instead, remove that service and mount
# ./dictionaries here (`- ./dictionaries:/app/dictionaries:ro`).
volumes:
- dictionaries:/app/dictionaries:ro
# Every release of every published studyset: backed up beside the database
# (backup.sh), never deleted with `down -v`.
- repositories:/app/repositories
networks:
- default
- edge
depends_on:
postgres:
condition: service_healthy
logging: *logging
# Keeps the dictionary files current from EDRDG's files of the day, once they are a
# week old (EDRDG's licence asks it of servers showing its dictionaries at least
# monthly; the curator's README), and the languages of DICTIONARY_LANGUAGES: Chinese
# from MDBG's CC-CEDICT as often, the rest from Wiktionary's dump whenever kaikki.org
# makes a new one (about monthly, some 3 GB, converted as it downloads): in a
# container of its own, the curator never in the server's image or process. It only
# downloads and writes into the volume; the files are never backed up, as they can be
# had again.
dictionaries:
build:
context: ..
dockerfile: apps/learn/halkyon_learn_curator/Dockerfile
image: halkyon-learn-dictionaries
restart: unless-stopped
# Docker's stop signal reaches the curator through a small init.
init: true
# JMdict's conversion takes some 400 MB for a minute a week, Wiktionary's some
# 300 MB for ten minutes a month, KRDict's some 600 MB for half a minute.
mem_limit: 1g
# Unset: German, Greek, English, Korean and Chinese; empty: none.
command: ["--every", "7", "--languages", "${DICTIONARY_LANGUAGES-de,el,en,ko,zh}"]
volumes:
- dictionaries:/dictionaries
logging: *logging
postgres:
image: postgres:16
restart: unless-stopped
# The server's session is UTC anyway (the driver sets it); this makes the
# database's own default UTC too, for psql, COPY loads and future sidecars.
command: ["postgres", "-c", "timezone=UTC", "-c", "log_timezone=UTC"]
environment:
POSTGRES_USER: halkyon_learn
POSTGRES_DB: halkyon_learn
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U halkyon_learn -d halkyon_learn"]
interval: 5s
timeout: 5s
retries: 12
logging: *logging
networks:
edge:
name: halkyon-learn-edge
# IPv6 too (the second reading's M5), so Docker forwards a visitor's IPv6
# connection to Caddy as it is, rather than through its userland proxy,
# which would show every IPv6 visitor as the network's gateway and so make
# them share every per-network limit. Needs Docker 27 or later with ip6tables
# (its default; not switched off in /etc/docker/daemon.json), and the network
# made anew: `docker compose down` (never -v) and `up -d` once, as Compose
# refuses a network whose settings changed. A unique local /64 (RFC 4193), the
# host's own; the CI runner's unit denies fd00::/8.
enable_ipv6: true
ipam:
config:
- subnet: 172.30.80.0/24
gateway: 172.30.80.1
# Containers without an address of their own (the server) get one from the
# upper half, so the fixed ones below it (Caddy .2, sidecars from .10) are
# never taken: the server started first once and took Caddy's (2026-10-14).
# (With a range alone, Docker would move the gateway to its first address.)
ip_range: 172.30.80.128/25
- subnet: fd4a:6c6b:7e80::/64
gateway: fd4a:6c6b:7e80::1
# As above: Caddy's ::2 and the sidecars' below it stay free.
ip_range: fd4a:6c6b:7e80::8000:0/97
volumes:
caddy_data:
caddy_config:
dictionaries:
postgres_data:
repositories:

1
deploy/compose.yaml Symbolic link
View file

@ -0,0 +1 @@
compose.learn.yaml

View file

@ -1,7 +1,7 @@
# Dictionary files # Dictionary files
For an instance that supplies its dictionary files itself, rather than the For an instance that supplies its dictionary files itself, rather than the
`dictionaries` service keeping them current (`compose.yaml`): put the files `dictionaries` service keeping them current (`compose.learn.yaml`): put the files
(`*.jsonl`, `*.jsonl.gz`, made by `halkyon_learn_curator`) here and mount this (`*.jsonl`, `*.jsonl.gz`, made by `halkyon_learn_curator`) here and mount this
folder into the server as the comment there says. Mind EDRDG's licence: a server folder into the server as the comment there says. Mind EDRDG's licence: a server
showing its dictionaries must update them at least once a month. Not kept in git. showing its dictionaries must update them at least once a month. Not kept in git.

View file

@ -4,7 +4,7 @@ Services an instance may run beside itself (decided 2026-10-14), each switched o
naming its compose file after the instance's own in `deploy/.env`: naming its compose file after the instance's own in `deploy/.env`:
```sh ```sh
COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml COMPOSE_FILE=compose.learn.yaml:compose.jellyfin.yaml
``` ```
A sidecar's compose file adds its service and, if it is reached from outside, mounts A sidecar's compose file adds its service and, if it is reached from outside, mounts
@ -25,7 +25,7 @@ site changes.
| Photon, which finds places for Train's route planner, beside Train | `compose.search.yaml` | none | | Photon, which finds places for Train's route planner, beside Train | `compose.search.yaml` | none |
| Halkyon Todo's server | `compose.todo.yaml` | `todo.caddy` | | Halkyon Todo's server | `compose.todo.yaml` | `todo.caddy` |
Both together: `COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml:compose.languagetool.yaml`. Both together: `COMPOSE_FILE=compose.learn.yaml:compose.jellyfin.yaml:compose.languagetool.yaml`.
## Jellyfin ## Jellyfin
@ -63,7 +63,7 @@ Steps, in order; ✱ marks a step as root.
`deploy/.env`, `deploy/.env`,
```sh ```sh
COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml COMPOSE_FILE=compose.learn.yaml:compose.jellyfin.yaml
JELLYFIN_DOMAIN=jellyfin.example.org JELLYFIN_DOMAIN=jellyfin.example.org
``` ```
@ -221,7 +221,7 @@ home machine.
`deploy/.env`, `JELLYFIN_URL` left empty, `deploy/.env`, `JELLYFIN_URL` left empty,
```sh ```sh
COMPOSE_FILE=compose.yaml:compose.jellyfin-home.yaml COMPOSE_FILE=compose.learn.yaml:compose.jellyfin-home.yaml
JELLYFIN_DOMAIN=jellyfin.halkyon.eu JELLYFIN_DOMAIN=jellyfin.halkyon.eu
``` ```
@ -283,7 +283,7 @@ Steps, in order; ✱ marks a step as root.
``` ```
2. **Switch it on** (the operator, on the instance's server, as `deploy`): in 2. **Switch it on** (the operator, on the instance's server, as `deploy`): in
`deploy/.env`, `COMPOSE_FILE=compose.yaml:compose.languagetool.yaml`. `deploy/.env`, `COMPOSE_FILE=compose.learn.yaml:compose.languagetool.yaml`.
3. **Start it** (the operator, on the instance's server, as `deploy`): 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 `cd ~/halkyon/deploy && docker compose up -d`. The server is recreated with it
@ -361,7 +361,7 @@ Steps, in order; ✱ marks a step as root.
added to `COMPOSE_FILE`, and Train's settings (`.env.example`, *With Halkyon Train*): added to `COMPOSE_FILE`, and Train's settings (`.env.example`, *With Halkyon Train*):
```sh ```sh
COMPOSE_FILE=compose.yaml:compose.languagetool.yaml:compose.train.yaml COMPOSE_FILE=compose.learn.yaml:compose.languagetool.yaml:compose.train.yaml
TRAIN_DOMAIN=train.halkyon.eu TRAIN_DOMAIN=train.halkyon.eu
TRAIN_POSTGRES_PASSWORD=<as in step 2> TRAIN_POSTGRES_PASSWORD=<as in step 2>
TRAIN_SESSION_KEY_HASH_PEPPER=<openssl rand -hex 32> TRAIN_SESSION_KEY_HASH_PEPPER=<openssl rand -hex 32>
@ -414,7 +414,7 @@ Steps, in order; ✱ marks a step as root.
added to `COMPOSE_FILE` after Train's, and the secret the keys are made with: added to `COMPOSE_FILE` after Train's, and the secret the keys are made with:
```sh ```sh
COMPOSE_FILE=compose.yaml:compose.languagetool.yaml:compose.train.yaml:compose.maps.yaml COMPOSE_FILE=compose.learn.yaml:compose.languagetool.yaml:compose.train.yaml:compose.maps.yaml
MAP_KEY_SECRET=<openssl rand -hex 32> MAP_KEY_SECRET=<openssl rand -hex 32>
``` ```
@ -460,7 +460,7 @@ Steps, in order; ✱ marks a step as root.
added to `COMPOSE_FILE` after Train's: added to `COMPOSE_FILE` after Train's:
```sh ```sh
COMPOSE_FILE=compose.yaml:compose.languagetool.yaml:compose.train.yaml:compose.maps.yaml:compose.search.yaml 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 4. **Start it** (the operator, as `deploy`): `docker compose up -d --build`. Train's
@ -506,7 +506,7 @@ Steps, in order:
added to `COMPOSE_FILE`, and Todo's settings (`.env.example`, *With Halkyon Todo*): added to `COMPOSE_FILE`, and Todo's settings (`.env.example`, *With Halkyon Todo*):
```sh ```sh
COMPOSE_FILE=compose.yaml:compose.languagetool.yaml:compose.train.yaml:compose.todo.yaml COMPOSE_FILE=compose.learn.yaml:compose.languagetool.yaml:compose.train.yaml:compose.todo.yaml
TODO_DOMAIN=todo.halkyon.eu TODO_DOMAIN=todo.halkyon.eu
TODO_POSTGRES_PASSWORD=<as in step 1> TODO_POSTGRES_PASSWORD=<as in step 1>
TODO_SESSION_KEY_HASH_PEPPER=<openssl rand -hex 32> TODO_SESSION_KEY_HASH_PEPPER=<openssl rand -hex 32>

View file

@ -557,7 +557,7 @@ the assistants' (`ASSISTANTS`, `ASSISTANT_ID_KEY`, `ASSISTANT_<ID>_…`,
`ASSISTANT_CURRENCY`, `ASSISTANT_OWN_KEYS`), from `deploy/assistants.env`, its `ASSISTANT_CURRENCY`, `ASSISTANT_OWN_KEYS`), from `deploy/assistants.env`, its
example commented, on the register alone (a member offers none); example commented, on the register alone (a member offers none);
`TRUSTED_PROXIES`, the addresses of the reverse proxies in front of the server (Caddy's `TRUSTED_PROXIES`, the addresses of the reverse proxies in front of the server (Caddy's
on its compose network, which Halkyon Learn's `deploy/compose.yaml` sets), separated by on its compose network, which Halkyon Learn's `deploy/compose.learn.yaml` sets), separated by
commas or spaces: without, every visitor behind the proxy shares each per-network limit; commas or spaces: without, every visitor behind the proxy shares each per-network limit;
anything but addresses stops the server (`addInstanceRoutes`). anything but addresses stops the server (`addInstanceRoutes`).

Some files were not shown because too many files have changed in this diff Show more