halkyon/deploy/compose.yaml

220 lines
10 KiB
YAML
Raw Normal View History

# 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).
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).
DOMAIN: ${DOMAIN:?set DOMAIN in deploy/.env}
feat(profile): profile pages on the account domain, and links verified by rel="me" A profile now has a page on the web, at the root of the account domain (https://halkyon.eu/@raisondetredev where the operator set ACCOUNT_DOMAIN, else on the instance's web server), so the address belongs to the identity rather than to Learn and stays when other services share it (ADR 0011). The page shows what the app's profile sheet shows, in English or German, without JavaScript or cookies, asks search engines not to list it, marks every link rel="me" so other sites can verify links to it, and sends a visitor who asked on another host to its own address. Caddy forwards /@… to the web server, and serves nothing else on the account domain; compose now passes ACCOUNT_DOMAIN on, which it never did. A link is verified as Mastodon verifies one: its page links back to the profile's with rel="me". profile.checkLinks fetches each link's page, at most ten times an hour, over HTTPS on port 443 and to public addresses only, connecting to the address it checked and verifying the certificate for the host, with three redirects, 1 MiB and ten seconds at most, and keeps what links back on the profile (verifiedLinks, a column of a table no device syncs). A changed link loses its check, a new handle all of them. The app shows the page's address to copy, checks a link saved by itself and on Check links, and marks verified links in the form and in others' profiles; a check answered after a newer save only adds its checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-15 20:29:30 +02:00
# The account domain whose profile pages this instance serves, where it isn't
# 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: ${SESSION_KEY_HASH_PEPPER:?set 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:-}
feat(base)!: sign-ups as the operator says, waiting accounts, operators (ADR 0021, step 1) SIGN_UP takes open (unset), approval or closed; anything else stops the server as it starts. Closed refuses a SimpleLogin sign-up before its handle is counted, an address sign-up only once the code has proved the address, so nothing tells whether an address has an account. Approval lets a new account wait, at most ten at once: no session is issued for a waiting or suspended account, only a pass (32 random bytes, kept as its SHA-256, 30 days, five per account) and four BIP-39 words the operator reads out to match it. The pass looks, forgets, and deletes an account that was never approved; nothing else. Operators hold the scope operator, given first by SQL (D32), then named in the app: InstanceOperatorEndpoint lists, approves and refuses waiting accounts, finds accounts, suspends them (ending their sessions), and names and takes back operators, never the last. account.operating() counts the waiting for the badge. Members ask the register (five new operations, a refusal kind signUp), and say closed while it is out of reach. Never-approved accounts go after 14 days in the daily pruning. The migration only adds the tables waiting_account and waiting_pass, so the server before it keeps working on the new schema. BREAKING CHANGE: a register and its members must run this version together: a member asks the register operations an older one doesn't know. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-25 15:04:21 +01:00
# 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:-}
feat(profile): profile pages on the account domain, and links verified by rel="me" A profile now has a page on the web, at the root of the account domain (https://halkyon.eu/@raisondetredev where the operator set ACCOUNT_DOMAIN, else on the instance's web server), so the address belongs to the identity rather than to Learn and stays when other services share it (ADR 0011). The page shows what the app's profile sheet shows, in English or German, without JavaScript or cookies, asks search engines not to list it, marks every link rel="me" so other sites can verify links to it, and sends a visitor who asked on another host to its own address. Caddy forwards /@… to the web server, and serves nothing else on the account domain; compose now passes ACCOUNT_DOMAIN on, which it never did. A link is verified as Mastodon verifies one: its page links back to the profile's with rel="me". profile.checkLinks fetches each link's page, at most ten times an hour, over HTTPS on port 443 and to public addresses only, connecting to the address it checked and verifying the certificate for the host, with three redirects, 1 MiB and ten seconds at most, and keeps what links back on the profile (verifiedLinks, a column of a table no device syncs). A changed link loses its check, a new handle all of them. The app shows the page's address to copy, checks a link saved by itself and on Check links, and marks verified links in the form and in others' profiles; a check answered after a newer save only adds its checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-15 20:29:30 +02:00
# The domain people's handles are on; empty: 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; 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
feat: a dictionary's corrections reach the cards made from it, by offer Every dictionary entry now has a version: 16 hex digits of the SHA-256 of what cards take of it (headword, reading, entry), made by the loader and carried on every hit. A card keeps the version it took (card schema 8). DictionaryEndpoint.changed returns, of the entries named with the versions cards keep (1,000 at most a call; the app asks 500 at a time), those whose version is another now. The card browser says how many cards are behind their dictionary and takes the corrections at a tap (decided 2026-10-09: by offer, never by themselves). Taking one writes the entry as it is now over the revision in which the card last took it, beside whatever came after, so the revisions merge per side as two devices' edits do (ADR 0003): a side only the dictionary changed takes the correction, a side the person changed too is a conflict settled in the composer, whose words now name that case. Cards made before versions are never offered one. The server rereads its dictionary folder hourly now, not daily: the dictionaries service fills its volume after an instance's first start, and an hour with nothing changed costs a hash of each file. Migration 20261009145434785-dictionary-entry-version: adds dictionary_entry.version, text NOT NULL DEFAULT '' (rows loaded before have none and are never reported as corrected), and by hand empties dictionary_source."fileHash", so every source loads once more and its entries get their versions; the schema stays as definition.sql says. The old server goes on working until the switch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 17:32:00 +02:00
# 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
feat(dictionary): German, Greek, English, Chinese and Korean words A studyset in German, Greek, English, Chinese or Korean finds its dictionary as one in Japanese does (ADR 0008), and its subtitles are cut into words by its language (`WordCutting`): Chinese by its longest words as they stand, the longer words over each cut offered as for Japanese; Korean at its spaces, a word the dictionary lacks as it stands taken without the particle it ends with (집에서 is 집, 아이 stays a child); every other language at its spaces, a word written exactly so first (the noun Essen before the verb essen). A studyset finds the dictionary of its language tag, else of its language alone (zh for zh-Hans). A Wiktionary word shows its genders, principal forms, IPA, romanisation and Hanja; a German or Greek noun of one gender goes onto its card with its article (das Haus, το σπίτι); its tag is its word class, named in full (Noun, Adjective, Preposition), as Wiktionary's adj and adv would read as JMdict's codes. Articles, pronouns, prepositions and conjunctions are left out of a video's words. Chinese words show their pinyin over each character on the Watch screen, carry their HSK level as a tag (HSK-1 … HSK-7-9), and the dictionary adds a level whole; the Watch screen filters by HSK levels for Chinese, JLPT's for Japanese, and by none for a language without. A reading is typed in a session only when it is kana, so pinyin isn't. Words are looked up on the web on Jisho in Japanese, on Wiktionary at the language's section otherwise, and a search's hint speaks of kana and romaji only in Japanese. `DICTIONARY_LANGUAGES` in deploy/.env names the languages an instance keeps (unset: de,el,en,ko,zh; empty: none). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-14 07:45:15 +02:00
# 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
feat(dictionary): German, Greek, English, Chinese and Korean words A studyset in German, Greek, English, Chinese or Korean finds its dictionary as one in Japanese does (ADR 0008), and its subtitles are cut into words by its language (`WordCutting`): Chinese by its longest words as they stand, the longer words over each cut offered as for Japanese; Korean at its spaces, a word the dictionary lacks as it stands taken without the particle it ends with (집에서 is 집, 아이 stays a child); every other language at its spaces, a word written exactly so first (the noun Essen before the verb essen). A studyset finds the dictionary of its language tag, else of its language alone (zh for zh-Hans). A Wiktionary word shows its genders, principal forms, IPA, romanisation and Hanja; a German or Greek noun of one gender goes onto its card with its article (das Haus, το σπίτι); its tag is its word class, named in full (Noun, Adjective, Preposition), as Wiktionary's adj and adv would read as JMdict's codes. Articles, pronouns, prepositions and conjunctions are left out of a video's words. Chinese words show their pinyin over each character on the Watch screen, carry their HSK level as a tag (HSK-1 … HSK-7-9), and the dictionary adds a level whole; the Watch screen filters by HSK levels for Chinese, JLPT's for Japanese, and by none for a language without. A reading is typed in a session only when it is kana, so pinyin isn't. Words are looked up on the web on Jisho in Japanese, on Wiktionary at the language's section otherwise, and a search's hint speaks of kana and romaji only in Japanese. `DICTIONARY_LANGUAGES` in deploy/.env names the languages an instance keeps (unset: de,el,en,ko,zh; empty: none). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-14 07:45:15 +02:00
# JMdict's conversion takes some 400 MB for a minute a week, Wiktionary's some
feat(curator): characters of Chinese and Korean, the Greek alphabet, Korean words from KRDict For parity with Japanese's KANJIDIC2 and kana: - Unihan (Unicode License v3) makes characters' dictionaries: the 8,105 standard simplified characters (unihan-hans) and the traditional ones in use (unihan-hant), told apart and ranked by CC-CEDICT's words (Unihan calls 这 its own traditional variant; 後 ranks as the 后 of 以后), with pinyin, meanings, strokes, Kangxi radical, HSK 2025 character levels, the 3,500 common ones and Hong Kong's grades; and Korean Hanja (unihan-ko) with their sounds, the 1,800 of education, and their native readings from Wiktionary (물 수), with the 40 Hangul letters as kana are. - The Greek alphabet, the curator's own (CC0): letters with names, transliteration and sound. - KRDict, the Basic Korean Dictionary for learners (CC BY-SA 2.0 KR, downloaded without an account), replaces Wiktionary for Korean's words: learners' levels, pronunciation where it differs from the spelling (같이 [가치]), Hanja, English senses; Wiktionary's update keeps the words' forms (가다's 99) and the Hanja's readings beside it for both. - The update converts KRDict when its page offers another export and the characters weekly, and removes the files of languages no longer kept or of sources replaced. Measured on the real data: Unihan 3 s; KRDict 54,870 entries in 24 s and under 600 MB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-14 12:08:10 +02:00
# 300 MB for ten minutes a month, KRDict's some 600 MB for half a minute.
mem_limit: 1g
feat(dictionary): German, Greek, English, Chinese and Korean words A studyset in German, Greek, English, Chinese or Korean finds its dictionary as one in Japanese does (ADR 0008), and its subtitles are cut into words by its language (`WordCutting`): Chinese by its longest words as they stand, the longer words over each cut offered as for Japanese; Korean at its spaces, a word the dictionary lacks as it stands taken without the particle it ends with (집에서 is 집, 아이 stays a child); every other language at its spaces, a word written exactly so first (the noun Essen before the verb essen). A studyset finds the dictionary of its language tag, else of its language alone (zh for zh-Hans). A Wiktionary word shows its genders, principal forms, IPA, romanisation and Hanja; a German or Greek noun of one gender goes onto its card with its article (das Haus, το σπίτι); its tag is its word class, named in full (Noun, Adjective, Preposition), as Wiktionary's adj and adv would read as JMdict's codes. Articles, pronouns, prepositions and conjunctions are left out of a video's words. Chinese words show their pinyin over each character on the Watch screen, carry their HSK level as a tag (HSK-1 … HSK-7-9), and the dictionary adds a level whole; the Watch screen filters by HSK levels for Chinese, JLPT's for Japanese, and by none for a language without. A reading is typed in a session only when it is kana, so pinyin isn't. Words are looked up on the web on Jisho in Japanese, on Wiktionary at the language's section otherwise, and a search's hint speaks of kana and romaji only in Japanese. `DICTIONARY_LANGUAGES` in deploy/.env names the languages an instance keeps (unset: de,el,en,ko,zh; empty: none). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-14 07:45:15 +02:00
# 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: