Skip to content

fleet.settings.yml

Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: none, retired with Core.

Each consumer fleet repository carries one fleet.settings.yml at its root: the environment every host in that fleet shares. It names the apex, the Cloudflare zone, the lore repository the hosts converge against, the hostname namespace, and each tunable the roster does not carry per host. This page is the key reference — what a key holds, whether it is required, what its absence costs. The mechanisms the tunables steer are modelled in The fleet and Sync; the per-host lines live in fleet/hosts.yaml, which The fleet owns.

fleet:validate parses the file on every merge request and on the default branch. An error finding fails the job; a warning is advisory. The whole rule surface is offline, so a settings mistake is caught before any Cloudflare call.

Required keys

Five keys are hard-required. A missing or blank one is the error fleet.settings.yml is missing '<key>', one finding per key.

Key Holds Dev fleet's value
apex the hostname that follows whichever host carries role: primary dev-evocation.nerthus.pl
zone_id the Cloudflare zone id for nerthus.pl b8b5299cf58831c19522572e5723cbe6
lore_repo_url the SSH clone URL of the lore repository this fleet deploys git@gitlab.com:margonem/nerthus/it/repozytorium-fabularne-prodkopia.git
tunnel_prefix the prefix on every named tunnel this fleet creates nerthus-dev-
hostname_pattern how a town slug becomes a hostname dev-<town>.nerthus.pl

apex does two jobs. Provision lists it as an extra hostname on the primary's tunnel ingress, because the apex CNAME resolves to the primary while the Host header travelling with the request stays the apex — an ingress document without it answers the catch-all 404. The apex also supplies the zone suffix validation checks each rendered hostname against for the first-level rule.

zone_id appears in every Cloudflare API path and is no secret, so it is committed here rather than carried as a CI variable. tunnel_prefix keeps two fleets from colliding in one account's tunnel list: torneg on the dev fleet is the tunnel nerthus-dev-torneg. hostname_pattern carries the namespace split as well as the shape — the dev fleet creates dev-* names alone, and a prod fleet refuses any town slug that starts with dev-.

Publishing

Key Holds Absent
publish_mode the primary's publish posture — mr, push or none mr
rada_hosts the towns allowed to publish with push empty

The publish posture is a property of the fleet rather than of the role. A contributor device publishes as merge requests so every daemon write meets review, and push belongs to a Rada-operated box listed in rada_hosts. Validation enforces exactly that: publish_mode: push with a primary outside rada_hosts fails with primary 'torneg' is a contributor device, so publish_mode must be 'mr' (list Rada-operated hosts in rada_hosts to allow 'push'). A replica publishes nothing whatever the file says, and none on the primary leaves the fleet with no publisher at all, which no rule refuses. Sync owns what each mode does to a batch.

The moderation corpus

Key Holds Absent
mc_repo_url the SSH clone URL of the moderation corpus every host clones at /mc no clone: the moderator roster is empty and the regulation corpus falls back to the lore tree

The moderation corpus is a second repository beside the lore — the moderator roster, the versioned regulations, the sanction record — separate because merge rights are per repository. Provision renders the key as mcRepoUrl into rendered/<town>.json, the agent carries it as MC_REPO_URL into the compose env, and the container reads it as NERTHUS_MC_URL. Absent is a supported state, reported rather than crashed, and a corpus that cannot be cloned does not stop the boot: the daemon serves with an empty roster and says so. The keys are read-only on every host; jadzia.nerthus is the only account that writes.

The transcript archive

Key Holds Absent
logi_repo_url the SSH clone URL of the transcript archive every host clones at /logi no clone: the host reads nerthus.logs/ from its lore checkout, and a fetch on it writes there

The same channel as the moderation corpus: logiRepoUrl into rendered/<town>.json, LOGI_REPO_URL into the compose env, NERTHUS_LOGI_URL into the container. Absent is supported — an un-migrated host keeps serving the archive still inside its lore checkout — which is what lets a fleet migrate host by host.

This corpus is written, unlike the other two. Every fetch on every host writes a transcript into it and the daemon's sync tick commits and pushes the checkout (Sync), so Invoke-FleetProvision shares the fleet's deploy keys onto the project with the write bit — a read-only grant here is a fleet that archives play it can never publish.

The annotation tree

Key Holds Absent
lingw_repo_url the SSH clone URL of the annotation tree every host clones at /lingw no clone: the host reads nerthus.lang/ from its lore checkout, and serves no lemma layer if that checkout has none

The same channel, one hop at a time: lingwRepoUrl into rendered/<town>.json, LINGW_REPO_URL into the compose env, NERTHUS_LINGW_URL into the container. Absent is supported and mild, which is what lets a fleet migrate host by host rather than all at once.

Read-only, and more strictly than the moderation corpus. No host writes this tree at all: one CI job regenerates it whole and opens a merge request, and each daemon's tick for the checkout runs with publishMode: none. Invoke-FleetProvision shares the fleet's deploy keys onto the project without the write bit.

