halkyon/deploy/compose.learn.yaml
Tizian 「ティツィアーン」 fd9c2dfe76
feat(meditate,deploy): Meditate beside the instance, and every server's public address from .env
Meditate's deploy step (ADR 0038, step 7; ADR 0039, D14), as Todo's: the
server's Dockerfile with the library's check compiled beside it, its
production config on its own database and role, compose.meditate.yaml as a
member of the register, its site sending /library/* to the web server and
the rest to the API server, the operator's library read-only in a compose
file of its own, and redeploy.sh, backup.sh and the image replay taking it
in.

Every server's public address now comes from .env (Serverpod's
SERVERPOD_API_SERVER_PUBLIC_HOST and _WEB_SERVER_, which win over the
config file): Train, Todo and Meditate from their domains, Learn from
LEARN_PUBLIC_HOST, unset learn.halkyon.eu as before. Without it a server on
halkyon.li would name halkyon.eu in its SimpleLogin callback, profile pages
and privacy page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-08 19:59:39 +01:00

233 lines
11 KiB
YAML

# 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).
#
# 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: ${LEARN_POSTGRES_PASSWORD:?set LEARN_POSTGRES_PASSWORD in deploy/.env}
SERVERPOD_PASSWORD_serverSideSessionKeyHashPepper: ${LEARN_SESSION_KEY_HASH_PEPPER:?set LEARN_SESSION_KEY_HASH_PEPPER in deploy/.env}
# Its public address, which config/production.yaml names otherwise: the host of
# LEARN_DOMAIN where the instance isn't learn.halkyon.eu (halkyon.li's
# learn.halkyon.li). A host alone, never a URL; unset: learn.halkyon.eu, as the
# file has it, which the local rehearsal keeps too.
SERVERPOD_API_SERVER_PUBLIC_HOST: ${LEARN_PUBLIC_HOST:-learn.halkyon.eu}
SERVERPOD_WEB_SERVER_PUBLIC_HOST: ${LEARN_PUBLIC_HOST:-learn.halkyon.eu}
# 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: ${LEARN_POSTGRES_PASSWORD:?set LEARN_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: