Set up credentials (.env for config, the vault for secrets)¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: Configure a Platform instance.
Every provisioning script and CI helper takes its configuration from one gitignored .env file at
the root of your nerthus.core checkout, and its secrets from the vault —
Nerthus.Core.Infra.Common/sejf/. vm/Import-NerthusEnv.ps1 loads .env into the process
environment and looks a required key up in the vault when .env does not carry it. A fleet
pipeline runs on no device and reads no .env: its credentials are CI/CD variables on the consumer
fleet repository, and the roster below is that half.
.env stopped holding secrets on 2026-08-21¶
It used to hold all of them, and this page used to teach that. Project 76 moved every live laptop
credential into the secret tree, because a secret in a .env file is a secret outside the one list
the estate reconciles — which is the failure sejf exists to prevent, not a tidiness preference.
Precedence is three deep now, and the first two are unchanged: the process environment wins,
then .env, then the vault. So setting a secret in .env still works and still overrides — useful
for a local experiment against a throwaway credential. It is simply no longer where they live.
Three ways a tool gets a secret¶
| when | how | |
|---|---|---|
| the wrapper | the default — anything that takes its credentials from the environment | sejf/Invoke-SejfEnv.ps1 decrypts the names you ask for into a child process's environment and runs your command there. Nothing is written to disk and nothing is left in your shell |
| the ephemeral render | a consumer that demands a file — docker compose --env-file, gitlab-runner's config.toml |
sejf/Invoke-SejfRender.ps1 writes 0600, runs the command, and deletes the file in a finally, so a failed run leaves nothing behind |
| the loader fallback | anything already using vm/Import-NerthusEnv.ps1 |
a required key absent from .env is looked up in the vault automatically. This is why adoption needed no flag day |
# the wrapper: one credential into one command, nothing at rest
../Nerthus.Core.Infra.Common/sejf/Invoke-SejfEnv.ps1 -Names DISCORD_BOT_TOKEN \
-Command pwsh -Arguments 'vm/Register-NerthusDiscordCommands.ps1'
# two at once, from two different documents — resolving the name to its file is the wrapper's job
../Nerthus.Core.Infra.Common/sejf/Invoke-SejfEnv.ps1 -Names GITLAB_TRIGGER_TOKEN,DISCORD_BOT_TOKEN \
-Command pwsh -Arguments '-File','vm/Set-NerthusGitLabPipelines.ps1'
# a consumer that needs a file, and the file is gone when the command returns
../Nerthus.Core.Infra.Common/sejf/Invoke-SejfRender.ps1 -Names NERTHUS_RUNNER_TOKEN \
-To compose/.env -Command docker -Arguments 'compose','--env-file','compose/.env','up','-d'
There is no -- in those lines. PowerShell consumes -- before a script sees it, so the
command is two named parameters: -Command and -Arguments.
A name you ask for may not be the name the tree files it under. The wrapper resolves an entry
name first and then a declared env: alias — GITLAB_TRIGGER_TOKEN finds GL_TRIGGER_TOKEN,
CF_TOKEN finds CF_API_TOKEN — because matching them by similarity would be a guess that reads
as a measurement. A name in two documents that both carry a real value is refused, not guessed:
pass -From ci/nerthus.core.infra.dev.yaml to say which you mean.
On a machine with no age identity the answer is «key X: not in .env and no age identity at ~/.config/sops/age/nerthus-sejf.key», which is a different problem from «the key is nowhere» and says so. See Publish an age key.
The file¶
Create .env at the root of the nerthus.core checkout with the values for the project you are wiring — the dev lore
repository (repozytorium-fabularne-prodkopia) for a test bed, never production secrets on a shared machine:
The test bed is the shared dev corpus, and that is a change in what this file points at
nerthus.testrepo — a public, disposable project this file named for months — does not
exist: glab api projects/margonem%2Fnerthus%2Fit%2Fnerthus.testrepo answers
404 Project Not Found, on a credential that reads repozytorium-fabularne-prodkopia in the same
breath. What replaces it is not disposable. repozytorium-fabularne-prodkopia is private, it is
a real clone of the campaign rather than a fixture, every host in the dev fleet converges on
it, and other people and other tooling read it. Point a daemon at it and you are pointing a
daemon at shared state.
# .env (gitignored) — NON-SECRET CONFIG ONLY
GITLAB_PROJECT_PATH=margonem/nerthus/it/repozytorium-fabularne-prodkopia # project PATH, not a numeric id
GITLAB_PUSH_USER=oauth2 # push username for the report /zgłoś MR (PAT authenticates, so oauth2 is foolproof)
DISCORD_APP_ID=…
DISCORD_PUBLIC_KEY=… # Ed25519 hex, interaction verify — public by design
DISCORD_GUILD_ID=…
DISCORD_BOT_INVITE_URL=https://discord.com/api/oauth2/authorize?client_id=…&scope=applications.commands%20bot
NERTHUS_INFRA_PROJECT_PATH=margonem/nerthus/it/nerthus.core.infra.dev
NERTHUS_FLEET_PROJECT_PATH=…
The secrets that used to sit beside those — DISCORD_BOT_TOKEN, CF_TOKEN,
GITLAB_TRIGGER_TOKEN — are in the vault, and the table below says which document holds each.
GITLAB_ACCESS_TOKEN is in neither: both laptop copies were expired and were deleted, and
re-minting it is a person's act because it is a personal token and an agent minting one would
put a person's identity behind unattended jobs.
The committed .env.example at the checkout root lists the full key set with inert placeholders; copy it to .env and fill in the values. .env itself stays gitignored.
Key catalogue¶
Each key, what it holds, and which component reads it today. This table is the registry — extend it when you add a component (see Add a key for a new component).
| Key | What it holds | Read by |
|---|---|---|
GITLAB_PROJECT_PATH |
Project PATH (group/.../project) of the target GitLab project — URL-encoded onto /projects/:id calls, so no numeric id is needed |
Set-NerthusGitLabPipelines (-Project), Publish-NerthusReportWorker (deploys GL_PROJECT_PATH); Set-NerthusCiLocalEnvFromRemote does not read it — the off-CI write-back parses the same path straight out of the remote URL into CI_PROJECT_PATH, so that shim makes no API call and cannot fail on a bad PAT before the push does |
GITLAB_ACCESS_TOKEN |
GitLab PAT, api + write_repository scope. In neither .env nor the vault, and the estate holds no live one — three copies were found expired and deleted rather than vaulted (two in the repositories on 2026-08-20, a third outside them on 2026-08-21); re-minting is a person's act, because a personal token behind unattended jobs puts a person's identity there. The runner provisioner no longer needs one to move a runner — only to mint, -Force or -Unregister |
Set-NerthusGitLabPipelines (-Token / -SettlePushToken), New-NerthusCiTestRepo (-Token, https push), Set-NerthusCiLocalEnvFromRemote (push tokens) |
GITLAB_TRIGGER_TOKEN |
GitLab pipeline trigger token. In the vault: edge/nerthus-report.yaml as GL_TRIGGER_TOKEN, with env: GITLAB_TRIGGER_TOKEN declared, so the wrapper answers to either name |
minted by Set-NerthusGitLabPipelines -PassThruTriggerToken; deployed as the Worker's GL_TRIGGER_TOKEN secret by Publish-NerthusReportWorker; fired by tests/e2e/ |
GITLAB_PUSH_USER |
Push username for the report /zgłoś MR branch — the PAT authenticates, so oauth2 works (any non-empty value does) |
Initialize-NerthusCiTestBed → Set-NerthusGitLabPipelines -PushUser (sets the unprotected NERTHUS_PUSH_USER CI/CD var, paired with the PAT as NERTHUS_PUSH_TOKEN) |
DISCORD_APP_ID |
Discord application/client id | Register-NerthusDiscordCommands (-AppId), Publish-NerthusReportWorker (deploys DISCORD_APP_ID) |
DISCORD_BOT_TOKEN |
Discord bot token — registers the commands and posts @discord channel messages (the daemon/CI read it). In the vault: ci/nerthus.core.infra.dev.yaml |
Register-NerthusDiscordCommands (-Token); the daemon (Start-NerthusDaemon, host-local or the same CI var) |
DISCORD_PUBLIC_KEY |
Discord Ed25519 interaction-verify key (hex) | Publish-NerthusReportWorker (deploys DISCORD_PUBLIC_KEY, which the Worker verifies interactions with) |
DISCORD_GUILD_ID |
Discord test guild (server) id | Register-NerthusDiscordCommands (-GuildId) |
DISCORD_BOT_INVITE_URL |
Bot-invite OAuth URL | Register-NerthusDiscordCommands (printed up front so guild registration does not 403) |
CF_TOKEN |
Cloudflare API token — DNS · Edit (the report Worker's custom domain), Workers Scripts · Edit. In the vault: ci/nerthus.core.infra.dev.yaml as CF_API_TOKEN, with env: CF_TOKEN declared |
Publish-NerthusReportWorker (-Token) |
CF_ACCOUNT_ID |
Cloudflare account id | wrangler worker deploys (Publish-NerthusReportWorker) |
The three GitLab keys are read directly; the four Discord non-secret values also live as committed
placeholder literals in vm/report-worker.wrangler.toml, which Publish-NerthusReportWorker
overrides at deploy time from these keys.
The fleet contract repo (nerthus.core.infra.common) carries the same idiom for its operator-run
edge scripts: a root Import-NerthusEnv.ps1, a .gitignored .env, and a committed
.env.example naming CF_ZONE, CF_TOKEN, and CF_ACCOUNT_ID — the keys Set-NerthusEdgeRules
and Publish-NerthusCorsWorker default from. The token value may be the same; each repo's
.env.example names the scopes its own scripts spend (Publish the API).
Load it¶
Run the loader once per shell before the other scripts, or let
vm/Initialize-NerthusCiTestBed.ps1 load it for the whole chain:
Precedence is deliberate, so a real run stays authoritative:
- A value already in the environment wins — an explicit
$env:you exported, or GitLab's ownCI_*inside a pipeline..envonly fills the gaps, unless you pass-Force. - A script's own
-Paramstill overrides.envwhen you pass it. - A missing
.envis a silent no-op, so the scripts stay safe to run where no file exists (a real CI runner, a fresh checkout).
The fleet repository's CI/CD variables¶
A consumer fleet repository (nerthus.core.infra.dev today) carries seven CI/CD variables. They
are the consumer half of the fleet contract: a fleet job runs in CI, reads no device, and takes
every credential it spends from this list.
| Key | What it holds | Masked | Absent |
|---|---|---|---|
CF_API_TOKEN |
the fleet's own Cloudflare token | yes | fleet:provision throws mid-run against the Cloudflare API |
CF_ACCOUNT_ID |
the account the tunnels and the KV namespace live in | no | the tunnel and KV calls have no account path |
CF_KV_NAMESPACE_ID |
the health Worker's KV namespace id | no | provision logs health Worker expectations NOT updated and exits green |
NERTHUS_INFRA_PUSH_TOKEN |
machine-user PAT, api + write_repository |
yes | GitLab refuses the provision state commit-back |
NERTHUS_LORE_PROJECT_PATH |
the lore project this fleet deploys | no | the membership check and the deploy-key reconcile are skipped |
DISCORD_BOT_TOKEN |
the bot token fleet:notify posts as |
yes | notify prints the notice into the job log and exits green |
DISCORD_GUILD_ID |
the guild holding kanal-wewnetrzny-it |
no | the channel cannot be resolved by name |
Every absence surfaces late rather than at validation, which is why the roster is set as one act.
Two are worth spelling out. CF_KV_NAMESPACE_ID is the only channel between the pipeline and the
health Worker, so without it the Worker holds no expectations and sweeps nothing — no alerts, no
failover, no daily summary (Publish the API
creates the namespace). NERTHUS_LORE_PROJECT_PATH is what lets provision reconcile the lore
deploy keys, and that reconcile is how a promoted primary's key gains can_push — with automatic
failover armed, a fleet missing this variable fails over into a primary that cannot publish.
The Cloudflare token is a separate instance from the CF_TOKEN on any workstation, and it spends
more scopes:
- Account → Cloudflare Tunnel · Edit — creating and reading each host's named tunnel.
- Zone → DNS · Edit — the per-town CNAMEs and the apex pointer.
- Zone → Zone WAF · Edit and Zone → Cache Rules · Edit — one ruleset phase each,
http_ratelimitandhttp_request_cache_settings. - Zone → Workers Routes · Edit — attaching the CORS Worker to each town hostname.
- Account → Workers KV Storage · Edit — writing the health Worker's expectations.
- Zone → SSL and Certificates · Read — provision's last pass reads the zone's Universal SSL setting, unguarded, after the tunnel, DNS, secret and ruleset work has already landed. A token without this scope throws there and leaves a half-applied provision.
A wrong or missing scope surfaces as a bare "Authentication error" rather than as the permission it wanted, so mint the token against this list rather than debugging it from the message.
Set the whole roster in one idempotent pass with the fleet contract's own script, from a checkout of the contract:
Each value defaults from the same-named environment key, so .env plus
./Import-NerthusEnv.ps1 fills most of them. CF_API_TOKEN deliberately does not fall back to
CF_TOKEN: the workstation token is a different instance by doctrine, and reusing it has to be an
explicit -CfApiToken $env:CF_TOKEN. Drop -WhatIf to apply.
Three properties matter when you run it:
- It preflights the PAT. The script authenticates with
-Token, or withGITLAB_ACCESS_TOKEN, and that PAT needs theapiscope and Maintainer on the consumer repository. It reports the access level it found rather than a raw 403 three variables in. - Every write is delete-then-create. A plain
PUTkeeps a staleMaskedorProtectedflag, so replacing is what makes the flags land as the table says. - Every variable is Protected by default. An infra repository keeps
mainprotected and its fleet jobs run only there — the opposite of the lore repository's permanently unprotectedmain. This script never touches branch protection.
A run that could not set everything names the gaps: NOT set (no value supplied): CF_KV_NAMESPACE_ID
— the jobs reading these will fail or silently degrade. An incomplete run must not read as a
complete one.
Add a key for a new component¶
When a future component needs a credential, wire it the same way so .env stays the one
configuration surface:
- Name the key and add it to the file and the catalogue above, with its component in the Read by column.
- Default the component's parameter from it. Load
.envin the script body, thenif (-not $Thing) { $Thing = $env:THE_KEY }, and throw a clear error if it is still empty — do not use[Parameter(Mandatory)](it prompts instead of reading the env). The existing scripts are the pattern. - Never log the value. Pass secrets to external tools over stdin (as
Publish-NerthusReportWorkerdoes withwrangler secret put), never on a command line, and redact any credential from error messages (Invoke-NerthusCiGitscrubsoauth2:<token>@).
Where the master list is¶
This page's .env is a local copy, not a source. The estate's own list is sejf/ in
nerthus.core.infra.common: a cleartext, value-free inventory.yaml beside SOPS-over-age
material under ci/, edge/, hosts/ and services/, with a self-test that reports where the
list and the values disagree.
It exists because of a failure that was not a leak: a Worker secret that was supposed to exist did not, its config and its runbook both said it did, and eleven months of fleet alerts went nowhere because nothing compared the two. The requirement is one list, reconciled — not somewhere safe to put values.
The secret tree is that page: what the tree is, how to add a credential to
it, how to rotate one, and what rotate_by means. Treat the inventory as the register of what
should exist and this page as how a device is given its copy. An entry with no rotate_by is
unknown rather than healthy — a sweep that counted those as fine would print a clean report over
exactly the credentials whose expiry gets discovered during the outage their fallback exists for.
Security¶
- Keep
.envgitignored. It is listed in.gitignore; never commit it, and never let a convenience wrapper write a resolved value into a tracked file (report-worker.wrangler.tomlkeeps production literals; test values stay in.envand reach the Worker via--var). - These are live plaintext secrets even on a test bed — a real bot token and an
api + write_repositoryPAT that can push and read the repo. - Rotation is cheap here — prefer it to caution. On a test bed, reset the token in its portal, revoke and re-mint the PAT and trigger token, and re-run the (now env-driven) provisioner. Make rotation the default response to any suspected exposure rather than an incident.
See also¶
- Set up a CI test repository — seeds and stands up the whole test-bed
edge from this
.env - Set up the GitLab pipelines — the provisioner whose params default from the GitLab keys
- Set up session reports — the Discord app and Worker the Discord keys wire
- Publish the API — the edge steps that spend the Cloudflare keys, and the KV
namespace
CF_KV_NAMESPACE_IDnames fleet.settings.yml— the committed half of a fleet repository's configuration, beside these variables