The image registry

Key Holds Absent
registry_user the machine account every host pulls the daemon image as no docker login: each host pulls with whatever credential its Docker already holds

Not a secret and not per host — it names an account, and the token beside it travels per town as age ciphertext in secrets/<town>.registry.age, the channel the tunnel token uses. The agent decrypts it and logs in before compose, taking the registry host off the image reference rather than from a second value that could name a different registry.

Absent is a supported state, and it is the state a fleet is in until this is armed. A device whose Docker already holds a credential keeps pulling exactly as it did, so putting the registry into the contract can never take a converging host offline. What it buys is the other direction: a host whose ambient credential lapses has one in git that provision can rotate, instead of a converge loop that dies at docker pull with nothing to say about why.

Every remote at once

fleet.settings.yml says which repository fills each key. What each key IS — the descriptor field provision renders, the compose variable the agent writes, the container variable the entrypoint reads, the corpora.json key, the publish posture, the write bit, and what a host does when the key is unset — is declared once in schema/remotes.yml in the contract repository, and the provision, agent and validation code paths are derived from it. The split is deliberate: the shape of a corpus is the same in every fleet, and which project fills it is not, since dev and prod point at different repositories.

Two consequences worth knowing before editing either file. Adding a repository to the fleet is one entry in the manifest plus one value here, rather than five hand-edits that can disagree. And sejf:verify refuses a manifest entry whose credential has no inventory.yaml row — a remote nobody can authenticate to is a red pipeline rather than a fleet that quietly stops converging (The secret tree).

The manifest declares more corpora than a fleet has to fill, and how many it fills is a decision this file records. schema/remotes.yml at v0.9.6 declares seven remotes: six kind: git and the registry. Of the six, lore is not a corpus — its own entry sets corpus_key: null, because the lore checkout is /repo itself rather than a tree beside it — which leaves five corpus keys, mc_repo_url, logi_repo_url, lingw_repo_url, dziel_repo_url and adnot_repo_url. The dev fleet fills three of the five, measured 2026-08-23: mc_repo_url, logi_repo_url and lingw_repo_url, beside the required lore_repo_url. dziel_repo_url and adnot_repo_url are unset, and their volumes mount empty on every host, which the container entrypoint, the compose template and each corpus tick all treat as a supported state and none of them as an error. Filling either is one line and no code change — and for adnot_repo_url it also settles an authorization question, which is why Infra.Dev's own fleet.settings.yml carries a comment saying why the line is absent. An unset key is invisible to fleet:validate: the stale-descriptor rule fires only for a key that is set here and missing from a descriptor, so it can never report on a corpus this file has not named.

The labelled-sample store is the second corpus the fleet writes. Since Infra.Common 0.9.16 (remotes.yml: adnotacje.write: true, publish_mode: mr) provision raises every host key's write bit on the store project, because Nerthus.Core's Kto to? panel deposits a narrator's rulings there and the daemon publishes each batch as a merge request GitLab merges when the store's validator is green (ruling H125.1, 2026-09-05; before it, 52 rulings sat as a dirty file on one host). The lang tree above stays read-only: nothing on a host writes it.

Cadence and capacity

Key Holds Bounds Absent
sync_interval_min minutes between sync ticks integer ≥ 1 5
peer_watch_interval_min minutes between peer-watch probes 1–60 5
primary_lag_minutes minutes the primary waits after a versions.lock commit integer 15
reader_threads threads answering the reads the route table marks poolable 0–4 none
search_index which full-text tiers a host builds an index for lore, session, speech, all, off, comma-joined; the booleans still parse none, and GET /search answers 503 SearchIndexDisabled

sync_interval_min, reader_threads and search_index each take either one value for the whole fleet or a mapping keyed by role:

sync_interval_min:
  primary: 5
  replica: 15
reader_threads:
  primary: 2
  replica: 2

Only primary and replica exist as keys there; any other name fails validation, because a typo would render nothing and be discovered as a host that never started serving concurrently. peer_watch_interval_min and reader_threads fail the pipeline when a present value falls outside its bounds — the daemon would clamp or ignore it, and a config mistake should die where it is named. A sync_interval_min below 1 is refused later, by the container at boot.

primary_lag_minutes and discord_guild_id are the two keys a device reads straight out of this file: the agent greps the first while honouring the upgrade wave and the second while rendering the host env. Every other tunable reaches a host through the rendered descriptor.

search_index is validated against the container entrypoint's own allowlist, verbatim, because the entrypoint throws on anything else: a typo here is a host that stops booting at its next converge, which is the same class of mistake as an out-of-range reader_threads and the more expensive one, since it lands on a host that was already serving. An index costs memory this host holds for as long as it serves — on the reference corpus the lore tier is about 420 MB resident and 6.4 s to build cold — so it is opt-in per tier and per role rather than a fleet-wide switch. A replica named with an empty value is deliberate rather than an omission: it takes the built index through /derived from the primary, which costs holding one rather than building one. Search owns the read behaviour and server.search_index the committed key this one overrides.

