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