Deploy the VM instance¶
This guide sets up a long-lived nerthusd on a server: a systemd-supervised daemon serving its own clone of repozytorium-fabularne, publishing accepted writes back to GitLab as a dedicated bot account. It covers procedure only — the sync design (tick pipeline, freshness gate, divergence handling) lives in Sync.
Prerequisites¶
- A VM with git, PowerShell, and SSH network access to GitLab.com.
- Maintainer access to the lore repository (to add the bot as a member).
- The publish mode you intend to run — decide in step 4; the bot's access level depends on it.
1. Create the bot account¶
- Register a plain free GitLab.com account (e.g.
nerthus-bot), verify its email, enable 2FA. - Add it to the lore repository:
pushmode — Maintainer (it commits directly to the default branch).mrmode — Developer (it only pushesvm/*merge-request branches).
- In both modes, confirm no protected-branch rule covers
vm/*— the ledger rule (step 4) usesvm/*branches even in push mode. - If the module repository is private, invite the bot there as Reporter.
The credential is an account SSH key, not a deploy key and not a personal access token: one identity covers both repos, commits and merge requests are attributed to the bot, and no token string exists in env or config.
The bot costs no seat, even though the lore repository is private. GitLab's Free-tier five-user limit keys on the visibility of the top-level namespace, not the project's — and the lore repository's top-level namespace, margonem, is public. Private subgroups and projects nested under a public top-level group stay exempt.
2. Generate the SSH key on the VM¶
Generate the keypair on the VM itself — the private key never leaves the host:
Add the public half to the bot's GitLab profile. The key file must be 0600, owned by a dedicated non-sudo service user, with no passphrase (the daemon is unattended). The blast radius is the bot's membership on the two repositories, nothing else — but the lore repository is contributor-restricted, so a leaked key exposes closed lore to whoever holds it. Treat the key as the credential it is.
3. Prepare the host layout¶
Create the nerthus service user and clone both repositories over SSH — the lore repo from git@gitlab.com:margonem/nerthus/narratorzy/repozytorium-fabularne.git:
/srv/nerthus/
.ssh/id_ed25519 # bot key (0600, user nerthus)
module/ # Nerthus.Core clone
repo/ # repozytorium-fabularne clone (SSH origin)
.nerthus/local/sync.json # host-local sync enablement (step 4)
Bind the key per-clone, never globally, with hard network bounds so a hung SSH cannot freeze the dispatch thread. Run inside /srv/nerthus/repo (and the module clone):
git config core.sshCommand 'ssh -i /srv/nerthus/.ssh/id_ed25519 -o IdentitiesOnly=yes -o ConnectTimeout=10 -o ServerAliveInterval=10 -o ServerAliveCountMax=3'
git config user.name "nerthus-bot"
git config user.email "<bot email>"
The daemon never sets git identity itself — it is clone configuration. Git history attributes every VM publish to the bot; the human actor lives in the VM's audit log (.nerthus/log/audit.jsonl, gitignored).
4. Enable sync — .nerthus/local/sync.json¶
Sync enablement is host-local by construction — no committed key can enable it, so home clones and CI runners never become accidental syncing instances. Write the enablement file in the gitignored local/ directory of the repo clone:
Rules:
- Enabled ⇔ the resolved interval is ≥ 1.
publishModeispushormr, defaults topush, and setting it alone never enables sync. - Boot parameters
-SyncIntervalMinutes/-SyncPublishModetake precedence when explicitly bound; a bound-SyncIntervalMinutes 0force-disables sync for that boot even whensync.jsonenables it. - Every boot path reads the file, including a client auto-spawn — a crashed systemd daemon plus one cmdlet boots an equally protected syncing instance.
- Config is read once at boot: an interval change needs a restart;
POST /synccovers "sync now".
Two committed keys tune (but can never enable) sync: server.sync_max_age_min (default 60, the staleness bound) and server.sync_branch (default empty = auto-resolve from origin/HEAD) — semantics in Configuration. The remote is always literally origin.
Choosing the publish mode:
| Mode | Bot access | Behavior |
|---|---|---|
push (default) |
Maintainer | Commits land directly on the default branch; rebase conflicts fall back to a vm/<utc-stamp> merge-request branch |
mr |
Developer | Every batch becomes a merge request on a vm/<utc-stamp> branch |
push is recommended: interactive writes were already authorized by the capability ACL, and mr mode carries real caveats (published changes leave the VM's serving view until the merge request merges back; a closed-unmerged merge request is permanent loss from the VM's perspective). Regardless of mode, a batch that touches nerthus.ledger.md always publishes as a merge request titled Rozliczenie z VM (<host>) <stamp> — settle output keeps its Council review. Rationale for all of this is in Sync.
5. Install the systemd unit¶
Install and enable vm/nerthusd.service from the module repository:
sudo cp /srv/nerthus/module/vm/nerthusd.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now nerthusd
The unit is a supervisor, not the only safe boot path — any boot path reads sync.json (step 4). Boot refuses a second daemon when runtime/daemon.port exists and that port answers GET /health; stale discovery files from a dead daemon are overwritten normally.
Note
If the VM daemon is the community's notification home, do not pass -NoDiscord there — exactly one long-lived daemon should own Discord delivery (Logs).
6. Verify health¶
Get-NerthusSyncshowsenabled: true, matching local and origin SHAs,stale: false.Invoke-NerthusSync -WhatIfreturns a plan (wouldPublish,wouldConverge) — the dry run still fetches by design.- A write route answers normally.
- Break-test: cut the network, wait past
sync_max_age_min(or set it low), confirm write routes answer403 SyncStale, then confirmPOST /syncclears it when connectivity returns.
A healthy GET /sync looks like:
{
"enabled": true, "git": true, "intervalMinutes": 5, "publishMode": "push",
"remote": "origin", "branch": "main", "currentBranch": "main",
"local": { "sha": "0d1e9a7c…", "dirtyFiles": 0, "ahead": 0, "behind": 0 },
"origin": { "sha": "0d1e9a7c…", "fetchedAtUtc": "2026-07-09T11:40:03Z" },
"lastTick": { "at": "2026-07-09T11:40:04Z", "ok": true, "action": "converged",
"published": null, "convergedFiles": [], "warnings": [], "error": null },
"nextDueUtc": "2026-07-09T11:45:04Z", "staleAfterMinutes": 60, "stale": false
}
currentBranch differing from branch exposes a stranded mr-mode state. Route rows and envelopes are in the API reference; the sync.read/sync.run capability ids are in Capabilities. Routine publishes commit as Zmiany z VM (<host>) <utc-stamp> — e.g. Zmiany z VM (nerthus-vm-01) 20260705-201004 — authored by nerthus-bot.
Maintenance¶
Module updates. Run promptly after a home-side index-format bump lands — the VM pulls the bumped schema.json automatically, and its writes answer 403 SchemaTooNew until the module catches up:
GET /health exposes the on-disk vs expected format version for monitoring.
Deliberate upstream force-push or branch rename. The VM detects it as diverged and freezes writes on schedule — it never pushes the removed history back. Recovery:
- Stop
nerthusd(or removelocal/sync.json). - Confirm no unpushed VM work:
Get-NerthusSync→local.ahead. git -C /srv/nerthus/repo fetch && git reset --hard origin/<branch>, then restart.
Generated-index merge conflicts. The machine sections of nerthus.entities.md are regenerated wholesale, so conflicts there are not hand-mergeable:
- Take origin's version of the machine sections wholesale.
- Hand-merge only the durable roster sections (
## Gracze/## Postacie Graczy— Players) and any conflicted hand-authored lore files. - Re-run the idempotent importer to regenerate the machine sections (Adoption).
- Re-apply any writes the take-origin step discarded — they are visible in the VM's audit log.
No CI/CD variables are involved anywhere above — the VM path is independent of the settlement pipeline's credential (Set up pipelines).
Publishing. To expose this daemon on the public internet at evocation.nerthus.pl behind a Cloudflare Tunnel, continue with Publish the API.