Run the daemon locally in Docker¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: The Platform instance image.
Run nerthusd from the fleet image against a lore clone on your own machine. The container carries the whole runtime — PowerShell 7.5, the compiled C# substrate, git, the ICU data the collation check demands — so the device needs Docker and nothing else. This is the development posture: one daemon, one clone, no tunnel, no Discord, and nothing published anywhere.
It spends no CI minutes and no Worker requests. What it costs is local: about 1.6 GB resident and roughly two minutes of boot against the dev corpus of 3 548 entities, both measured on the image built below.
What this buys you¶
The module's client spawns a daemon by itself, and that daemon loads lib/Nerthus.Core.Substrate.dll from the clone. lib/*.dll is gitignored — it is a build output, produced by CI and copied into the image — so a fresh clone has no assembly and the boot dies before it opens a port. The client waits out its timeout and reports the symptom rather than the cause:
The real error is in <repo>/.nerthus/log/daemon.out.err, and it names the missing DLL. Building it by hand needs a .NET SDK whose framework matches the PowerShell doing the loading; the image settles both questions, because CI built the assembly at net9.0 for the pwsh 7.5 that ships beside it. Architecture owns the substrate contract.
Prerequisites¶
- Docker with the Compose plugin, and a memory allowance the daemon fits in. On macOS and Windows raise Docker Desktop's VM limit to at least 4 GB in Settings › Resources — the default half-your-RAM allowance is usually enough, a hand-lowered 2 GB is not.
- A clone of the lore repository, e.g.
~/nerthus/repozytorium-fabularne(https://gitlab.com/margonem/nerthus/narratorzy/repozytorium-fabularne). The container never clones for you here; the bind mount below is your working tree. - A clone of
nerthus.coreto build the image from.
1. Build the image¶
Two stages: the .NET SDK compiles the substrate to net9.0, then the runtime stage installs the pwsh tarball onto Ubuntu and copies the assembly into lib/. The result is around 670 MB. Nothing about the build is fleet-specific — the fleet's own images are the same Dockerfile published under a digest, which Build the fleet image by hand covers, pins, and proposes.
2. Start the daemon¶
docker run -d --name nerthusd \
-p 8788:8788 \
-v ~/nerthus/repozytorium-fabularne:/repo \
--entrypoint pwsh \
nerthusd:local \
-NoProfile -File /opt/nerthus/daemon/Start-NerthusDaemon.ps1 \
-Repo /repo -Port 8787 -SyncIntervalMinutes 0
On Windows PowerShell the line continuation is a backtick, not a backslash.
If the daemon needs a credential¶
That command starts a daemon with no secrets at all, which is the right default locally: it serves
reads, and the @discord dispatch it cannot authenticate simply does not fire. When you want the
real thing — the bot token, so dispatch posts — do not put it back in a .env. Wrap the run:
../Nerthus.Core.Infra.Common/sejf/Invoke-SejfEnv.ps1 -Names DISCORD_BOT_TOKEN \
-Command docker -Arguments 'run','-d','--name','nerthusd','-p','8788:8788',\
'-e','DISCORD_BOT_TOKEN','-v',"$HOME/nerthus/repozytorium-fabularne:/repo",\
'--entrypoint','pwsh','nerthusd:local',\
'-NoProfile','-File','/opt/nerthus/daemon/Start-NerthusDaemon.ps1',\
'-Repo','/repo','-Port','8787','-SyncIntervalMinutes','0'
-e DISCORD_BOT_TOKEN with no value is the whole trick: Docker copies it out of the
environment the wrapper built for this one process, so the value never appears on a command line,
in shell history, or in docker inspect's command. See
Set up credentials.
Two choices in that command carry the whole local posture, and both are deliberate.
The entrypoint is bypassed. docker/entrypoint.ps1 renders .nerthus/local/sync.json on every boot, which turns the sync tick on. A sync-enabled daemon that has never converged is stale from its first second, and the write gate answers 403 SyncStale until a converge succeeds — see Sync & deployment. On a laptop there is no origin to converge with, so the tick would never clear the gate and every write would be refused. -SyncIntervalMinutes 0 bound at boot overrides the file and disables sync outright.
The published port is 8788, not 8787. The daemon binds http://127.0.0.1:8787/ inside the container's namespace, so -p 8787:8787 forwards to an address nothing listens on and every request from the host is refused. On a fleet host that is the point: cloudflared joins the daemon's namespace and reaches it over plain loopback, and the stack publishes no port at all. Locally you rebuild that arrangement with a forwarder in the same namespace.
A local daemon also serves no sibling corpus: the bypassed entrypoint renders no local/corpora.json, so the daemon reports an absent moderation tree — an empty moderator roster, regulations read from the lore tree. To serve one, mount a moderation clone (-v ~/nerthus/repozytorium-moderatorskie:/mc) and write .nerthus/local/corpora.json in the mounted lore clone with { "mc": "/mc" }; Configuration owns the file. The same pattern serves the transcript archive and the annotation tree ("logi", "lang") — on a lore clone that predates the August 2026 splits both still fall back to the in-lore trees with no config at all, while a post-split clone serves no archive and no lemma layer until the mounts are given.
3. Reach it from the host¶
docker run -d --name nerthusd-port \
--network container:nerthusd \
alpine/socat TCP-LISTEN:8788,fork,reuseaddr TCP:127.0.0.1:8787
The sidecar holds no ports of its own — a container joining another's namespace cannot publish any, which is why -p 8788:8788 sits on the daemon in step 2. Confirm the daemon is up:
GET /health is anonymous. It answers "status": "ok", "mode": "ReadWrite", the entity count once the model is built, and a sync block that should read "enabled": false for a local run. Until the model is built there is no answer at all: the port opens at the end of the boot, not at the start.
4. Point the client at it¶
The image ships the daemon, not the client — Import-Module inside the container fails, because /opt/nerthus/client does not exist. Run the cmdlets from your host clone instead, and address the container explicitly:
Import-Module ~/nerthus/Nerthus.Core/Nerthus.Core.psd1
$Daemon = 'http://127.0.0.1:8788/v1/api'
$Token = (Get-Content ~/nerthus/repozytorium-fabularne/.nerthus/runtime/daemon.token).Trim()
Get-NerthusStatus -Daemon $Daemon -Token $Token
Get-NerthusEntities -Type Lokacja -Daemon $Daemon -Token $Token
The machine token is minted at boot into .nerthus/runtime/daemon.token, and the bind mount puts it in your working tree where the host can read it. Every Verb-Nerthus* cmdlet takes -Daemon and -Token; passing them skips discovery and auto-spawn, so the host never tries to start a second daemon of its own. Without the header the same call answers 401.
Initialize-NerthusRepo is the exception — it starts a daemon by design and takes no -Daemon. Adopt through the route instead, against the container that is already serving the clone:
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{}' 'http://127.0.0.1:8788/v1/api/import'
?dryRun=true previews it. Adopt a lore repository owns what the import does and what you commit afterwards; the five-stage vm/Initialize-NerthusLoreRepo.ps1 wrapper spawns its own local daemon, so it belongs to a host-native run rather than to this one.
Pick the role deliberately¶
NERTHUS_ROLE decides the write posture, and it only reaches the daemon through the entrypoint — the command in step 2 bypasses it, which is why that daemon comes up read-write with sync off.
| Role | Boots | Publishes | Local use |
|---|---|---|---|
replica (image default) |
-ReadOnly -NoDiscord |
never | reading a corpus you do not intend to change |
primary |
read-write, Discord live | NERTHUS_SYNC_PUBLISH_MODE, mr unless told otherwise |
anything that writes — adoption, import, session work |
| step 2's bypass | read-write, sync disabled | never | local development |
On the fleet the primary is the host the apex CNAME follows, so the role is also the answer to which machine serves nerthus.pl (The fleet). Locally the apex is irrelevant and the write posture is the whole reason to care. Development wants what a primary can do without what a primary does: writes allowed, nothing leaving the machine.
Running the entrypoint path as a primary gets you halfway and then stops:
docker run -d --name nerthusd \
-e NERTHUS_ROLE=primary -e NERTHUS_SYNC_PUBLISH_MODE=none \
-v ~/nerthus/repozytorium-fabularne:/repo nerthusd:local
That boots "mode": "ReadWrite" and immediately reports "stale": true with no converge behind it, so the write gate refuses writes on exactly the daemon you started in order to make them. Use it when you want to exercise the container as the fleet runs it; use step 2 when you want to work.
A primary with a publish mode will push
NERTHUS_SYNC_PUBLISH_MODE defaults to mr for a primary, and push writes to the default branch directly. Both act on the remote of whatever clone you mounted. Set none, or disable sync as step 2 does, before pointing a writable daemon at a clone whose origin you care about.
Verify¶
A healthy local run reads "mode": "ReadWrite", "sync": { "enabled": false }, and "imageDigest": "" — the digest is empty because you started the image by tag rather than by the pin a fleet host carries.
The bind mount is your real working tree, and the daemon writes into it: nerthus.entities.md, the Lokacje/ registry, .nerthus/. It runs no git command of its own once sync is off, so reviewing and committing stays yours.
When it did not work¶
| Symptom | Cause | Fix |
|---|---|---|
curl to 8788 refuses instantly |
the forwarder is not running, or the daemon container was started without -p 8788:8788 |
step 3; the publish must sit on the daemon, not on the sidecar |
curl hangs, then refuses; docker logs is silent |
the model is still building; the port opens last | wait — two minutes on the dev corpus, longer with nerthus.logs/ present |
Container exits at once, log names Nerthus C# substrate not built |
the image was built from a tree where the substrate stage failed | rebuild; the daemon refuses to compile sources at boot by design |
403 SyncStale on every write |
a sync-enabled daemon that has never converged | disable sync as step 2 does, or converge — Sync & deployment |
WARN … lore deploy key '/run/keys/lore.key' is not a file |
the entrypoint path with no key mounted | harmless on a populated mount: it serves what it has and fetches nothing |
a daemon is already serving /repo (port …) |
a second boot onto a clone one daemon already owns | stop the first, or mount a different clone |
| Boot dies with an ICU or collation refusal | the substrate was built with invariant globalization, or libicu is missing |
rebuild from the shipped Dockerfile without touching its apt-get line |
Tear it down¶
The clone survives — it is your directory, not a volume. .nerthus/runtime/ is left behind with a port file naming a daemon that no longer answers; the next boot overwrites it.
See also¶
- Adopt a lore repository — the one-time import, and what to commit after it
- Sync & deployment — the converge tick, publish modes, and the freshness gates
- The fleet — roles, the apex, and how a real host differs from this one
- Configuration — the container environment and the host-local files it renders