halkyon/deploy/host/first-start.md
Tizian 「ティツィアーン」 a29ad45ef1
Some checks are pending
CI / check (push) Has started running
build: the organisation on code.halkyon.app is halkyon-days
ADR 0045, D12, step 1: project-halkyon was renamed halkyon-days, its
repositories keeping their names. Every URL naming it moves along: the
servers' source-code links, which the AGPL requires, and their tests;
the forks in the pubspecs and the lock (appflowy-editor, pure-zstd,
serverpod-offline-sync); the snaps, the F-Droid listings and the
release configurations; the docs. The curator's credit for the
alphabets names the repository by its name since ADR 0015, halkyon,
rather than halkyon_learn. Forgejo redirects the old addresses until
the name is taken again.

ADRs 0015 and 0039 keep the name they were written with, and the local
folders theirs.

halkyon_editor's gen-l10n caches under build/, committed before its
.gitignore named the folder, are no longer tracked.

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

8.2 KiB
Raw Permalink Blame History

halkyon.li's first start

The instance on balthasar, once (ADR 0039), after the host's steps 1 to 6 (README.md): Learn, Train, Todo and Meditate with Jellyfin, LanguageTool, BRouter, Train's map and place search, all from the start (the developer's choice of 8 Nov 2026). Each step leaves what came before running. Commands run on balthasar as the developer (ssh balthasar from melchior) unless a step says otherwise; those for deploy after sudo -iu deploy. Later deploys are redeploy.sh's (D13).

  1. The sidecars' users, each ID the sidecar's alone, as its compose file runs it (useradd warns that the IDs lie above the system range, which is meant: they must never be a person's, ../sidecars/README.md):

    for each in jellyfin:61096 languagetool:61081 brouter:61177 maptiles:61178 photon:61179; do
      name=${each%:*} id=${each#*:}
      sudo groupadd --system --gid "$id" "$name"
      sudo useradd --system --uid "$id" --gid "$id" --no-create-home --shell /usr/sbin/nologin "$name"
    done
    
  2. deploy, which owns the checkout and runs the instance, in Docker's group (so as good as root on this host: its key may run redeploy.sh alone, step 3). Its home is on the HDD, as /home is:

    sudo useradd --create-home --shell /bin/bash --groups docker deploy
    sudo -iu deploy
    ssh-keygen -t ed25519 -N '' -C 'deploy@balthasar: halkyon, read-only' -f ~/.ssh/id_ed25519
    cat ~/.ssh/id_ed25519.pub
    

    In Forgejo (code.halkyon.app, the repository's Settings › Deploy keys), that key as a deploy key, without write access. Then, still as deploy, the clone; SSH first shows the forge's host key, which must be SHA256:98mCW/wdTIK7Jl6vwDE4qQqLECa8wkgDWye7+nQONn4 (ED25519):

    git clone ssh://git@code.halkyon.app/halkyon-days/halkyon.git ~/halkyon
    
  3. The developer's keys: the GPG key redeploy.sh checks every commit's signature with (deploy/allowed-signers), and the SSH key it alone may be run with. On melchior:

    gpg --export --armor B172B9ED76A32EBCABA24A9F07F6FCF405488B64 >developer.asc
    scp developer.asc ~/.ssh/raisondetredev@ramiel.pub balthasar:/tmp/
    

    On balthasar, as deploy:

    gpg --import /tmp/developer.asc
    git -C ~/halkyon log -1 --format='%G? %GP'   # U B172B9ED…B64: good, vouched for by the list
    printf 'restrict,command="/home/deploy/halkyon/deploy/redeploy.sh" %s\n' \
      "$(cat /tmp/raisondetredev@ramiel.pub)" >>~/.ssh/authorized_keys
    chmod 600 ~/.ssh/authorized_keys
    ssh-keygen -lf ~/.ssh/authorized_keys   # SHA256:eSJ3RtDT0oJxCUq33B50snmkt1jSYw19Z0U6X+W9+hA
    

    (rm /tmp/developer.asc /tmp/raisondetredev@ramiel.pub as the developer afterwards.) On melchior, in ~/.ssh/config, through the tunnel, so it works at home and away alike:

    Host balthasar-deploy
        HostName 10.61.97.2
        User deploy
        IdentityFile ~/.ssh/raisondetredev@ramiel
        IdentitiesOnly yes
        ProxyJump ramiel
    
  4. .env, as deploy: every secret made here, the developer's settings, never over an existing file (halkyon-li-env.sh):

    cd ~/halkyon
    sh deploy/host/halkyon-li-env.sh deploy/.env
    cat deploy/.env   # the whole file into Proton Pass: Infra, a note "halkyon.li .env"
    
  5. SimpleLogin's app, the only way in (D4): in SimpleLogin, Settings › Developer › New website, named Halkyon (halkyon.li), its URL https://learn.halkyon.li, its redirect URL (callback) https://learn.halkyon.li/simplelogin-callback. Its client ID and secret into deploy/.env (SIMPLELOGIN_CLIENT_ID, SIMPLELOGIN_CLIENT_SECRET, as deploy with nano ~/halkyon/deploy/.env) and into the note in Proton Pass.

  6. The sidecars' folders, and Jellyfin moved in. As the developer:

    d=/home/deploy/halkyon/deploy
    sudo install -d -o languagetool -g languagetool $d/languagetool $d/languagetool/fasttext
    sudo install -d -o brouter -g brouter $d/brouter/segments
    sudo install -d -o maptiles -g maptiles $d/maps/tiles
    sudo install -d -o photon -g photon $d/photon/data
    

    Then ../sidecars/README.md, Jellyfin on halkyon.li, steps 2 to 5: the library to /data/media, the configuration and cache into the deploy, network.xml for Caddy, ADR 0032's folder removed. .env names the library already.

  7. The databases. PostgreSQL first, alone, which makes the instance's superuser halkyon_learn with .env's password; then a role and a database for each of the other three, as ../sidecars/README.md explains for Train's (Train, step 2: each role logs in and is nothing more, owns its database alone and connects to no other). As deploy, the passwords read from .env rather than typed:

    cd ~/halkyon/deploy
    docker compose up -d --wait postgres
    set -a; . ./.env; set +a
    docker compose exec -T postgres psql -U halkyon_learn -d postgres -v ON_ERROR_STOP=1 \
      -v train="$TRAIN_POSTGRES_PASSWORD" -v todo="$TODO_POSTGRES_PASSWORD" \
      -v meditate="$MEDITATE_POSTGRES_PASSWORD" <<'SQL'
    CREATE ROLE halkyon_train LOGIN PASSWORD :'train';
    CREATE DATABASE halkyon_train OWNER halkyon_train;
    CREATE ROLE halkyon_todo LOGIN PASSWORD :'todo';
    CREATE DATABASE halkyon_todo OWNER halkyon_todo;
    CREATE ROLE halkyon_meditate LOGIN PASSWORD :'meditate';
    CREATE DATABASE halkyon_meditate OWNER halkyon_meditate;
    REVOKE CONNECT ON DATABASE halkyon_learn FROM PUBLIC;
    REVOKE CONNECT ON DATABASE halkyon_train FROM PUBLIC;
    REVOKE CONNECT ON DATABASE halkyon_todo FROM PUBLIC;
    REVOKE CONNECT ON DATABASE halkyon_meditate FROM PUBLIC;
    SQL
    exit   # leaves deploy's shell, with .env's secrets in it
    
  8. The first start, as deploy in ~/halkyon/deploy. It builds every image (the four servers, the dictionaries, BRouter, the map's and Photon's), half an hour or more, then starts everything; each server applies its migrations as it starts:

    docker compose up -d --build
    docker compose ps   # every service up; the servers healthy within a minute or two
    

    What builds on afterwards, by itself: the dictionaries (some 3 GB downloaded within the first hour), BRouter's segments for southern Bavaria (minutes), the map's cut (an hour or more) and Photon's Germany (7.5 GB, an hour or more). Until then Train has no routes, an empty map and no search. From melchior, anywhere:

    for name in learn todo train meditate jellyfin; do
      curl -s -o /dev/null -w "$name.halkyon.li %{http_code}\n" https://$name.halkyon.li/
    done
    

    Every name answers (200, or a redirect), over a certificate Caddy got at home, as ramiel passes TLS through unopened.

  9. The first operator (ADR 0021, D20, D32): in Halkyon Learn, another instance, https://learn.halkyon.li, Sign in with SimpleLogin, a handle chosen; the account then waits. As deploy, in ~/halkyon/deploy, the developer's handle in lower case in place of their_handle:

    docker compose exec -T postgres psql -U halkyon_learn -d halkyon_learn -v ON_ERROR_STOP=1 <<'SQL'
    BEGIN;
    UPDATE "serverpod_auth_core_user"
    SET "scopeNames" = ("scopeNames"::jsonb || '["operator"]')::json
    WHERE "id" = (SELECT "authUserId" FROM "public_profile" WHERE "handleKey" = 'their_handle')
      AND NOT ("scopeNames"::jsonb ? 'operator');
    UPDATE "serverpod_auth_core_session"
    SET "scopeNames" = ("scopeNames"::jsonb || '["operator"]')::json
    WHERE "authUserId" = (SELECT "authUserId" FROM "public_profile" WHERE "handleKey" = 'their_handle')
      AND NOT ("scopeNames"::jsonb ? 'operator');
    COMMIT;
    SQL
    

    UPDATE 1 twice, or the first once and the second UPDATE 0 while there's no session yet. Back in the app, Try again on the wait screen: an operator never waits, and Settings shows This instance. Everyone else now signs up and waits for the operator, who approves them there, the four words read out first.

  10. Backups (D18): ../backups.md, Setting it up, for halkyon.li: on the same Storage Box a sub-account of its own (halkyon-li), a key and a restic repository of their own, the repository's password into Proton Pass (Infra, "halkyon.li restic"), then the timer.

Then the CI runner, README.md, step 7, and the Instance console (ADR 0040).