Skip to content

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 vaultNerthus.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 filedocker 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-NerthusCiTestBedSet-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:

./vm/Import-NerthusEnv.ps1

Precedence is deliberate, so a real run stays authoritative:

  • A value already in the environment wins — an explicit $env: you exported, or GitLab's own CI_* inside a pipeline. .env only fills the gaps, unless you pass -Force.
  • A script's own -Param still overrides .env when you pass it.
  • A missing .env is 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_ratelimit and http_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:

./ci/Set-NerthusFleetCiVariables.ps1 -Project margonem/nerthus/it/nerthus.core.infra.dev -WhatIf

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 with GITLAB_ACCESS_TOKEN, and that PAT needs the api scope 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 PUT keeps a stale Masked or Protected flag, so replacing is what makes the flags land as the table says.
  • Every variable is Protected by default. An infra repository keeps main protected and its fleet jobs run only there — the opposite of the lore repository's permanently unprotected main. 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:

  1. Name the key and add it to the file and the catalogue above, with its component in the Read by column.
  2. Default the component's parameter from it. Load .env in the script body, then if (-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.
  3. Never log the value. Pass secrets to external tools over stdin (as Publish-NerthusReportWorker does with wrangler secret put), never on a command line, and redact any credential from error messages (Invoke-NerthusCiGit scrubs oauth2:<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 .env gitignored. 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.toml keeps production literals; test values stay in .env and reach the Worker via --var).
  • These are live plaintext secrets even on a test bed — a real bot token and an api + write_repository PAT 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