Skip to content

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

  1. Register a plain free GitLab.com account (e.g. nerthus-bot), verify its email, enable 2FA.
  2. Add it to the lore repository:
    • push mode — Maintainer (it commits directly to the default branch).
    • mr mode — Developer (it only pushes vm/* merge-request branches).
  3. In both modes, confirm no protected-branch rule covers vm/* — the ledger rule (step 4) uses vm/* branches even in push mode.
  4. 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:

ssh-keygen -t ed25519 -f /srv/nerthus/.ssh/id_ed25519

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:

{ "intervalMinutes": 5, "publishMode": "push" }

Rules:

  • Enabled ⇔ the resolved interval is ≥ 1. publishMode is push or mr, defaults to push, and setting it alone never enables sync.
  • Boot parameters -SyncIntervalMinutes / -SyncPublishMode take precedence when explicitly bound; a bound -SyncIntervalMinutes 0 force-disables sync for that boot even when sync.json enables 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 /sync covers "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

  1. Get-NerthusSync shows enabled: true, matching local and origin SHAs, stale: false.
  2. Invoke-NerthusSync -WhatIf returns a plan (wouldPublish, wouldConverge) — the dry run still fetches by design.
  3. A write route answers normally.
  4. Break-test: cut the network, wait past sync_max_age_min (or set it low), confirm write routes answer 403 SyncStale, then confirm POST /sync clears 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:

git -C /srv/nerthus/module pull && sudo systemctl restart nerthusd

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:

  1. Stop nerthusd (or remove local/sync.json).
  2. Confirm no unpushed VM work: Get-NerthusSynclocal.ahead.
  3. 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:

  1. Take origin's version of the machine sections wholesale.
  2. Hand-merge only the durable roster sections (## Gracze / ## Postacie GraczyPlayers) and any conflicted hand-authored lore files.
  3. Re-run the idempotent importer to regenerate the machine sections (Adoption).
  4. 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.