Edge and failover

Key Holds Absent
cors_worker the Worker script name provision attaches to each town hostname provision skips the worker-route pass
health_worker_url the health Worker's public GET the fleet-state reader drops its swept column
auto_failover.enabled whether the health Worker may propose a role flip the Worker sweeps and alerts, and proposes nothing
auto_failover.after_minutes wall-clock minutes the primary must be dark before the Worker arms 30
auto_failover.after_sweeps the legacy form of the same bound, converted at 15 minutes per sweep
auto_failover.cooldown_hours hours in which a second automatic promotion is blocked 24

cors_worker: nerthus-cors names the deployed CORS shim. With it set, provision upserts one worker route per town hostname — dev-torneg.nerthus.pl/* and its siblings on the dev fleet — alongside the apex route the Worker's own publisher binds (Publish the API). Unset, the route pass does not run at all and every town hostname answers without CORS headers.

health_worker_url is the URL wrangler deploy prints for the fleet-health Worker. -HealthWorkerUrl on the reader wins over NERTHUS_FLEET_HEALTH_URL, which wins over this key.

after_minutes is the operative bound and wins whenever it is present. A file carrying only after_sweeps is converted at 15 minutes per sweep — the cadence that key was written against — whatever the Worker's current cron, so an old value keeps meaning the duration it always meant.

The auto_failover block is not read from git at run time. Provision merges it into the health Worker's KV expectations, so the Worker takes its config from KV, and the same values are rendered into every host descriptor — the daemon's own corroboration gate and the Worker then reach the same verdict about when a primary is dark. The fleet owns the selection and promotion rules.

Discord

Key Holds Absent
discord_guild_id the id of the Discord server the fleet's senders post into the agent renders an empty DISCORD_GUILD_ID and the daemon's sender stays unwired

The guild id names one server the whole fleet posts into and is no secret, so it is committed here for the same reason zone_id is. The agent reads it straight out of this file — the same grep path as primary_lag_minutes — and renders DISCORD_GUILD_ID into the host env file for every role; a replica boots -NoDiscord from its role and never uses it. The bot token travels separately, as an age-encrypted secret (Rotate secrets).

How a setting reaches a host

A tunable travels one path, and each hop is inspectable:

  1. fleet.settings.yml on the default branch.
  2. rendered/<town>.json, written by fleet:provision and committed back.
  3. The agent's compose .env on the device.
  4. The entrypoint's .nerthus/local/sync.json and .nerthus/local/server.json.
  5. The daemon at boot.

A descriptor rendered before a key existed keeps composing and keeps booting, so a half-deployed setting has no other symptom: the fleet reads healthy while the daemon side of it never starts. Validation therefore holds every committed descriptor to the keys current settings would render, and warns rendered/torneg.json predates the current settings — missing readerThreads (from reader_threads); re-run fleet:provision. It is a warning rather than an error, because the repair is to re-run provision and a blocking pipeline would block the run that fixes it.

Writing the auto_failover block into KV needs CF_KV_NAMESPACE_ID on the fleet repository. Without it provision logs that the expectations were not updated and exits green (Set up credentials).

The dev fleet's file

The values above as one document, comments stripped:

apex: dev-evocation.nerthus.pl
zone_id: b8b5299cf58831c19522572e5723cbe6
lore_repo_url: git@gitlab.com:margonem/nerthus/it/repozytorium-fabularne-prodkopia.git
mc_repo_url: git@gitlab.com:margonem/nerthus/it/repozytorium-moderatorskie-dev.git
logi_repo_url: git@gitlab.com:margonem/nerthus/it/repozytorium-transkryptow-dev.git
lingw_repo_url: git@gitlab.com:margonem/nerthus/it/repozytorium-lingwistyczne-dev.git
registry_user: stefan.nerthus
tunnel_prefix: nerthus-dev-
hostname_pattern: dev-<town>.nerthus.pl
publish_mode: mr
sync_interval_min:
  primary: 5
  replica: 15
peer_watch_interval_min: 5
primary_lag_minutes: 15
reader_threads:
  primary: 2
  replica: 2
search_index:
  primary: lore
  replica: ''
cors_worker: nerthus-cors
auto_failover:
  enabled: true
  after_sweeps: 2
  cooldown_hours: 24
discord_guild_id: "778532216442454026"

A production fleet's file is this one with production values and nothing else different.

See also

  • The fleet — the roster, the converge loop, the health worker, failover
  • Sync — what publish_mode and sync_interval_min do inside the daemon
  • Publish the API — the CORS Worker and the fleet-health Worker these keys point at
  • Set up credentials — the fleet repository's CI/CD variables
  • Bootstrap a fleet host — adding the roster line these settings render