This is the full developer documentation for Northplane
# Northplane
> Monitoring and alarming in one static binary — from the first ping to the phone call that wakes someone up.
Install in five minutes
One binary, one container, or a Compose stack with automatic TLS. Open `/setup`, create the admin, add a host. [Quickstart →](/docs/getting-started/quickstart/)
Monitor anything
17 built-in checks, Nagios plugins, SNMP polling and traps, the `np-agent`, heartbeats, discovery, business services and SLAs, dashboards and reports. [Monitoring →](/docs/monitoring/hosts-and-services/)
Alarm everyone
Phone, SMS, MQTT, ESPA, e-mail and webhooks in — voice calls with IVR, SMS, push, ntfy, Slack, Teams and tickets out. On-call schedules, escalation chains, durable retries, full audit. [Alarming →](/docs/alarming/overview/)
API-first, AI-ready
Every capability is a REST endpoint with RBAC and tenants; the UI, the `np` CLI, the AI agent chat and the MCP server all use it. [API reference →](/docs/reference/api-overview/)
## Find your way
[Section titled “Find your way”](#find-your-way)
[I operate an instance](/docs/administration/configuration/)Configuration reference, authentication, storage, TLS, upgrades, security hardening.
[I run production](/docs/deployment/overview/)Deployment variants, the CI/CD pipeline, provisioning, operations runbook and the verified environment inventory.
[I integrate with it](/docs/reference/api-overview/)REST conventions, ingest webhooks, SSE stream, the OpenAPI reference, the four CLIs and the MCP server.
[I work on the code](/docs/development/setup/)Dev loop, tests, backend and frontend architecture, release process, and how these docs are built.
[I use the UI](/docs/ui/navigation/)Every page, dialog and Admin tab explained, with keyboard shortcuts and the tenant switcher.
[I want the concepts first](/docs/concepts/architecture/)Architecture, object model, checks and states, events, alerts and incidents, tenancy, federation.
## At a glance
[Section titled “At a glance”](#at-a-glance)
| | |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Server** | `northplaned` — one static Go binary with the React UI, this manual, Swagger UI and the MCP server embedded |
| **Storage** | SQLite (default, zero-ops) or PostgreSQL; NP-TSDB for metrics; durable outbox; tamper-evident audit chain |
| **Inputs** | built-in checks, Nagios plugins, `np-agent`, SNMP v1/v2c/v3 polling and traps, heartbeats, webhooks, Alertmanager, IMAP, MQTT, ESPA 4.4.4 / ESPA-X, phone (Twilio or Asterisk), SMS |
| **Outputs** | voice with IVR acknowledgement, SMS, e-mail, mobile push (FCM/APNs), Web Push, ntfy, Slack, Teams, webhooks, MQTT, ServiceNow / Jira / Zendesk tickets |
| **Control plane** | REST API with RBAC, tenants, sites (federation), API tokens, OIDC / LDAP, YAML config bundles, `np` CLI |
| **AI** | agent chat over 10 LLM provider types with policy gates and approvals; MCP server for Claude, Cursor, VS Code and friends |
| **Runs as** | binary + systemd, Docker (distroless), Docker Compose with Caddy TLS, or behind your own reverse proxy |
These pages are shipped inside every Northplane binary at `/docs/` and mirror the version they came with. The public showcase instance is [doktrace.com](https://doktrace.com) (its docs: [doktrace.com/docs](https://doktrace.com/docs/)). Machine-readable copies for AI assistants: [`llms.txt`](/docs/llms.txt), [`llms-full.txt`](/docs/llms-full.txt).
# Demo mode
> northplaned serve --demo and NORTHPLANE_DEMO seed a complete, idempotent showcase — hosts, checks, alarm chain, on-call, BPI, dashboard, report, two demo users — guarded against real data and kept in its own data directory.
Demo mode seeds a self-contained showcase environment into the default tenant so you can click through a populated instance: real built-in checks against loopback and public targets, a passive job with a heartbeat, the full notification/escalation/on-call stack, a business-service tree with an SLA, a dashboard, a scheduled report, inbound event sources, a recurring downtime and two demo users. Seeding only writes configuration — the scheduler and executor then run the checks live.
## Enabling it
[Section titled “Enabling it”](#enabling-it)
| How | Behaviour |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `northplaned serve --demo` | seeds **unconditionally** on every start (idempotent). Optional flags: `--demo-snmp host:161` (target of the SNMP demo checks, default `127.0.0.1:161`) and `--demo-traps udp://:9162` (listen address of the demo SNMP-trap source). This is what `make dev` and the e2e suite use. |
| `demo: true` in `config.yaml` or `NORTHPLANE_DEMO=true` | seeds on start, but only after the **real-data guard** passes (below). This is the demo/real switch of the production stacks. |
Seeding runs before the HTTP listener comes up; a failure is fatal (`demo seed: …`). The log reports what happened:
```text
demo: user ready name=demo-operator email=operator@demo.local password=operator-demo-2026! role=operator
demo: user ready name=demo-viewer email=viewer@demo.local password=viewer-demo-2026! role=viewer
demo: hint msg="passive service demo-batchjob & heartbeat demo-cron have no live feeder — …"
demo: hint msg="channel demo-email points at a mock SMTP sink on 127.0.0.1:2525 and demo-hook at http://127.0.0.1:18081/hook — …"
demo: hint msg="event-source demo-hook-in uses authMode=token with secretRef \"demo-hook-in-token\" — …"
demo: environment seeded counts=map[alert-rule:2 business-service:4 channel:2 …]
```
## Demo users
[Section titled “Demo users”](#demo-users)
| Login | Password | Role | Can |
| -------------------------------------------- | --------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `operator@demo.local` (name `demo-operator`) | `operator-demo-2026!` | `operator` | read everything, create and edit hosts/services, ack/resolve/raise alerts, incidents, downtimes, silences, on-call, dashboards, reports — but **no** Admin tabs and no config documents (templates, rules, channels need `config:write`). |
| `viewer@demo.local` (name `demo-viewer`) | `viewer-demo-2026!` | `viewer` | read only. |
The demo does **not** create an administrator. The admin is the break-glass account that `northplaned serve` seeds on every start unless `NP_DEFAULT_ADMIN_DISABLED` is set (`admin@localhost` with a generated password in the log, or `NP_DEFAULT_ADMIN_EMAIL` / `NP_DEFAULT_ADMIN_PASSWORD`) — see the [Quickstart](/docs/getting-started/quickstart/#2-create-the-admin-account).
Demo users close /setup
The demo users are local accounts, so after `--demo` the first-run `/setup` page is closed even if you disabled the default-admin seeding. If you run `NP_DEFAULT_ADMIN_DISABLED=1 northplaned serve --demo` you have no admin at all; create one headlessly like the e2e suite does — `northplaned bootstrap-admin` for a `*:*` token, then `POST /api/v1/users` with `roles: ["admin"]` — or keep the default-admin seeding on.
## What is seeded
[Section titled “What is seeded”](#what-is-seeded)
Every artefact is named `demo-…`, labelled `demo=true`, and lives in the default tenant.
| Kind | Names and key settings |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Templates | `demo-host-base` (host: interval 30 s, retry 10 s, 2 attempts, timeout 10 s), `demo-web-service` (service: 60 s, timeout 10 s) |
| Hosts (folder `/demo`) | `demo-gateway` (`127.0.0.1`, `builtin:icmp`, 15 s; labels `role=gateway`, `site=demo`); `demo-web` (`builtin:https` against `https://example.org`, parent `demo-gateway`, template `demo-host-base`); `demo-dns` (`builtin:dns -H example.org`, parent `demo-gateway`); `demo-snmp-device` (`builtin:snmp` sysUpTime against the `--demo-snmp` target, 30 s) |
| Services | `demo-snmp-ifwalk` (`snmp-walk` ifOperStatus, 60 s) and `demo-tls` (`tls-cert example.org:443 -w 21 -c 7`, every 6 h) on `demo-snmp-device`; `demo-web-latency` (`https -w 1.0 -c 3.0`, 30 s) on `demo-web`; `demo-batchjob` (passive, `stalenessAfter` 10 m, contact group `demo-ops`, `notifyOn` critical+recovery) on `demo-gateway` |
| Heartbeat | `demo-cron` — expect every 5 m, grace 1 m, severity warning |
| Contacts, group | `demo-alice` (`alice@demo.local`, e-mail), `demo-bob` (`bob@demo.local`, webhook + e-mail), group `demo-ops` |
| Channels | `demo-email` (SMTP `127.0.0.1:2525`, from `northplane@demo.local`, `allowPlaintext`), `demo-hook` (webhook `http://127.0.0.1:18081/hook`) |
| Alert group | `demo-storm` (group by host, 5 m window, min count 3) |
| Escalation policy | `demo-escalation`: step 0 → `demo-ops` by e-mail; +15 m unless acked → `demo-bob` by webhook |
| Alert rules | `demo-critical` (CEL: hard `state_change` to CRITICAL/DOWN; severity critical; title `demo: {{ .event.object }} is {{ .event.state }}`; policy `demo-escalation`; group `demo-storm`; sets label `demo=true`), `demo-heartbeat-rule` (heartbeat rule on `demo-cron`, every 5 m, warning) |
| On-call schedule | `demo-oncall` (Europe/Vienna; layer `primary`, weekly alice → bob, anchored Monday 2026-01-05 08:00) |
| Business services | root `demo-webshop` (rule worst, SLA 99.9 % monthly) with leaves `demo-webshop-web`, `demo-webshop-dns`, `demo-webshop-gateway` bound by selectors such as `role=web,demo=true` |
| Dashboard | `demo-overview` (shared): counters, problems, metric chart of `demo-web-latency` (`time`, 3 h), BPI `demo-webshop`, table with selector `demo=true` |
| Report | `demo-availability`: availability over 30 days for `demo=true`, folder `/demo`, schedule `daily@07:00`, e-mailed to alice, keep 7 |
| Event sources | `demo-hook-in` (webhook, token auth, `secretRef: demo-hook-in-token`), `demo-traps` (SNMP trap listener on the `--demo-traps` address, community `public`, severity warning), `demo-imap` (IMAP `127.0.0.1:3143`, **disabled**) |
| Downtime | `demo-batchjob-nightly`: fixed, next 03:00 Europe/Vienna for 1 h, `RRULE FREQ=DAILY;BYHOUR=3;BYMINUTE=0` |
| Users | `demo-operator`, `demo-viewer` (above) |
What you will see after a minute: `demo-gateway` UP (if ICMP works for the server’s user), `demo-web`, `demo-dns`, `demo-tls` and `demo-web-latency` OK when the host has internet access, the SNMP objects CRITICAL/UNKNOWN unless `--demo-snmp` points at a reachable SNMP agent, `demo-batchjob` turning UNKNOWN (stale) after 10 minutes, and the `demo-cron` heartbeat reported missing right away — which opens a warning alert through `demo-heartbeat-rule`, so the alarm pipeline has something to show.
### Parts that need a helping hand
[Section titled “Parts that need a helping hand”](#parts-that-need-a-helping-hand)
The seeder writes configuration only; a few pieces point at infrastructure it does not start:
* `demo-email` and `demo-hook` deliver to a mock SMTP sink on `127.0.0.1:2525` and a webhook sink on `127.0.0.1:18081`. Nothing listens there by default, so their deliveries fail, retry with backoff and end up under **Admin → Dead letters** — a realistic demonstration of the outbox, but run a sink (any SMTP test server, any HTTP echo) on those ports if you want green deliveries.
* `demo-hook-in` authenticates inbound webhooks with the secret `demo-hook-in-token`, which is not created. Store it (`PUT /api/v1/secrets/demo-hook-in-token`, or **Admin → Secrets**) and then `POST /api/v1/ingest/demo-hook-in` with `Authorization: Bearer `.
* `demo-batchjob` and `demo-cron` have no feeder. Submit a result (`POST /api/v1/results` with `{"results":[{"host":"demo-gateway","service":"demo-batchjob","state":0,"output":"batch ok"}]}`) and beat the heartbeat (`POST /api/v1/heartbeats/demo-cron/beat`), both with a token holding `objects:write`, to watch them recover. (The log hint names `/checks/results`; the real path is `/api/v1/results`.)
* The SNMP demo wants an SNMP agent: `--demo-snmp 10.0.0.1:161` targets a real device with community `public`; traps sent to the `--demo-traps` port (`9162/udp`, publish it in Docker) show up as events.
## Idempotency and the real-data guard
[Section titled “Idempotency and the real-data guard”](#idempotency-and-the-real-data-guard)
* **Idempotent.** Re-running the seed updates in place: configuration resources are upserted by name, objects are matched by kind, host and name, and ids are derived deterministically from the names (SHA-256-based, UUID-shaped), so cross-references such as BPI parents stay valid. Existing demo users are reported again instead of failing. You can leave `--demo` on permanently.
* **Guarded.** With `demo: true` / `NORTHPLANE_DEMO=true` the server first checks whether the default tenant already contains **any host without the label `demo=true`** (up to 5000 hosts; a query error counts as “real data”). If so it logs `NORTHPLANE_DEMO is set but this database already holds real (non-demo) hosts — skipping demo seeding to protect production data; use a dedicated data dir/volume for the demo, or unset NORTHPLANE_DEMO` and starts without seeding. The explicit `--demo` flag bypasses the guard — do not use it on a production data directory.
* **No teardown command.** Demo artefacts are easy to find (label `demo=true`, prefix `demo-`, the Objects page filter `demo=true`), but the clean way to get rid of them is the one the production stacks use: a separate data directory you can delete.
## Demo and real data directories in the production stack
[Section titled “Demo and real data directories in the production stack”](#demo-and-real-data-directories-in-the-production-stack)
The CI-managed stacks under `deploy/` treat `NORTHPLANE_DEMO` as a switch that also selects the data directory inside the same volume:
deploy/.env (rendered by the deploy workflow, excerpt)
```ini
NORTHPLANE_DEMO=true
NORTHPLANE_DATA_DIR=/var/lib/northplane/demo # false → /var/lib/northplane/real
```
Demo mode uses `/var/lib/northplane/demo`, real mode `/var/lib/northplane/real`, so flipping the switch never mixes the datasets and each side keeps its own database, events, TSDB and `secret.key`. The GitHub variable `NORTHPLANE_DEMO` (and the `demo` dropdown of the manual Deploy run: `repo-default` / `true` / `false`) controls it; the public showcase instance has run in real mode since 2026-08-20 with its demo directory kept alongside. Details: [CI/CD](/docs/deployment/ci-cd/) and [Operations](/docs/deployment/operations/).
For a hand-run container the same idea is `-e NORTHPLANE_DEMO=true -e NORTHPLANE_DATA_DIR=/var/lib/northplane/demo`, or simply a second named volume.
## Development and tests use it too
[Section titled “Development and tests use it too”](#development-and-tests-use-it-too)
* `make dev` starts the backend with `-demo` (set `NP_DEV_DEMO=0` to skip) and prints the demo credentials; the generated break-glass admin password appears in the `[api]` log lines.
* The Playwright end-to-end suite (`make e2e`) boots an isolated `northplaned serve --demo` with `NP_DEFAULT_ADMIN_DISABLED=1`, mints a token with `bootstrap-admin`, creates its own admin through `POST /api/v1/users`, and pins the browser locale to `de-DE` — so the demo data is what the e2e tests click through ([Testing](/docs/development/testing/)).
* The CI `e2e` job does the same against every commit.
## Related
[Section titled “Related”](#related)
* [Quickstart](/docs/getting-started/quickstart/) and [First steps](/docs/getting-started/first-steps/)
* [Configuration](/docs/administration/configuration/) — the `demo` key and `NORTHPLANE_DEMO`
* [CLI: northplaned](/docs/reference/cli-northplaned/) — `serve --demo`, `--demo-snmp`, `--demo-traps`
* [Storage](/docs/administration/storage/) — data directory layout and backups
# First steps
> Orientation after the install — the UI in ten minutes, creating objects in the UI and with a bundle, templates, a minimal channel → contact → escalation policy → rule chain, your first API token and the np CLI, and installing np-agent.
You have a running instance and an admin login ([Quickstart](/docs/getting-started/quickstart/)). This page is the guided tour that follows: where things are in the UI, how to create objects properly, how the alarm chain fits together, and how to talk to the API.
## The UI in ten minutes
[Section titled “The UI in ten minutes”](#the-ui-in-ten-minutes)
The sidebar has sixteen entries; labels follow your browser language (German or English — there is no in-app switch). The important stops, in the order you will use them:
| Page | What it is for |
| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Overview (Übersicht)** `/` | Four KPI tiles (hosts up, services OK, active problems, open alerts), the open problem list, a service-status donut, open incidents, who is on call now, the last 20 events. **Wallboard** (`/?wallboard=1`) is the same page without chrome, refreshing every 10 s. |
| **Problems (Probleme)** | Every object in a hard non-OK state, with hover actions **Ack (Quittieren)**, **Downtime** and **Check now (Jetzt prüfen)**; a checkbox includes acknowledged/downtime objects. |
| **Objects (Objekte)** | All hosts and services. Two filter boxes: a **label selector** (`env=prod,role in (db,cache)`) and a **full-text** search over name and output, plus kind and state selects; filters live in the URL so views are linkable. Buttons **New host**, **New service**, **Batch add (Massenanlage)**. Click a row for the detail page with **Overview / History / Configuration** tabs. |
| **Alerts (Alarme)** | Open and acknowledged alerts with **Ack** and **Resolve**; **Trigger alarm (Alarm auslösen)** raises a manual alert through an escalation policy. **Incidents** groups alerts; **Events** is the raw, filterable event log with an NDJSON export. |
| **Alert rules (Alarm-Regeln)** `/alerting` | Tabs **Alert rules** (with a tester), **Groups**, **Escalations** (with a simulator), **IVR menus** — the alarming configuration. |
| **On-Call (Bereitschaft)** | Schedules with layers, 14-day timeline, overrides, ICS export, who is on duty now. |
| **Dashboards**, **Business services**, **Reports** | Grid dashboards with 11 widget types; BPI trees with SLA budgets; scheduled availability/SLA/alert/on-call/audit reports. |
| **Maintenance (Wartung)** | Silences and downtimes (fixed, flexible, recurring RRULE). |
| **Templates** | Tabs **Templates**, **Check commands**, **Time periods**. |
| **Discovery** | CIDR scans and one-click adoption of the suggestions. |
| **AI agent (KI-Agent)** | The agent chat workspace (needs a provider connection). |
| **Admin (Administration)** | 21 tabs: Users, Roles, Contacts, Contact groups, Channels, Event sources, Webhooks, Heartbeats, Tenants, Sites, Secrets, API tokens, MCP, Agents, Dead letters, Config bundles, Audit log, AI approvals, AI providers, System health, Appearance. |
Useful everywhere:
* **Ctrl/⌘ K** opens the command palette: jump to pages, search objects by name, open the Wallboard or the API docs. **Ctrl/⌘ I** toggles the assistant sidebar. Two-key chords `g o`, `g p`, `g h`, `g a`, `g e` go to Overview, Problems, Objects (hosts), Alerts, Events.
* The sidebar’s **Refresh (Aktualisierung)** select (5 s–60 s or off, default 30 s) controls how often the live lists poll. The UI polls; it does not hold an SSE connection.
* **Admin → Appearance (Darstellung)** sets the instance-wide colour theme (31 to choose from) and light/dark mode; every user sees the same branding.
* An admin with `admin:tenants` sees a **tenant switcher** at the top of the sidebar.
The full map of every page and dialog is in the [User interface](/docs/ui/navigation/) section.
## Create objects
[Section titled “Create objects”](#create-objects)
### In the UI
[Section titled “In the UI”](#in-the-ui)
**Objects → New host (Host anlegen)** opens a dialog with four tabs:
1. **Basics (Basis)** — Name (unique per tenant, cannot be renamed later), Folder (`/` by default, e.g. `/prod/web`), Address, Labels (key/value chips). For a service: Host.
2. **Check (Prüfung)** — the check command as *kind + remainder*: `builtin` (e.g. `icmp`, `http`, `tcp`, `dns`, `snmp` — the field suggests all 17), a *named check command* from the catalog, `exec` (a Nagios plugin under `pluginsDir`), `agent:exec` (run by `np-agent`), or `passive`. A new object starts as `passive`, so pick a kind. Then Arguments (one per entry), Templates, and the scheduling box: Interval (60 s), Retry interval (15 s), Max attempts (3), Timeout (30 s), Check period (`24x7`).
3. **Notifications (Benachrichtigungen)** — contact groups and contacts notified directly on hard changes, which states notify (`notifyOn`), notification period.
4. **Advanced (Erweitert)** — parents (host reachability), check/notification/flap-detection overrides, threshold mode, staleness deadline and text for passive objects, zone, custom vars (`$_HOSTKEY$` macros), a Markdown runbook.
**Batch add (Massenanlage)** creates many objects at once, one per line in the grammar `name address [tmpl,tmpl] [k=v,k=v]`, with a shared folder, check command (default `builtin:icmp`) and mode `partial` or `all-or-nothing`; the dialog previews and validates before it posts to `POST /api/v1/objects:batch`.
Field-by-field reference: [Hosts and services](/docs/monitoring/hosts-and-services/).
### With a bundle and `np apply`
[Section titled “With a bundle and np apply”](#with-a-bundle-and-np-apply)
Everything the dialog does is also a YAML document. A **bundle** is a multi-document YAML file (`---` separated) of `kind` / `metadata` / `spec` documents; kinds are applied in dependency order (templates before hosts before services), and re-applying the same bundle is a no-op. This one creates a template, a host and two services:
web-01.yaml
```yaml
kind: Template
metadata: { name: linux-base }
spec:
kind: host
spec:
checkCommand: builtin:icmp
interval: 30s
maxCheckAttempts: 2
---
kind: Host
metadata:
name: web-01
folder: /prod
labels: { env: prod, role: web }
spec:
address: 10.0.0.10
templates: [linux-base]
---
kind: Service
metadata:
name: https
host: web-01
spec:
checkCommand: builtin:http
args: ["-u", "https://10.0.0.10/", "--insecure", "-w", "1", "-c", "3"]
---
kind: Service
metadata:
name: ssh
host: web-01
spec:
checkCommand: builtin:tcp
args: ["-p", "22"]
```
Note the shape of the `Template` document: its `spec` is the template resource itself, so the inheritable object settings sit one level deeper under `spec.spec`. Host and Service documents put the object spec directly under `spec`.
Apply it with the CLI (needs an API token with `config:write`, see [below](#create-an-api-token-and-use-np)):
```bash
np apply -f web-01.yaml --dry-run # would apply create Template/linux-base …
np apply -f web-01.yaml # applied create Host/web-01 …
np export > everything.yaml # canonical bundle of the whole tenant
```
The same YAML can be pasted into **Admin → Config bundles (Config-Bundles)**, which shows the plan (create/update/delete with field diffs) and applies it in a second step. Fields absent from a bundle are left unmanaged; `--prune` deletes what the bundle no longer contains. Full format, kinds and semantics: [Config bundles](/docs/administration/config-bundles/).
### Templates
[Section titled “Templates”](#templates)
A template is an `ObjectSpec` fragment that objects (and other templates) inherit. Resolution is `built-in defaults ⊕ templates in declared order (later wins) ⊕ the object's own spec`; `vars` are merged key by key, list fields are replaced wholesale. The object detail page shows the resolved result under **Configuration → Effective configuration** together with the template chain, and the API returns it from `GET /api/v1/objects/{id}/effective-config`. Manage templates, reusable named check commands (`exec`/`builtin`/`agent`/`passive` with `$ARGn$`) and time periods under **Templates**. Details: [Templates](/docs/monitoring/templates/) and [Object model](/docs/concepts/object-model/).
## A minimal alarm chain
[Section titled “A minimal alarm chain”](#a-minimal-alarm-chain)
State changes alone do not notify anyone. Northplane notifies through a chain of four resources — channel → contact → escalation policy → alert rule. The minimal version, in the UI:
1. **Channel** — **Admin → Channels (Kanäle) → Create (Anlegen)**. Type `ntfy`, name `ntfy`, **Enabled (Aktiv)** on, Server URL `https://ntfy.sh`, a private topic name. Save and click **Send test (Test senden)**. (Any other type works the same; e-mail needs `provider`, `host`, `from`, credentials — see [Channels](/docs/alarming/channels/).)
2. **Contact** — **Admin → Contacts (Kontakte) → Create**. Name `alice`, E-Mail, optional phone (E.164, for SMS/voice), time zone. Preferences (which channel types at which times and severities) are optional when the policy names its channels explicitly, as below.
3. **Escalation policy** — **Alerting → Escalations (Eskalationen) → Create**. Name `default`; one step: after `0s`, notify **Contact** `alice`, Channels `ntfy`. Add a second step `after 15m`, **unless acked**, to a contact group or the on-call schedule with `voice`/`sms` later. Save; **Simulate (Simulieren)** shows who would be paged when.
4. **Alert rule** — **Alerting → Alert rules (Alarm-Regeln) → New rule (Regel anlegen)**. Name `critical`, source **CEL match**:
```text
event.type == "state_change" && event.stateType == "hard" && (event.state == "CRITICAL" || event.state == "DOWN")
```
Severity `critical`, Escalation `default`, optional Title `{{ .event.object }} is {{ .event.state }}`. **Test rule (Regel testen)** replays the last 24 h of events and lists the alerts that would open.
5. **Try it** — **Alerts → Trigger alarm (Alarm auslösen)**: title, severity, escalation policy `default` → the step fires immediately and ntfy shows the alert. Or break the `https` service (point `-u` at a closed port): after `maxCheckAttempts` × `retryInterval` (3 × 15 s by default) the state goes hard CRITICAL, the rule opens an alert, the chain starts. **Ack** stops the chain; the **Events** page shows the `alert_opened`, `escalation` and `notification` records, and **Admin → Dead letters** collects deliveries that failed permanently.
The same chain as a bundle:
alarm-chain.yaml
```yaml
kind: Channel
metadata: { name: ntfy }
spec:
type: ntfy
enabled: true # required — a channel without it is disabled
config: { url: https://ntfy.sh, topic: northplane-7f3a9c2d }
---
kind: Contact
metadata: { name: alice }
spec:
email: alice@example.org
timeZone: Europe/Vienna
---
kind: EscalationPolicy
metadata: { name: default }
spec:
steps:
- after: 0s
notify: { contact: alice }
channels: [ntfy]
- after: 15m
unlessAcked: true
notify: { contact: alice }
channels: [email] # needs an enabled email channel
---
kind: AlertRule
metadata: { name: critical }
spec:
match: 'event.type == "state_change" && event.stateType == "hard" && (event.state == "CRITICAL" || event.state == "DOWN")'
severity: critical
title: "{{ .event.object }} is {{ .event.state }}"
escalationPolicy: default
```
Three things that trip up first-time setups:
* **Channels are selected by type, not by name.** A step or preference says `ntfy` or `email`, and the notifier uses the first *enabled* channel of that type in name order. Keep one enabled channel per type unless you know why not.
* **`enabled` is not defaulted.** Channels and event sources created through the API or a bundle without `enabled: true` are disabled; the UI sets it for you.
* **A step’s `channels` list overrides the contact’s preferences completely**, including their time-period and minimum-severity gating. Leave the list empty to route by preferences.
Suppression (downtimes, silences, flapping, dependencies), incidents, ack paths and the full pipeline: [Alarming overview](/docs/alarming/overview/).
## Create an API token and use `np`
[Section titled “Create an API token and use np”](#create-an-api-token-and-use-np)
Browser sessions use a cookie; everything else — `np`, `np-agent`, scripts, MCP clients, federation edges — authenticates with an API token `np_` + 48 hex characters, sent as `Authorization: Bearer np_…`.
* **Admin → API tokens (API-Tokens)**: Name plus a comma-separated list of scopes (default `objects:read,alerts:read`). The token is shown **once**. Scopes are `resource:action` permissions with wildcards: `objects:read,alerts:read` for a read-only client, `objects:write` for an agent, `config:write` for `np apply`, `*:*` for an admin automation. The table lists prefix, scopes and last use; **Revoke (Widerrufen)** deletes. Via the API: `POST /api/v1/api-tokens` also takes `roles`, `ipBind` CIDRs and `expiresAt` ([API tokens](/docs/administration/api-tokens/)).
* **Headless**: `northplaned bootstrap-admin -config /etc/northplane/config.yaml` (on the server, against the same data directory) mints a token named `bootstrap-admin` with scope `*:*` and prints it once; it refuses if that token already exists. Minting any token closes the `/setup` page.
Then point the CLI at the instance:
```bash
export NP_SERVER=https://monitoring.example.net # default: https://localhost:8443
export NP_TOKEN=np_0123456789abcdef…
np doctor # /system/info + /system/health, works without a token
np get hosts # STATE NAME HOST LABELS
np get problems
np get alerts
np describe # object JSON + effective config
np apply -f web-01.yaml --dry-run
np ack -m "looking into it"
np oncall
```
Global flags (`--server`, `--token`, `--json`, `--insecure`) must come **before** the command. A development server speaks plain HTTP on loopback, so use `--server http://127.0.0.1:8443` there. `np -h` or `np help` prints usage (`np --help` is rejected as an unknown flag). Every command maps to one or two API calls — the table is in [CLI: np](/docs/reference/cli-np/); the raw API is browsable on the instance at `/api/docs` and documented in the [API overview](/docs/reference/api-overview/):
```bash
curl -s -H "Authorization: Bearer $NP_TOKEN" "$NP_SERVER/api/v1/hosts?limit=5"
```
## Install an agent
[Section titled “Install an agent”](#install-an-agent)
`np-agent` runs on the monitored host and **pushes** results to `POST /api/v1/results` every `interval` (60 s): a host heartbeat plus the services `load`, `memory`, `disk /` (one per configured mount), `processes`, `network` on Linux/macOS (`cpu` instead of `load`/`network` on Windows), and any local Nagios plugins you list under `checks:`. No inbound port on the host is needed.
The server **does not create objects from agent results** — results for an unknown host or service are rejected (`unknown host …`, `unknown object …`). The Admin → Agents tab says the host “appears automatically”; it does not. Create the host and the services you want first, as passive objects with a staleness deadline so a silent agent turns them UNKNOWN:
agent-web-01.yaml
```yaml
kind: Host
metadata: { name: web-01, labels: { agent: "true" } }
spec:
address: 10.0.0.10
checkCommand: passive
stalenessAfter: 3m
---
kind: Service
metadata: { name: load, host: web-01 }
spec: { checkCommand: passive, stalenessAfter: 3m }
---
kind: Service
metadata: { name: memory, host: web-01 }
spec: { checkCommand: passive, stalenessAfter: 3m }
---
kind: Service
metadata: { name: "disk /", host: web-01 }
spec: { checkCommand: passive, stalenessAfter: 3m }
---
kind: Service
metadata: { name: processes, host: web-01 }
spec: { checkCommand: passive, stalenessAfter: 3m }
---
kind: Service
metadata: { name: network, host: web-01 }
spec: { checkCommand: passive, stalenessAfter: 3m }
```
Then, on **Admin → Agents**:
1. Install the binary on the host with the tab’s one-liner (`curl … install.sh | sh`; set `NP_BINARIES=np-agent` to skip the server and CLI), or take `np-agent` from the release tarball or your source build and put it in `/usr/local/bin` (Windows: `np-agent.exe` from the zip).
2. **Create token** with the host name filled in — it mints a token with exactly the scope `objects:write` and pastes it into the `agent.yaml` shown below it.
3. Write `/etc/northplane/agent.yaml` (Windows: `C:\ProgramData\northplane\agent.yaml`):
/etc/northplane/agent.yaml
```yaml
server: https://monitoring.example.net
token: np_…
hostname: web-01 # must equal the Host object's name; default: OS hostname
interval: 60s
disk: ["/"]
# insecure: true # only for a self-signed server certificate
```
4. Start it with the unit snippet from the tab (`systemctl enable --now np-agent`, a launchd plist on macOS, `sc.exe create np-agent …` on Windows) or by hand: `np-agent -config /etc/northplane/agent.yaml`. The log line `np-agent: started host=web-01 …` appears, and within a minute the objects leave **PENDING**. A wrong token shows as `submit failed, buffering … err="HTTP 401"` on the agent and nothing on the server.
The agent keeps up to 10 000 results in memory while the server is unreachable and replays them. Pull mode (the server hands out `agent:exec:` checks; needs `objects:read` too and a `pullAllow` list on the agent) and the NCPA-style listener mode are described in [Agent](/docs/monitoring/agent/).
## Where to go next
[Section titled “Where to go next”](#where-to-go-next)
* [Demo mode](/docs/getting-started/demo-mode/) — seed a complete showcase to click through.
* [Hosts and services](/docs/monitoring/hosts-and-services/) and [Built-in checks](/docs/monitoring/builtin-checks/) — every field and every check flag.
* [Alarming overview](/docs/alarming/overview/), then [Event sources](/docs/alarming/event-sources/), [Escalation policies](/docs/alarming/escalation-policies/), [Contacts and on-call](/docs/alarming/contacts-and-oncall/), [Voice and IVR](/docs/alarming/voice-and-ivr/).
* [Users, roles and permissions](/docs/administration/users-roles-permissions/) and [Authentication](/docs/administration/authentication/) — before you invite colleagues.
* [Security](/docs/administration/security/) — the hardening checklist for anything that faces a network.
* [Agent chat](/docs/ai/agent-chat/) and [MCP server](/docs/ai/mcp-server/) — when you want an assistant on top of the API.
# Installation
> Every way to install Northplane in depth — release tarball and install.sh, northplaned init with systemd, the distroless Docker image, Docker Compose with bundled Caddy, building from source — plus the platform matrix, the PostgreSQL option, file locations, upgrading and uninstalling.
Northplane is one static binary (`northplaned`) plus the `np` CLI and the `np-agent` host agent. Pick the variant that matches how you run services; they all produce the same server with the same data layout, so you can start with one and move to another later.
| Variant | Best for | TLS | You get |
| ----------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------- |
| Release tarball (+ `northplaned init`, systemd) | VMs and bare metal, Linux/macOS | your certificate pair, or a reverse proxy with `trustProxy` | `northplaned`, `np`, `np-agent` in `/usr/local/bin` |
| Docker image | container hosts, Kubernetes, quick trials | your certificate pair, a proxy, or `NORTHPLANE_TLS_INSECURE=true` locally | `northplaned` + `np` in a distroless image (no `np-agent`) |
| Docker Compose with bundled Caddy | a single box that should just have HTTPS | automatic (Let’s Encrypt or an internal CA) | Northplane + Caddy, optional PostgreSQL |
| Build from source | development, unreleased versions, other platforms | as above | `bin/northplaned`, `bin/np`, `bin/np-agent`, `bin/np-gen` |
Releases live on [GitHub](https://github.com/myfoxit/northplane/releases), the container image on GHCR (`ghcr.io/myfoxit/northplane`); both are public — no login, no token.
## Install the binaries or the image
[Section titled “Install the binaries or the image”](#install-the-binaries-or-the-image)
* Release tarball
### Release assets
[Section titled “Release assets”](#release-assets)
Releases are tagged `v*`. Each release carries:
| Asset | Contents |
| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
| `northplane__linux_amd64.tar.gz`, `northplane__linux_arm64.tar.gz` | `northplaned`, `np`, `np-agent`, `LICENSE` |
| `northplane__darwin_amd64.tar.gz`, `northplane__darwin_arm64.tar.gz` | `northplaned`, `np`, `np-agent`, `LICENSE` |
| `northplane__windows_amd64.zip` | `np.exe`, `np-agent.exe`, `LICENSE` — **no** `northplaned` (it needs Unix process groups for plugin execution) |
| `checksums.txt` | `sha256sum` lines for every asset |
The asset name keeps the leading `v` of the tag (`northplane_v1.2.0_linux_amd64.tar.gz`) while the version baked into the binaries strips it (`northplaned version` → `northplaned 1.2.0`). Binaries are static (`CGO_ENABLED=0`), so they run on any glibc or musl distribution.
### `install.sh`
[Section titled “install.sh”](#installsh)
The one-line installer resolves the newest release through the GitHub API, downloads the matching tarball and `checksums.txt`, verifies the SHA-256 and installs `northplaned np np-agent`:
```bash
curl -fsSL https://raw.githubusercontent.com/myfoxit/northplane/main/install.sh | sh
```
What it does, exactly:
* Supports Linux and macOS on `x86_64`/`amd64` and `aarch64`/`arm64`; needs `curl`, `tar` and `sha256sum` or `shasum`.
* Installs into `/usr/local/bin`. If that is not writable it uses `sudo` (the password prompt comes from `/dev/tty`, so this also works in the `curl … | sh` form); without `sudo` it falls back to `~/.local/bin` (created if missing, with a note if it is not on `PATH`).
* Picks the newest release; if only pre-releases exist it takes the newest of those.
* Is safe to re-run: existing binaries are replaced.
* Writes no configuration and no service unit; it ends with the two next steps — `northplaned serve` for a loopback trial and `sudo northplaned init` + `systemctl enable --now northplaned` for a service.
| Variable | Effect |
| ------------------------- | ---------------------------------------------------------------------------- |
| `NP_VERSION=v1.2.3` | install that release instead of the newest (`1.2.3` is accepted too) |
| `NP_INSTALL_DIR=/opt/bin` | install directory (no fallback to `~/.local/bin` when set) |
| `NP_BINARIES="np-agent"` | install a subset — what the **Admin → Agents** tab shows for monitored hosts |
Manual equivalent (any release, any platform from the matrix):
```bash
tag=v1.2.0 os=linux arch=amd64
curl -fsSLO "https://github.com/myfoxit/northplane/releases/download/${tag}/northplane_${tag}_${os}_${arch}.tar.gz"
curl -fsSLO "https://github.com/myfoxit/northplane/releases/download/${tag}/checksums.txt"
grep " northplane_${tag}_${os}_${arch}.tar.gz\$" checksums.txt | sha256sum -c - # macOS: shasum -a 256 -c -
tar -xzf "northplane_${tag}_${os}_${arch}.tar.gz"
sudo install -m 0755 northplaned np np-agent /usr/local/bin/
northplaned version
```
A first trial needs no configuration: `northplaned serve` listens on `127.0.0.1:8443` (plain HTTP is allowed on loopback) and stores data under your user’s data directory. For a permanent install continue with [Set up as a service](#set-up-as-a-service-with-northplaned-init).
* Docker image
### Image facts
[Section titled “Image facts”](#image-facts)
| Item | Value |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Image | `ghcr.io/myfoxit/northplane` — tags `latest`, `main-<12-char sha>` (pushed for every green CI run of `main`), and for releases `X.Y.Z` and `X.Y`; every tag is multi-arch (`linux/amd64` + `linux/arm64`) |
| Base | `gcr.io/distroless/static-debian12:nonroot` — **no shell**, runs as uid/gid **65532** |
| Contents | `/usr/local/bin/northplaned`, `/usr/local/bin/np` (no `np-agent`) with the UI and this documentation embedded |
| Defaults | `ENV NORTHPLANE_DATA_DIR=/var/lib/northplane`, `ENV NORTHPLANE_LISTEN=:8443`, `VOLUME /var/lib/northplane`, `EXPOSE 8443`, `ENTRYPOINT ["/usr/local/bin/northplaned"]`, `CMD ["serve"]` |
| Health | probe from outside: `GET /healthz` → `ok`, `GET /readyz` → JSON with subsystems (the image has no shell for a `HEALTHCHECK`) |
Because the listener is bound to all interfaces, the container **refuses to start** unless one of these is set: `NORTHPLANE_TLS_CERT_FILE` + `NORTHPLANE_TLS_KEY_FILE` (PEM, readable by uid 65532), `NORTHPLANE_TRUST_PROXY=true` (a TLS-terminating proxy in front that sets `X-Forwarded-Proto`), or `NORTHPLANE_TLS_INSECURE=true` (development only). The error is `no TLS configured on a non-loopback listener — set tls.certFile/keyFile, or trustProxy behind a TLS-terminating proxy, or tls.insecure for dev`.
### Run it
[Section titled “Run it”](#run-it)
```bash
docker run -d --name northplane --restart unless-stopped \
-p 8443:8443 \
-v northplane-data:/var/lib/northplane \
-v /etc/northplane/certs:/certs:ro \
-e NORTHPLANE_TLS_CERT_FILE=/certs/fullchain.pem \
-e NORTHPLANE_TLS_KEY_FILE=/certs/privkey.pem \
-e NORTHPLANE_BASE_URL=https://monitoring.example.net \
ghcr.io/myfoxit/northplane:latest
```
* Any `config.yaml` key can be set through its `NORTHPLANE_*` environment variable (see [Configuration](/docs/administration/configuration/)); a file mounted at `/etc/northplane/config.yaml` is picked up automatically because that path wins whenever it exists.
* A bind-mounted data directory must be writable by uid 65532 (`chown 65532:65532 /srv/northplane`). A named volume inherits the ownership from the image.
* The secrets-at-rest master key defaults to `/var/lib/northplane/secret.key` inside the volume. The production stacks mount a host-side key read-only and point at it with `NORTHPLANE_SECRET_KEY_FILE=/etc/northplane/secret.key`; if that path turns out unusable the server logs `configured secretKeyFile unusable — falling back to the data directory`. Back the key up — without it encrypted secrets are unreadable ([Secrets](/docs/administration/secrets/)).
* Publish additional ports only for listeners you enable: `9162/udp` (SNMP traps), `2023` (ESPA), `8123` (ESPA-X), `4573` (FastAGI) — see the ports table in the [Deployment overview](/docs/deployment/overview/).
* Set `NP_DEFAULT_ADMIN_EMAIL` / `NP_DEFAULT_ADMIN_PASSWORD` to choose the break-glass admin, or `NP_DEFAULT_ADMIN_DISABLED=1` to use `/setup`; otherwise read the generated password from `docker logs northplane` ([Quickstart](/docs/getting-started/quickstart/#2-create-the-admin-account)).
* To run `np` from the image: `docker exec northplane /usr/local/bin/np --server https://127.0.0.1:8443 --insecure --token np_… get hosts` (`http://` when the container runs with `NORTHPLANE_TLS_INSECURE=true`; `--insecure` only skips certificate verification for a certificate that does not match `127.0.0.1`).
Build your own image from a checkout with `make docker` (tags `northplane:`, default `1.0.0-dev`) or `docker build --build-arg VERSION= -t northplane .`. The Dockerfile builds the UI (Node 22), the documentation and the Go binaries in separate stages.
* Docker Compose + Caddy
### The bundled stack
[Section titled “The bundled stack”](#the-bundled-stack)
The repository root ships `docker-compose.yml` and `caddy/Caddyfile`: Northplane on the Compose network only, Caddy publishing 80/443 and terminating TLS.
docker-compose.yml (repository root, abbreviated)
```yaml
services:
northplane:
image: ghcr.io/myfoxit/northplane:latest
# build: . # uncomment to build from source instead of the published image
restart: unless-stopped
environment:
NORTHPLANE_LISTEN: ":8443"
NORTHPLANE_TRUST_PROXY: "true" # Caddy terminates TLS and sets X-Forwarded-*
NORTHPLANE_BASE_URL: "https://${DOMAIN:-localhost}"
# NORTHPLANE_STORAGE_DSN: "postgres://np:np@db:5432/northplane?sslmode=disable"
volumes:
- northplane-data:/var/lib/northplane
expose:
- "8443"
caddy:
image: caddy:2-alpine
restart: unless-stopped
depends_on: [northplane]
ports: ["80:80", "443:443"]
environment:
DOMAIN: "${DOMAIN:-localhost}"
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
healthcheck:
test: ["CMD", "wget", "-qO-", "http://northplane:8443/healthz"]
interval: 30s
timeout: 5s
retries: 3
volumes:
northplane-data:
caddy-data:
caddy-config:
```
caddy/Caddyfile
```text
# DOMAIN=localhost (default) → Caddy issues an internal self-signed cert.
# DOMAIN=monitoring.example.net (public DNS → this host) → automatic Let's Encrypt.
{$DOMAIN:localhost} {
reverse_proxy northplane:8443
}
```
1. Get the two files (clone the repository or copy them) and log in to GHCR — or uncomment `build: .` to build the image from the checkout.
2. Local trial: `docker compose up -d` → `https://localhost` with Caddy’s internal CA (accept the browser warning once). Production: `DOMAIN=monitoring.example.net docker compose up -d` with an A record pointing at the host and ports 80/443 reachable — Caddy obtains and renews a Let’s Encrypt certificate. `NORTHPLANE_BASE_URL` follows `DOMAIN` automatically.
3. First login: the stack sets `NP_DEFAULT_ADMIN_DISABLED: "1"`, so `/setup` is open — create the admin there. For unattended installs replace that line with `NP_DEFAULT_ADMIN_EMAIL` / `NP_DEFAULT_ADMIN_PASSWORD` before the first start (or drop it to get a seeded `admin@localhost` whose generated password appears once in `docker compose logs northplane`).
4. Watch it: `docker compose ps`, `docker compose logs -f northplane`, `docker compose logs -f caddy` (ACME activity). Caddy’s healthcheck probes `http://northplane:8443/healthz` every 30 s because the distroless Northplane container cannot probe itself.
Optional PostgreSQL: uncomment the `db` service (`postgres:16`, volume `pg-data`), set `NORTHPLANE_STORAGE_DSN` and add `db` to `depends_on` — see [PostgreSQL](#postgresql-instead-of-sqlite).
The `deploy/` directory holds the CI-managed production variants of the same idea: `deploy/docker-compose.yml` + `deploy/Caddyfile` (bundled Caddy with `DOMAIN`, a bare-IP `https://{$SERVER_IP}` site with an internal certificate, `ACME_EMAIL`, a host-side `secret.key` bind mount, separate data directories for demo and real mode) and `deploy/docker-compose.vm.yml` (no Caddy — an external proxy terminates TLS). They are documented in [Docker Compose deployment](/docs/deployment/docker-compose/) and [Proxmox VM](/docs/deployment/proxmox-vm/).
## Set up as a service with `northplaned init`
[Section titled “Set up as a service with northplaned init”](#set-up-as-a-service-with-northplaned-init)
`northplaned init` turns a bare binary into a permanent install. Run it as root on the target host:
```bash
sudo northplaned init # --dir /etc/northplane --data /var/lib/northplane --user northplane
sudo systemctl enable --now northplaned
```
| Flag | Default | Meaning |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| `--dir` | `/etc/northplane` as root; `~/.config/northplane` (macOS: `~/Library/Application Support/northplane`) otherwise | configuration directory |
| `--data` | `/var/lib/northplane` as root; `~/.local/share/northplane` / `$XDG_DATA_HOME/northplane` (macOS: `~/Library/Application Support/northplane`) otherwise | data directory written into the config |
| `--user` | `northplane` | system account the service runs as (created when missing — root on Linux only) |
It creates both directories (0750) and writes three files; it **refuses to overwrite** an existing `config.yaml` (` exists — refusing to overwrite`):
| File | Mode | Content |
| --------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/config.yaml` | 0640 | the commented bootstrap template: `listen: "127.0.0.1:8443"`, `dataDir`, `secretKeyFile`, empty `storage.dsn` (= SQLite), empty `tls`, commented `oidc`/`ldap`/`federation` blocks, `ai.provider: none`, `backup.target: ""`. Shown verbatim in [Configuration](/docs/administration/configuration/). |
| `/secret.key` | 0600 | 32 random bytes as 64 hex characters — the AES-256-GCM master key for secrets at rest. **Back it up.** |
| `northplaned.service` | 0644 | the systemd unit below — written straight to `/etc/systemd/system/` when `init` runs as root on a Linux host with systemd, otherwise next to the config for manual use |
As root on Linux, `init` additionally creates the locked system user (`useradd --system --no-create-home --shell nologin`), hands it the configuration directory, `config.yaml`, `secret.key` and the data directory, and installs the unit — so the printed next step is literally `systemctl enable --now northplaned`. On other systems (or without `useradd`) it prints what is left to do by hand.
/etc/systemd/system/northplaned.service (generated)
```ini
[Unit]
Description=Northplane monitoring server
Documentation=https://github.com/myfoxit/northplane
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/northplaned serve -config /etc/northplane/config.yaml
Restart=on-failure
RestartSec=2
User=northplane
Group=northplane
StateDirectory=northplane
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/var/lib/northplane
[Install]
WantedBy=multi-user.target
```
`ExecStart` uses the path of the binary that ran `init`. There is deliberately no `WatchdogSec`: `northplaned` does not speak `sd_notify`, and a watchdog without keep-alives would restart the service every interval.
The command ends with:
```text
next steps:
1. review /etc/northplane/config.yaml (listen/TLS, storage backend, OIDC)
2. systemctl enable --now northplaned
3. open http://127.0.0.1:8443/setup in the browser to create the admin account
(or headless: northplaned bootstrap-admin -config /etc/northplane/config.yaml)
```
A complete first start on a systemd host:
1. Install the binaries (installer, tarball or source build) and run `sudo northplaned init`.
2. Review `/etc/northplane/config.yaml`. To serve the network set `listen: ":8443"` **and** either `tls.certFile`/`tls.keyFile` or `trustProxy: true` behind a TLS-terminating proxy; the loopback default exists so that plaintext is never exposed by accident. Set `baseUrl` to the public URL (used in notification links, ack links and OIDC redirects). Keys and defaults: [Configuration](/docs/administration/configuration/), TLS options: [TLS and proxy](/docs/administration/tls-and-proxy/).
3. Start it and watch the log:
```bash
sudo systemctl enable --now northplaned
journalctl -u northplaned -f
```
Environment variables such as `NP_DEFAULT_ADMIN_DISABLED=1` or `NP_DEFAULT_ADMIN_PASSWORD=…` go into a drop-in (`sudo systemctl edit northplaned` → `[Service]` / `Environment=…`).
4. Create the admin: open `/setup` (only open while no local user and no API token exist and the default-admin seeding is disabled — see [Quickstart](/docs/getting-started/quickstart/#2-create-the-admin-account)), or run `sudo -u northplane northplaned bootstrap-admin -config /etc/northplane/config.yaml` for a headless `*:*` token.
If `secret.key` is not readable by the service user (for example after moving files by hand), the server warns `configured secretKeyFile unusable — falling back to the data directory` and generates a second key under `/var/lib/northplane/secret.key` — workable, but then the key in `/etc/northplane` is not the one in use. No SIGHUP reload exists: configuration changes need `systemctl restart northplaned`. Shutdown is graceful (SIGTERM, 30 s budget for in-flight requests and workers).
## Build from source
[Section titled “Build from source”](#build-from-source)
Prerequisites: **Go 1.25** and **Node.js 22** (npm). The Go build is CGO-free (pure-Go SQLite), so no C toolchain is needed.
```bash
git clone https://github.com/myfoxit/northplane.git && cd northplane
make all # = make web (UI) + make docs (this manual) + make build
./bin/northplaned version
```
| Target | What it does |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `make web` | `npm ci` + Vite build in `web/`, copies `web/dist` to `internal/web/dist` (embedded via `go:embed`) |
| `make docs` | `npm ci` + Astro/Starlight build in `docs/` (fails on broken links), stages `docs/dist` into `internal/docs/dist` |
| `make build` | `go build -ldflags "-X main.version=$(VERSION)"` → `bin/northplaned`, `bin/np`, `bin/np-agent`, `bin/np-gen`; `VERSION` defaults to `1.0.0-dev` |
| `make docker` | builds the container image `northplane:$(VERSION)` |
| `make test` / `make race` | `go vet` + `go test` (CI runs the race detector) |
| `make dev` | hot-reload development loop: Vite on `:5173`, auto-rebuilt backend on `127.0.0.1:8443`, demo data seeded (`NP_DEV_DEMO=0` to skip) |
A plain `go build ./cmd/northplaned` also works, but it embeds whatever `internal/web/dist` is committed (which may be stale) and no documentation — `/docs/` then answers `501 documentation not embedded in this build — run make docs`. Release-style static cross builds:
```bash
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath \
-ldflags "-s -w -X main.version=1.2.0" -o northplaned ./cmd/northplaned
```
Cross-compiling `northplaned` for Windows is not supported (CI skips it); `np` and `np-agent` build for `windows/amd64`. The development workflow (worktrees, lint, tests, e2e) is described in [Development setup](/docs/development/setup/).
## Platform matrix
[Section titled “Platform matrix”](#platform-matrix)
| | Linux amd64 / arm64 | macOS amd64 / arm64 | Windows amd64 |
| --------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `northplaned` | yes — tarball, container image, source | yes — tarball, source | **no** (not built: plugin execution needs Unix process groups) |
| `np` | yes | yes | yes (`np.exe` in the release zip) |
| `np-agent` | yes — load, memory, disk, processes, network, plugins | yes — same as Linux | yes (`np-agent.exe`) — memory, disk, CPU, processes, plugins; no load or network collectors; runs as a console process under `sc.exe` |
| `install.sh` | yes | yes | no — use the release zip |
| Service manager | systemd unit from `northplaned init`; agent unit snippet in **Admin → Agents** | launchd plist snippet for the agent in **Admin → Agents** | `sc.exe create` snippet for the agent |
| Container | `linux/amd64`, `linux/arm64` | via Docker Desktop (Linux image) | via Docker Desktop (Linux image) |
`builtin:icmp` uses unprivileged datagram ICMP first and falls back to a raw socket; on Linux without root or `cap_net_raw` it reports `UNKNOWN - icmp socket: …`. Nagios plugins for `exec:` checks are looked up under `pluginsDir`, auto-detected from `/usr/lib/nagios/plugins`, `/usr/lib64/nagios/plugins`, `/usr/local/libexec/nagios`, `/opt/homebrew/libexec`, then `/plugins`.
## PostgreSQL instead of SQLite
[Section titled “PostgreSQL instead of SQLite”](#postgresql-instead-of-sqlite)
SQLite (default, `storage.dsn: ""`) is the fully supported and CI-green backend and needs nothing else. PostgreSQL is selected by a DSN:
config.yaml
```yaml
storage:
dsn: "postgres://np:secret@db:5432/northplane?sslmode=require"
eventRetentionMonths: 12
```
or `NORTHPLANE_STORAGE_DSN=postgres://…`. Facts to know before you choose it:
* Driver `pgx`; schema migrations run automatically on every start (also by `northplaned migrate`). Pool: 16 open / 8 idle connections. Events are stored in monthly partitions; the `janitor` enforces `eventRetentionMonths` nightly.
* The **NP-TSDB stays on local disk** under `/tsdb` regardless of the relational backend — the data directory (and its `secret.key`) is still required and still needs backups.
* `northplaned backup` does not dump PostgreSQL; the manifest records the schema version and a note that relational backup is the operator’s job (`pg_dump`/PITR). The TSDB is still copied.
* Known caveat: the audit-log chain verification (`POST /api/v1/audit:verify`, `np audit verify`) fails on PostgreSQL because `jsonb` normalises the stored JSON that the row hash was computed over. The CI job for PostgreSQL is non-blocking for this reason.
* Moving an existing SQLite install: stop the server, run `northplaned storage migrate --to "postgres://…" -config /etc/northplane/config.yaml`, point `storage.dsn` at the target, start again (offline copy; the TSDB is untouched).
The Compose file has a commented `postgres:16` service ready to uncomment. Details, table layout and sizing: [Storage](/docs/administration/storage/).
## Where files live
[Section titled “Where files live”](#where-files-live)
| What | Location |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Config file | `-config `; default `/etc/northplane/config.yaml` as root; for other users `/etc/northplane/config.yaml` **if it exists**, else `~/.config/northplane/config.yaml` (Linux) / `~/Library/Application Support/northplane/config.yaml` (macOS). A missing file is fine — defaults and `NORTHPLANE_*` variables apply. |
| Data directory (`dataDir`) | `/var/lib/northplane` as root; `$XDG_DATA_HOME/northplane` or `~/.local/share/northplane` (Linux); `~/Library/Application Support/northplane` (macOS); `/var/lib/northplane` in the container. Holds `core.db` (+ `-wal`/`-shm`), `events-YYYYMM.db` segments, `tsdb/`, `artifacts/`, optionally `plugins/` and the fallback `secret.key`. |
| Secret key | `secretKeyFile` from the config (`northplaned init` writes `/secret.key`); fallback `/secret.key`, generated on first start. |
| Binaries | `/usr/local/bin/{northplaned,np,np-agent}` (tarball/installer), `bin/` (source build), `/usr/local/bin/{northplaned,np}` in the image. |
| Agent config | `/etc/northplane/agent.yaml` (root or if it exists), else `~/.config/northplane/agent.yaml`; Windows `C:\ProgramData\northplane\agent.yaml` — see [Agent](/docs/monitoring/agent/). |
## Upgrading
[Section titled “Upgrading”](#upgrading)
Upgrades are in-place: replace the binary (or pull the new image tag) and restart; pending schema migrations are applied automatically on start and the embedded UI and docs are always the matching version. Back up `secret.key` and the data directory (or run `northplaned backup`) first, and read [Upgrades](/docs/administration/upgrades/) for rollback notes per variant.
## Uninstalling
[Section titled “Uninstalling”](#uninstalling)
There is no uninstall script. Remove what the variant created:
```bash
# systemd install
sudo systemctl disable --now northplaned
sudo rm /etc/systemd/system/northplaned.service && sudo systemctl daemon-reload
sudo rm /usr/local/bin/northplaned /usr/local/bin/np /usr/local/bin/np-agent
sudo rm -r /etc/northplane /var/lib/northplane # config, secret.key, database, TSDB
sudo userdel northplane
# docker run
docker rm -f northplane && docker volume rm northplane-data
# docker compose (also removes the Caddy volumes)
docker compose down -v
```
Agents are removed on their hosts the same way (`systemctl disable --now np-agent`, `/etc/northplane/agent.yaml`, the binary); revoke their API tokens under **Admin → API tokens**.
# What is Northplane?
> Northplane is a single-binary monitoring and alarm server — checks, SNMP, agents, escalation, voice/SMS/push, an AI agent and an MCP server — with the UI, the API and the documentation embedded in one static executable.
Northplane is a monitoring **and** alarming server in one static binary. It polls hosts and services (built-in checks, Nagios plugins, SNMP, an optional host agent), turns state changes and external events into alerts, escalates those alerts to the people on call over phone, SMS, push, e-mail, chat and ticket systems, and records everything in an append-only event log with a hash-chained audit trail. Everything is driven through one REST API; the web UI, the `np` CLI, the AI agent chat and the MCP server are all clients of that API and share the same roles and permissions.

The server binary is called `northplaned`. It ships with the React UI, the Swagger UI and this documentation embedded, uses SQLite and its own time-series store (NP-TSDB) by default, and needs no external services to run. PostgreSQL, a TLS-terminating proxy, OIDC/LDAP and AI providers are optional additions, not prerequisites.
## Who it is for
[Section titled “Who it is for”](#who-it-is-for)
* **Operators and sysadmins** who want Nagios-style monitoring (active checks, plugins, SNMP, dependencies, soft/hard states, downtimes) without an external database, web server or message broker to look after.
* **On-call engineers** who need reliable alarming: escalation policies, on-call schedules, voice calls with IVR, SMS, push to the Northplane alarm app, acknowledgement from the phone, and an outbox with retries and dead letters so a notification is never silently lost.
* **Integrators and developers** who want an API-first system: OpenAPI 3.1 spec, RFC 9457 errors, declarative YAML config bundles, webhooks in and out, a typed CLI, MCP for AI assistants.
* **Control rooms and factories**: inbound alarms over ESPA 4.4.4, ESPA-X, MQTT, IMAP and Asterisk/FastAGI; outbound MQTT; wallboards; business-service trees with SLAs.
Northplane assumes Linux fluency but no prior knowledge of the product — this section takes you from zero to a monitored host with a working alarm chain.
## The one-binary idea
[Section titled “The one-binary idea”](#the-one-binary-idea)
`northplaned` contains the scheduler, the check executor, the result pipeline and state machine, the alerting engine, the escalation engine, the notifier with its outbox, every inbound listener (SNMP traps, IMAP, MQTT, ESPA, FastAGI), the report scheduler, the MCP server, the UI and the docs. A fresh install looks like this:
```bash
northplaned serve
# northplane: listening addr=127.0.0.1:8443 scheme=http storage=sqlite objects=0 ai=false
```
Configuration is deliberately minimal: `config.yaml` (or `NORTHPLANE_*` environment variables) holds only what must exist before the API is reachable — listen address, data directory, TLS, storage DSN, OIDC/LDAP, federation. Every other object — hosts, services, templates, channels, rules, policies, schedules, dashboards — is managed through the API, the UI or YAML bundles, and can be exported again as a bundle.
Three more binaries come with it:
| Binary | Role |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `np` | CLI — a thin client of the public API (`np get hosts`, `np apply -f bundle.yaml`, `np ack …`). |
| `np-agent` | Host agent for Linux, macOS and Windows: pushes load/memory/disk/process/network results and local plugin output over HTTPS (no inbound ports), optionally pulls checks from the server or listens NCPA-style. |
| `np-gen` | Developer scaffolding for new resource kinds; not needed to run Northplane. |
## At a glance
[Section titled “At a glance”](#at-a-glance)
| Aspect | What you get |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Server | `northplaned`, static Go binary (CGO-free), Linux/macOS amd64+arm64; also a distroless container image. Default listen `127.0.0.1:8443`; plaintext is refused on non-loopback listeners unless TLS is configured, a trusted proxy terminates TLS, or `tls.insecure` is set for development. |
| UI | Embedded React SPA at `/`; German or English from the browser language; 31 colour themes; wallboard mode; command palette (Ctrl/⌘ K). |
| Storage | SQLite by default (`core.db` in the data directory, WAL mode, monthly event segments) or PostgreSQL via `storage.dsn`. Schema migrations run automatically. |
| Metrics | NP-TSDB, embedded under `/tsdb`: perfdata of every check, raw samples 30 days, 5-minute aggregates 400 days, 1-hour aggregates 5 years. |
| Checks | 17 built-in checks (ICMP, TCP, HTTP/HTTPS, TLS certificate, DNS, SMTP, IMAP, NTP, SSH banner, SNMP get/walk, NRPE, agent, HTTP flow …), any Nagios plugin via `exec:`, passive results, `np-agent`, SNMP traps, heartbeats, network discovery. |
| Alarming | Event sources (webhook, Alertmanager, e-mail, SNMP trap, MQTT, ESPA/ESPA-X, Twilio voice and SMS, Asterisk), CEL alert rules, incidents, escalation policies, on-call schedules, contacts, IVR menus, 13 channel types (e-mail, SMS, voice, push, ntfy, Slack, Teams, webhook, MQTT, ServiceNow, Zendesk, Jira, generic ticket), outbox with retries and dead letters. |
| API | REST under `/api/v1`, OpenAPI 3.1 at `/api/openapi.json`, Swagger UI at `/api/docs`, RFC 9457 problem details, ETag/If-Match versioning, SSE event stream, NDJSON exports, YAML config bundles. |
| CLI | `np` (uses `NP_SERVER` / `NP_TOKEN`). |
| Agent | `np-agent` (push, pull and listener modes; token-authenticated HTTPS). |
| AI | Agent chat page (`/agent`) and sidebar with 10 provider types (Anthropic, OpenAI, Google, xAI, Mistral, DeepSeek, Groq, OpenRouter, Ollama, OpenAI-compatible), tool policy with approvals, incident summaries. |
| MCP | Streamable HTTP at `/mcp` and stdio via `northplaned mcp`; 22 tools, 3 prompts; same RBAC as the API. |
| Identity | Local users (argon2id), OIDC (code + PKCE), LDAP/AD sync, API tokens (`np_…`), roles and permissions, tenants, federation sites (edge instances pulling config from a main instance). |
| Operations | `/healthz`, `/readyz`, `/metrics` (OpenMetrics self-metrics), structured JSON logs, hash-chained audit log, `northplaned backup`, dead-man URL. |
| Licence | MIT. |
## Feature tour
[Section titled “Feature tour”](#feature-tour)
### Monitoring
[Section titled “Monitoring”](#monitoring)
* **Objects** — hosts and services with folders, labels and label selectors; templates with multi-inheritance and an “effective config” view; UUIDv7 ids; optimistic locking with `If-Match`.
* **Checks** — `builtin:` in-process checks, `exec:` for Nagios/Monitoring plugins with full perfdata parsing, `agent:exec:` executed by `np-agent`, `passive` for results pushed through the API, named check commands with `$ARGn$` and custom-variable macros.
* **State machine** — interval/retry scheduling with deterministic splay, soft and hard states, host UP/DOWN/UNREACHABLE with parent reachability, flapping detection, freshness/staleness for passive objects, acknowledgements, check-now.
* **SNMP** — `snmp` and `snmp-walk` checks (v1/v2c/v3) plus an SNMP trap receiver that turns traps into events and alerts.
* **Nagios compatibility** — `northplaned import nagios` converts an existing Nagios/Icinga configuration into a bundle with a deviation report; NRPE client built in.
* **Heartbeats, discovery, maintenance** — dead-man inputs with grace periods, CIDR scans with suggestions, downtimes (fixed, flexible, recurring via RRULE), silences, time periods.
* **Metrics, dashboards, business services, reports** — NP-TSDB charts on every object, dashboards with 11 widget types and a wallboard mode, BPI trees with worst/best/quorum/weighted rules and SLA budgets, scheduled availability/SLA/alert/on-call/audit reports delivered by e-mail.
### Alarming
[Section titled “Alarming”](#alarming)
* **Inputs** — event sources for webhooks, Prometheus Alertmanager, e-mail (IMAP), SNMP traps, MQTT, ESPA 4.4.4 and ESPA-X, inbound Twilio voice and SMS, Asterisk FastAGI; manual alarms from the UI, the API, the alarm app or an IVR menu.
* **Rules** — CEL expressions over the event (`event.type`, `event.state`, `event.labels.*`, `event.payload.*`), Go templates for titles and dedup keys, pending-for, auto-close, label injection (`np.sound`, `np.volume` for the alarm app), heartbeat rules.
* **Escalation** — policies with timed steps, “unless acked”, repeats, on-call schedules with layers and overrides (and a backup person), contact groups, ticket and webhook actions; timers are persisted and survive restarts.
* **Outputs** — e-mail (SMTP/sendmail/Resend/SES), SMS and voice (Twilio, Asterisk AMI, generic HTTP gateways) with DTMF acknowledgement, push (Web Push, FCM, APNs) for the Northplane alarm app, ntfy, Slack, Teams, webhooks with HMAC signatures, MQTT, ServiceNow/Zendesk/Jira/generic tickets with auto-close.
* **Acknowledge from anywhere** — UI, `np ack`, API, signed ack links, SMS keyword, IVR digit, DTMF during a call, the app. Snooze re-arms the chain later.
* **Reliability** — outbox with exponential backoff, dead-letter queue with replay, supervised workers, suppression by downtime/silence/flapping/dependencies with re-arm, every delivery attempt recorded as an event.
### Platform
[Section titled “Platform”](#platform)
* **API-first** — every capability is a documented endpoint; the UI never does anything the API cannot. Tenants (`X-Northplane-Tenant`), roles with `resource:action` permissions, API tokens with scopes, expiry and IP binding, secrets at rest (AES-256-GCM) referenced as `$SECRET:name$`.
* **Config as code** — multi-document YAML bundles with plan/apply/export/prune, applied through the API, `np apply` or the Admin UI; the same mechanism distributes configuration to federated edge sites.
* **Deployment** — one binary with systemd, a distroless container, or a Compose stack with a bundled Caddy for automatic TLS; SQLite or PostgreSQL; backups with `northplaned backup`.
* **Observability of the monitor itself** — health and readiness endpoints, OpenMetrics, structured logs, audit chain verification, outbound dead-man pings.
### AI and API
[Section titled “AI and API”](#ai-and-api)
* **Agent chat** — a chat workspace with tool use against the live instance, per-user or shared provider connections, approval flow for mutating tools, budget and redaction settings.
* **MCP server** — connect Claude Code, Claude Desktop, Cursor, VS Code, Windsurf, Codex or Gemini CLI to your instance over HTTP or stdio with a scoped token; tools respect the token’s RBAC.
* **Typed clients** — the UI’s TypeScript types are generated from the OpenAPI spec (`openapi-typescript`); you can do the same for your own integrations.
## How the documentation is organised
[Section titled “How the documentation is organised”](#how-the-documentation-is-organised)
| Section | Read it when you want to … |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Getting started** (this section) | install Northplane, log in, add the first objects, try the demo. |
| **Concepts** | understand the architecture, the object model, checks and states, events, alerts and incidents, tenancy and federation. |
| **Monitoring** | configure hosts and services, built-in checks, plugins, the agent, SNMP, discovery, heartbeats, metrics, dashboards, business services, reports, maintenance and templates. |
| **Alarming** | build the alarm pipeline: event sources, rules, channels, voice/IVR, mobile push, contacts and on-call, escalation, acknowledgement, reliability, outgoing webhooks. |
| **AI & MCP** | use the agent chat and connect MCP clients. |
| **User interface** | find your way around every page, dialog and Admin tab. |
| **Administration** | configure the server, authentication, users and roles, tenants, tokens, secrets, TLS, storage, bundles, branding, observability, upgrades and security. |
| **Deployment** | choose and run a deployment variant, Compose, Proxmox, CI/CD, provisioning, operations, environments. |
| **Reference** | look up every subcommand of `northplaned`, `np`, `np-agent`, `np-gen`, the API conventions and the generated REST reference. |
| **Development** | build, test and extend Northplane, and edit these docs. |
| **Project** | see the roadmap and known issues. |
Every running instance serves this manual at `/docs/` and the interactive API reference at `/api/docs`, so the documentation always matches the version you run.
## Where to go next
[Section titled “Where to go next”](#where-to-go-next)
[Quickstart](/docs/getting-started/quickstart/)Docker, Compose or a single binary — running with a monitored host in five minutes.
[Installation](/docs/getting-started/installation/)Every install variant in depth: tarball, systemd, Docker, Compose with Caddy, build from source.
[First steps](/docs/getting-started/first-steps/)The UI in ten minutes, your first bundle, a minimal alarm chain, an API token, an agent.
[Demo mode](/docs/getting-started/demo-mode/)Seed a complete showcase environment with one flag — and keep it away from real data.
[Architecture](/docs/concepts/architecture/)Components, request path, workers, storage and the event bus.
[Alarming overview](/docs/alarming/overview/)The pipeline from inputs to phone calls, with worked examples.
[Configuration reference](/docs/administration/configuration/)Every config.yaml key, environment variable and default.
[API overview](/docs/reference/api-overview/)Conventions, authentication, errors, pagination, SSE, curl examples.
# Quickstart
> Run Northplane with Docker, Docker Compose or the single binary, create the admin account, monitor your first host and HTTPS service, and send a test notification — in about five minutes.
This page gets you from nothing to a running instance with one monitored host, one HTTPS service and a working notification channel. It deliberately takes shortcuts (plain HTTP on your own machine, a public ntfy topic); [Installation](/docs/getting-started/installation/) covers the production variants and [First steps](/docs/getting-started/first-steps/) continues with templates, the alarm chain, API tokens and the agent.
You need one of: Docker, Docker Compose, or a `northplaned` binary for Linux/macOS (amd64/arm64). The one-line installer fetches the newest release tarball; the container image is `ghcr.io/myfoxit/northplane` — both public, no login needed. Building from source (`make all`) is the third option ([Installation](/docs/getting-started/installation/#build-from-source)).
## 1. Start the server
[Section titled “1. Start the server”](#1-start-the-server)
* Docker
The image runs `northplaned serve` as the distroless `nonroot` user (uid 65532) with `NORTHPLANE_LISTEN=:8443` and `NORTHPLANE_DATA_DIR=/var/lib/northplane`. Because `:8443` is a non-loopback listener, the server **refuses to start** without TLS unless you explicitly allow plaintext for a local trial:
```bash
docker run -d --name northplane \
-p 8443:8443 \
-v northplane-data:/var/lib/northplane \
-e NORTHPLANE_TLS_INSECURE=true \
-e NP_DEFAULT_ADMIN_DISABLED=1 \
ghcr.io/myfoxit/northplane:latest
docker logs -f northplane
```
Open ****. The log shows `northplane: listening addr=:8443 scheme=http storage=sqlite` followed by `first run: open http://127.0.0.1:8443/setup to create your admin account`.
* `NORTHPLANE_TLS_INSECURE=true` allows plain HTTP on the non-loopback listener. Without it the container exits with `no TLS configured on a non-loopback listener — set tls.certFile/keyFile, or trustProxy behind a TLS-terminating proxy, or tls.insecure for dev`. Never publish such a port beyond your machine — use the Compose stack or real certificates instead.
* `NP_DEFAULT_ADMIN_DISABLED=1` keeps the interactive `/setup` page open (see [step 2](#2-create-the-admin-account) for why).
* The named volume `northplane-data` holds the SQLite database, the event segments, the NP-TSDB and the auto-generated `secret.key`. A bind mount must be writable by uid 65532.
* Docker Compose
The repository root ships a `docker-compose.yml` with Northplane behind a bundled **Caddy** that terminates TLS, plus `caddy/Caddyfile`. Northplane itself runs with `NORTHPLANE_TRUST_PROXY=true` on the Compose network only; Caddy publishes 80/443.
```bash
git clone https://github.com/myfoxit/northplane.git && cd northplane
docker compose up -d
docker compose logs -f northplane
```
Open ****. With `DOMAIN` unset, Caddy issues an internal self-signed certificate (your browser warns once); with `DOMAIN=monitoring.example.net docker compose up -d` and public DNS pointing at the host, Caddy fetches a Let’s Encrypt certificate automatically.
The Compose file sets `NP_DEFAULT_ADMIN_DISABLED: "1"`, so the interactive **`/setup`** page is open on first start — create your admin account there. For unattended installs replace that line with a chosen `NP_DEFAULT_ADMIN_EMAIL` / `NP_DEFAULT_ADMIN_PASSWORD` pair **before** the first `up` (or remove it to get a seeded `admin@localhost` with a generated password in the logs).
* Single binary
Install the binaries with the one-line installer (or unpack the release tarball — it contains `northplaned`, `np`, `np-agent` and `LICENSE`) and start the server. No config file is needed for a trial: the defaults listen on the loopback interface in plain HTTP and put all data under your user’s data directory.
```bash
curl -fsSL https://raw.githubusercontent.com/myfoxit/northplane/main/install.sh | sh
NP_DEFAULT_ADMIN_DISABLED=1 NORTHPLANE_LOG_FORMAT=text northplaned serve
```
```text
northplane: listening addr=127.0.0.1:8443 scheme=http storage=sqlite objects=0 ai=false
first run: open http://127.0.0.1:8443/setup to create your admin account
```
Open ****.
* Default listen address is `127.0.0.1:8443`. Plaintext is allowed there because it is loopback; to serve the network you need `listen: ":8443"` **and** `tls.certFile`/`tls.keyFile` (or a TLS-terminating proxy with `trustProxy: true`) — see [TLS and proxy](/docs/administration/tls-and-proxy/).
* Data directory: `/var/lib/northplane` as root, `~/.local/share/northplane` (or `$XDG_DATA_HOME/northplane`) as a normal Linux user, `~/Library/Application Support/northplane` on macOS. A `secret.key` for secrets-at-rest is generated there on first start.
* Logs go to **stderr**, JSON by default; `NORTHPLANE_LOG_FORMAT=text` makes them readable.
* The macOS/Linux tarballs are the only ones with `northplaned`; the Windows zip contains `np` and `np-agent` only.
## 2. Create the admin account
[Section titled “2. Create the admin account”](#2-create-the-admin-account)
There are two ways to get the first administrator, and which one you get depends on one environment variable:
* **Interactive `/setup`** — the page is open only while the instance has **no local user and no API token**. On every start, `northplaned serve` also runs the *default-admin seeding*: unless `NP_DEFAULT_ADMIN_DISABLED` is set to any non-empty value (or `NP_DEFAULT_ADMIN_PASSWORD` is set to an empty string), it creates a local admin `admin@localhost` when no enabled local admin exists. That local user closes `/setup` before you ever see it — which is why the commands above set `NP_DEFAULT_ADMIN_DISABLED=1`. Fill in name, e-mail, a password of at least 12 characters and the confirmation; you are logged in as `admin` immediately.
* **Seeded break-glass admin** — leave the seeding enabled and read the one-time log line `seeded default admin with a GENERATED password — save it now, it is not recoverable` (fields `email=admin@localhost`, `password=<32 hex chars>`), or choose your own credentials with `NP_DEFAULT_ADMIN_EMAIL`, `NP_DEFAULT_ADMIN_PASSWORD` and optionally `NP_DEFAULT_ADMIN_NAME`. Then log in at `/login`. The login page is German: **E-Mail**, **Passwort**, **Anmelden**.
Headless alternative: `northplaned bootstrap-admin -config ` mints an API token with scope `*:*` (printed once) — creating any token also closes `/setup`. Details: [Authentication](/docs/administration/authentication/).
## 3. Add a host and an HTTPS service
[Section titled “3. Add a host and an HTTPS service”](#3-add-a-host-and-an-https-service)
1. In the sidebar open **Objects (Objekte)** and click **New host (Host anlegen)**. On the **Basics (Basis)** tab enter Name `example-web` and Address `example.org`.
2. Switch to the **Check (Prüfung)** tab. A new object starts as `passive` (no active check), so set the check command kind to `builtin` and type `icmp` in the builtin-check field. Leave the interval and retry settings at their defaults (60 s, 15 s, 3 attempts, 30 s timeout) and **Save (Speichern)**.
3. Click **New service (Service anlegen)**: Name `https`, Host `example-web`. On the **Check** tab choose `builtin` / `http` and add the arguments one per entry: `-u`, `https://example.org/`, `-w`, `1`, `-c`, `3`. The built-in `http`/`https` check only uses TLS when `-S` is given or `-u` is a full `https://` URL, so pass the full URL. Save.
4. Both rows show **PENDING (AUSSTEHEND)** until the first result. The scheduler runs a new object within one interval; hover a row and click **Check now (Jetzt prüfen)** to force it. The host turns **UP** and the service **OK** with an output like `HTTP OK - 200 OK https://example.org/ in 0.123s, 1234 bytes, cert expires in 80d`.
5. Click the service row: the detail page shows state, last/next check, perfdata meters (`time`, `size`, `cert_days`) and, after a few results, a chart from the NP-TSDB. The **Configuration (Konfiguration)** tab shows the effective spec with every default resolved.
ICMP needs privileges on Linux
As an unprivileged Linux user `builtin:icmp` may report `UNKNOWN - icmp socket: … (unprivileged ICMP unavailable — run as root, grant cap_net_raw, or use builtin:tcp)`. Either grant the capability (`sudo setcap cap_net_raw+ep /usr/local/bin/northplaned`) or switch the host check to `builtin:tcp` with arguments `-p`, `443`. The container and a root-run binary are fine.
The same two objects as a YAML bundle, for `np apply` or **Admin → Config bundles**:
quickstart.yaml
```yaml
kind: Host
metadata:
name: example-web
spec:
address: example.org
checkCommand: builtin:icmp
---
kind: Service
metadata:
name: https
host: example-web
spec:
checkCommand: builtin:http
args: ["-u", "https://example.org/", "-w", "1", "-c", "3"]
```
## 4. Send a test notification
[Section titled “4. Send a test notification”](#4-send-a-test-notification)
1. Open **Admin → Channels (Kanäle)** and click **Create (Anlegen)**. Choose Type `ntfy`, Name `ntfy`, keep **Enabled (Aktiv)** on, set Server URL `https://ntfy.sh` and a Topic nobody will guess, e.g. `northplane-7f3a9c2d`. Save.
2. In the channel row click **Send test (Test senden)**. The server posts a synthetic `info` alert titled `Test notification from Northplane ()` to the topic and the row shows `✓ sent`; a failure shows the transport error instead.
3. Open `https://ntfy.sh/northplane-7f3a9c2d` in another tab (or the ntfy app) — the message is there. ntfy.sh topics are public, so treat the topic name as a secret or run your own ntfy server.
Any channel type can be tested the same way; for types that deliver to a *contact target* (e-mail, SMS, voice, push) the UI button sends without a target, so use the API with one:
```bash
curl -X POST http://127.0.0.1:8443/api/v1/channels/ntfy:test-notification \
-H "Authorization: Bearer $NP_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"target": "you@example.com"}'
```
(`$NP_TOKEN` is an API token from **Admin → API tokens** or `northplaned bootstrap-admin`; the endpoint needs `config:write`.) Channel reference: [Channels](/docs/alarming/channels/).
## 5. Where to go next
[Section titled “5. Where to go next”](#5-where-to-go-next)
* [First steps](/docs/getting-started/first-steps/) — the UI tour, templates, a complete channel → contact → escalation policy → rule chain, API tokens and `np`, installing `np-agent`.
* [Demo mode](/docs/getting-started/demo-mode/) — `northplaned serve --demo` seeds a full showcase (hosts, checks, alerts, on-call, dashboard, report, two demo users) in a separate data directory.
* [Installation](/docs/getting-started/installation/) — `northplaned init` with a systemd unit, TLS, PostgreSQL, Compose with Let’s Encrypt, building from source.
* [Deployment overview](/docs/deployment/overview/) — which variant fits which environment, and the ports you may need to open.
# Alerts and incidents
> The alert entity and its life cycle (open, acked, snoozed, resolved, expired), deduplication keys, manual alarms, suppression order and re-arm, every acknowledgement path, incidents from rules and the correlator, and the np.* labels.
An **alert** is the thing people get paged for. Rules turn events into alerts; escalation policies decide who is notified; acknowledging an alert stops the chain. An **incident** groups alerts that belong together — created by a rule, by the alarm-storm correlator, or by a human or the AI agent. This page is the mental model; the how-to pages are under [Alarming](/docs/alarming/overview/).
## From event to alert
[Section titled “From event to alert”](#from-event-to-alert)
```text
event (ingress | state_change | heartbeat_missed | incident_update)
└─► alert rule matches (CEL `match`) ── or: heartbeat rule, manual POST /alerts
└─► [pendingFor] condition must hold N seconds
└─► UpsertAlert(dedupKey) ── existing open/acked alert? fold in, no new chain
└─► alert_opened event
├─► rule.incident → own incident
├─► suppressed? (downtime / flapping / host down / silence) → wait, re-arm later
└─► StartChain(escalationPolicy) → steps → notifications
```
A matching event is a **clear** rather than an open when `event.severity == "ok"`, `event.state` is `OK`/`UP`, or the payload carries `resolve: true`; a clear resolves the open/acked alert with the same dedup key. With `resolveOnOk` (default `true`) even a *non-matching* clear event resolves — so a rule that only matches `CRITICAL` still closes its alert on the next `OK` of the same object. Rule fields, CEL and templates are on [Alert rules](/docs/alarming/alert-rules/).
## The alert entity
[Section titled “The alert entity”](#the-alert-entity)
| Field | Meaning |
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `id`, `tenantId` | |
| `ruleId` | id of the rule that opened it; `"manual"` for API/phone/SMS/app alarms |
| `objectId`, `incidentId` | optional links |
| `status` | `open` → `acked` → `resolved`; `expired` when `autoCloseAfter` hit |
| `severity` | `critical` \| `warning` \| `info` \| `ok` — from the rule, else the event |
| `title` | rendered from the rule’s `title` template (default: event summary, else ` is `) |
| `dedupKey` | see below |
| `openedAt`, `ackedAt`, `ackedBy`, `resolvedAt`, `snoozedUntil` | life-cycle timestamps |
| `payload` | the triggering event payload; for manual alarms `{summary, manual: true, by, via, escalationPolicy}` |
| `labels` | event labels ⊕ the rule’s `setLabels` (⊕ later merges such as `recordingUrl`, `transcript`) |
| `eventIds` | the last 50 triggering event ids |
| `ticket` | `{channel, type, ref, url, autoClose}` once an escalation ticket action created one |
API: `GET /api/v1/alerts` (filters `status`, `severity`, `objectId`, `ruleId`, `incidentId`, `since`; newest first; default 100, max 1000), `GET /api/v1/alerts/{id}`, `POST /api/v1/alerts` (manual), `POST /api/v1/alerts/{id}:ack|:resolve|:snooze`. Permissions: `alerts:read`, `alerts:write` (raise), `alerts:ack` (ack/resolve/snooze).
## Life cycle
[Section titled “Life cycle”](#life-cycle)
```text
┌──────────── clear event / :resolve / DTMF 6 / incident :resolve ────────────┐
│ ▼
open ──ack──► acked ──────────────────────────────────────────────────────────────► resolved
▲ │ │ ▲ (final)
│ │ │ └── :snooze {until}: acked + snoozedUntil, chain stopped
│ └─────────┼───── autoCloseAfter (rule) ───────────────────────────────────────► expired
│ │ (final)
└────────────┘ snooze expires: back to open, chain restarts from step 0
```
| Transition | Trigger | Effects |
| ---------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| open → acked | any [ack path](#acknowledging-snoozing-and-resolving) | `ackedAt/ackedBy` set, escalation chain cancelled, sticky ack mirrored onto the object’s `check_state`, `ack` event, audit `alert.ack` |
| open → acked (snoozed) | `POST :snooze {until}` | as ack plus `snoozedUntil`; object ack comment `snoozed until …` |
| acked (snoozed) → open | every 5 s the engine re-opens alerts whose `snoozedUntil` has passed | acked fields cleared, sticky ack cleared, `escalation` event `snooze expired — alarm re-armed`, chain restarts **from step 0 with `openedAt` rebased to now** |
| open/acked → resolved | clear event, `POST :resolve`, DTMF `6`, IVR/AGI resolve, `POST /incidents/{id}:resolve` | chain cancelled, `alert_resolved` event, rule-created incident auto-resolved when it was the last active alert, ticket auto-close job if `ticket.autoClose` |
| open/acked → expired | rule `autoCloseAfter` elapsed since `openedAt` (checked every 5 s) | like resolve, status `expired` |
Re-firing events for an already open or acked alert **fold in** (see dedup) and never restart the chain; only a snooze wake-up does.
## Deduplication keys
[Section titled “Deduplication keys”](#deduplication-keys)
The dedup key decides whether an event opens a new alert or updates an existing one. Default when the rule has no `dedupKey` template:
1. `/` when the event has an object;
2. else `/` when the normalised event carries one (Alertmanager fingerprints, SNMP `source/agent/trapOid`, mail Message-ID, ESPA-X call id, …);
3. else `/`.
A custom `dedupKey` is a Go template over `{{ .event.* }}`, `{{ .object.id }}` and `{{ .rule.name }}`. Storage enforces a partial unique index on `(tenant, dedupKey)` for status `open`/`acked`; a re-fire raises the severity if the new one is higher (never lowers it), replaces title and payload with the newest event, appends the event id (last 50), and emits **no** new `alert_opened`. Heartbeat rules use `heartbeat/`; manual alarms use whatever `dedupKey` the caller sends (phone: `call/`, SMS: `sms/`, AGI: `agi/`).
## Manual alerts
[Section titled “Manual alerts”](#manual-alerts)
`POST /api/v1/alerts {title, message?, severity?, escalationPolicy?, labels?, objectId?, dedupKey?}` (permission `alerts:write`) creates an alert directly — the web **Trigger alarm** dialog, the alarm app, phone/IVR (`via: voice`, `asterisk-agi`) and SMS (`action: alert`) all use it. Manual alarms have `ruleId: "manual"`, default severity `critical`, must name an existing policy if they name one, emit `alert_opened` (fan-out only, never through rules) and start the chain at once. They **ignore suppression** by design: downtimes, silences and flapping do not hold a manual alarm back. A repeated trigger with the same `dedupKey` folds into the existing open/acked alarm and returns 200 instead of 201.
## Suppression and re-arm
[Section titled “Suppression and re-arm”](#suppression-and-re-arm)
Suppression is evaluated for **rule-created** alerts when they open and again every 5 s while they stay open and unacked. The checks run in this order; the first hit wins and its reason is recorded in a `notification` event with `status: "suppressed"`:
| # | Condition | Reason string |
| - | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| 1 | the alert’s object has `downtimeDepth > 0` | `object in downtime` |
| 2 | the object is flapping | `object flapping` |
| 3 | the object is a host in state UNREACHABLE | `host unreachable (parent down)` |
| 4 | the object is a service whose host is hard non-UP | `host down` |
| 5 | the object is a service whose host has `downtimeDepth > 0` | `host in downtime` |
| 6 | an active downtime lists the object, or its selector matches the **alert labels** | `downtime ` |
| 7 | an active silence whose selector (empty = all) matches the alert labels and whose `textRegex` (if set) matches the alert **title** | `silence ` |
While suppressed the alert **exists** and is visible as open; only the escalation chain is withheld. If the rule has an escalation policy the alert is remembered and re-checked every 5 s: once nothing suppresses it any more (downtime deleted or expired, silence expired, flapping stopped, host back UP) the chain starts — an already elapsed `after` offset fires at the next 2 s escalation poll. Acked, resolved or expired alerts are forgotten.
In-memory state
The re-arm set and `pendingFor` drafts live in memory. After a restart, alerts that opened while suppressed are not re-armed automatically, and a pending condition starts counting again.
Not part of suppression: object acknowledgements (a sticky ack on an object does not hold back new rule alerts; it only mutes direct object notifications) and notification periods. Downtimes and silences themselves are described on [Maintenance](/docs/monitoring/maintenance/); flapping and reachability on [Checks and states](/docs/concepts/checks-and-states/).
## Acknowledging, snoozing and resolving
[Section titled “Acknowledging, snoozing and resolving”](#acknowledging-snoozing-and-resolving)
| Path | How | Notes |
| ------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Web UI | Alerts page / Problems page ack dialog | `POST /alerts/{id}:ack {comment}` |
| API / CLI | `POST /api/v1/alerts/{id}:ack`, `np ack` | permission `alerts:ack`; only from `open` (otherwise 404) |
| Ack link | `GET /api/v1/ack/{token}` from e-mails/pushes | HMAC-signed token `...`, valid 24 h, needs `baseUrl`; answers an HTML “Quittiert” page, acks only `open` alerts |
| Alarm app | ack/snooze/resolve from the app | the app authenticates with its own API token (`alerts:ack`) and calls the same `:ack` / `:snooze` / `:resolve` routes |
| SMS | reply starting with the source’s `ackKeyword` (default `ACK`) from a contact’s phone number | acks the newest open alert |
| IVR (inbound call) | menu option `ack-alert` (default menu digit `3`) | Twilio `voice-inbound` or Asterisk FastAGI |
| Outbound voice DTMF | press `4` during a Twilio/Asterisk alarm call (`6` = resolve) | `POST /api/v1/voice/gather/{token}` |
| Snooze | `POST /alerts/{id}:snooze {until}` (future RFC 3339) | alert becomes `acked` with `snoozedUntil`; wake-up restarts the chain from step 0 |
| Resolve | `POST /alerts/{id}:resolve`, DTMF `6`, IVR `resolve-alert`, incident resolve, clear event | final |
Every path writes an `ack`/`alert_resolved` event and an audit entry (`alert.ack`, `alert.snooze`, `alert.resolve`). Note that `POST /alerts/{id}:ack` uses the caller’s **home** tenant and ignores `X-Northplane-Tenant` (the `:resolve` and `:snooze` routes honour the header). Walk-throughs for every path are on [Acknowledge and snooze](/docs/alarming/acknowledge-and-snooze/).
## Incidents
[Section titled “Incidents”](#incidents)
| Field | Meaning |
| ----------------------------------------------------- | --------------------------------------------------------------------------- |
| `id`, `tenantId`, `version` | `PUT /incidents/{id}` needs `If-Match` |
| `status` | `open` \| `resolved` |
| `severity`, `title`, `summary`, `impact`, `ticketUrl` | `summary` is written by humans or the AI (`POST /incidents/{id}:summarize`) |
| `createdBy` | a user name, `correlation`, `rule:` or the AI agent |
| `openedAt`, `resolvedAt` | |
Three ways an incident comes into being:
* **Rule-driven** — `incident: true` on a rule gives every alert it opens its own incident (`createdBy: rule:`); when the incident’s last open/acked alert resolves, the incident auto-resolves (`incident_update` with `status: resolved`). Only `rule:`-created incidents auto-resolve.
* **Correlator (alarm storms)** — a bus subscriber sweeps every 10 s over `alert_opened` events of the last **120 s**; if at least **5** fresh alerts share one dominant `key=value` label pair, they are attached to an incident `Alarm storm: alerts sharing =` (severity critical, `createdBy: correlation`; an existing incident of a clustered alert is reused) and an AI summary job is queued when a provider is configured. Manual alarms participate because they emit `alert_opened` too.
* **Manual / API / AI** — `POST /api/v1/incidents {title, severity?, summary?, impact?, ticketUrl?, alertIds?}`; this one publishes `incident_update {action: "created"}` **through the rules**, so a rule such as `event.type == "incident_update" && event.payload.action == "created"` can alarm on app-created incidents.
`POST /incidents/{id}:resolve` resolves the incident and all its open/acked alerts (chains stopped, no event emitted); `:merge {sourceIds}` moves alerts into the target and resolves the sources. The [Incidents page](/docs/ui/alerts-incidents-events/) shows cards with AI summary and resolve actions; the AI side is on [Agent chat](/docs/ai/agent-chat/).
## Alerts vs. direct object notifications
[Section titled “Alerts vs. direct object notifications”](#alerts-vs-direct-object-notifications)
Objects can also notify **without** a rule: `spec.contacts` / `spec.contactGroups` are notified on hard state changes (gated by `enableNotifications`, `notifyOn`, `notificationPeriod`, object downtime and a sticky ack). Those deliveries go through the same outbox and produce `notification` events with an empty `alertId`, but they create no alert and have no escalation chain. Use rules + policies for anything that must be acknowledged or escalated; see [Contacts and on-call](/docs/alarming/contacts-and-oncall/).
## The `np.*` labels
[Section titled “The np.\* labels”](#the-np-labels)
A few labels on an alert are interpreted by outputs. They can come from a rule’s `setLabels`, the manual trigger dialog, an IVR option’s `labels` or the event itself.
| Label | Consumer | Effect |
| ------------------- | ---------------------- | -------------------------------------------------------------------------- |
| `np.sound` | mobile push (FCM/APNs) | tone name (`np_klaxon`, `np_sirene`, `np_puls`); APNs `sound = .caf` |
| `np.volume` | mobile push | `0.0`–`1.0` critical-alert volume (APNs, with overrideSilent) |
| `np.overrideSilent` | mobile push | `"true"` → APNs critical interruption level, FCM high priority |
| `np.tts` | voice channel | spoken text override for the alarm call |
Details: [Mobile push](/docs/alarming/mobile-push/) and [Voice and IVR](/docs/alarming/voice-and-ivr/).
## Where to go next
[Section titled “Where to go next”](#where-to-go-next)
* [Alarming overview](/docs/alarming/overview/) — the end-to-end picture and recipes.
* [Escalation policies](/docs/alarming/escalation-policies/) — steps, repeats, `unlessAcked`, persisted timers.
* [Reliability](/docs/alarming/reliability/) — outbox retries, dead letters, what is in memory.
* [Events](/docs/concepts/events/) — the event types referenced above.
# Architecture
> How the single northplaned process is built — request path, monitoring and alarming pipelines, background workers, storage, NP-TSDB, event bus, API-first design and security posture.
Northplane is one Go binary, `northplaned`, that runs everything: the HTTP API, the embedded React UI and this documentation, the check scheduler and executor, the alarming engine, notification delivery, the SNMP-trap/MQTT/ESPA/FastAGI listeners, the MCP server, the federation edge worker and the housekeeping jobs. There is no message broker, no separate worker process and no external time-series database. State lives in one SQLite file (or a PostgreSQL database), monthly event segments and the bundled NP-TSDB under a single data directory.
This page explains how the pieces fit together. The pages that follow in this section explain the data model ([Object model](/docs/concepts/object-model/)), the check life cycle ([Checks and states](/docs/concepts/checks-and-states/)), the [event stream](/docs/concepts/events/), the [alert and incident model](/docs/concepts/alerts-incidents/), [tenancy and RBAC](/docs/concepts/tenancy-rbac/) and [federation](/docs/concepts/federation/).
## Overview
[Section titled “Overview”](#overview)
One northplaned process: HTTP front door, worker pipeline, embedded storage. Every client speaks the same REST API.
## Detailed block diagram
[Section titled “Detailed block diagram”](#detailed-block-diagram)
```text
browser (React SPA) np CLI np-agent MCP clients (HTTP) webhooks · Alertmanager · Twilio · cron beats
│ │ │ │ │
└────────────────┴─────────┴─────────────┴───────────────────────────┘
│ HTTPS 443
┌─────────▼─────────┐
│ reverse proxy │ Caddy / nginx terminates TLS
│ (optional) │ → trustProxy: true, listen :8443
└─────────┬─────────┘
│ http(s) :8443
┌─────────────────────────────────────────▼──────────────────────────────────────────────────┐
│ northplaned — one Go process │
│ │
│ root mux /api/ /metrics /healthz /readyz /auth/ /login /setup /register /status/ │
│ /mcp /docs/ / (SPA + assets) │
│ wrapping securityHeaders → 30 s request deadline (streams exempt) → API middleware │
│ (X-Request-Id, panic recovery, metrics, auth np_… | np_session) │
│ → per route: CSRF → login required → RBAC permission → handler │
│ │
│ ┌─ monitoring ─────────────────────────────┐ ┌─ alarming ───────────────────────────────┐ │
│ │ catalog (in-memory effective config) │ │ ingress adapters: webhook, alertmanager, │ │
│ │ → scheduler (timing wheel, 1 s slots) │ │ traps (UDP), mailin (IMAP), mqttin, │ │
│ │ → executor (builtin pool / exec pool) │ │ espa (TCP), agi, telephony, beats │ │
│ │ → results → pipeline → state machine │ │ → events → alerting engine (CEL rules, │ │
│ │ → state_change events, NP-TSDB │ │ dedup, pendingFor, suppression) │ │
│ │ passive results: POST /api/v1/results, │ │ → alerts → escalation (persisted │ │
│ │ np-agent push, freshness probes │ │ timers) → notify → outbox → channels │ │
│ └──────────────────────────────────────────┘ │ correlator · webhook-dispatcher │ │
│ └──────────────────────────────────────────┘ │
│ │
│ event bus (in-memory channels) results 8192 · events 16384 · notifications 4096 │
│ subscribers: SSE hub, correlator, webhook dispatcher │
│ │
│ workers scheduler executor pipeline alerting correlator escalation notify traps mailin │
│ mqttin espa agi api-janitor webhook-dispatcher report-scheduler dead-man │
│ [ldap-sync] [federation-edge] [ai] — all supervised, restart after 1 s │
│ │
│ storage core.db (SQLite WAL) or PostgreSQL · events-YYYYMM.db segments / partitions │
│ NP-TSDB dataDir/tsdb · secret.key (AES-256-GCM secret store) · audit chain │
└────────────────────────────────────────────────────────────────────────────────────────────┘
```
## The process at a glance
[Section titled “The process at a glance”](#the-process-at-a-glance)
| Part | What it does | Where to read more |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
| HTTP server | One listener (`listen`, default `127.0.0.1:8443`); TLS from `tls.certFile`/`tls.keyFile`, or plaintext behind a trusted proxy (`trustProxy`) or on loopback. | [TLS and proxy](/docs/administration/tls-and-proxy/) |
| REST API | `/api/v1/...`, RFC 9457 errors, cursor pagination, `If-Match` versioning, OpenAPI 3.1 generated from the route registry. | [API overview](/docs/reference/api-overview/) |
| Embedded UI | React single-page app compiled into the binary (`//go:embed`), served at `/`. | [Navigation](/docs/ui/navigation/) |
| Embedded docs | This Starlight site, served at `/docs/` without login. | [Documentation](/docs/development/documentation/) |
| Catalog | In-memory cache of every object with its resolved (effective) spec, template chain, command class and argv — the scheduler and pipeline never touch SQL on the hot path. | [Object model](/docs/concepts/object-model/) |
| Scheduler | 86 400-slot timing wheel (1 s granularity, 250 ms tick), deterministic splay, priority lane for check-now and retries. | [Checks and states](/docs/concepts/checks-and-states/) |
| Executor | Runs `builtin:` checks in-process (pool 1024) and `exec:` Nagios plugins as child processes (pool `execPoolSize`, default `min(256, 32×CPU)`); sends freshness probes for passive/agent objects. | [Builtin checks](/docs/monitoring/builtin-checks/), [Plugins and Nagios](/docs/monitoring/plugins-and-nagios/) |
| Pipeline + state machine | Turns results into soft/hard states, host UP/DOWN/UNREACHABLE, flapping, events and TSDB samples; batches writes every 250 ms or 500 results. | [Checks and states](/docs/concepts/checks-and-states/) |
| Alerting engine | Single goroutine consuming the event queue; CEL rules, dedup, `pendingFor`, suppression, heartbeats, auto-close, snooze wake-up on a 5 s tick. | [Alerts and incidents](/docs/concepts/alerts-incidents/), [Alert rules](/docs/alarming/alert-rules/) |
| Escalation + notify | Persisted escalation timers (2 s poll), outbox with retries/DLQ (3 s poll), channel drivers. | [Escalation policies](/docs/alarming/escalation-policies/), [Reliability](/docs/alarming/reliability/) |
| Listeners | SNMP trap receiver (UDP), IMAP poller, MQTT subscriber, ESPA/ESPA-X TCP, FastAGI for Asterisk, Twilio webhooks. | [Event sources](/docs/alarming/event-sources/) |
| AI / MCP | Agent chat, approvals and the MCP server (stdio and `/mcp`) share one tool registry that re-checks REST permissions. | [Agent chat](/docs/ai/agent-chat/), [MCP server](/docs/ai/mcp-server/) |
| Federation edge | Optional worker that pulls a config bundle from a main instance and reports status. | [Federation](/docs/concepts/federation/) |
## Request path
[Section titled “Request path”](#request-path)
1. **Proxy (optional).** In the reference deployments Caddy terminates TLS on 443 and forwards to `northplane:8443` over plain HTTP; the server runs with `NORTHPLANE_TRUST_PROXY=true`, `NORTHPLANE_LISTEN=:8443` and `NORTHPLANE_BASE_URL=https://`. Without a proxy, give `northplaned` its own certificate pair. A non-loopback listener with neither TLS nor `trustProxy` nor `tls.insecure` refuses to start. See [TLS and proxy](/docs/administration/tls-and-proxy/).
2. **Root mux.** The server mounts the API handler at `/api/`, `/metrics`, `/healthz`, `/readyz`; the server-rendered auth pages at `/auth/`, `/login`, `/setup`, `/register`, `/status/`; the MCP Streamable-HTTP endpoint at `/mcp`; the documentation at `/docs/`; and the SPA at `/`. Every response passes through `securityHeaders` (nosniff, `X-Frame-Options: DENY`, CSP for non-API paths, HSTS on HTTPS) and a 30 s `http.TimeoutHandler`; the streaming paths `/api/v1/stream`, `/api/v1/events:export`, `/api/v1/ai/chat` and `/mcp` are exempt from the deadline.
3. **API middleware.** Assigns `X-Request-Id` (UUIDv7), recovers panics into `np:internal`, records `np_http_*` metrics and authenticates: `Authorization: Bearer np_…` resolves an API token (prefix lookup + argon2id verify, expiry, IP bind); otherwise the `np_session` cookie resolves a DB-backed session; otherwise the request is anonymous.
4. **Per route.** Cookie sessions with `Sec-Fetch-Site: cross-site` are rejected (`np:auth/csrf`); routes with a permission require a principal (`np:auth/required`) holding that permission (`np:auth/forbidden`); then the handler runs with the tenant from `X-Northplane-Tenant` (only for `admin:tenants` holders) or the principal’s own tenant.
5. **SPA gate.** An unauthenticated *document* navigation to `/` is redirected to `/login`; API calls are never redirected, they get a 401 problem document. The UI polls the API (no SSE) at the user’s refresh interval.
Details and the full error catalog are in the [API overview](/docs/reference/api-overview/); the authentication flows are in [Authentication](/docs/administration/authentication/).
## The monitoring pipeline
[Section titled “The monitoring pipeline”](#the-monitoring-pipeline)
```text
catalog.Entry ──► scheduler (due) ──► executor ──► CheckResult ──► pipeline
├─ host mapping (UP/DOWN/UNREACHABLE)
├─ state machine (soft/hard, flapping)
├─ check_state upsert (batched)
├─ state_change / flapping events ──► event bus
├─ retry timer (soft) / parent cascade
└─ perfdata ──► NP-TSDB
```
* Object create/update pushes the entry into the catalog and the scheduler; template, check-command and time-period changes reload the whole tenant catalog and re-schedule.
* Active checks (`builtin:`/`exec:`) are dispatched from the wheel; passive and agent objects only get freshness probes when `stalenessAfter` is set; results for them arrive via `POST /api/v1/results`.
* The pipeline keeps `check_state` rows in memory and flushes them in batches; failed flushes are re-queued.
[Checks and states](/docs/concepts/checks-and-states/) explains the timing and state rules; [Metrics and NP-TSDB](/docs/monitoring/metrics-and-tsdb/) the perfdata path.
## The alarming pipeline
[Section titled “The alarming pipeline”](#the-alarming-pipeline)
```text
event sources ─┐
state_change ─┼─► event bus ─► alerting engine ─► alert (dedup, pendingFor, suppression)
heartbeats ─┘ ├─► alert_opened event ─► correlator (storms → incident)
incident_update (API) ├─► escalation chain (persisted timers)
│ └─► notify ─► outbox ─► channels (email, SMS, voice, push, …)
└─► webhook subscriptions (outgoing)
```
Every input becomes an `Event`; alert rules (CEL) decide what becomes an alert; escalation policies decide who is notified and when; channels deliver through the outbox with retries and a dead-letter queue. Manual alarms (API, phone, SMS, app) create alerts directly and bypass rules and suppression. Read [Alerts and incidents](/docs/concepts/alerts-incidents/) for the model and the [Alarming overview](/docs/alarming/overview/) for the end-to-end walk-through.
## Background workers
[Section titled “Background workers”](#background-workers)
`serve` starts every worker under a supervisor: a panic is logged as `server: background worker panicked; restarting` and the worker restarts after 1 s. On SIGINT/SIGTERM the HTTP server drains and workers get a 30 s shutdown budget.
| Worker | Cadence | Job |
| ------------------------------------------ | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `scheduler` | 250 ms tick | timing wheel, due objects → queue |
| `executor` | continuous | runs builtin/exec checks, freshness probes |
| `pipeline` | batch 250 ms / 500 results | results → state machine → events → TSDB |
| `alerting` | event-driven + 5 s tick | rules, pending alerts, heartbeats, auto-close, suppression re-arm, snooze wake |
| `correlator` | 10 s sweep | alarm storms → incident |
| `escalation` | 2 s poll | due escalation steps (table `escalations`) |
| `notify` | 3 s poll or bus wake-up | outbox delivery, retries, dead letters |
| `traps`, `mailin`, `mqttin`, `espa`, `agi` | 30 s reconcile | listeners/pollers for the matching event-source types |
| `api-janitor` | 30 s / 10 min / hourly | downtime depths + flexible downtimes; expired sessions + idempotency rows; nightly (02:00–03:59 local) TSDB maintenance + event retention, otherwise TSDB flush |
| `webhook-dispatcher` | bus subscriber | outgoing webhook subscriptions → outbox |
| `report-scheduler` | 10 s after start, then 1 min | scheduled reports |
| `dead-man` | `deadManInterval` (1 m) | GET `deadManUrl`; skipped when the results queue exceeds 7000 |
| `ldap-sync` | `ldap.syncInterval` (15 m) | only with `ldap.url` |
| `federation-edge` | `federation.interval` (1 m) | only in `federation.mode: edge` |
| `ai` | — | only when the AI service exposes a run loop |
See [Observability](/docs/administration/observability/) for health endpoints, `/metrics` and logs.
## Storage
[Section titled “Storage”](#storage)
| Store | Default | Notes |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Relational core | SQLite `/core.db`, WAL, pool 16, writes serialised in-process | `storage.dsn: postgres://…` switches to PostgreSQL (pgx, pool 16/8). Schema migrations (currently 9) run automatically on every open; `northplaned storage migrate --to` copies between backends offline. |
| Events | `/events-YYYYMM.db` monthly segment files (SQLite) or `events_YYYYMM` range partitions (PostgreSQL) | Append-only; `storage.eventRetentionMonths` (default 12, `0` = forever) drops whole months nightly. |
| Config documents | table `resources` (`tenant_id, kind, name, doc JSON, version`) | Templates, rules, channels, policies, schedules, dashboards, roles, sites, … — the same documents that YAML [config bundles](/docs/administration/config-bundles/) carry. |
| Dedicated tables | `objects`, `object_labels`, `check_state`, `alerts`, `incidents`, `downtimes`, `silences`, `heartbeats`, `users`, `tenants`, `sessions`, `api_tokens`, `secrets`, `audit_log`, `outbox`, `escalations`, `idempotency`, `kv`, `push_subscriptions`, `report_archive`, `ai_*` | Hot-path and security-relevant data. |
| Secrets | table `secrets`, AES-256-GCM, master key in `secret.key` | Referenced as `$SECRET:name$`; see [Secrets](/docs/administration/secrets/). |
| Audit | `audit_log` with a SHA-256 hash chain | `POST /api/v1/audit:verify`, `np audit verify`; no purge. |
| NP-TSDB | `/tsdb` | see below |
| Backup | `northplaned backup` → `backup.target` | `VACUUM INTO` copy of `core.db`, event segments, TSDB tree, manifest; no periodic loop. |
Everything about files, DSNs, retention and restore is on the [Storage](/docs/administration/storage/) page.
## NP-TSDB
[Section titled “NP-TSDB”](#np-tsdb)
Every check result’s perfdata (`'label'=value[UOM];warn;crit;min;max`) becomes samples in the embedded time-series store: one series per `(objectId, metric, unit)` plus `np_exec_time` per result. Raw samples are kept in a WAL and flushed into immutable two-hour blocks (Gorilla compression); 5-minute and 1-hour aggregates are built nightly; retention is hard-coded at **30 days raw, 400 days 5-minute, 5 years 1-hour**, with a cap of 100 000 series. `POST /api/v1/metrics/query` picks the finest tier with data for the requested range. The TSDB is backend-independent — it is not touched by `storage migrate` and is copied as a directory by `backup`. Details: [Metrics and NP-TSDB](/docs/monitoring/metrics-and-tsdb/).
## Event bus
[Section titled “Event bus”](#event-bus)
The bus is in-memory only (Go channels): `Results` (executor → pipeline, 8192), `Events` (ingress and pipeline → alerting engine, 16384, blocking — never dropped), `Notifications` (outbox wake-ups, 4096) and `AI` (256, dropped under load). Subscribers — the SSE hub (buffer 512), the correlator (1024) and the webhook dispatcher (1024) — see every event of every tenant and filter themselves; a slow subscriber loses messages and is flagged for resync. Persistence is *not* a bus feature: producers insert events into the event store before or while publishing, so the stored history is complete even when a live subscriber overflows. Engine and API lifecycle events (`alert_opened`, `ack`, `config`, …) are published *fan-out only*, i.e. they reach SSE, webhooks and the correlator but do not re-enter the alert rules. See [Events](/docs/concepts/events/).
## API-first
[Section titled “API-first”](#api-first)
There is exactly one way to read or change anything: the REST API under `/api/v1`.
* The React UI and the `np` CLI are ordinary REST clients. The `np-agent` pushes results to `POST /api/v1/results` and pulls central checks from `GET /api/v1/agent/checks`.
* Every route is registered through one helper that records method, path, summary, permission and request/response types; the OpenAPI 3.1 document (`GET /api/openapi.json`, `northplaned openapi`) and the TypeScript types of the UI are generated from that registry, and each operation carries `x-required-permission`. Swagger UI is served at `/api/docs`.
* The AI tools and the MCP server (Streamable HTTP at `/mcp`, stdio via `northplaned mcp`) do not go through HTTP handlers — they call the store and services directly — but every tool checks the same permission name as the equivalent REST route and mutating tools go through a propose/approve gate; nothing is reachable through AI that the same principal could not do over the REST API.
* YAML [config bundles](/docs/administration/config-bundles/) are the declarative form of the same documents: `np apply`, the Admin tab, the Nagios importer, the AI config tools and the federation edge all call one applier.
## Embedding
[Section titled “Embedding”](#embedding)
The binary embeds the UI build (`internal/web/dist`, `Cache-Control: immutable` for `/assets/*`), this documentation (`/docs/`, public, own CSP, pre-compressed) and the vendored Swagger UI. A build without the UI answers `501 UI not embedded in this build`; a build without the docs answers 501 at `/docs/`. Because the UI is part of the binary, server and UI can never be out of sync; the CI type-drift gate (`make types-check`) enforces that the generated TypeScript types match the OpenAPI document.
## Security posture (summary)
[Section titled “Security posture (summary)”](#security-posture-summary)
| Area | Behaviour |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Transport | TLS 1.2+ with a configured cert/key; plaintext only on loopback, with `tls.insecure` (dev) or behind a proxy with `trustProxy`. No ACME — terminate at Caddy/nginx. HSTS when HTTPS. |
| Authentication | Local accounts (argon2id, passwords ≥ 12 chars, per-IP login throttle), OIDC (code + PKCE), LDAP/AD (sync + search-then-bind), API tokens (`np_` + 48 hex, hashed, scopes/roles, expiry, IP bind, shown once). |
| Sessions | `np_session` cookie: HttpOnly, SameSite=Lax, Secure on HTTPS; 12 h or 30 d with “remember me”; DB-backed. Cross-site cookie requests to the API are rejected (`np:auth/csrf`); no CORS. |
| Authorization | Permission strings `resource:action` with wildcards, per-route checks, built-in roles `admin`/`operator`/`viewer`/`ai-agent`, custom roles with includes and IdP group mapping. See [Tenancy and RBAC](/docs/concepts/tenancy-rbac/). |
| Isolation | Every row is tenant-scoped; cross-tenant reads return 404; only `admin:tenants` may switch tenants with `X-Northplane-Tenant`. |
| Secrets | AES-256-GCM secret store keyed by `secret.key` (0600); values are write-only through the API; `$SECRET:name$` references in channel/source/check config. |
| Audit | Hash-chained audit log for every mutation and login; NDJSON export; integrity verification. |
| Headers | `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: same-origin`, CSP with `frame-ancestors 'none'` on non-API paths. |
| Ingest | Per-source auth (`token`/`hmac`/`basic`/`none`), per-source rate limits (50 ev/s, burst 200), optional Twilio signature verification, SSRF guards in HTTP checks, discovery and webhooks. |
| Unauthenticated by design | `/healthz`, `/readyz`, `/metrics`, `/api/openapi.json`, `/api/docs`, `/docs/`, `GET /api/v1/system/health`, `GET /api/v1/system/info`, public status pages — restrict at the proxy where needed. |
| Be aware | `X-Forwarded-For` is not used (source IPs behind a proxy are the proxy’s address); role folder/selector scopes are stored but not enforced; system roles are editable through the API. |
The hardening checklist lives on the [Security](/docs/administration/security/) page.
## Where to go next
[Section titled “Where to go next”](#where-to-go-next)
* [Installation](/docs/getting-started/installation/) and the [Deployment overview](/docs/deployment/overview/) for how the process is run in practice.
* [Configuration](/docs/administration/configuration/) for every `config.yaml` key and environment variable mentioned here.
* [Backend](/docs/development/backend/) for the Go package map behind these components.
# Checks and states
> Active, passive and agent checks; scheduling with interval, retry, attempts and splay; soft and hard states; host UP/DOWN/UNREACHABLE and reachability; the flapping algorithm; freshness; acknowledgements; dependencies; check-now; and the events a check emits.
A check produces a **result** (state 0–3, output text, optional perfdata). The pipeline folds results into the object’s saved state using Nagios-style rules: a problem is *soft* until it has been confirmed `maxCheckAttempts` times, then *hard*; hosts map to UP/DOWN/UNREACHABLE; repeated state changes mark an object as *flapping*. This page explains those rules exactly. The check types themselves are documented on [Builtin checks](/docs/monitoring/builtin-checks/), [Plugins and Nagios](/docs/monitoring/plugins-and-nagios/), [Agent](/docs/monitoring/agent/) and [SNMP](/docs/monitoring/snmp/).
## Active, passive and agent checks
[Section titled “Active, passive and agent checks”](#active-passive-and-agent-checks)
| Class | `checkCommand` | Who runs it | Result source |
| -------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------- |
| active builtin | `builtin:` | `northplaned`, in-process (pool 1024) | `scheduler` |
| active exec | `exec:` or a named CheckCommand of type `exec` | `northplaned`, child process (pool `execPoolSize`) | `scheduler` |
| agent | `agent:exec:` or a named CheckCommand of type `agent` | `np-agent` on the host (pulled from `GET /api/v1/agent/checks`), pushed back as results | `agent` |
| passive | `passive` or empty | anything that can `POST /api/v1/results` (scripts, np-agent collectors, NSCA-style bridges) | `passive` |
Only the two active classes are dispatched by the scheduler. Passive and agent objects are never executed by the server; if they have `stalenessAfter` set, the server sends a periodic **freshness probe** instead (see [Freshness and staleness](#freshness-and-staleness)). Results with source `passive` or `agent` are treated as **hard immediately** (`maxCheckAttempts` is forced to 1 for them — the classic `passive_*_checks_are_soft=0`).
Passive results are posted as `{"results":[{"host":"web01","service":"http","state":2,"output":"CRITICAL - … | t=1s"}]}`; omit `service` for a host result; `state` may be numeric (0–3) or symbolic (`OK`, `WARNING`, `CRITICAL`, `UNKNOWN`, `UP`, `DOWN`, `UNREACHABLE`). The first output line is split at the first `|` into text and perfdata; further lines become long output. Unknown objects are listed under `rejected`; the call returns 202.
Passive host results: use 2 / CRITICAL for DOWN
`DOWN` parses to the numeric value 1 and `UNREACHABLE` to 2. Host results are mapped with the same table as active checks (see below), where 1 (WARNING) counts as **UP**. Submit `2` or `CRITICAL` for a down host.
## Scheduling
[Section titled “Scheduling”](#scheduling)
The scheduler is a timing wheel with 86 400 one-second slots (a 24 h ring) ticked every 250 ms.
| Parameter | Default | Rule |
| --------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `interval` | `60s` | cadence of an active object; truncated to whole seconds and clamped to **1 s … 24 h** |
| splay | — | deterministic offset `FNV-64a(objectId) mod interval`; the first due time is the next grid point `now.Truncate(interval) + splay`. No random jitter, stable across restarts |
| `retryInterval` | `15s` | after a **soft** result from the scheduler, a one-shot timer triggers a recheck after `retryInterval` (the wheel cadence is unchanged). Passive/agent/freshness results never trigger retries |
| `maxCheckAttempts` | `3` | attempts before a problem becomes hard (see next section) |
| `timeout` | `30s` | context deadline for builtin checks; process-group kill for plugins (`UNKNOWN - plugin timed out after … (killed)`) |
| `enableChecks: false` | — | object removed from the wheel; with `stalenessAfter` it becomes a freshness-probe entry |
| check-now | — | `POST /api/v1/objects/{id}/check-now` (permission `checks:run`) puts the object on a priority lane (cap 256); it does **not** reset the regular cadence |
Due times are drift-free (next = planned + interval, catching up after stalls). The output queue holds 4096 jobs; when it is full the entry is postponed by one second instead of blocking the wheel. `check_state.nextCheck` shows the next planned run.
Every catalog change (object create/update/delete, template/check-command/time-period change) is pushed into the scheduler immediately; there is no reload command.
## Soft and hard states
[Section titled “Soft and hard states”](#soft-and-hard-states)
The state machine runs per result with `maxCheckAttempts` (≤ 0 → 3) and the flap thresholds from the effective spec.
| Situation | Outcome |
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| result OK | state OK, **hard**, `attempt = 1`, `lastOk` set. If the previous state was a *hard* problem: **recovery** (hard change, `lastHardChange` set, sticky acknowledgement cleared). Recovery from a *soft* problem is silent — no hard change, hence no notification |
| OK → problem | `maxCheckAttempts == 1` → hard immediately; otherwise **soft**, `attempt = 1` |
| soft problem continues (same or other severity) | `attempt++`; when `attempt >= maxCheckAttempts` → **hard**, `attempt = maxCheckAttempts`, `lastHardChange` set |
| hard problem → *different* problem severity (e.g. WARNING → CRITICAL) | immediate hard change, `attempt = 1`, `lastHardChange` set |
| same hard problem continues | stays hard, `attempt = maxCheckAttempts` |
Every result updates `output`, `longOutput`, `perfdata`, `latencyMs`, `execMs` and `lastCheck`. Only **hard** transitions drive direct object notifications and — through the `stateType == "hard"` condition you put in alert rules — alerts. `GET /api/v1/problems` lists hard non-OK states.
## Host states and reachability
[Section titled “Host states and reachability”](#host-states-and-reachability)
Hosts reuse the numeric state space as `UP=0`, `DOWN=1`, `UNREACHABLE=2`. A host check result is mapped before it enters the state machine:
1. result **OK or WARNING → UP**; **CRITICAL or UNKNOWN → DOWN** (the classic Nagios default: a slow ping is still up).
2. a DOWN host that lists `spec.parents` (host names) and whose parents are **all** non-UP in a **hard** state → **UNREACHABLE** (`allParentsDown`: at least one parent, none UP, none soft).
3. a hard host transition immediately schedules a check-now for every host that lists it as a parent, so dependents flip to DOWN/UNREACHABLE quickly.
UNREACHABLE is deliberately quiet: the `state_change` event it emits carries severity `warning` instead of `critical`, rule-created alerts on an UNREACHABLE host are suppressed with reason `host unreachable (parent down)`, and service alerts are suppressed while the host is hard non-UP (`host down`) — see [Alerts and incidents](/docs/concepts/alerts-incidents/). There is no separate dependency resource; `parents` is the dependency graph.
Severity mapping used for events: host UP → `ok`, DOWN → `critical`, UNREACHABLE → `warning`; service OK → `ok`, WARNING → `warning`, CRITICAL → `critical`, UNKNOWN → `warning`.
## Flapping
[Section titled “Flapping”](#flapping)
The detector keeps a 21-bit history per object; a bit is set when a result’s **raw** state differs from the previous raw state (soft/hard does not matter). The flap percentage is a weighted change rate with newer checks weighing more:
```text
weight(i) = 0.8 + 0.4 · i / 20 i = 0 (oldest) … 20 (newest)
flapPct = 100 · Σ weight(i)·changed(i) / Σ weight(i)
```
* flapping **starts** when `flapPct >= flapThresholdHigh` (default **50 %**),
* flapping **stops** when `flapPct < flapThresholdLow` (default **25 %**),
* `enableFlapDetection: false` (per object or template) disables it; turning it off while flapping emits a stop.
Strict alternation gives \~100 %; 21 stable checks bring it back to 0. The pipeline emits `flapping_start` / `flapping_end` events (severity `info`); while `check_state.flapping` is set, rule-created alerts for the object are suppressed (`object flapping`) and direct object notifications are withheld. See [Maintenance](/docs/monitoring/maintenance/) for how suppression interacts with downtimes.
## Freshness and staleness
[Section titled “Freshness and staleness”](#freshness-and-staleness)
Passive and agent objects can declare `stalenessAfter`. The wheel then fires a **freshness probe** every `stalenessAfter`; the pipeline ignores it if `lastCheck` is younger than `stalenessAfter`, otherwise it applies a synthetic `UNKNOWN` result with `stalenessText` (default `UNKNOWN - check result is stale (freshness threshold exceeded)`).
Implications, read straight from the implementation:
* Detection latency lies between 1× and 2× `stalenessAfter` because the probe cadence is not re-armed from the last real result.
* The synthetic result carries source `freshness`, which is **not** forced hard: with the default `maxCheckAttempts: 3` a stale object goes soft first and becomes hard after further probes. Set `maxCheckAttempts: 1` on passive objects if staleness should be hard at once.
* The probe updates `lastCheck`, and no retry timer applies (retries are scheduler-sourced only).
* A real result clears the condition on arrival (passive results are hard immediately).
`heartbeat` resources are the simpler tool for “something should call in every N minutes” without an object — see [Heartbeats](/docs/monitoring/heartbeats/).
## Acknowledgements
[Section titled “Acknowledgements”](#acknowledgements)
There is no object-level ack endpoint. You acknowledge an **alert** (UI, `POST /api/v1/alerts/{id}:ack`, `:snooze`, ack link, SMS keyword, IVR digit, DTMF, app), and the API mirrors `ackedBy`/`ackComment` onto the object’s `check_state` when the alert has an `objectId`. The ack is **sticky**: it is cleared only on a hard recovery (or when a snooze wakes up). Effects on the object side:
* acknowledged problems disappear from `GET /api/v1/problems` unless `includeHandled=true`;
* direct object notifications for problems are skipped while acked (recoveries still go out);
* on the alert side an ack ends the escalation chain.
`Acknowledgement{sticky, expiresAt}` exists in the model but expiring acks are not implemented. All ack paths are listed on [Acknowledge and snooze](/docs/alarming/acknowledge-and-snooze/).
## Events emitted by a check
[Section titled “Events emitted by a check”](#events-emitted-by-a-check)
| Event | When | Payload (gist) | Severity |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| `state_change` | raw state changed **or** a hard state was entered — soft transitions are emitted too, with `stateType: "soft"` | `{object, host?, kind, fromState, toState, from, to, stateType, attempt, output, labels, metric}` | from the new state (host UNREACHABLE → `warning`) |
| `flapping_start` / `flapping_end` | flap edges | `{object, flapPct, labels}` | `info` |
That is why rules for “page me” conditions should test `event.stateType == "hard"`. The event catalogue is on [Events](/docs/concepts/events/).
## Timing and limits quick reference
[Section titled “Timing and limits quick reference”](#timing-and-limits-quick-reference)
| Item | Value |
| ---------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `interval` / `retryInterval` / `maxCheckAttempts` / `timeout` defaults | 60 s / 15 s / 3 / 30 s |
| interval clamp | 1 s … 24 h, whole seconds |
| wheel tick / slots | 250 ms / 86 400 |
| queues | scheduler out 4096, priority 256, results 8192 |
| pipeline batch | every 250 ms or 500 results |
| exec pool / builtin pool | `min(256, 32 × CPU)` (config `execPoolSize`) / 1024 |
| plugin stdout / stderr cap | 64 KiB / 16 KiB |
| flap window / thresholds | 21 checks / 25 % low, 50 % high |
| passive results | 202, unknown objects reported in `rejected`; 503 when the pipeline is stalled |
# Events
> The event model, the complete event type table with emitters and payloads, how events flow through the in-memory bus, persistence and retention in monthly segments, querying, the SSE stream, NDJSON export and the Events page.
An **event** is the unit of history in Northplane. Every state change, every inbound alarm, every notification attempt, every ack, downtime, silence, escalation step and configuration change is appended to the event store and fanned out live. Alert rules read events; the SSE stream, outgoing webhooks and the correlator subscribe to them; the UI and reports query them.
## The event model
[Section titled “The event model”](#the-event-model)
```json
{
"id": "0199a8c4-5e21-7b3c-9a0e-2f1d7c8b4e55",
"tenantId": "00000000-0000-7000-8000-000000000001",
"ts": "2026-08-23T10:15:00.123Z",
"type": "state_change",
"objectId": "0199a8c0-…",
"severity": "critical",
"payload": { "object": "db-01", "kind": "host", "from": "UP", "to": "DOWN", "stateType": "hard", "attempt": 3, "output": "CRITICAL - no reply from 10.0.0.5 within 5s", "labels": { "env": "prod" } }
}
```
| Field | Type | Meaning |
| ---------- | ----------------------------------------- | ----------------------------------------------------------------------------------------- |
| `id` | string | UUIDv7 — time-ordered; used as pagination cursor and as the SSE `id:` / `Last-Event-ID` |
| `tenantId` | string | every event belongs to exactly one tenant |
| `ts` | RFC 3339 | event time (UTC) |
| `type` | string | one of the types below |
| `objectId` | string, optional | the monitored host/service, when the event concerns one |
| `sourceId` | string, optional | the **event source id** for `ingress` events; the **heartbeat id** for `heartbeat_missed` |
| `severity` | `critical` \| `warning` \| `info` \| `ok` | optional |
| `payload` | JSON object | type-specific, see the table |
Events are append-only: there is no update or delete endpoint, only retention.
## Event types
[Section titled “Event types”](#event-types)
| Type | Emitted by | Payload (gist) | Severity |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
| `state_change` | pipeline — on every raw state change and whenever a hard state is entered (soft transitions included, with `stateType: "soft"`) | `{object, host?, kind, fromState, toState, from, to, stateType, attempt, output, labels, metric}` — `from`/`to` are labels such as `UP`, `CRITICAL`; `metric` is the first perfdata label | from the new state; host UNREACHABLE is forced to `warning` |
| `flapping_start` / `flapping_end` | pipeline | `{object, flapPct, labels}` | `info` |
| `ingress` | every event-source adapter: webhook, Alertmanager receiver, email/IMAP poller, SNMP-trap receiver, MQTT subscriber, ESPA / ESPA-X listeners, SMS inbound with `action: event` | the normalised event (`NormEvent`): `{source, receivedAt, dedupKey?, severity, summary, labels?, payload?, resolve?}`; `payload` archives the original body | set by the adapter mapping or the source’s default severity |
| `heartbeat_missed` | alerting engine heartbeat sweep (every 5 s); the beat endpoint on recovery | `{heartbeat, labels, summary}`; on recovery additionally `resolve: true` | the heartbeat’s severity; `ok` on recovery |
| `alert_opened` | alerting engine when a rule opens a new alert; `POST /api/v1/alerts` and the phone/SMS/AGI paths for manual alarms | `{alertId, title, severity, rule, labels}`; manual alarms add `via` and use `rule: "manual"` | the alert’s severity |
| `alert_resolved` | alerting engine on a clear event; `POST /api/v1/alerts/{id}:resolve`, DTMF `6`, IVR/AGI resolve | `{alertId, title, rule}` or `{alertId, title}` (+ `by`, `via` for AGI) | `ok` |
| `ack` | `POST /api/v1/alerts/{id}:ack` and `:snooze`, ack link, SMS keyword, IVR digit, DTMF `4`, AGI | `{alertId, by, comment}`; snooze: `comment: "snoozed until "`; link/SMS/IVR/DTMF: `{alertId, via: "ack-link"}`; AGI: `{alertId, by, via}` | `info` |
| `escalation` | escalation engine per step firing, including repeats; alerting engine when a snooze expires | `{alertId, step, repeat, contacts: [names], channels}` (channels = the step’s override list, empty when contact preferences were used); wake-up: `{alertId, title, comment: "snooze expired — alarm re-armed", policy}` | `info` |
| `notification` | notifier per delivery attempt (alerts and direct object notifications); alerting engine for alerts opened while suppressed | `NotificationRecord{alertId, stepIndex, repeat?, contactId?, contact?, channel, channelId?, target? (masked), status, attempt, error?, providerId?, latencyMs?}`; `status` ∈ `pending`, `sent`, `failed`, `dead`, `suppressed` (with `error` = suppression reason) | `info` |
| `incident_update` | alerting engine (rule-created incident opened / auto-resolved), correlator (alarm storm), `POST /api/v1/incidents` | `{incidentId, alertId?, title, createdBy?, status}`; correlator: `{incidentId, title, alerts, cluster: "k=v"}`; API create: `{incidentId, title, summary, createdBy, status, action: "created", labels}` | incident severity; auto-resolve `ok`; correlator `critical` |
| `downtime` | `POST /api/v1/downtimes` | `{downtimeId, comment, start, end}` | `info` |
| `silence` | `POST /api/v1/silences` | `{silenceId, comment, expiresAt}` | `info` |
| `config` | API on any configuration mutation (objects, templates, rules, channels, bundles, …) | `{kinds: ["host"]}`, `{kinds: ["alert-rule"]}`, … | `info` |
| `system` | AI service when the monthly token budget warning fires | `{summary}` | `warning` |
| `ai_action`, `comment`, `anomaly`, `forecast` | defined in the taxonomy but **not emitted** by any code path in this version | — | — |
Two things to remember when writing rules: the alert engine receives only what is published *to* it (`ingress`, `state_change`, `flapping_*`, `heartbeat_missed`, and `incident_update` from `POST /api/v1/incidents`); engine- and API-generated lifecycle events (`alert_opened`, `ack`, `escalation`, `notification`, `config`, `downtime`, `silence`, …) are *fan-out only* so that they never re-enter the rules. And for `ingress` events the inner `payload` keys are hoisted to `event.payload.` in CEL (`event.payload.subject`, `event.payload.body`, …). See [Alert rules](/docs/alarming/alert-rules/).
## How events flow
[Section titled “How events flow”](#how-events-flow)
```text
producers bus (in-memory) consumers
───────────────────────────────────── ────────────────────── ───────────────────────────
pipeline (state_change, flapping) ──► Events queue (16384) ──► alerting engine (rules)
ingress adapters (ingress) ──► │
heartbeat sweep (heartbeat_missed) ──► │
POST /incidents (incident_update) ──► │
▼ fan-out
engine/API lifecycle events ──────────► subscribers only ───────► SSE hub (512)
(alert_opened, ack, notification, …) "FanoutOnly" correlator (1024)
webhook dispatcher (1024)
every producer also ──────────────────► event store (segments) — persisted before/while publishing
```
* The bus does not persist anything; producers insert into the event store themselves (best-effort, failures are counted in `np_events_dropped_total`), so a slow live subscriber never loses history.
* The `Events` queue blocks producers when full rather than dropping; subscriber buffers drop and mark the subscriber for resync (the SSE stream then sends a `resync` frame).
* There are no topics: every subscriber sees every event of every tenant and filters by tenant, type and selector itself.
## Persistence and retention
[Section titled “Persistence and retention”](#persistence-and-retention)
| Backend | Layout |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| SQLite (default) | one file per month in the data directory: `events-YYYYMM.db` (+ `-wal`, `-shm`), own connection pool (4), indexes on `(tenant_id, ts)` and `(object_id, ts)`; cross-month queries fan out and merge in Go |
| PostgreSQL | parent table `events … PARTITION BY RANGE (ts)`, child partitions `events_YYYYMM` created on demand with the same indexes |
Retention is `storage.eventRetentionMonths` (default **12**, `0` = keep forever; file-only, no environment variable). The janitor enforces it once a night (between 02:00 and 03:59 local time) by deleting whole segment files or dropping whole partitions whose month is older than the cutoff. There is also a storage-level `PurgeEventPayloads(tenant, type, before)` that blanks payloads to `{}` for GDPR retention classes; it is not wired to any API route or scheduled job in this version. Events are included in `northplaned backup` (every `events-*.db` is copied, the current month last). See [Storage](/docs/administration/storage/).
## Querying events
[Section titled “Querying events”](#querying-events)
`GET /api/v1/events` (permission `events:read`) returns `{items, nextCursor}` newest first.
| Query parameter | Meaning |
| ---------------------- | ------------------------------------------------ |
| `types` | comma-separated list of event types |
| `objectId`, `sourceId` | exact match |
| `severity` | exact match |
| `from`, `to` | RFC 3339 window (unparseable values are ignored) |
| `cursor` | the `id` of the last item of the previous page |
| `limit` | default 200, max 1000 |
Example:
```bash
curl -s "https://np.example.com/api/v1/events?types=state_change,alert_opened&from=2026-08-23T00:00:00Z&limit=50" \
-H "Authorization: Bearer np_…"
```
`GET /api/v1/alerts/{id}` lists the triggering event ids of an alert (`eventIds`, last 50); reports and the Overview page’s “Recent events” card are built on the same query.
## Live stream (SSE)
[Section titled “Live stream (SSE)”](#live-stream-sse)
`GET /api/v1/stream` (permission `events:read`) is a Server-Sent-Events feed of everything fanned out on the bus for the caller’s tenant. Filter with `?types=a,b` and `?selector=` (matched against `payload.labels`); resume with `Last-Event-ID: ` (replays persisted events from one second before that id, up to 500). Each frame is `event: `, `id: `, `data: `; a `: ping` comment arrives every 15 s, or `event: resync` when the server had to drop frames for this client. Authentication is the normal bearer token or session cookie — there is no `?token=` query parameter, and the embedded UI does **not** use the stream (it polls). The full wire reference is in the [API overview](/docs/reference/api-overview/).
```bash
curl -N "https://np.example.com/api/v1/stream?types=alert_opened,alert_resolved" -H "Authorization: Bearer np_…"
```
## NDJSON export
[Section titled “NDJSON export”](#ndjson-export)
`GET /api/v1/events:export` (permission `events:read`) streams `application/x-ndjson`, one event per line, **ascending** by time, with the same filters as the list endpoint (`objectId`, `sourceId`, `severity`, `types`, `from`, `to`); `cursor`/`limit` are ignored, pages of 1000 are read internally and the export stops after **100 000** events. The endpoint is exempt from the 30 s request deadline. Use `from`/`to` windows for SIEM shipping of large histories. (The audit log has its own export, `GET /api/v1/audit:export` — see [Observability](/docs/administration/observability/).)
```bash
curl -s "https://np.example.com/api/v1/events:export?types=notification&from=2026-08-01T00:00:00Z" \
-H "Authorization: Bearer np_…" > notifications-august.ndjson
```
## Events in the UI
[Section titled “Events in the UI”](#events-in-the-ui)
The **Events (Ereignisse)** page lists the newest 200 events with a type filter (`state_change`, `alert_opened`, `alert_resolved`, `notification`, `escalation`, `ack`, `ingress`, `config`, `downtime`, `silence`, `heartbeat_missed`, `ai_action`) and an object-id filter; each row expands to the pretty-printed payload, and the “NDJSON Export” link downloads the current type filter. The Overview page shows the 20 most recent events, and the object detail’s **History (Historie)** tab shows the last 30 events of that object. See [Alerts, incidents and events (UI)](/docs/ui/alerts-incidents-events/).
## Related
[Section titled “Related”](#related)
* [Alerts and incidents](/docs/concepts/alerts-incidents/) — how events become alerts.
* [Event sources](/docs/alarming/event-sources/) — every adapter that produces `ingress` events, with its labels and dedup keys.
* [Outgoing webhooks](/docs/alarming/webhooks-out/) — subscribe an HTTP endpoint to event types and selectors.
* [Metrics and NP-TSDB](/docs/monitoring/metrics-and-tsdb/) — numeric history lives there, not in events.
# Federation
> The main/edge model — a customer-site northplaned that dials out to a main instance, pulls its configuration bundle with ETag-conditional requests and reports heartbeats; the Site resource, what flows in which direction, limits, config keys and the VM104 example.
Federation lets one **main** instance manage the configuration of remote **edge** instances without any inbound connectivity to the customer site. An edge is a complete `northplaned` — its own scheduler, plugins, agents, channels, users and data directory — that additionally runs a `federation-edge` worker: every minute it pulls its configuration bundle from the main instance and posts a status heartbeat. Monitoring itself stays local; only configuration goes down and only status comes up.
## Main and edge
[Section titled “Main and edge”](#main-and-edge)
| Role | What it is | How it is configured |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
| **Main** | a normal instance (no special mode) that holds one **Site** document per edge, in the tenant that owns that customer, plus an API token with scope `sites:connect` | `POST /api/v1/sites` / Admin → Sites, [Tenants and sites](/docs/administration/tenants-and-sites/) |
| **Edge** | a normal instance started with `federation.mode: edge` pointing at the main | `federation:` block in `config.yaml` or `NORTHPLANE_FEDERATION_*` |
There is no `main` mode value — `federation.mode` is either empty (standalone, which includes the main) or `edge`. The edge keeps working when the main is unreachable; it simply logs a warning per tick and continues with the last applied configuration.
## The Site resource
[Section titled “The Site resource”](#the-site-resource)
`kind: site`, `/api/v1/sites` (read `objects:read`, write `config:write`), tenant-scoped like every configuration document.
| Field | Meaning |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | the site name the edge puts into `federation.site` |
| `description`, `labels` | free |
| `bundle` | a multi-document YAML **config bundle** as a string — exactly the format of `np apply` / `bundles:apply`. It is parsed and validated on save (422 `np:validation/site` when invalid). Empty means “nothing managed centrally yet” |
| `disabled` | `true` → the edge gets 403 `np:sites/disabled` on pull and heartbeat |
| `version` | `PUT` needs `If-Match` |
Runtime status is kept separately (KV key `site_status::`, not versioned) and merged into `GET /api/v1/sites:overview` as `SiteView = Site + connected + status`, where `status = {lastSeenAt, version, bundleEtag, applyError, stats{hosts, services, alertsOpen}, sourceIp}` and `connected` means the last heartbeat is younger than **5 minutes**. The Admin → Sites (Standorte) tab shows that table.
## The pull / heartbeat loop
[Section titled “The pull / heartbeat loop”](#the-pull--heartbeat-loop)
```text
edge (federation-edge worker, every federation.interval, default 1m)
1. GET {mainUrl}/api/v1/sites/{site}:pull
Authorization: Bearer
If-None-Match: ""
304 → nothing to do
200 → body = bundle YAML (≤ 8 MiB), ETag = ""
empty bundle → remember the tag, apply nothing
else ApplyBundleYAML(DefaultTenant) — same applier as `np apply`, no prune
success → ETag advances, audit entry federation.apply (actor system/federation)
failure → ETag kept (retry next tick), error reported in the heartbeat
2. POST {mainUrl}/api/v1/sites/{site}:heartbeat
{version, bundleEtag, applyError, stats: {hosts, services, alertsOpen}} → 204
```
Pull runs before the heartbeat so that the heartbeat always reports the post-apply state. The HTTP client timeout is 30 s. On the main, `:heartbeat` requires that the site exists in the **token’s** tenant and is not disabled, stores the status with `sourceIp = RemoteAddr`, and answers 204; `:pull` answers 304/200 as above. Both routes require the permission `sites:connect` and nothing else — a `sites:connect` token can heartbeat or pull **any** site in its tenant (there is no per-site binding).
To roll out a change: edit the Site’s `bundle` on the main (`PUT /api/v1/sites/{name}` with `If-Match`, or the Admin tab); within one interval the edge fetches the new revision and applies it. A bundle that fails to apply is retried every tick and shows up as `applyError` in the overview until a new revision applies.
## What flows where
[Section titled “What flows where”](#what-flows-where)
| Direction | Content | Not included |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| main → edge | the bundle: hosts, services, templates, check commands, time periods, rules, alert groups, policies, schedules, contacts, contact groups, channels, event sources, IVR menus, business services, dashboards, reports, webhook subscriptions, saved filters, static groups, roles (`Role` is allowed in apply) | `Tenant` and `Heartbeat` kinds (applier warns `unsupported kind`), secrets, users, API tokens, sites, branding, overrides |
| edge → main | status only: edge version, applied bundle ETag, apply error, counters `hosts`/`services`/`alertsOpen` (counted in the edge’s Default tenant), source IP | **no** check results, alerts, events, metrics or notifications — the main does not see the edge’s monitoring data |
Consequences:
* Bundles are applied into the edge’s **Default tenant** only.
* Channels referenced by the bundle need their secrets on the edge: `$SECRET:name$` values, SMTP passwords, tokens and the like must be created on the edge (`PUT /api/v1/secrets/{name}` there), because secrets are not bundle kinds.
* Agents at the customer site talk to the **edge** (`server: https://`) with a token minted **on the edge**; nothing in federation provisions edge credentials.
* Applying is not transactional: a bundle that fails halfway leaves earlier documents applied (the same rule as `np apply`), and prune is never used by the edge, so documents removed from the bundle stay on the edge until deleted there.
* `applyConfig: false` turns the edge into a heartbeat-only reporter (useful to show “connected” without central configuration).
## Edge configuration
[Section titled “Edge configuration”](#edge-configuration)
| Key | Env | Default | Meaning |
| ------------------------------- | -------------------------------- | ------- | --------------------------------------------------------------------------------------------- |
| `federation.mode` | `NORTHPLANE_FEDERATION_MODE` | `""` | `""` (standalone/main) or `edge`; anything else fails validation |
| `federation.mainUrl` | `NORTHPLANE_FEDERATION_MAIN_URL` | — | `https://…` (or `http://…`) of the main; required in edge mode |
| `federation.token` | `NORTHPLANE_FEDERATION_TOKEN` | — | `np_…` token minted on the main **in the site’s tenant** with scope `sites:connect`; required |
| `federation.site` | `NORTHPLANE_FEDERATION_SITE` | — | the Site name on the main; required |
| `federation.interval` | — (file only) | `1m` | tick interval; values ≤ 0 fall back to 1 m |
| `federation.insecureSkipVerify` | — | `false` | skip TLS verification towards the main |
| `federation.applyConfig` | — | `true` | `false` = heartbeat only |
config.yaml (edge)
```yaml
federation:
mode: edge
mainUrl: "https://main.example.net"
token: "np_…" # minted on main, scope sites:connect
site: "customer-a"
interval: 60s
```
The start-up log shows `federation: edge mode`; the worker is listed as `federation-edge`. Validation messages are listed on [Configuration](/docs/administration/configuration/).
## Limits and caveats
[Section titled “Limits and caveats”](#limits-and-caveats)
* One tenant on the main ↔ many sites; each edge serves exactly one site name. There is no multi-level topology: an edge is a standalone instance with the edge worker enabled, and because `Site` documents are not a bundle kind a main cannot configure an edge’s own sites — nothing propagates across more than one hop.
* Bundle size limit 8 MiB; export on the main lists at most 5000 objects / 2000 documents per kind, so very large central bundles should be authored rather than exported.
* The edge is an independent security domain: its admin users, `secret.key`, tokens and audit log are its own. Back them up separately.
* A disabled site stops both pull and heartbeat; the edge keeps its last configuration.
* Status is only as fresh as the last heartbeat; `connected` flips to false 5 minutes after the edge stops calling in.
## Worked example: VM104 as an edge of doktrace.com
[Section titled “Worked example: VM104 as an edge of doktrace.com”](#worked-example-vm104-as-an-edge-of-doktracecom)
The reference setup (see [Environments](/docs/deployment/environments/)): the production main runs on VM101 behind Caddy as `https://doktrace.com`; a second instance, `np-staging`, runs on VM104 (`10.10.10.14`) in the same Proxmox host and is configured as the edge of the tenant **MyFoxIT**.
1. On the main, in the MyFoxIT tenant (central admin with `X-Northplane-Tenant: `): create the Site `vm104-edge` whose `bundle` declares the hosts to monitor at the site (`np-staging`, `lab-web`), the passive services the local np-agent fills, a notification channel, a contact and an escalation policy; mint a token with scope `sites:connect` in the same tenant.
2. On VM104, put the `federation:` block into `/opt/northplane/config.yaml` (`mode: edge`, `mainUrl: https://doktrace.com`, `site: vm104-edge`, the token, `interval: 60s`). Because the container runs as uid 65532, a bind-mounted config file must be readable by that uid (`chown 65532 config.yaml && chmod 640 config.yaml`); a `0600 root:root` file fails with *permission denied*.
3. The edge pulls the bundle on its first tick, applies it into its Default tenant and starts heartbeating; `GET /api/v1/sites:overview` with the tenant header on the main shows `connected: true`, the edge version and `stats`.
4. An np-agent on VM104 pushes to the **edge** (`server: https://localhost:8443`, a token minted on the edge, hostname `np-staging`) and turns the bundle’s passive services green.
5. Changing the monitoring at the site = `PUT` the Site document on the main; the edge picks it up within 60 s.
## Where to go next
[Section titled “Where to go next”](#where-to-go-next)
* [Tenants and sites](/docs/administration/tenants-and-sites/) — creating sites, tokens and the Admin tab.
* [Config bundles](/docs/administration/config-bundles/) — the bundle format carried in `Site.bundle`.
* [Configuration](/docs/administration/configuration/) — the `federation:` keys in context.
* [Deployment overview](/docs/deployment/overview/) — the edge-proxied VM variant used for VM104.
# Object model
> Hosts and services, folders, labels and selectors, templates and effective configuration, check-command references and macros, the ObjectSpec fields with their defaults, saved state, IDs and If-Match versioning.
Everything Northplane monitors is an **object**: a **host** or a **service** that belongs to a host. Objects carry a small, Nagios-inspired spec (what to check, how often, how many attempts), are grouped by **folders** and **labels**, inherit settings from **templates**, and reference a **check command**. This page defines those terms precisely; the how-to for creating and editing objects is on [Hosts and services](/docs/monitoring/hosts-and-services/).
## Hosts and services
[Section titled “Hosts and services”](#hosts-and-services)
Hosts and services share one table and one JSON shape; `kind` tells them apart.
| Field | Type | Meaning |
| ------------------------ | ------------------- | --------------------------------------------------------------------------------------------- |
| `id` | string | UUIDv7, assigned by the server (see [IDs, versions and If-Match](#ids-versions-and-if-match)) |
| `tenantId` | string | owning tenant |
| `kind` | `host` \| `service` | |
| `name` | string | required; **cannot be renamed** through `PUT` (recreate the object instead) |
| `hostId` | string | services only; the parent host. Deleting a host cascades to its services. |
| `folder` | string | default `/`; a path with subtree semantics |
| `labels` | map | free key/value pairs, indexed for selectors |
| `spec` | ObjectSpec | see [ObjectSpec reference](#objectspec-reference) |
| `version` | int64 | optimistic-locking version, starts at 1 |
| `createdAt`, `updatedAt` | RFC 3339 | |
Identity is `(tenant, kind, host, name)`: host names are unique per tenant, service names are unique per host. When you create a service you reference the host by **name or id** (`host` in the request body, `metadata.host` in a bundle). A service check runs against the **host’s** `address`; a service’s own `address` is not used as the target.
Objects are reached through `GET /api/v1/objects`, `/hosts`, `/services`, `GET|PUT|DELETE /api/v1/objects/{id}`, created with `POST /api/v1/hosts` and `POST /api/v1/services`, or in bulk with `POST /api/v1/objects:batch` (`mode: all-or-nothing` (default) or `partial`). Every create/update/delete is audited (`host.create`, `service.update`, …), emits a `config` event and updates the scheduler immediately.
## Folders
[Section titled “Folders”](#folders)
A folder is a `/`-separated path (`/`, `/prod`, `/prod/db`). Folders are purely organisational today: the object list filters by folder prefix (`GET /api/v1/objects?folder=/prod`), bundle export can be restricted to a subtree (`GET /api/v1/config/bundles:export?folder=/prod`), and the UI groups by them. Role scopes have a `folder` field, but it is **not enforced** in this version (see [Tenancy and RBAC](/docs/concepts/tenancy-rbac/)).
## Labels and selectors
[Section titled “Labels and selectors”](#labels-and-selectors)
Labels are the primary grouping mechanism. Everything that needs “a set of objects” — downtimes, silences, business-service leaves, dashboard widgets, metric queries, webhook subscriptions, bundle prune, the Objects list filter — takes a **label selector** instead of a static group.
```text
selector = requirement *("," requirement) ; comma = AND
requirement = KEY "=" VALUE | KEY "==" VALUE | KEY "!=" VALUE
| KEY "in" "(" VALUE *("," VALUE) ")"
| KEY "notin" "(" VALUE *("," VALUE) ")"
| KEY ; key exists
| "!" KEY ; key does not exist
KEY = [A-Za-z0-9_.\-/]+
VALUE = unquoted text up to the next "," or ")" (trimmed)
```
Example: `env=prod,role in (db,cache),!legacy,site!=wien`.
Semantics worth knowing:
* `!=` and `notin` also match objects that do **not** have the key at all.
* `=`, `in`, *exists* and *not-exists* are pushed down into SQL via the `object_labels` index; negations are evaluated in Go afterwards.
* Values cannot contain commas or parentheses; there is no quoting.
* The empty selector matches everything; an unparseable selector is a 422 (`np:validation/selector`) on the API and matches nothing in a webhook subscription.
Labels also travel with events and alerts: a `state_change` event carries the object’s labels, alert rules can add labels (`setLabels`), and silences/downtimes with a selector are matched against an alert’s labels. Event sources merge their own `labels` into every event they emit. The Objects page offers both the selector filter and a full-text filter; see [Objects (UI)](/docs/ui/objects/).
## Templates and effective configuration
[Section titled “Templates and effective configuration”](#templates-and-effective-configuration)
A **template** (`/api/v1/templates`, bundle kind `Template`) is a reusable `ObjectSpec` fragment. An object lists templates in `spec.templates`; templates may list templates themselves.
Resolution (`EffectiveSpec`) is: **built-in defaults ⊕ templates (in declared order, later wins, recursively) ⊕ the object’s own spec**. Rules:
* Scalar fields are replaced when the overlay sets them; `vars` are merged key-wise; `templates`, `parents`, `args`, `contacts`, `contactGroups` and `notifyOn` are replaced wholesale (Nagios `use` semantics).
* Unknown template names and cycles/duplicates are rejected with 422 at write time. An object whose chain breaks later (for example because a template was deleted) is still indexed with defaults and shows as a configuration error.
* `GET /api/v1/objects/{id}/effective-config` returns `{spec, templateChain}` — the fully resolved spec and the ordered template names; the object detail view shows the same ([Objects (UI)](/docs/ui/objects/)).
* Template, check-command and time-period changes trigger a full tenant catalog reload and re-schedule of all objects.
Template fields that are stored but not applied
A template has a `kind` (`host`, `service` or `command`) and `labels`. Neither is enforced or merged anywhere in this version: a template of kind `service` can be attached to a host, and template labels are **not** copied onto objects. Put labels on the objects (or in the bundle’s `metadata.labels`).
The [Templates](/docs/monitoring/templates/) page shows how to build a template hierarchy; [Config bundles](/docs/administration/config-bundles/) how to ship it as YAML.
## Check commands
[Section titled “Check commands”](#check-commands)
`spec.checkCommand` is a string whose prefix selects the execution class:
| Value | Class | Meaning |
| --------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `passive` or empty | passive | Never executed actively; results arrive via `POST /api/v1/results` (scripts, np-agent, NSCA replacements). Only freshness probes run when `stalenessAfter` is set. |
| `builtin:` | builtin | In-process Go check (`icmp`, `http`, `tcp`, `dns`, `snmp`, `tls-cert`, `agent`, … — 17 in total). `spec.args` are the check’s flags. |
| `exec:` | exec | Nagios plugin executed by the server (`` resolved under `pluginsDir` unless absolute). `spec.args` are appended to argv. |
| `agent:exec:` | agent | Executed by np-agent on the host; pulled via `GET /api/v1/agent/checks`. argv = `[] + args`. |
| any other bare name | named | Looks up a stored **CheckCommand** resource of that name (what the Nagios importer produces). Unknown name → configuration error. |
A `CheckCommand` resource (`/api/v1/check-commands`, bundle kind `CheckCommand`) has `name`, `type` (`exec` | `builtin` | `agent` | `passive`), `line` (argv; for `builtin` the first element is the check name, the rest are flags), `env` (export `NAGIOS_*`/`NORTHPLANE_*` environment macros to exec plugins) and `timeout`. For named `exec`/`agent` commands the object’s `args` are **not** appended to `line`; they are only available as `$ARG1$…$ARG32$` inside it. Environment macro export happens only for named commands with `env: true`, never for inline `builtin:`/`exec:` references.
Caution
`CheckCommand.timeout` is stored but the executor uses only the object’s effective `spec.timeout`. `checkPeriod` is enforced: scheduled runs and freshness probes outside the period are skipped (manual check-now always runs). `zone` is resolved into the catalog but not consulted.
### Macros
[Section titled “Macros”](#macros)
Arguments (inline `args` and `CheckCommand.line`) are expanded element by element — never through a shell — before execution. Unknown macros stay verbatim; `$$` is a literal `$`.
| Macro | Value |
| --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `$ARG1$` … `$ARG32$` | the object’s `spec.args[n-1]`, empty when unset |
| `$SECRET:name$` | a value from the tenant’s secret store (left verbatim if unresolvable; never expanded in the agent pull endpoint) |
| `$USER1$` | the plugins directory (`pluginsDir`); other `$USERn$` are not defined |
| `$_HOSTFOO$` / `$_SERVICEFOO$` | `spec.vars["foo"]` of the host / service (case-insensitive key) |
| `$HOSTNAME$`, `$HOSTALIAS$`, `$HOSTDISPLAYNAME$` | host name |
| `$HOSTADDRESS$` | effective host `address`, falling back to the host name |
| `$SERVICEDESC$`, `$SERVICEDISPLAYNAME$` | service name |
| `$MAXHOSTATTEMPTS$`, `$MAXSERVICEATTEMPTS$` | effective `maxCheckAttempts` |
| `$TIMET$`, `$LONGDATETIME$`, `$SHORTDATETIME$`, `$DATE$`, `$TIME$` | current time in the classic Nagios formats |
| `$HOSTSTATE$`, `$SERVICESTATE$`, `$HOSTOUTPUT$`, `$SERVICEPERFDATA$`, `$LASTSERVICECHECK$`, … | state-based macros; defined for contexts that carry check state, but the executor supplies none while running a check, so in check arguments they stay unexpanded |
| `$NOTIFICATIONTYPE$`, `$NOTIFICATIONNUMBER$`, `$CONTACTNAME$`, `$CONTACTEMAIL$` | notification context only |
The complete flag reference of every builtin check is on [Builtin checks](/docs/monitoring/builtin-checks/); plugin execution, exit codes and output grammar on [Plugins and Nagios](/docs/monitoring/plugins-and-nagios/); agent checks on [Agent](/docs/monitoring/agent/).
## ObjectSpec reference
[Section titled “ObjectSpec reference”](#objectspec-reference)
All fields are optional at rest; the table lists the value after template resolution and defaults.
| Field | Type | Default | Meaning |
| --------------------------- | ---------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `address` | string | — | check target (hosts); services use their host’s address |
| `templates` | \[]string | — | template names, applied in order, later wins |
| `parents` | \[]string | — | parent **host names** for reachability (hosts only) |
| `checkCommand` | string | `""` = passive | see [Check commands](#check-commands) |
| `args` | \[]string | — | builtin flags / plugin args / `$ARGn$` values |
| `interval` | duration | `60s` | normal check cadence; clamped to 1 s … 24 h |
| `retryInterval` | duration | `15s` | recheck cadence while in a soft state |
| `maxCheckAttempts` | int | `3` | attempts before a problem becomes hard |
| `timeout` | duration | `30s` | per-execution timeout (builtin and exec) |
| `checkPeriod` | string | `24x7` | time-period name (stored, not enforced) |
| `notificationPeriod` | string | — | time-period name for direct object notifications (evaluated in UTC) |
| `enableNotifications` | bool | `true` | direct object notifications on/off |
| `contacts`, `contactGroups` | \[]string | — | contacts/groups notified directly on hard changes (validated to exist) |
| `notifyOn` | \[]string | all + recovery | subset of `warning`, `critical`, `unknown`, `down`, `unreachable`, `recovery` |
| `enableChecks` | bool | `true` | `false` removes the object from the wheel (freshness probe only if `stalenessAfter`) |
| `enableFlapDetection` | bool | `true` | |
| `flapThresholdLow` | float | `25` | % — flapping stops below |
| `flapThresholdHigh` | float | `50` | % — flapping starts at or above |
| `stalenessAfter` | duration | — | passive/agent freshness: synthetic UNKNOWN when no result arrives within this window |
| `stalenessText` | string | `UNKNOWN - check result is stale (freshness threshold exceeded)` | output text of the synthetic result |
| `thresholdMode` | `static` \| `adaptive` | `static` | reserved for AI baselines; checks use static thresholds |
| `zone` | string | — | satellite zone (stored only) |
| `runbook` | string | — | Markdown shown in the object detail |
| `vars` | map | — | custom variables (`$_HOSTFOO$`), merged key-wise across the chain; `vars.flow` feeds `builtin:http-flow` |
Durations are Go strings (`30s`, `5m`, `24h`); a bare integer in JSON means seconds.
## Saved state
[Section titled “Saved state”](#saved-state)
Each object has exactly one `check_state` row (the `state` member of an object read with `withState=true`, the default):
| Field | Meaning |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `state` | services `OK=0`, `WARNING=1`, `CRITICAL=2`, `UNKNOWN=3`; hosts `UP=0`, `DOWN=1`, `UNREACHABLE=2` |
| `stateType` | `soft` or `hard` |
| `attempt` | current attempt counter (1 … `maxCheckAttempts`) |
| `output`, `longOutput`, `perfdata` | raw plugin output of the last result |
| `latencyMs`, `execMs` | planned→started delay and execution time |
| `lastCheck`, `nextCheck`, `lastHardChange`, `lastOk` | timestamps; `lastCheck` null = PENDING (never checked) |
| `flapping`, `flapPct` | flap detector output |
| `ackedBy`, `ackComment` | sticky acknowledgement mirrored from the alert that was acked |
| `downtimeDepth` | number of active downtimes covering the object (recomputed every 30 s and on downtime changes) |
A **problem** is a hard non-OK state; `GET /api/v1/problems` lists problems and hides acknowledged or in-downtime ones unless `includeHandled=true`. The rules that drive these fields are on [Checks and states](/docs/concepts/checks-and-states/).
## IDs, versions and If-Match
[Section titled “IDs, versions and If-Match”](#ids-versions-and-if-match)
* All persistent entities use **UUIDv7** ids (time-ordered, canonical lowercase `8-4-4-4-12`), minted by the server. Because ids are time-sortable they double as pagination cursors and SSE resume points.
* Objects are addressed by id (`/api/v1/objects/{id}`); config documents by **name** (`/api/v1/templates/{name}`), and every `{name}` path also accepts the document’s id.
* Every object and config document has an integer `version` that starts at 1 and increases on each write. Reads return `ETag: ""`.
* `PUT` on an object or config document must send `If-Match: ""` (also accepted: `3`, `W/"3"`). Missing header → **428** `np:precondition/if-match`; stale version → **409** `np:conflict/version`; creating a name that exists → **409** `np:conflict/duplicate`.
* A `PUT` on an object replaces `spec` wholesale (send the full spec), replaces `labels` when present and `folder` when non-empty. Bundle apply bypasses the version check (unconditional upsert) and preserves ids.
Read the [API overview](/docs/reference/api-overview/) for the full convention set (errors, pagination, idempotency) and [Hosts and services](/docs/monitoring/hosts-and-services/) for worked examples.
# Tenancy and RBAC
> Tenants and the default tenant, acting on another tenant with X-Northplane-Tenant, principals, the permission grammar, built-in and custom roles, tenant-scoped tokens and roles, what is instance-wide, and the known gaps.
Northplane is multi-tenant from the first row: every object, alert, event, configuration document, token and secret belongs to exactly one **tenant**, and every request runs as a **principal** with a tenant and a set of **permissions**. A single-company installation simply never leaves the default tenant. This page defines the model; the administrative how-to (creating users, roles, tokens, tenants) is in [Users, roles and permissions](/docs/administration/users-roles-permissions/) and [Tenants and sites](/docs/administration/tenants-and-sites/).
## Tenants
[Section titled “Tenants”](#tenants)
| Fact | Detail |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Resource | `{id, name, slug, disabled, version, createdAt, updatedAt}`; `slug` is unique |
| Default tenant | id `00000000-0000-7000-8000-000000000001`, name `Default`, slug `default`; created by the schema migrations |
| Create | `POST /api/v1/tenants {name, slug}` → `201 {id}` (permission `admin:tenants`); the four built-in roles are seeded into the new tenant in the same transaction |
| List | `GET /api/v1/tenants` (`admin:tenants`) |
| Update / delete | **not available** — there are no `PUT`/`DELETE` routes and `disabled` is not evaluated anywhere; the Admin → Tenants (Mandanten) tab says so |
| Bundles | `Tenant` is in the bundle vocabulary but the applier skips it with a warning |
Data is isolated by a `tenant_id` column on every table that holds tenant data; reads of another tenant’s object return **404**, never 403, and listings never leak. Users have a **home tenant** (`users.tenantId`, default = Default): local and LDAP logins land there, OIDC logins always land in the Default tenant.
## Acting on another tenant
[Section titled “Acting on another tenant”](#acting-on-another-tenant)
Every handler resolves the request tenant like this:
```go
if t := r.Header.Get("X-Northplane-Tenant"); t != "" && p.Allow("admin:tenants") {
return t // the tenant *id* (UUID), not the slug
}
return p.TenantID // the principal's own tenant
```
So a principal holding `admin:tenants` (or a wildcard such as `*:*` or `admin:*`) may act on any tenant by sending the header; everyone else is pinned to their own tenant and the header is silently ignored. Mutations done through the header are audited under the **acted-on** tenant with the operator’s actor id. The UI’s tenant switcher (visible only when `whoami.permissions` implies `admin:tenants`) stores the selection in `localStorage` (`np.activeTenant`) and adds the header to every call. `GET /api/v1/whoami` always reports the **home** tenant.
Known exception: `POST /api/v1/alerts/{id}:ack` uses the home tenant and ignores the header, so a central operator cannot ack a customer’s alert through the switcher (`:resolve` and `:snooze` work). See [Project → Roadmap and known issues](/docs/project/roadmap-and-known-issues/).
## Principals
[Section titled “Principals”](#principals)
| Actor type | Comes from | Tenant | Permissions |
| ---------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `user` | `np_session` cookie (local password, LDAP or OIDC login) | session tenant = the user’s home tenant (OIDC: Default) | the user’s role names, expanded on **every request** — changing a role’s permissions applies immediately; changing a user’s role list applies at next login |
| `token` | `Authorization: Bearer np_…` | the tenant the token was minted in | `scopes` ∪ permissions of the token’s `roles` (resolved in the token’s tenant) |
| `ai_agent` | a token created with `aiAgent: true`; the AI tool runner | as token | as token — used for audit attribution |
| `system` | internal actions, for example the federation edge applying a pulled bundle (audit actor `federation`) | — | — |
Anonymous requests reach only routes without a permission (`/healthz`, `/readyz`, `/metrics`, `GET /api/v1/system/info|health`, OpenAPI, the docs, ingest endpoints with their own auth). The details of logins, sessions and tokens are on [Authentication](/docs/administration/authentication/) and [API tokens](/docs/administration/api-tokens/).
## Permissions
[Section titled “Permissions”](#permissions)
A permission is a string `resource:action`. A held permission *implies* a wanted one when:
* they are equal, or the held one is `*:*` or `*`;
* otherwise both contain `:` and `(heldResource == "*" || heldResource == wantResource) && (heldAction == "*" || heldAction == wantAction)`.
Hence `admin:*` covers `admin:users`, `*:read` covers `objects:read`, and a malformed value without a colon matches only itself. Every REST route declares at most one permission (visible as `x-required-permission` in the OpenAPI document; routes without one are either anonymous or merely require a login); the AI/MCP tools check the same names.
The families in use:
| Family | Permissions |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Monitoring | `objects:read`, `objects:write`, `checks:run`, `metrics:read` |
| Alarming | `alerts:read`, `alerts:ack`, `alerts:write`, `incidents:read`, `incidents:write`, `downtimes:write`, `silences:write`, `events:read`, `oncall:read`, `oncall:write` |
| Configuration | `config:write` (all configuration documents, bundles, heartbeat definitions, branding) — reads of configuration use `objects:read` |
| Reports | `reports:render` |
| Administration | `admin:read`/`admin:write` (roles), `admin:users`, `admin:tokens`, `admin:secrets`, `admin:audit`, `admin:tenants`, `admin:ai` |
| Federation | `sites:connect` (edge heartbeat + bundle pull) |
| Present in built-in roles or UI presets but not checked by any route | `dashboards:read`, `dashboards:write`, `reports:read`, `config:propose` (roles); `maintenance:write` (MCP token preset) — harmless but inert |
The complete permission → route table is on [Users, roles and permissions](/docs/administration/users-roles-permissions/).
## Roles
[Section titled “Roles”](#roles)
A **role** is a configuration document (`kind: role`, `/api/v1/roles`, permissions `admin:read`/`admin:write`) with `name`, `permissions[]`, `includes[]` (nested role names, expanded recursively up to depth 8), `idpGroups[]` (OIDC / LDAP group identifiers mapped onto this role at login or sync), `scope {tenantId, folder, selector}` and `system`. Roles are **per tenant** and resolved in the principal’s tenant.
Built-in roles, seeded into every tenant with `system: true`:
| Role | Summary |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `admin` | `*:*` — everything, including tenant switching |
| `operator` | day-to-day operations: objects read/write, checks, alerts (read/ack/write), incidents, downtimes, silences, events, metrics, on-call read/write, dashboards, reports (read/render). **No** `config:write` and no `admin:*` — an operator manages hosts and services but not templates, rules, channels or users |
| `viewer` | read-only: objects, alerts, incidents, events, metrics, on-call, dashboards, reports |
| `ai-agent` | what the AI tool runner needs: reads plus `alerts:ack`, `incidents:write`, `checks:run`, `downtimes:write`, `silences:write`, `config:propose`, `reports:render` |
On every start the server reconciles the system role `operator` to include `alerts:write`. Custom roles (for example a tenant-administrator role that adds `admin:users`, `admin:tokens`, `config:write`) are ordinary documents, also deliverable through bundles (`kind: Role`; bundle export omits roles).
Stored but not enforced
`scope.folder`, `scope.selector` and `scope.tenantId` on a role are persisted and editable, but the authenticator never populates a folder scope and no code evaluates the selector — treat them as reserved. Likewise `system: true` is honoured by the UI (no edit/delete buttons) but not by the API: `PUT`/`DELETE /api/v1/roles/{name}` with `admin:write` will change a built-in role.
## Tenant-scoped tokens and roles
[Section titled “Tenant-scoped tokens and roles”](#tenant-scoped-tokens-and-roles)
* An API token is minted in the tenant of its creator (or the `X-Northplane-Tenant` target) and stays bound to it; its `roles` are resolved **in that tenant**. A token with `admin:tenants` can still switch via the header.
* Roles exist per tenant. A central administrator sees a customer tenant’s roles only through the header; creating a tenant seeds the four built-ins, nothing else.
* `POST /api/v1/users` creates the user in the request tenant, which is how a central admin provisions a customer login (send the header).
* Secrets (`$SECRET:name$`), event sources, channels, policies and every other document are per tenant; secrets are keyed `(tenant, name)`.
* Ingest URLs carry no tenant: `POST /api/v1/ingest/{source}` resolves the source by name or id **across all tenants** (first match by tenant slug order), so event-source names are effectively global for ingest purposes. Ack links likewise search all tenants for the alert id.
## What is tenant-scoped and what is instance-wide
[Section titled “What is tenant-scoped and what is instance-wide”](#what-is-tenant-scoped-and-what-is-instance-wide)
| Tenant-scoped | Instance-wide |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| objects, check state, alerts, incidents, events, downtimes, silences, heartbeats | the process configuration (`config.yaml`, `NORTHPLANE_*`: TLS, listen, OIDC/LDAP, federation, AI provider, `allowSignup`, demo mode) |
| all configuration documents: templates, check commands, time periods, rules, policies, schedules, contacts, channels, event sources, dashboards, reports, sites, IVR menus, webhooks, saved filters, roles | **branding** (theme + mode): one document under the Default tenant; the tenant header is ignored on `GET`/`PUT /api/v1/branding` |
| API tokens, secrets, idempotency keys, user preferences (per tenant and actor) | **users**: `GET /api/v1/users` lists every account of the installation (no tenant filter), e-mail addresses are unique globally, and the Admin → Users tab shows them all even to a tenant-scoped `admin:users` holder |
| audit search and export (`GET /api/v1/audit`, `:export`) | audit **verify** (walks the whole chain); `secret.key`; push subscriptions (keyed by actor id); the SSE hub and bus (filtered per connection) |
## Known gaps
[Section titled “Known gaps”](#known-gaps)
* The Admin page renders all 21 tabs regardless of permissions; a tenant user without `admin:tenants` sees a Tenants tab whose actions 403, and the page fires a few requests (`/roles`, `/tenants`, `/ai/policy`) that 403. Only the tenant switcher and the Appearance controls are permission-gated client-side.
* `POST /alerts/{id}:ack` ignores the tenant header (above).
* Tenants cannot be renamed, disabled or deleted through the API.
* Role folder/selector scopes are not enforced; system roles are editable through the API.
All of these are tracked on [Roadmap and known issues](/docs/project/roadmap-and-known-issues/).
## Where to go next
[Section titled “Where to go next”](#where-to-go-next)
* [Users, roles and permissions](/docs/administration/users-roles-permissions/) — the full permission list, route table and role JSON.
* [Tenants and sites](/docs/administration/tenants-and-sites/) — creating tenants, provisioning customer logins and tokens.
* [Authentication](/docs/administration/authentication/) — local login, OIDC, LDAP, sessions.
* [Federation](/docs/concepts/federation/) — when one tenant owns a remote edge instance.
# API tokens
> Creating, scoping, rotating and revoking Northplane API tokens — token format, scopes versus roles, expiry and IP binding, the aiAgent flag, the Admin tab, and how np, np-agent, MCP and federation use them.
API tokens are the credential for everything that is not a browser: `curl`, the `np` CLI, `np-agent`, MCP clients, CI pipelines and federation edges. A token is a bearer secret (`Authorization: Bearer np_…`) that carries its own permissions; the server never hands out JSON logins. Browser sessions are described in [Authentication](/docs/administration/authentication/).
## Token format and storage
[Section titled “Token format and storage”](#token-format-and-storage)
* Cleartext: `np_` + 48 hexadecimal characters (24 random bytes), 51 characters in total. It is **shown exactly once** — on creation and on rotation — and cannot be retrieved later.
* Stored: the first 8 hex characters after `np_` as an indexed lookup `prefix`, and an **argon2id** hash of the 48-character body (same parameters as passwords). Authentication loads all tokens with the prefix and verifies the hash constant-time.
* Metadata: `name`, `scopes[]`, `roles[]`, `ipBind[]`, `aiAgent`, `expiresAt`, `lastUsedAt`, `createdBy`, `tenantId`, `version`, `createdAt`. `lastUsedAt` is touched at most once per minute.
* A token principal has `actorType: token` (or `ai_agent`), `actorId` = the token id, `name` = the token name, `tenantId` = the token’s tenant. Audit entries written under a token show that actor.
## Scopes and roles
[Section titled “Scopes and roles”](#scopes-and-roles)
A token’s effective permissions are its **scopes** ∪ the permissions of its **roles** (roles resolved in the token’s tenant, nested `includes` expanded). Scopes use the same `resource:action` strings and wildcard rules as role permissions — `*:*`, `*`, `resource:*`, `*:action` — see the [permission reference](/docs/administration/users-roles-permissions/#permission-reference).
Prefer scopes for machines (least privilege, self-describing) and roles when a token should track a role that administrators maintain. Typical scope sets:
| Purpose | Scopes |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `np-agent` pushing results | `objects:write` (+ `objects:read` when `pull: true`) |
| Heartbeat beat URL (cron job) | `objects:write` |
| Federation edge | `sites:connect` |
| Read-only dashboard / export | `objects:read`, `alerts:read`, `incidents:read`, `events:read`, `metrics:read` |
| MCP “Read only” preset | `objects:read,alerts:read,incidents:read,events:read,oncall:read,metrics:read,reports:render` |
| MCP “Read + operate” preset | read set + `alerts:ack,objects:write,maintenance:write` — note that `maintenance:write` is inert (no route or tool checks it), so with this preset only acknowledging works among the mutating tools; add `downtimes:write`, `silences:write` and `checks:run` yourself (or use the `ai-agent` role) for downtimes, silences and rechecks |
| MCP “Read + configure” preset | read set + `config:write,oncall:write` |
| CI applying bundles | `objects:read`, `config:write` (bundle plan needs read, apply needs write) |
| Break-glass | `*:*` (what `northplaned bootstrap-admin` mints) |
## Create a token
[Section titled “Create a token”](#create-a-token)
**UI:** **Admin → API tokens (API-Tokens)** has a **Token erstellen / Create token** card with Name and a comma-separated scopes field (default `objects:read,alerts:read`). After creation the token is displayed once in an amber box (“Einmalig sichtbar — jetzt sichern” / “Shown once — save it now”). The UI supports only name and scopes; roles, IP binding, expiry and rotation are API-only. Two other tabs mint tokens for you: **Admin → Agents** (scope `objects:write`, prefilled into an `agent.yaml`) and **Admin → MCP** (a scope preset with `aiAgent: true`).
**API:** [`POST /api/v1/api-tokens`](/docs/reference/api/operations/post_api_tokens/) (`admin:tokens`):
```bash
curl -s -X POST https://monitoring.example.net/api/v1/api-tokens \
-H "Authorization: Bearer np_" -H "Content-Type: application/json" \
-d '{"name":"ci-deploy","scopes":["objects:read","objects:write"],"ipBind":["10.0.0.0/8"],"expiresAt":"2027-01-01T00:00:00Z"}'
```
```json
{"token":"np_<48 hex>",
"meta":{"id":"0199…","tenantId":"00000000-0000-7000-8000-000000000001","name":"ci-deploy","prefix":"<8 hex>",
"scopes":["objects:read","objects:write"],"ipBind":["10.0.0.0/8"],"expiresAt":"2027-01-01T00:00:00Z",
"createdBy":"Administrator","version":1,"createdAt":"…"}}
```
| Field | Required | Meaning |
| ----------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | yes | Label shown in the list and used as the principal’s name; `northplaned bootstrap-admin` refuses to run if a token named `bootstrap-admin` already exists |
| `scopes` | `scopes` or `roles` | Permission strings |
| `roles` | `scopes` or `roles` | Role names, resolved in the token’s tenant |
| `ipBind` | no | List of IPs or CIDRs the token may be used from (see below) |
| `aiAgent` | no | Marks the token as an AI agent credential (see below) |
| `expiresAt` | no | RFC 3339 timestamp after which the token is rejected |
A missing `name`, or neither `scopes` nor `roles`, yields `422 np:validation/token`. The response is `201` with `token` (cleartext) and `meta` (what `GET` will show later). Audit action `token.create` (name, scopes, roles, aiAgent).
## Use a token
[Section titled “Use a token”](#use-a-token)
```bash
# REST
curl -s https://monitoring.example.net/api/v1/hosts -H "Authorization: Bearer np_<48 hex>"
# np CLI — flag or environment
np --server https://monitoring.example.net --token np_<48 hex> get hosts
export NP_SERVER=https://monitoring.example.net NP_TOKEN=np_<48 hex>
np get problems
# np-agent — agent.yaml `token:` or environment
NORTHPLANE_TOKEN=np_<48 hex> np-agent
# MCP over stdio
NORTHPLANE_TOKEN=np_<48 hex> northplaned mcp
```
| Consumer | Where the token goes | Notes |
| ---------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `np` | `--token` / `NP_TOKEN` | [np CLI](/docs/reference/cli-np/) |
| `np-agent` | `token` in `agent.yaml` or `NORTHPLANE_TOKEN` | needs `objects:write`; [Agent](/docs/monitoring/agent/) |
| MCP over HTTP (`/mcp`) | `Authorization: Bearer` on every request | MCP sessions are bound to the token’s actor; [MCP server](/docs/ai/mcp-server/) |
| MCP over stdio | `NORTHPLANE_TOKEN` | the session inherits exactly the token’s scopes; **`ipBind` is not evaluated** on this path (only expiry) |
| Federation edge | `federation.token` in `config.yaml` | scope `sites:connect`; [Tenants and sites](/docs/administration/tenants-and-sites/) |
| Heartbeats | `curl -H "Authorization: Bearer np_…" ` | [Heartbeats](/docs/monitoring/heartbeats/) |
| Swagger UI `/api/docs` | “Authorize” | or the logged-in session cookie |
Inbound webhooks do **not** use platform tokens; an event source has its own `authMode` and secret — see [Event sources](/docs/alarming/event-sources/).
## List, rotate, revoke
[Section titled “List, rotate, revoke”](#list-rotate-revoke)
| Endpoint | Behaviour |
| -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`GET /api/v1/api-tokens`](/docs/reference/api/operations/get_api_tokens/) | Metadata of the tokens in the request’s tenant (never the secret or hash) |
| [`POST /api/v1/api-tokens/{id}:rotate`](/docs/reference/api/operations/post_api_tokens_id_rotate/) | Mints a **new** token with the same name, scopes, roles, `ipBind`, `aiAgent` and `expiresAt` (`createdBy` = the caller), deletes the old one immediately and returns `200 {"token": "np_…", "meta": {…}}`. Audit `token.rotate` with `newId`. |
| [`DELETE /api/v1/api-tokens/{id}`](/docs/reference/api/operations/delete_api_tokens_id/) | Revokes: `204`; the token stops working on the next request. Audit `token.revoke`. |
All need `admin:tokens`. The Admin tab lists Name (with a sparkle marker for AI agent tokens), Prefix (`np_…`), Scopes and “Zuletzt / Last used”, with a **Widerrufen / Revoke** button per row.
```bash
# rotate: the new cleartext is in .token, the old token is gone at once
curl -s -X POST https://monitoring.example.net/api/v1/api-tokens/:rotate \
-H "Authorization: Bearer np_"
```
Rotation is atomic from the API’s point of view, but the consumer must be updated immediately — plan it like any credential rollover (mint → deploy → verify → revoke is the alternative when you need an overlap window: create a second token, switch consumers, then delete the first).
## Expiry and IP binding
[Section titled “Expiry and IP binding”](#expiry-and-ip-binding)
* `expiresAt` in the past → `401 np:auth/invalid` with detail `token expired`. There is no grace period and no notification before expiry; the `lastUsedAt` column tells you which tokens are still in use.
* `ipBind` is a list of IPs or CIDRs. A request from an address outside the list → `401` with detail `token not valid from this address`. The address compared is the **TCP peer address** (`RemoteAddr`) — `X-Forwarded-For` is never consulted — so behind a reverse proxy every client appears as the proxy: either bind to the proxy’s address or do not bind at all. See [TLS and reverse proxies](/docs/administration/tls-and-proxy/).
* `ipBind` is ignored on the bare-token path used by `northplaned mcp` (stdio), where there is no TCP peer.
## AI agent tokens
[Section titled “AI agent tokens”](#ai-agent-tokens)
`"aiAgent": true` makes requests with the token authenticate as actor type **`ai_agent`** instead of `token`. Nothing else changes (permissions still come from scopes and roles), but audit entries, the **Audit log** tab (purple badge) and the API tokens list (sparkle) single these tokens out, and AI tools check the same permission names. The **Admin → MCP** tab always mints with `aiAgent: true`. See [Agent chat](/docs/ai/agent-chat/) and [MCP server](/docs/ai/mcp-server/).
## Tokens and tenants
[Section titled “Tokens and tenants”](#tokens-and-tenants)
A token belongs to the tenant it was minted in — the creator’s active tenant, so a central admin mints a customer’s token by sending `X-Northplane-Tenant: ` with the `POST`. The token then reads and writes that tenant, resolves its `roles` there, and `GET /api/v1/api-tokens` lists it only under that tenant. A token with `admin:tenants` (for example `*:*`) can itself switch tenants with the header. See [Tenants and sites](/docs/administration/tenants-and-sites/).
## The bootstrap token
[Section titled “The bootstrap token”](#the-bootstrap-token)
`northplaned bootstrap-admin -config ` is the headless way to get a first credential without the browser `/setup` page: it mints a token named `bootstrap-admin` with scope `*:*` in the Default tenant (`createdBy: "northplaned init"`), prints it once together with `export NP_TOKEN=np_…`, and refuses to run if a token with that name exists (“revoke it first via API”). Creating any token closes the `/setup` first-run gate. See [northplaned CLI](/docs/reference/cli-northplaned/) and [Authentication](/docs/administration/authentication/).
## Security advice
[Section titled “Security advice”](#security-advice)
* One token per consumer, named after it (`np-agent-`, `site-`, `ci-deploy`), with the smallest scope set that works. Revoke instead of sharing.
* Set `expiresAt` on everything that is not a long-lived agent, and review `lastUsedAt` periodically; unused tokens are the ones to delete.
* Use `ipBind` only when the server sees real client addresses (no proxy in front, or the proxy is the only allowed source).
* Treat `*:*` tokens (`bootstrap-admin`) as break-glass: use them to create scoped tokens, then revoke them.
* Token use is not rate-limited and not CSRF-checked (that protection is for cookies) — keep tokens out of browsers and query strings; the `?token=` form exists only for inbound event-source webhooks.
* Every create/rotate/revoke is in the audit log (`token.create`, `token.rotate`, `token.revoke`) — see [Observability](/docs/administration/observability/). The hardening checklist is in [Security](/docs/administration/security/).
# Authentication
> How people and machines authenticate to Northplane — local login, sessions and cookies, the first-run setup page and default admin, self-registration, OIDC single sign-on, LDAP directory login and sync, and where API tokens fit in.
Northplane has exactly two credential types: an **API token** (`Authorization: Bearer np_…`) for machines and a **session cookie** (`np_session`) for browsers. Everything else on this page — the login form, the first-run page, OIDC and LDAP — is a way to obtain one of the two. Authorization (what a principal may do) is covered in [Users, roles and permissions](/docs/administration/users-roles-permissions/).
## How a request is authenticated
[Section titled “How a request is authenticated”](#how-a-request-is-authenticated)
For every request under `/api/` (and for `/mcp`) the server resolves a *principal* in this order:
1. If the `Authorization` header starts with `Bearer np_`, the value is looked up as an [API token](/docs/administration/api-tokens/). The principal’s tenant is the token’s tenant; its permissions are the token’s scopes plus the permissions of the token’s roles.
2. Otherwise, if the request carries the `np_session` cookie, the session is loaded, the user row is re-read, and the session’s role names are expanded into permissions.
3. Otherwise the request is **anonymous** (no principal).
What you get back:
| Situation | Response |
| ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The bearer token or the cookie is present but invalid (unknown token, expired token, token used from a non-bound IP, expired session, disabled user) | `401 np:auth/invalid` — the `detail` says why (`invalid token`, `token expired`, `token not valid from this address`, `session invalid`, `user invalid`). This is decided in the middleware, so it applies to every API path, including ones that need no login. |
| Anonymous request to a route that requires a permission (or a login) | `401 np:auth/required` |
| Authenticated, but the route’s permission is not held | `403 np:auth/forbidden` — `detail` is the missing permission name |
Only bearer values that start with `np_` are inspected; a `Bearer abc123` sent to an inbound webhook (`/api/v1/ingest/{source}`) is left alone for the event source’s own auth mode (see [Event sources](/docs/alarming/event-sources/)).
`GET /api/v1/whoami` requires no permission (401 when anonymous) and returns `{actorType, actorId, name, tenantId, permissions[]}`. `actorType` is `user`, `token`, `ai_agent` or `system`; `tenantId` is the principal’s **home** tenant, even when the request carries an `X-Northplane-Tenant` header (see [Tenants and sites](/docs/administration/tenants-and-sites/)).
```bash
curl -s https://monitoring.example.net/api/v1/whoami -H "Authorization: Bearer np_<48 hex>"
```
```json
{"actorType":"token","actorId":"0199…","name":"ci-deploy","tenantId":"00000000-0000-7000-8000-000000000001","permissions":["objects:read","objects:write"]}
```
## Local login (`/login`)
[Section titled “Local login (/login)”](#local-login-login)
Local login is a plain HTML form, not a JSON endpoint: `GET /login` renders it, `POST /login` with the form fields `email`, `password` and optionally `remember=1` consumes it. There is no `/api/v1/login`; scripts and integrations use API tokens instead.
What `POST /login` does, in order:
1. **Rate limit** per client IP (see below). When throttled the page re-renders with the message “Zu viele Anmeldeversuche. Bitte kurz warten.” and a `Retry-After: 30` header.
2. **Look up the user by e-mail.** Disabled accounts are excluded from the lookup, so they fail exactly like unknown accounts.
3. **Directory accounts** (non-local users whose subject starts with `ldap|`, when LDAP is configured) are verified against the directory with a search-then-bind (see [LDAP](#ldap-and-active-directory)). Their session roles are the roles from the last sync (fallback `viewer`); the audit action is `login.ldap`.
4. **Everyone else** goes through an argon2id verification — against the real hash for local users, against a fixed dummy hash for unknown or non-local (OIDC) accounts, so timing does not reveal whether an e-mail exists. Any failure (unknown, disabled, non-local, wrong password) produces the same “Anmeldung fehlgeschlagen.” with HTTP 401.
5. **Session roles** are the user’s role names (legacy rows with an empty role list fall back to `["admin"]`). The audit action is `login.local`.
6. A session is minted — 12 h, or 30 days with “remember me” — in the user’s **home tenant** (empty means the Default tenant), the `np_session` cookie is set and the browser is redirected to `/`.
The login page is German
`/login`, `/setup` and `/register` are server-rendered, JavaScript-free pages with hard-coded German labels (E-Mail, Passwort, “Angemeldet bleiben”, “Anmelden”). They are not branded by [Branding and themes](/docs/administration/branding-and-themes/). The page shows a **Single Sign-On** button when OIDC is configured and a “Neu hier? Konto erstellen” link when self-registration is enabled. While the first-run gate is open, `GET /login` redirects to `/setup`.
### Login rate limiter
[Section titled “Login rate limiter”](#login-rate-limiter)
A per-client-IP token bucket shared by `/login`, `/setup` and `/register`: burst **8**, refill **1 token every 15 s** (about 4 attempts per minute sustained). The bucket map is garbage-collected when it grows past 4096 entries (buckets idle for more than 1 h are dropped). The client IP is the host part of the TCP peer address — `X-Forwarded-For` is **not** consulted — so behind a reverse proxy all users share the proxy’s bucket (see [TLS and reverse proxies](/docs/administration/tls-and-proxy/)). The limits are hard-coded.
### Password policy and hashing
[Section titled “Password policy and hashing”](#password-policy-and-hashing)
* Minimum length **12 characters** (counted in Unicode runes), enforced everywhere a local password is set: `/setup`, `/register`, `POST /api/v1/users` with a password, `POST /api/v1/users/{id}:set-password`, `POST /api/v1/users/me:change-password`. There is no other complexity rule.
* Hash: **argon2id** with `time=1`, `memory=64 MiB`, `threads=4`, `keyLen=32` and a 16-byte random salt, stored as `hex(salt)$hex(hash)`; verification is constant-time. API token bodies are hashed with the same function.
* The hash is never serialised by the API (`passHash` is excluded from every user representation).
## Sessions and cookies
[Section titled “Sessions and cookies”](#sessions-and-cookies)
| Property | Value |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Session id | `base64url(hex(24 random bytes))`, stored server-side in the `sessions` table (`id, user_id, tenant_id, data{roles,groups}, created_at, expires_at`) — sessions survive restarts |
| Cookie | `np_session`; `Path=/`; `HttpOnly`; `SameSite=Lax`; `Secure` when the request is HTTPS (direct TLS, or `X-Forwarded-Proto: https` with `trustProxy: true`); `Max-Age` = TTL |
| TTL | 12 h by default; 30 days when “Angemeldet bleiben” (remember me) is ticked; OIDC sessions are always 12 h. Not configurable. |
| Per-request checks | The user row is reloaded on every request: a **disabled** user is rejected immediately (`user invalid`), an expired session answers `session invalid` |
| Permission changes | Role **names** are stored in the session and expanded into permissions on every request — editing a role’s permission list takes effect immediately; changing a *user’s* role list takes effect at the next login |
| Last seen | `users.last_seen_at` is stamped at most once per minute (shown in **Admin → Users (Benutzer)**) |
| Cleanup | Expired sessions are purged every 10 minutes by the janitor |
| Password change | Changing or resetting a password does **not** invalidate existing sessions |
| Logout | `GET /auth/logout` deletes the server-side session, clears the cookie (`Max-Age=-1`) and redirects to `/login`. There is no IdP (RP-initiated) logout for OIDC sessions. |
## Cross-site request protection
[Section titled “Cross-site request protection”](#cross-site-request-protection)
* A **cookie-authenticated** API request whose browser sets `Sec-Fetch-Site: cross-site` is rejected with `403 np:auth/csrf` (“cross-site request blocked”). Token-authenticated requests are unaffected. The check is applied to routes registered in the API route table; raw routes such as `/api/v1/ingest/{source}`, `/api/v1/ack/{token}` and `/mcp` are not wrapped (they have their own authentication).
* The cookie is `SameSite=Lax`. The login, setup and register forms carry no CSRF token; they rely on `SameSite=Lax` by design.
* There are no CORS headers anywhere: the API cannot be called from another origin in a browser. Server-side integrations use tokens.
* The SPA shell is gated server-side: an unauthenticated *document* navigation (GET/HEAD with `Accept: text/html`, not under `/assets/`) is redirected `302 /login`; API calls are never redirected (they get a 401 problem document, on which the SPA itself navigates to `/login`).
## First run: `/setup` and the default admin
[Section titled “First run: /setup and the default admin”](#first-run-setup-and-the-default-admin)
A fresh database has no accounts. Two mechanisms can create the first administrator, and they interact:
**The `/setup` page.** Its gate (`FirstRunOpen`) is open only while **no local user exists and no API token exists in the Default tenant**. SSO-provisioned (non-local) users do not close it; a storage error fails closed. `GET /setup` shows a form (Name, E-Mail, Passwort ≥ 12 characters, Bestätigen); `POST /setup` is rate-limited, re-checks the gate under a mutex (a racing second POST gets `409 setup already completed`), creates a **local user with role `admin`**, mints a 12 h session in the Default tenant, writes the audit action `setup.admin` and redirects to `/`. When the gate is closed, `/setup` redirects to `/login`.
**Default admin seeding.** `northplaned serve` runs `seedDefaultAdmin` on **every** start. It creates a local admin when all of the following hold: `NP_DEFAULT_ADMIN_DISABLED` is unset, `NP_DEFAULT_ADMIN_PASSWORD` is not set-to-empty, no *enabled local* user with role `admin` exists, and the chosen e-mail is free.
| Variable | Default | Effect |
| --------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NP_DEFAULT_ADMIN_DISABLED` | unset | Any non-empty value skips the seeding entirely |
| `NP_DEFAULT_ADMIN_EMAIL` | `admin@localhost` | E-mail of the seeded account |
| `NP_DEFAULT_ADMIN_NAME` | `Administrator` | Display name |
| `NP_DEFAULT_ADMIN_PASSWORD` | unset | Password to use. **Unset** → a random 32-hex-character password is generated and logged **once** at WARN level (“seeded default admin with a GENERATED password — save it now, it is not recoverable”). **Set but empty** → seeding is skipped. Set → used, logged as “seeded default admin — CHANGE THE PASSWORD”. |
There is no hard-coded default password. These are process environment variables, not `config.yaml` keys — see [Configuration](/docs/administration/configuration/).
On a default install `/setup` is closed
Because the default admin is seeded before the HTTP listener starts, a default install already has a local user when you first open the browser — `/setup` redirects to `/login`, and the “first run: open …/setup” log line is printed only when the gate really is open. Log in with `NP_DEFAULT_ADMIN_EMAIL` and the logged or configured password. If you want the interactive `/setup` flow instead, start with `NP_DEFAULT_ADMIN_DISABLED=1` (or `NP_DEFAULT_ADMIN_PASSWORD=` empty) — this is what the E2E harness and `deploy/.env.example` do.
Other things that close the gate:
* **Any API token** in the Default tenant. `northplaned bootstrap-admin` is the headless alternative to `/setup`: it mints a token named `bootstrap-admin` with scope `*:*` (see [API tokens](/docs/administration/api-tokens/)).
* **Demo mode** (`serve --demo` / `NORTHPLANE_DEMO=true`) seeds the local users `operator@demo.local` (role `operator`) and `viewer@demo.local` (role `viewer`), which are local users — see [Demo mode](/docs/getting-started/demo-mode/).
## Self-registration (`/register`)
[Section titled “Self-registration (/register)”](#self-registration-register)
* Enabled by `allowSignup: true` in `config.yaml` or `NORTHPLANE_ALLOW_SIGNUP=true`; otherwise `GET`/`POST /register` answer 404.
* While the first-run gate is open, `/register` redirects to `/setup` so the first visitor cannot sign up as a viewer and silently close the gate.
* A successful registration creates a **local** user with roles `["viewer"]` in the Default tenant, mints a 12 h session and writes the audit action `user.register`. E-mail addresses are unique across the instance (duplicate → “Diese E-Mail ist bereits registriert.”).
* The login page shows the “Konto erstellen” link only when signup is enabled.
Registered users can only read (see the `viewer` role in [Users, roles and permissions](/docs/administration/users-roles-permissions/)); an administrator promotes them in **Admin → Users (Benutzer)**. Leave signup off unless you want a public read-only console — see [Security](/docs/administration/security/).
## OIDC single sign-on
[Section titled “OIDC single sign-on”](#oidc-single-sign-on)
OIDC (Authorization Code + PKCE, S256) is configured in the `oidc:` section of `config.yaml`. Microsoft Entra ID and Keycloak are the providers the code was written against.
| Key | Type | Default | Env override | Meaning |
| ------------------- | ------ | ---------------------------------- | ------------------------------- | ------------------------------------------------------------- |
| `oidc.issuer` | string | `""` (= OIDC off) | `NORTHPLANE_OIDC_ISSUER` | Discovery URL. Empty disables SSO entirely. |
| `oidc.clientId` | string | — | `NORTHPLANE_OIDC_CLIENT_ID` | Required as soon as any `oidc.*` key is set |
| `oidc.clientSecret` | string | — | `NORTHPLANE_OIDC_CLIENT_SECRET` | |
| `oidc.scopes` | list | `[openid, profile, email, groups]` | — | Scopes requested; explicitly emptied → `openid profile email` |
| `oidc.groupsClaim` | string | `groups` | — | ID-token claim that holds the group list |
| `oidc.adminGroup` | string | — | — | Any user carrying this group value also gets role `admin` |
config.yaml
```yaml
baseUrl: "https://monitoring.example.net" # required: the redirect URL is baseUrl + /auth/callback
oidc:
issuer: "https://login.microsoftonline.com//v2.0"
clientId: "…"
clientSecret: "…"
adminGroup: ""
```
Register `https://monitoring.example.net/auth/callback` as the redirect URI at the provider.
**Flow.** `GET /auth/oidc` (the **Single Sign-On** button) stores a random `state` and PKCE verifier in the cookies `np_oidc_state` / `np_oidc_verifier` (`Path=/auth`, HttpOnly, `Secure` on HTTPS, 600 s) and redirects to the provider. `GET /auth/callback` checks the state, exchanges the code with the verifier, requires an `id_token`, verifies it against `clientId`, and reads the claims `name` (fallback `email`) and `email`. The user is provisioned or updated by **subject** = `issuer + "|" + sub` (name, e-mail, last seen). Then a 12 h session is minted and the browser lands on `/`. Any failure re-renders the login page with “SSO-Anmeldung fehlgeschlagen: …”. Calling `/auth/oidc` without OIDC configured answers `501 SSO not configured`.
**Group → role mapping.** The groups in `groupsClaim` are matched (exact string) against the `idpGroups` of the roles in the **Default tenant**; every matching role is granted. A user in `adminGroup` additionally gets `admin`. With no match the user gets `["viewer"]`. Roles are recomputed at every login from the IdP groups; the user row usually keeps an empty role list. See [Roles](/docs/administration/users-roles-permissions/) for `idpGroups`.
OIDC behaviour to know
* `baseUrl` must be set — the redirect URL is derived from it.
* OIDC sessions are always in the **Default tenant** and always 12 h (no “remember me”).
* OIDC users are rows with `local: false` and no password; they cannot use the password form. A **disabled** account is never resurrected by SSO (“account disabled”).
* OIDC logins and logouts are **not** written to the audit log, and there is no RP-initiated logout at the IdP.
* If discovery fails at boot, SSO is disabled with a warning and the server still starts.
* The `Secure` flag on the OIDC cookies follows `trustProxy` like the session cookie.
## LDAP and Active Directory
[Section titled “LDAP and Active Directory”](#ldap-and-active-directory)
The `ldap:` section enables two things: a background **directory sync** that provisions and disables users, and **password verification** on `/login` for synced users. LDAP is on when `ldap.url` is set.
| Key | Default | Env override | Meaning |
| ------------------------- | --------------------------------- | ------------------------------- | ------------------------------------------------------------------------------ |
| `ldap.url` | `""` (= off) | `NORTHPLANE_LDAP_URL` | `ldap://host:389` or `ldaps://host:636` (must start with one of the two) |
| `ldap.startTls` | `false` | — | Upgrade `ldap://` with StartTLS before any bind |
| `ldap.insecureSkipVerify` | `false` | — | Skip TLS verification (TLS 1.2 minimum, `ServerName` = host of `url`) |
| `ldap.bindDn` | — | `NORTHPLANE_LDAP_BIND_DN` | Service account; when set, `bindPassword` is required |
| `ldap.bindPassword` | — | `NORTHPLANE_LDAP_BIND_PASSWORD` | |
| `ldap.baseDn` | — | `NORTHPLANE_LDAP_BASE_DN` | Required when LDAP is configured |
| `ldap.userFilter` | `(&(objectClass=person)(mail=*))` | — | User search filter |
| `ldap.userAttr` | `mail` | — | Login / e-mail attribute (AD: `userPrincipalName`) |
| `ldap.nameAttr` | `cn` | — | Display name; empty → e-mail |
| `ldap.idAttr` | `""` (= DN) | — | Stable id attribute (`entryUUID`, `objectGUID`); binary values are hex-encoded |
| `ldap.groupAttr` | `memberOf` | — | Membership attribute read from the user entry |
| `ldap.groupFilter` | — | — | Optional member search with `{dn}` / `{user}` placeholders (escaped) |
| `ldap.groupBaseDn` | = `baseDn` | — | Base for `groupFilter` |
| `ldap.syncInterval` | `15m` | — | Sync period (≤ 0 → 15 m) |
| `ldap.defaultRoles` | `[viewer]` | — | Roles when no group maps |
| `ldap.adminGroup` | — | — | Group DN or CN mapped to `admin` (lower-cased compare) |
| `ldap.disableMissing` | `true` | — | Disable `ldap\|` users that vanished from the directory |
config.yaml
```yaml
ldap:
url: "ldaps://dc1.example.net:636"
bindDn: "cn=svc-northplane,ou=service,dc=example,dc=net"
bindPassword: "…" # or NORTHPLANE_LDAP_BIND_PASSWORD
baseDn: "dc=example,dc=net"
userFilter: "(&(objectClass=person)(mail=*))"
userAttr: mail # AD: userPrincipalName
idAttr: "" # AD: objectGUID, OpenLDAP: entryUUID (stable across DN moves)
groupAttr: memberOf
adminGroup: "cn=northplane-admins,ou=groups,dc=example,dc=net"
syncInterval: 15m
defaultRoles: [viewer]
disableMissing: true
```
**Sync pass.** The `ldap-sync` worker runs once at boot and then every `syncInterval` (concurrent runs coalesce). It searches the whole subtree under `baseDn` (paged, 500 per page) for `dn`, `userAttr`, `nameAttr`, `groupAttr` and `idAttr`. Per entry: subject = `ldap|` + lower-cased DN (or `ldap|` + the `idAttr` value), e-mail lower-cased, roles from the `idpGroups` of the roles in the Default tenant (matching is lower-cased and accepts the full group DN **or** the first RDN value, i.e. the `cn`), plus `adminGroup`, plus `defaultRoles` when nothing matched. Then it reconciles:
* creates missing users as `local: false` without a password, home tenant Default;
* updates name, e-mail and roles when they changed;
* skips a **locally disabled** account (never resurrects it) and skips an entry whose e-mail is already taken by another account (warning);
* with `disableMissing: true`, disables `ldap|` users that were not seen;
* never touches **local** (break-glass) users.
The result `{created, updated, unchanged, disabled, skipped, warnings[]}` is shown in the **Verzeichnis-Sync (LDAP)** card at the bottom of **Admin → Users (Benutzer)** (URL, interval, last run, counts, warnings, **Jetzt synchronisieren**) and returned by the API.
**Login verification.** For a user whose subject starts with `ldap|`, `POST /login` performs a search-then-bind: optional service bind, search `(&(=))` which must return exactly one entry, then bind as that DN with the submitted password. An empty password is rejected before any network call. Session roles are the roles from the last sync (fallback `viewer`); audit action `login.ldap`.
**Endpoints** (both need `admin:users`):
| Endpoint | Behaviour |
| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| [`GET /api/v1/directory/status`](/docs/reference/api/operations/get_directory_status/) | `{configured, url, syncInterval, lastSyncAt, lastError, lastResult}`; `{configured:false}` when LDAP is off |
| [`POST /api/v1/directory:sync`](/docs/reference/api/operations/post_directory_sync/) | Runs a pass now and returns the result; `501 np:directory/unconfigured` without LDAP, `502 np:directory/sync` on failure; audit `directory.sync` |
Note
LDAP-synced users always get the Default tenant as home tenant, and group mapping only looks at roles in the Default tenant. The Users tab marks them with an “ldap” badge.
## Machine authentication
[Section titled “Machine authentication”](#machine-authentication)
Everything that is not a browser authenticates with an API token. The token is created in **Admin → API tokens (API-Tokens)** or via the API and is shown once — see [API tokens](/docs/administration/api-tokens/) for scopes, expiry and IP binding.
| Client | How the token is supplied | Minimum permissions |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| REST / curl | `Authorization: Bearer np_…` | whatever the routes require |
| `np` CLI | `--token np_…` or env `NP_TOKEN` (server: `--server` / `NP_SERVER`, default `https://localhost:8443`) | per command, see [np CLI](/docs/reference/cli-np/) |
| `np-agent` | `token:` in `agent.yaml` or env `NORTHPLANE_TOKEN` | `objects:write` (+ `objects:read` when `pull: true`), see [Agent](/docs/monitoring/agent/) |
| MCP over HTTP (`/mcp`) | `Authorization: Bearer np_…` on every request; an anonymous request gets `401` with `WWW-Authenticate: Bearer resource_metadata="/api/v1/whoami"`; MCP sessions are bound to the token’s actor (another token reusing an `Mcp-Session-Id` → 403) | see [MCP server](/docs/ai/mcp-server/) |
| MCP over stdio (`northplaned mcp`) | env `NORTHPLANE_TOKEN`; the session inherits exactly the token’s scopes; `ipBind` is **not** evaluated on this path | see [MCP server](/docs/ai/mcp-server/) |
| Federation edge | `federation.token` in the edge’s `config.yaml`, minted on the main instance | `sites:connect`, see [Tenants and sites](/docs/administration/tenants-and-sites/) |
| Heartbeat beats | `GET`/`POST /api/v1/heartbeats/{name}/beat` with a bearer token | `objects:write` |
| Swagger UI (`/api/docs`) | “Authorize” with an `np_…` token, or the logged-in cookie (`withCredentials`) | — |
Inbound webhooks, telephony callbacks and ack links do **not** use platform credentials: event sources have their own `authMode` (`token` / `hmac` / `basic` / `none`), ack links are HMAC-signed one-time URLs. See [Event sources](/docs/alarming/event-sources/) and [Acknowledge and snooze](/docs/alarming/acknowledge-and-snooze/).
## Error codes
[Section titled “Error codes”](#error-codes)
| Code | HTTP | When |
| --------------------------- | ---- | ---------------------------------------------------------------------------------------------- |
| `np:auth/required` | 401 | No principal on a protected route (also `whoami`, `branding`, preferences, push subscriptions) |
| `np:auth/invalid` | 401 | Bad, expired or IP-bound token; invalid session; disabled user |
| `np:auth/forbidden` | 403 | Missing permission (`detail` names it) |
| `np:auth/csrf` | 403 | Cookie-authenticated request with `Sec-Fetch-Site: cross-site` |
| `np:auth/bad-password` | 403 | Wrong current password on `POST /api/v1/users/me:change-password` |
| `np:directory/unconfigured` | 501 | `directory:sync` without LDAP |
| `np:directory/sync` | 502 | LDAP sync failed |
All errors are RFC 9457 problem documents — see [API overview](/docs/reference/api-overview/).
# Branding and themes
> Instance-wide appearance of the Northplane console — colour theme and light/dark mode, the 31 themes, how the browser caches and adopts the setting, the favicon and logo, which pages are not branded, and the GET/PUT /api/v1/branding API.
Branding in Northplane is the **look of the console for this installation**: one colour theme and one light/dark mode, chosen by an administrator and seen by everyone who signs in. It is deliberately not per user and not per tenant. There is no logo, name or CSS upload.

## What can be branded
[Section titled “What can be branded”](#what-can-be-branded)
| Axis | Values | Default |
| ---------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
| Colour theme (`theme`) | One of the 31 theme ids below (``) | `obsidianFire` (“Obsidian & Fire”) for a browser with no cached choice |
| Mode (`mode`) | `dark`, `light`, `system` (`system` follows the operating-system preference live via `prefers-color-scheme`) | `dark` |
Every theme exists in both modes (30 dark and 30 light CSS blocks plus the built-in `northplane` palette on `:root`). The sidebar logo, the tenant-switcher chip and the favicon are tinted with the active theme’s accent.
## Set the instance appearance
[Section titled “Set the instance appearance”](#set-the-instance-appearance)
Open **Admin → Appearance (Darstellung)**. The card shows a **Mode** row (System / Hell / Dunkel — “Light/dark — every theme comes in both modes”) and a **Farbschema / Colour theme** radio grid with a swatch and label per theme. Picking a value applies it in your browser immediately and writes it to the server as the instance branding.
The controls are enabled only for principals whose permissions imply `config:write` (the built-in `admin` role; `operator` and `viewer` see a read-only banner: “Only administrators with config:write can change this instance’s appearance”). The hint under the title states the scope: “Applies to this instance — every user sees it, and switching customer does not change it.”
## Themes
[Section titled “Themes”](#themes)
| Id | Label | Id | Label |
| ---------------- | --------------------------------- | ---------------- | ----------------- |
| `northplane` | Northplane (Standard) | `terracotta` | Terracotta Warm |
| `currentRed` | Current (Red/Orange) | `steelViolet` | Steel & Violet |
| `warmAmber` | Warm Amber | `cloudPeach` | Cloud & Peach |
| `deepTeal` | Deep Teal + Coral | `carbonYellow` | Carbon & Yellow |
| `lavenderMint` | Lavender + Mint | `navyBronze` | Navy & Bronze |
| `forest` | Forest & Copper | `snowRuby` | Snow & Ruby |
| `midnightIndigo` | Midnight Indigo | `slateTangerine` | Slate & Tangerine |
| `sandOcean` | Sand & Ocean | `espressoTeal` | Espresso & Teal |
| `roseGold` | Rose Gold & Charcoal | `blushSage` | Blush & Sage |
| `electricDark` | Electric Blue Dark | `polarNight` | Polar Night |
| `mossStone` | Moss & Stone | `ivoryIndigo` | Ivory & Indigo |
| `obsidianFire` | Obsidian & Fire (product default) | `chalkMagenta` | Chalk & Magenta |
| `arcticBlue` | Arctic Blue | `volcanicAqua` | Volcanic & Aqua |
| `plumGold` | Plum & Gold | `linenOlive` | Linen & Olive |
| `neonMint` | Neon Mint Dark | `midnightRose` | Midnight Rose |
| | | `concreteOrange` | Concrete & Orange |
`northplane` is the base palette defined on `:root` (slate surfaces, blue accent); selecting it clears the `data-theme` attribute instead of applying an override block. The registry lives in `web/src/theme-data.ts`; unknown ids sent through the API are accepted by the server but ignored by the client.
## How the browser applies it
[Section titled “How the browser applies it”](#how-the-browser-applies-it)
The SPA keeps the two axes as synchronous local stores so the first paint never flashes the wrong palette, and a separate module talks to the server:
1. On boot the SPA reads `localStorage` keys `np.theme` and `np.mode` and applies them to `` before React renders (defaults `obsidianFire` / `dark` when nothing is cached). Other tabs pick up changes through the `storage` event.
2. Once the authenticated shell mounts, it fetches `GET /api/v1/branding` **once** and adopts `theme` and `mode` from the document — the server value wins over the local cache. A fetch failure (offline, 401) leaves the cached look in place. The document is **not** re-fetched when you switch tenants.
3. A user-driven change in the Appearance tab updates the local store and `PUT`s the whole document `{theme, mode}` back. A caller without `config:write` gets a 403 that the UI swallows — which is why the controls are locked for them in the first place.
There is no real per-user theme
What looks like a per-user override is only the per-browser `localStorage` cache: a value that differs from the instance document survives until the next shell mount, when the server document is adopted again. If the instance document is empty (`{}` — nobody has ever set branding), every browser simply keeps its own cached choice or the defaults. Language is likewise not a preference (it follows `navigator.language`). The only stored per-user setting is the refresh interval — see [Users, roles and permissions](/docs/administration/users-roles-permissions/).
## Favicon and logo
[Section titled “Favicon and logo”](#favicon-and-logo)
* The sidebar shows the lucide `radar` glyph next to the “Northplane” wordmark.
* Inside the SPA the browser-tab icon is the same glyph drawn at runtime into a data-URI SVG, tinted with the live `--sidebar-primary` colour (fallback `--primary`, then `#FF5C3A`) and re-rendered whenever theme or mode changes — so switching branding recolours the tab too.
* `public/favicon.svg` carries the same glyph in the default accent (`#FF5C3A`, the Obsidian & Fire accent) for the server-rendered pages that never boot the SPA.
* `meta name="theme-color"` is fixed to `#020617`.
None of these can be replaced through configuration in this version.
## Where branding does not apply
[Section titled “Where branding does not apply”](#where-branding-does-not-apply)
* `/login`, `/setup`, `/register` and the public status pages `/status/{slug}` are server-rendered static dark HTML with a “▲ Northplane” heading and the static favicon — they ignore theme and mode (and are German-only, see [Authentication](/docs/administration/authentication/)).
* The documentation under `/docs/` has its own Starlight theme.
* The REST API (`/api/…`) is unaffected, and the tenant header is ignored by the branding endpoints (below).
## API
[Section titled “API”](#api)
Branding is a single document (`kind: branding`, name `instance`) stored **under the Default tenant**; `X-Northplane-Tenant` is ignored on both calls.
| Endpoint | Permission | Behaviour |
| ---------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [`GET /api/v1/branding`](/docs/reference/api/operations/get_branding/) | none, but a login is required (401 when anonymous) | `{"theme": "…", "mode": "…"}` — `{}` when never set |
| [`PUT /api/v1/branding`](/docs/reference/api/operations/put_branding/) | `config:write` | Body `{"theme": "", "mode": "light\|dark\|system"}`; `mode` is validated (`422 mode must be one of light, dark, system`), `theme` is stored unvalidated; audit `branding.update` with before/after |
```bash
curl -s -X PUT https://monitoring.example.net/api/v1/branding \
-H "Authorization: Bearer np_<48 hex>" -H "Content-Type: application/json" \
-d '{"theme":"deepTeal","mode":"system"}'
```
The `PUT` replaces the whole document, so always send both fields. Setting branding in a [config bundle](/docs/administration/config-bundles/) is not possible (branding is not a bundle kind); use the API or the Appearance tab. For the UI side of theming (tokens, Tailwind variant, adding a theme) see [Frontend](/docs/development/frontend/).
# Config bundles
> The declarative YAML bundle format, supported kinds and apply order, plan/apply/export/prune semantics, the np CLI and Admin tab, a complete example and the GitOps workflow.
A **bundle** is a multi-document YAML file that describes configuration declaratively: hosts, services, templates, check commands, contacts, channels, escalation policies, alert rules, dashboards, reports and more. The server diffs a bundle against its current state (**plan**), applies it idempotently (**apply**) and can render its whole configuration back as a bundle (**export**). The same format and the same applier are used by `np apply`, the **Admin → Config bundles (Config-Bundles)** tab, the AI config tools, the Nagios importer and [federation](/docs/concepts/federation/).
Bundles are the GitOps vehicle of Northplane: keep them in a repository, review plans, apply on merge.
## Format
[Section titled “Format”](#format)
```yaml
kind: # required; one of the kinds below
metadata:
name: # required; must not contain newline or tab characters
host: # Service only (required for Service)
folder: /path # Host/Service only
labels: {k: v} # Host/Service: object labels; other kinds: the document's "labels" field
spec: {...} # the body (ObjectSpec for Host/Service; the resource document fields otherwise)
data: {...} # optional non-spec payload (dashboard layouts, report params); merged with spec
---
kind:
```
* Documents are separated by `---`; empty documents (only separators/comments) are skipped. There is **no `apiVersion`** field.
* Parse errors name the document: `bundle: document 3: unknown kind "Hosts"`, `bundle: document 2 (Service): missing metadata.name`.
* Identity inside a bundle is `Kind/name`, or `Service//` for services; duplicates are rejected (`duplicate Host/web-01`). A Service without `metadata.host` is rejected (`service requires metadata.host`).
* Structural errors come back as `422 np:validation/bundle` with all messages joined by `; `.
* The body may be JSON — JSON is valid YAML.
metadata.name is the name
For every kind the document’s name is taken from `metadata.name`. A `name` field inside `spec` is ignored and overwritten on apply; `metadata.labels` becomes the `labels` field of resource documents (and the object labels of hosts/services). `spec` and `data` are merged into one document — `data` exists only to keep non-spec payloads visually apart.
## Kinds and apply order
[Section titled “Kinds and apply order”](#kinds-and-apply-order)
Documents are applied in this fixed order (dependencies before dependents); export sorts the same way, then by host, then by name:
```text
Tenant, Role, TimePeriod, CheckCommand, Template, Contact, ContactGroup, Channel,
Schedule, IVRMenu, EscalationPolicy, EventSource, AlertGroup, AlertRule,
Host, Service, BusinessService, Heartbeat, Dashboard, Report, StaticGroup,
WebhookSubscription, SavedFilter
```
| Kind | Stored as | Applied | Exported | Notes |
| --------------------------------------------------------------------------------------------- | ------------------ | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `Host`, `Service` | objects table | yes | yes | `metadata.folder`, `metadata.labels`, `spec` = [ObjectSpec](/docs/concepts/object-model/); services resolve `metadata.host` by host **name** |
| `Template`, `CheckCommand`, `TimePeriod` | resource documents | yes | yes | see [Templates](/docs/monitoring/templates/) |
| `Contact`, `ContactGroup`, `Schedule` | resource documents | yes | yes | `ContactGroup.members` and schedule participants are contact **ids** |
| `Channel`, `EscalationPolicy`, `AlertRule`, `AlertGroup`, `EventSource`, `IVRMenu` | resource documents | yes | yes | validated like the REST API (channel `type` required, rule compiles, policy has ≥ 1 step) |
| `BusinessService`, `Dashboard`, `Report`, `WebhookSubscription`, `SavedFilter`, `StaticGroup` | resource documents | yes | yes | `Dashboard.spec` is the opaque UI widget document |
| `Role` | resource documents | yes | **no** | roles export via admin tooling only; apply works |
| `Tenant` | — | **no** | no | in the vocabulary, but the applier has no handler: plan warns `unsupported kind Tenant`, apply skips it silently. Create tenants via `POST /api/v1/tenants`. |
| `Heartbeat` | — | **no** | no | same — plan warns `unsupported kind Heartbeat`; manage heartbeats via the [heartbeats API](/docs/monitoring/heartbeats/) |
Not bundle kinds at all: sites, schedule overrides, users, API tokens, secrets, preferences, branding, downtimes, silences. Secrets are referenced from bundles as `$SECRET:name$` and created separately ([Secrets](/docs/administration/secrets/)).
## Plan, apply, export
[Section titled “Plan, apply, export”](#plan-apply-export)
All endpoints act on the request tenant (`X-Northplane-Tenant` for holders of `admin:tenants`, otherwise the caller’s tenant). The request body is read raw — the server does not inspect `Content-Type` — up to **8 MiB** (`413 np:bundle/size`).
| Endpoint | Permission | Behaviour |
| ------------------------------------------------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`POST /api/v1/config/bundles:plan`](/docs/reference/api/operations/post_config_bundles_plan/) `[?prune=true&selector=…]` | `objects:read` | Dry run. Returns `{plan:[{action, kind, name, host?, diff?}], warnings:[…], applyToken?}`. When the plan is non-empty an `applyToken` (`ap_` + 32 hex) is cached **in memory for 10 minutes**, bound to the tenant, single use. |
| [`POST /api/v1/config/bundles:apply?dryRun=true`](/docs/reference/api/operations/post_config_bundles_apply/) | `config:write` | Same as plan. |
| `POST /api/v1/config/bundles:apply?applyToken=ap_…` | `config:write` | Applies exactly the planned documents (the cached plan). Unknown, expired or foreign-tenant token → `409 np:bundle/token` (“re-plan”). |
| `POST /api/v1/config/bundles:apply` `[?prune=true&selector=…]` | `config:write` | Direct apply of the posted bundle (plan + apply in one call). |
| [`GET /api/v1/config/bundles:export`](/docs/reference/api/operations/get_config_bundles_export/) `[?folder=/x]` | `objects:read` | Canonical YAML (`Content-Type: application/yaml`). With `folder`, only Host/Service documents of that subtree are rendered (global resources are skipped). |
`prune=true` deletes every currently exported document whose identity is not in the bundle; `selector` restricts pruning to documents whose `metadata.labels` match the [label selector](/docs/concepts/object-model/). Prune failures do not abort the apply — they are reported as warnings.
The response of an apply is a `PlanResult` whose `plan` lists what was actually applied (`create`/`update`/`delete`), plus warnings.
### How the plan is computed
[Section titled “How the plan is computed”](#how-the-plan-is-computed)
* **Host/Service**: an object that does not exist is `create`. Otherwise `folder`, `labels` and every `spec.` are compared (JSON projection); differing fields appear in `diff` as `{"field": [old, new]}`. A Service whose host does not exist yet is planned as `create` — the host may be created earlier in the same bundle.
* **Resource documents**: missing → `create`; otherwise a field-wise diff of `spec ∪ data ∪ {labels}` against the stored document (envelope fields `id`, `tenantId`, `version`, `createdAt`, `updatedAt`, `name` ignored). **Fields absent from the bundle are unmanaged** — they are never diffed.
* The plan is sorted create → update → delete. Unsupported kinds produce a warning and no action.
An update writes the document as given in the bundle
The plan ignores fields your bundle does not mention, but when any field differs the applier **replaces** the stored document with the bundle’s `spec ∪ data ∪ labels` — fields you omitted are gone after that write. Keep resource documents complete: start from `np export`, edit, then plan/apply. (Host/Service specs are replaced wholesale too, exactly like a REST `PUT`.)
### How apply works
[Section titled “How apply works”](#how-apply-works)
1. The bundle is parsed and validated, and a plan is computed (`422 np:validation/bundle` on errors).
2. Documents are sorted by kind order and applied one by one: objects through the same `validateSpec` as the REST API (templates must resolve, `notifyOn` tokens valid, contacts/contact groups must exist); resource documents through `validateResourceDoc`. Creates use create-only semantics, updates are unconditional (no `If-Match`). Ids are preserved on update; a new document gets a fresh UUIDv7 unless its body carries an `id`.
3. Apply is **not transactional**: the first failure stops the run with `422 np:bundle/apply` (“apply failed at Kind/name”, the cause in `detail`) and an audit entry `bundle.apply` listing what was already applied. Earlier documents stay applied; fix the bundle and re-run — the re-run is idempotent.
4. Prune deletions run after all documents.
5. An audit entry `bundle.apply` is written, the catalog is reloaded and alert rules are recompiled, and the response lists the applied actions.
Re-applying an unchanged bundle yields an empty plan (`np apply` prints `no changes`) — bundles are safe to apply on every CI run.
## Using the np CLI
[Section titled “Using the np CLI”](#using-the-np-cli)
```bash
export NP_SERVER=https://monitoring.example.net NP_TOKEN=np_…
np apply -f bundle.yaml --dry-run # plan only
np apply -f bundle.yaml # apply
np apply -f bundle.yaml --prune # apply and delete everything not in the bundle
cat bundle.yaml | np apply -f - # read the bundle from stdin
np export > bundle.yaml # canonical export of the tenant
```
`np apply` posts to `…/bundles:apply` (with `dryRun=true` and/or `prune=true`) as `application/yaml` and prints one line per action — `applied create Host/web-01`, `would apply update Service/web-01/http`, `warning: unsupported kind Heartbeat` — or `no changes`. With `--json` the raw `PlanResult` is printed. The CLI has **no** `--selector` option for selective pruning; use the HTTP API for that. Permissions: `config:write` for apply, `objects:read` for dry-run and export. Full reference: [np CLI](/docs/reference/cli-np/).
## Admin → Config bundles tab
[Section titled “Admin → Config bundles tab”](#admin--config-bundles-tab)
**Admin → Config bundles (Config-Bundles)** offers the two operations without a CLI:

* **Export** — a download link for `northplane-bundle.yaml` (`GET /api/v1/config/bundles:export`): the complete configuration for backup, GitOps or migration.
* **Plan & Apply** — paste a bundle, click **Plan (dry run)**, review the table of actions (badges `create`/`update`/`delete`, kind, name, diff), then **Apply** — which sends the `applyToken` from the plan, so exactly the reviewed plan is executed (two-phase token, valid 10 minutes). “No changes — configuration is identical” means the bundle matches. The tab does not expose `prune`.
## A complete example
[Section titled “A complete example”](#a-complete-example)
bundle.yaml
```yaml
kind: TimePeriod
metadata: {name: business-hours}
spec:
days:
monday: ["09:00-17:00"]
tuesday: ["09:00-17:00"]
wednesday: ["09:00-17:00"]
thursday: ["09:00-17:00"]
friday: ["09:00-17:00"]
---
kind: Template
metadata: {name: linux-base}
spec:
kind: host
interval: 30s
maxCheckAttempts: 2
---
kind: Contact
metadata: {name: ops-alice}
spec:
email: alice@example.org
phone: "+431234567"
timeZone: Europe/Vienna
preferences:
- {profile: default, channels: [email]}
---
kind: ContactGroup
metadata: {name: ops}
spec:
members: [""]
---
kind: Channel
metadata: {name: ops-mail}
spec:
type: email
enabled: true
config:
provider: smtp
host: mail.internal
port: "587"
from: northplane@example.org
username: northplane
password: "$SECRET:smtp-pass$"
---
kind: EscalationPolicy
metadata: {name: default}
spec:
steps:
- {after: 0s, notify: {contactGroup: ops}, channels: [email]}
- {after: 15m, unlessAcked: true, notify: {contact: ops-alice}, channels: [sms]}
---
kind: AlertRule
metadata: {name: critical}
spec:
match: 'event.type == "state_change" && event.stateType == "hard" && (event.state == "CRITICAL" || event.state == "DOWN")'
severity: critical
escalationPolicy: default
---
kind: Host
metadata:
name: db-01
folder: /prod
labels: {env: prod, role: db}
spec:
address: 10.0.0.5
checkCommand: builtin:icmp
templates: [linux-base]
---
kind: Service
metadata:
name: postgres
host: db-01
labels: {env: prod}
spec:
checkCommand: builtin:tcp
args: ["5432"]
interval: 30s
contactGroups: [ops]
---
kind: Dashboard
metadata: {name: wallboard}
spec:
shared: true
data:
spec:
time: 24h
refresh: 30s
widgets:
- {type: counters, w: 12, h: 2}
- {type: problems, title: Open problems, selector: "env=prod", limit: 20, w: 12, h: 6}
```
Apply it, then verify:
```bash
np apply -f bundle.yaml --dry-run
np apply -f bundle.yaml
np apply -f bundle.yaml # → no changes
```
The channel’s `$SECRET:smtp-pass$` reference requires `PUT /api/v1/secrets/smtp-pass` beforehand ([Secrets](/docs/administration/secrets/)); channel config keys per type are in [Channels](/docs/alarming/channels/).
## GitOps workflow
[Section titled “GitOps workflow”](#gitops-workflow)
1. `np export > bundle.yaml` once to capture the current state (includes the demo data if the instance was seeded — remove what you do not want to manage).
2. Commit; edit in pull requests.
3. CI: `np apply -f bundle.yaml --dry-run` on pull requests (token with `objects:read`), `np apply -f bundle.yaml` on merge (token with `config:write`). Add `--prune` only when the repository is the complete source of truth for the tenant — prune deletes every exported document not in the bundle, including dashboards and reports users created in the UI.
4. Use a tenant-scoped token and the `X-Northplane-Tenant` header (or one token per tenant) for multi-tenant set-ups — a bundle is always applied into one tenant.
Export pages through the full inventory (5 000 objects / 2 000 resource documents per page) — exports are complete regardless of tenant size.
## Federation
[Section titled “Federation”](#federation)
A Site document’s `bundle` field **is** a bundle: the main instance validates it on save, and each edge pulls it (`GET /api/v1/sites/{name}:pull`, conditional on the ETag) and applies it into its own default tenant with the same applier — without prune, retrying every tick until a revision applies. An empty bundle means “nothing managed centrally yet”. See [Federation](/docs/concepts/federation/) and [Tenants and sites](/docs/administration/tenants-and-sites/).
## Other consumers of the format
[Section titled “Other consumers of the format”](#other-consumers-of-the-format)
* `northplaned import nagios --path /etc/nagios [--out northplane-import.yaml]` converts a Nagios/Icinga 1 configuration into a bundle (hosts, services, templates, commands, time periods, contacts, static groups) plus a deviation report; review, then `np apply -f northplane-import.yaml`. See [Plugins and Nagios](/docs/monitoring/plugins-and-nagios/).
* The AI tools `propose_config_change` / `apply_config_change` plan and apply bundles through the approval flow. See [Agent chat](/docs/ai/agent-chat/).
# Configuration reference
> Every config.yaml key, environment variable, default, validation rule and hard-coded constant of the northplaned server.
`northplaned` reads one small YAML file plus `NORTHPLANE_*` environment variables. The file is deliberately minimal: it holds only what must exist **before the API is reachable** (listen address, storage, TLS, identity providers, the secret-store key). Everything else — hosts, checks, channels, rules, users, tokens, secrets, dashboards — is managed through the API, the UI or [config bundles](/docs/administration/config-bundles/) and never lives in `config.yaml`.
This page is the complete reference. For task-oriented guides see [TLS and reverse proxies](/docs/administration/tls-and-proxy/), [Storage](/docs/administration/storage/), [Authentication](/docs/administration/authentication/) and [Secrets](/docs/administration/secrets/).
## Load order and precedence
[Section titled “Load order and precedence”](#load-order-and-precedence)
On every start (and for every subcommand that opens the store) `northplaned` builds its configuration in this order:
1. **Built-in defaults** (listed per key below).
2. **The config file** named by `-config ` (default: see [File locations](#file-locations)). The file is decoded strictly: **unknown keys are a hard error** (`config : … field not found`). An empty or comment-only file keeps the defaults; a missing file is fine (environment and defaults only); any other read error aborts.
3. **Environment overrides** `NORTHPLANE_*` — applied after the file, so they always win.
4. **Plugin directory auto-detection** if `pluginsDir` is still empty: the first existing directory among `/usr/lib/nagios/plugins`, `/usr/lib64/nagios/plugins`, `/usr/local/libexec/nagios`, `/opt/homebrew/libexec`, `/plugins`; when none exists, `/plugins` is used anyway.
5. **Validation** — an incoherent configuration refuses to start with `northplaned: config: config invalid: ` (exit code 1). See [Validation errors](#validation-errors).
**Precedence, highest first: environment variable → config file → built-in default.**
Things to know about the environment layer:
* There are **no CLI flags for individual keys**. The only config-related flag is `-config `; `serve --demo`, `--demo-snmp` and `--demo-traps` are behaviour flags, not config overrides.
* Variables are read with “is set” semantics: a variable that is set to an **empty string** overrides the file value with an empty value.
* Boolean variables (`NORTHPLANE_TLS_INSECURE`, `NORTHPLANE_TRUST_PROXY`, `NORTHPLANE_DEMO`, `NORTHPLANE_ALLOW_SIGNUP`) are parsed like Go’s `strconv.ParseBool` (`1`, `t`, `true`, `0`, `f`, `false`, any case); an unparsable value silently becomes `false`.
* `NORTHPLANE_EXEC_POOL_SIZE` must be an integer; a non-numeric value is ignored.
* Only a subset of keys has an environment equivalent (see [Environment variables](#environment-variables)); the rest are file-only.
* There is **no reload on SIGHUP**. Any configuration change requires a restart of `northplaned`.
## File locations
[Section titled “File locations”](#file-locations)
| What | Running as root (euid 0) | Running as a normal user |
| --------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| Config directory (`northplaned init --dir` default) | `/etc/northplane` | `os.UserConfigDir()/northplane` — Linux `~/.config/northplane`, macOS `~/Library/Application Support/northplane` |
| Config file (`-config` default) | `/etc/northplane/config.yaml` | `/etc/northplane/config.yaml` **if that file exists**, otherwise `/config.yaml` |
| Agent config (`np-agent`) | `/etc/northplane/agent.yaml` | same rule, `agent.yaml` |
| `secret.key` written by `init` | `/etc/northplane/secret.key` | `/secret.key` |
`-config` accepts any path; the default shown above applies to every subcommand (`serve`, `migrate`, `backup`, `mcp`, `bootstrap-admin`, `storage migrate`). `northplaned init` does not take `-config` — it takes `--dir` and `--data` instead (see [northplaned CLI](/docs/reference/cli-northplaned/)).
## Data directory defaults
[Section titled “Data directory defaults”](#data-directory-defaults)
`dataDir` is resolved at start-up when not set in the file or via `NORTHPLANE_DATA_DIR`:
| Situation | Default `dataDir` |
| ---------------------------------------------------------- | -------------------------------------------------------------------- |
| running as root | `/var/lib/northplane` |
| non-root, Linux (any non-macOS OS), `$XDG_DATA_HOME` set | `$XDG_DATA_HOME/northplane` |
| non-root, Linux (any non-macOS OS), `$XDG_DATA_HOME` unset | `~/.local/share/northplane` |
| non-root, macOS | `~/Library/Application Support/northplane` |
| everything above failed | `/var/lib/northplane` |
| Docker image | `/var/lib/northplane` (`ENV NORTHPLANE_DATA_DIR`, declared `VOLUME`) |
Paths derived from `dataDir` (not configurable individually): `/core.db` (SQLite core database), `/events-YYYYMM.db` (monthly event segments, SQLite mode), `/tsdb/` (NP-TSDB), `/artifacts/`, `/plugins/` (plugin fallback candidate) and `/secret.key` (fallback master key location). The full layout is documented in [Storage](/docs/administration/storage/#data-directory-layout).
## Complete key reference
[Section titled “Complete key reference”](#complete-key-reference)
Types: `string`, `bool`, `int`, `int64`, `[]string`, `duration` (a Go duration string such as `60s`, `15m`, `1h`). Keys are written in YAML nesting; `storage.dsn` means:
```yaml
storage:
dsn: ""
```
### Top level
[Section titled “Top level”](#top-level)
| Key | Type | Default | Env | Notes |
| ----------------- | --------- | ----------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `listen` | string | `127.0.0.1:8443` | `NORTHPLANE_LISTEN` | Bind address, `host:port`. Loopback on purpose: exposing the server requires an explicit listen **and** TLS decision ([TLS and reverse proxies](/docs/administration/tls-and-proxy/)). `:8443` (all interfaces), `[::1]:8443`, named ports (`:https`) and port `0` (kernel-assigned) are accepted. |
| `baseUrl` | string | `""` | `NORTHPLANE_BASE_URL` | External URL of the instance, without trailing slash. Used for OIDC redirect (`baseUrl + /auth/callback`), links in notifications and ack links, the Web Push VAPID subject, Twilio signature verification, the first-run `/setup` hint, and AI/MCP. Required for SSO and for correct links behind a proxy. |
| `dataDir` | string | platform default (see above) | `NORTHPLANE_DATA_DIR` | Root of all persistent state. |
| `trustProxy` | bool | `false` | `NORTHPLANE_TRUST_PROXY` | Honour `X-Forwarded-Proto` (first comma-separated value, case-insensitive `https`) from a TLS-terminating reverse proxy: sets `Secure` cookies and HSTS, and allows a plaintext listener on a non-loopback address. `X-Forwarded-For` is **not** used. Enable only when the proxy is trusted and strips inbound forwarded headers. |
| `deadManUrl` | string | `""` (disabled) | `NORTHPLANE_DEADMAN_URL` | Outgoing dead-man heartbeat: the server issues `GET` to this URL every `deadManInterval` (healthchecks.io-compatible). Skipped while the results queue is saturated, so a stalled pipeline stops the pings. See [Observability](/docs/administration/observability/#dead-man-switch). |
| `deadManInterval` | duration | `1m` | — | `<= 0` is treated as `1m` at runtime. |
| `pluginsDir` | string | auto-detected | `NORTHPLANE_PLUGINS_DIR` | Root directory of Nagios-compatible plugins for `exec:` check commands; relative plugin names are resolved under it. See [Plugins and Nagios](/docs/monitoring/plugins-and-nagios/). |
| `pluginsAllow` | \[]string | `nil` (no allowlist) | — | Optional allowlist of plugin **basenames** (`check_http`, not a path). When set, any `exec:` plugin whose basename is not listed is refused (permission error), including absolute paths. Paths containing `..` are always refused. |
| `execPoolSize` | int | `0` → `min(256, 32 × NumCPU)` | `NORTHPLANE_EXEC_POOL_SIZE` | Maximum concurrently running external plugin processes. Builtin checks use a separate pool of 1024. |
| `logLevel` | string | `info` | `NORTHPLANE_LOG_LEVEL` | `debug`, `info`, `warn`, `error`; anything else falls back to `info`. |
| `logFormat` | string | `json` | `NORTHPLANE_LOG_FORMAT` | `json` (slog JSON handler) or `text`; anything else means `json`. Logs always go to **stderr**. `northplaned mcp` forces `text`. |
| `secretKeyFile` | string | `""` | `NORTHPLANE_SECRET_KEY_FILE` | Path of the 32-byte master key (64 hex characters) for AES-256-GCM secrets at rest. Self-provisioning: generated if the file does not exist; if the path is unusable the server falls back to `/secret.key` with a loud warning. See [Secrets](/docs/administration/secrets/). |
| `demo` | bool | `false` | `NORTHPLANE_DEMO` | Seed the idempotent showcase environment at start-up (same data as `serve --demo`). Guarded: never seeds on top of a database that already holds real (non-demo) hosts. See [Demo mode](/docs/getting-started/demo-mode/). |
| `allowSignup` | bool | `false` | `NORTHPLANE_ALLOW_SIGNUP` | Expose the public `/register` page. Self-registered accounts always get the `viewer` role only. See [Authentication](/docs/administration/authentication/). |
| `storage` | section | | | see [storage](#storage) |
| `tls` | section | | | see [tls](#tls) |
| `oidc` | section | | | see [oidc](#oidc) |
| `ldap` | section | | | see [ldap](#ldap) |
| `ai` | section | | | see [ai](#ai) |
| `backup` | section | | | see [backup](#backup) |
| `federation` | section | | | see [federation](#federation) |
### storage
[Section titled “storage”](#storage)
| Key | Type | Default | Env | Notes |
| ------------------------------ | ------ | ------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `storage.dsn` | string | `""` | `NORTHPLANE_STORAGE_DSN` | Empty ⇒ embedded SQLite at `/core.db`. `postgres://…` or `postgresql://…` ⇒ PostgreSQL (pgx). Any other non-empty value is treated as a **SQLite file path**. |
| `storage.eventRetentionMonths` | int | `12` | — | Months of event segments (SQLite files) or partitions (PostgreSQL) to keep; `0` keeps everything. Enforced nightly by the janitor. |
Connection pools, pragmas and partitioning are described in [Storage](/docs/administration/storage/).
### tls
[Section titled “tls”](#tls)
| Key | Type | Default | Env | Notes |
| -------------- | ------ | ------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `tls.certFile` | string | `""` | `NORTHPLANE_TLS_CERT_FILE` | PEM certificate chain. Must be set together with `tls.keyFile`. |
| `tls.keyFile` | string | `""` | `NORTHPLANE_TLS_KEY_FILE` | PEM private key. |
| `tls.insecure` | bool | `false` | `NORTHPLANE_TLS_INSECURE` | Allow plaintext HTTP on a non-loopback listener (development only). Without it, plaintext is only allowed on loopback or with `trustProxy`. |
There is **no ACME/autocert option**; for public certificates put Caddy (or another terminating proxy) in front — see [TLS and reverse proxies](/docs/administration/tls-and-proxy/). Certificates are loaded once at start (no hot reload).
### oidc
[Section titled “oidc”](#oidc)
| Key | Type | Default | Env | Notes |
| ------------------- | --------- | ---------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `oidc.issuer` | string | `""` (SSO off) | `NORTHPLANE_OIDC_ISSUER` | OIDC discovery issuer URL. SSO is only constructed when set; a discovery failure at boot logs a warning and disables SSO (the server still starts). |
| `oidc.clientId` | string | `""` | `NORTHPLANE_OIDC_CLIENT_ID` | Required as soon as any `oidc.*` key is set. |
| `oidc.clientSecret` | string | `""` | `NORTHPLANE_OIDC_CLIENT_SECRET` | |
| `oidc.scopes` | \[]string | `[openid, profile, email, groups]` | — | Scopes requested; if explicitly emptied the code falls back to `openid profile email`. |
| `oidc.groupsClaim` | string | `groups` | — | ID-token claim read for the group → role mapping. |
| `oidc.adminGroup` | string | `""` | — | Group value whose members additionally receive the `admin` role. |
The flow (Authorization Code + PKCE), cookie names, role mapping and caveats are documented in [Authentication](/docs/administration/authentication/). `baseUrl` must be set: the redirect URL is `baseUrl + /auth/callback`.
### ldap
[Section titled “ldap”](#ldap)
| Key | Type | Default | Env | Notes |
| ------------------------- | --------- | --------------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------- |
| `ldap.url` | string | `""` (LDAP off) | `NORTHPLANE_LDAP_URL` | `ldap://host:389` or `ldaps://host:636`; must start with one of those prefixes. |
| `ldap.startTls` | bool | `false` | — | Upgrade an `ldap://` connection with StartTLS before any bind. |
| `ldap.insecureSkipVerify` | bool | `false` | — | Skip TLS certificate verification (TLS 1.2 minimum is always enforced). |
| `ldap.bindDn` | string | `""` | `NORTHPLANE_LDAP_BIND_DN` | Service account for sync/search. If set, `bindPassword` is required. |
| `ldap.bindPassword` | string | `""` | `NORTHPLANE_LDAP_BIND_PASSWORD` | |
| `ldap.baseDn` | string | `""` | `NORTHPLANE_LDAP_BASE_DN` | Search base; required when the block is used. |
| `ldap.userFilter` | string | `(&(objectClass=person)(mail=*))` | — | User search filter. |
| `ldap.userAttr` | string | `mail` | — | Login / e-mail attribute (Active Directory: `userPrincipalName`). |
| `ldap.nameAttr` | string | `cn` | — | Display-name attribute; empty falls back to the e-mail. |
| `ldap.idAttr` | string | `""` (= DN) | — | Stable identifier attribute (`entryUUID`, `objectGUID`); binary values are hex-encoded. |
| `ldap.groupAttr` | string | `memberOf` | — | Membership attribute read from the user entry. |
| `ldap.groupFilter` | string | `""` | — | Optional member search; `{dn}` and `{user}` placeholders are substituted (escaped). |
| `ldap.groupBaseDn` | string | `""` (= `baseDn`) | — | Base for the `groupFilter` search. |
| `ldap.syncInterval` | duration | `15m` | — | Values `<= 0` also mean `15m`. |
| `ldap.defaultRoles` | \[]string | `[viewer]` | — | Roles given when no group maps to a role. |
| `ldap.adminGroup` | string | `""` | — | Group DN or CN (compared lower-cased) mapped to `admin`. |
| `ldap.disableMissing` | bool | `true` | — | Disable directory users that disappeared from the directory. Local accounts are never touched. |
Sync behaviour, login verification and the directory endpoints are in [Authentication](/docs/administration/authentication/).
### ai
[Section titled “ai”](#ai)
| Key | Type | Default | Env | Notes |
| ----------------------------- | --------- | ------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ai.provider` | string | `none` | `NORTHPLANE_AI_PROVIDER` | One of `none` (or empty), `anthropic`, `azure-openai`, `openai-compat`. Anything else fails validation. |
| `ai.endpoint` | string | `""` | `NORTHPLANE_AI_ENDPOINT` | Provider endpoint; `anthropic` defaults to `https://api.anthropic.com`. |
| `ai.apiKeyEnv` | string | `""` | `NORTHPLANE_AI_API_KEY_ENV` | The **name** of the environment variable that holds the API key (the key itself never goes into the file). Read when the provider is constructed. |
| `ai.apiKey` | string | `""` | — | Static key — discouraged; intended for gateways with static keys. |
| `ai.model` | string | `claude-sonnet-4-6` | `NORTHPLANE_AI_MODEL` | Default model; `openai-compat` defaults to `gpt-4o`. |
| `ai.modelDeep` | string | `""` (= `model`) | — | Model for deeper analysis tasks. |
| `ai.maxMonthlyTokens` | int64 | `0` (unlimited) | — | Monthly token budget. |
| `ai.redaction.hostnames` | string | `""` | — | `""` or `pseudonymize`. |
| `ai.redaction.customPatterns` | \[]string | `nil` | — | Additional redaction patterns. |
Provider connections can also be created at runtime in **Admin → AI providers**; see [Agent chat](/docs/ai/agent-chat/).
### backup
[Section titled “backup”](#backup)
| Key | Type | Default | Env | Notes |
| ----------------- | -------- | --------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `backup.target` | string | `""` (disabled) | `NORTHPLANE_BACKUP_TARGET` | Directory that receives `northplane-/` snapshots written by `northplaned backup`. Only directories are implemented (the code comment mentions `s3://` as a future option). |
| `backup.interval` | duration | `5m` | — | **Parsed but unused.** There is no periodic backup loop in the server; backups run only when you call `northplaned backup`. Treat this key as reserved. |
See [Storage → Backup](/docs/administration/storage/#backup).
### federation
[Section titled “federation”](#federation)
| Key | Type | Default | Env | Notes |
| ------------------------------- | --------------- | ----------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `federation.mode` | string | `""` (standalone) | `NORTHPLANE_FEDERATION_MODE` | Only `""` or `edge`. There is no `main` value — a main instance is simply a standalone instance that holds Site documents and mints `sites:connect` tokens. |
| `federation.mainUrl` | string | `""` | `NORTHPLANE_FEDERATION_MAIN_URL` | URL of the main instance; must start with `http://` or `https://` in edge mode. |
| `federation.token` | string | `""` | `NORTHPLANE_FEDERATION_TOKEN` | API token (`np_…`) minted on the main instance with scope `sites:connect`. Required in edge mode. |
| `federation.site` | string | `""` | `NORTHPLANE_FEDERATION_SITE` | Name of the Site registered on the main instance. Required in edge mode. |
| `federation.interval` | duration | `1m` | — | Pull/heartbeat tick; `<= 0` means `1m`. |
| `federation.insecureSkipVerify` | bool | `false` | — | Skip TLS verification towards the main instance. |
| `federation.applyConfig` | bool (nullable) | unset ⇒ `true` | — | Pull the site bundle from main and apply it locally. `false` gives a heartbeat-only edge. |
The edge loop, what flows in which direction and the Site resource are described in [Federation](/docs/concepts/federation/) and [Tenants and sites](/docs/administration/tenants-and-sites/).
## Environment variables
[Section titled “Environment variables”](#environment-variables)
Every variable that `northplaned` maps onto a config key (naming scheme: `NORTHPLANE_` + section + `_` + field in SCREAMING\_SNAKE\_CASE of the YAML key):
| Variable | Key | Type |
| -------------------------------- | -------------------- | ------ |
| `NORTHPLANE_LISTEN` | `listen` | string |
| `NORTHPLANE_BASE_URL` | `baseUrl` | string |
| `NORTHPLANE_DATA_DIR` | `dataDir` | string |
| `NORTHPLANE_STORAGE_DSN` | `storage.dsn` | string |
| `NORTHPLANE_TLS_CERT_FILE` | `tls.certFile` | string |
| `NORTHPLANE_TLS_KEY_FILE` | `tls.keyFile` | string |
| `NORTHPLANE_TLS_INSECURE` | `tls.insecure` | bool |
| `NORTHPLANE_TRUST_PROXY` | `trustProxy` | bool |
| `NORTHPLANE_DEMO` | `demo` | bool |
| `NORTHPLANE_ALLOW_SIGNUP` | `allowSignup` | bool |
| `NORTHPLANE_OIDC_ISSUER` | `oidc.issuer` | string |
| `NORTHPLANE_OIDC_CLIENT_ID` | `oidc.clientId` | string |
| `NORTHPLANE_OIDC_CLIENT_SECRET` | `oidc.clientSecret` | string |
| `NORTHPLANE_LDAP_URL` | `ldap.url` | string |
| `NORTHPLANE_LDAP_BIND_DN` | `ldap.bindDn` | string |
| `NORTHPLANE_LDAP_BIND_PASSWORD` | `ldap.bindPassword` | string |
| `NORTHPLANE_LDAP_BASE_DN` | `ldap.baseDn` | string |
| `NORTHPLANE_FEDERATION_MODE` | `federation.mode` | string |
| `NORTHPLANE_FEDERATION_MAIN_URL` | `federation.mainUrl` | string |
| `NORTHPLANE_FEDERATION_TOKEN` | `federation.token` | string |
| `NORTHPLANE_FEDERATION_SITE` | `federation.site` | string |
| `NORTHPLANE_AI_PROVIDER` | `ai.provider` | string |
| `NORTHPLANE_AI_ENDPOINT` | `ai.endpoint` | string |
| `NORTHPLANE_AI_MODEL` | `ai.model` | string |
| `NORTHPLANE_AI_API_KEY_ENV` | `ai.apiKeyEnv` | string |
| `NORTHPLANE_PLUGINS_DIR` | `pluginsDir` | string |
| `NORTHPLANE_LOG_LEVEL` | `logLevel` | string |
| `NORTHPLANE_LOG_FORMAT` | `logFormat` | string |
| `NORTHPLANE_SECRET_KEY_FILE` | `secretKeyFile` | string |
| `NORTHPLANE_BACKUP_TARGET` | `backup.target` | string |
| `NORTHPLANE_DEADMAN_URL` | `deadManUrl` | string |
| `NORTHPLANE_EXEC_POOL_SIZE` | `execPoolSize` | int |
**File-only keys** (no environment equivalent): `deadManInterval`, `pluginsAllow`, `storage.eventRetentionMonths`, `oidc.scopes`, `oidc.groupsClaim`, `oidc.adminGroup`, all `ldap.*` keys except `url`/`bindDn`/`bindPassword`/`baseDn`, `ai.apiKey`, `ai.modelDeep`, `ai.maxMonthlyTokens`, `ai.redaction.*`, `backup.interval`, `federation.interval`, `federation.insecureSkipVerify`, `federation.applyConfig`.
### Non-config environment variables
[Section titled “Non-config environment variables”](#non-config-environment-variables)
These are read by the binaries directly and do not correspond to a config key:
| Variable | Read by | Effect |
| ---------------------------------------------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `NP_DEFAULT_ADMIN_DISABLED` | `northplaned serve` | Any non-empty value skips the break-glass admin seeding at start-up. |
| `NP_DEFAULT_ADMIN_EMAIL` | `northplaned serve` | E-mail of the seeded admin; default `admin@localhost`. |
| `NP_DEFAULT_ADMIN_NAME` | `northplaned serve` | Display name of the seeded admin; default `Administrator`. |
| `NP_DEFAULT_ADMIN_PASSWORD` | `northplaned serve` | Password of the seeded admin. **Set but empty = opt out of seeding**; unset = a random 32-hex-character password is generated and logged once at WARN level. |
| `NORTHPLANE_TOKEN` | `northplaned mcp` | The `np_…` API token that authenticates the stdio MCP session (required). Also read by `np-agent` to override `token` in `agent.yaml`. |
| `XDG_DATA_HOME` | `northplaned` | Data-directory resolution for non-root users on Linux. |
| the variable named in `ai.apiKeyEnv` | `northplaned` | Holds the AI provider API key. |
| `NP_SERVER`, `NP_TOKEN` | `np` CLI | Server URL (default `https://localhost:8443`) and token; see [np CLI](/docs/reference/cli-np/). |
| `NP_DEV_DIR`, `NP_DEV_LISTEN`, `NP_DEV_WEB_PORT`, `NP_DEV_DEMO`, `NP_DEV_POLL`, `NP_API` | `scripts/dev.sh` (`make dev`) | Development workflow knobs; see [Development setup](/docs/development/setup/). |
| `NORTHPLANE_TEST_PG_DSN` | Go test suite | Runs the storage tests against PostgreSQL (CI only). |
The default admin closes /setup
`serve` runs the default-admin seeding on **every** start: unless `NP_DEFAULT_ADMIN_DISABLED` is set (or `NP_DEFAULT_ADMIN_PASSWORD` is set to an empty string), and as long as no enabled local admin exists, it creates a local admin account before the HTTP listener opens. Because a local user then exists, the interactive first-run page `/setup` is closed on a default install. Set `NP_DEFAULT_ADMIN_DISABLED=1` if you want to create the first admin through `/setup`. Details: [Authentication](/docs/administration/authentication/).
## Validation errors
[Section titled “Validation errors”](#validation-errors)
`Load` validates the merged configuration (after environment overrides). Each of these conditions refuses to start with `northplaned: config: config invalid: `:
| Condition | Message |
| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `listen` empty | `listen: must be set (host:port, e.g. "127.0.0.1:8443")` |
| `listen` not `host:port` | `listen "": not a valid host:port: …` |
| `listen` has an empty port (`127.0.0.1:`) | `listen "": missing port` |
| port is neither numeric nor a known service name (port `0` is exempt) | `listen "": invalid port: …` |
| `tls.certFile` without `tls.keyFile` | `tls.certFile set without tls.keyFile` |
| `tls.keyFile` without `tls.certFile` | `tls.keyFile set without tls.certFile` |
| any of `oidc.issuer`, `clientId`, `clientSecret`, `adminGroup` set but `issuer` empty | `oidc configured but oidc.issuer is empty` |
| OIDC block used but `clientId` empty | `oidc configured but oidc.clientId is empty` |
| `ai.provider` not one of `none`, `anthropic`, `azure-openai`, `openai-compat` | `ai.provider "": must be one of none\|anthropic\|azure-openai\|openai-compat` |
| `ldap.url`, `bindDn` or `baseDn` set but `url` empty | `ldap configured but ldap.url is empty` |
| `ldap.url` without `ldap://` or `ldaps://` | `ldap.url "": must start with ldap:// or ldaps://` |
| LDAP block used but `baseDn` empty | `ldap configured but ldap.baseDn is empty` |
| `ldap.bindDn` set but `bindPassword` empty | `ldap.bindDn set without ldap.bindPassword (set it or NORTHPLANE_LDAP_BIND_PASSWORD)` |
| `federation.mode` not `""` or `edge` | `federation.mode "": must be empty or "edge"` |
| edge mode, `mainUrl` not `http(s)://` | `federation.mainUrl "": must be an http(s) URL` |
| edge mode, `token` empty | `federation.mode edge requires federation.token (mint on the main instance, scope sites:connect)` |
| edge mode, `site` empty | `federation.mode edge requires federation.site (the site name registered on the main instance)` |
Validation is deliberately conservative — it never rejects the development/demo defaults. Some problems are only detected later, when `serve` starts:
* **Fatal at start-up** (`northplaned: serve: …` / `storage: …` / `tsdb: …`): plaintext on a non-loopback listener without `trustProxy` or `tls.insecure`; a certificate/key pair that cannot be loaded (never falls back to plaintext); the listen address cannot be bound; the store or the TSDB cannot be opened (including failed migrations).
* **Warning only**: an unusable `secretKeyFile` (falls back to `/secret.key`, or disables the secret store); OIDC discovery failure (SSO disabled); VAPID key generation failure (web push disabled).
## Not configurable
[Section titled “Not configurable”](#not-configurable)
Operators often look for the following knobs in `config.yaml`. They are hard-coded in this version:
| Topic | Value |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| NP-TSDB retention | raw samples 30 days, 5-minute aggregates 400 days, 1-hour aggregates 5 years; series cap 100 000 |
| Session lifetime | 12 h for local/LDAP/OIDC logins, 30 days with “remember me” |
| Login rate limit | per client IP, burst 8, refill 1 attempt per 15 s (about 4/min); applies to `POST /login`, `/setup`, `/register`; throttled responses carry `Retry-After: 30` |
| Minimum password length | 12 characters (everywhere a local password is set) |
| HTTP server timeouts | `ReadHeaderTimeout` 10 s, `ReadTimeout` 60 s, `IdleTimeout` 120 s, `MaxHeaderBytes` 1 MiB, no global write timeout |
| Per-request response deadline | 30 s (`503 request timeout`), except `/api/v1/stream`, `/api/v1/events:export`, `/api/v1/ai/chat`, `/mcp` and `/mcp/*` |
| Body limits | JSON bodies 1 MiB, bundle bodies 8 MiB, ingest bodies 1 MiB |
| Graceful shutdown budget | 30 s (workers drained, then the process exits) |
| Ingest rate limits | per event source (`rateLimit` / `burst` fields of the EventSource resource, defaults 50/s and 200) — API-managed, not config |
| Notification retry policy | per channel (`retryMaxAttempts`, `retryBackoffSeconds`, `retryBackoffCapSeconds` in the channel config) — API-managed |
| Outgoing e-mail / SMTP | a notification channel, not config — see [Channels](/docs/alarming/channels/) |
| Metrics endpoint | always on at `/metrics`, unauthenticated, no key to disable it |
| Security headers and CSP | fixed, see [TLS and reverse proxies](/docs/administration/tls-and-proxy/#security-headers) |
| Background worker restart back-off | 1 s after a panic |
| Janitor cadence | downtime depths every 30 s, cleanup every 10 min, nightly maintenance between 02:00 and 03:59 local time |
| Audit log retention | none — the audit log is never purged |
| Event API page sizes | default 200, max 1000; NDJSON export cap 100 000 rows |
| Default admin seeding | controlled by `NP_DEFAULT_ADMIN_*` environment variables only |
## Example config.yaml
[Section titled “Example config.yaml”](#example-configyaml)
`northplaned init` writes this file (shown as generated for root: `--dir /etc/northplane`, `--data /var/lib/northplane`; the `dataDir` and `secretKeyFile` values are interpolated from the flags). It parses with the strict decoder and is a good starting point for any install:
/etc/northplane/config.yaml
```yaml
# Northplane bootstrap configuration (SPEC §15.2).
# Only pre-API settings live here — everything else is managed via API,
# UI or config bundles. Environment overrides: NORTHPLANE_*.
# Loopback default. To serve the network, set listen: ":8443" AND
# configure TLS below (plaintext on non-loopback refuses to start).
listen: "127.0.0.1:8443"
#baseUrl: "https://monitoring.example.net"
dataDir: "/var/lib/northplane"
secretKeyFile: "/etc/northplane/secret.key"
storage:
# Empty dsn = embedded SQLite under dataDir (default).
# PostgreSQL server mode: "postgres://np:secret@db:5432/northplane"
dsn: ""
eventRetentionMonths: 12
tls:
certFile: ""
keyFile: ""
# insecure: true # dev only; refused on non-loopback listeners
#oidc:
# issuer: "https://login.microsoftonline.com//v2.0"
# clientId: "…"
# clientSecret: "…"
# adminGroup: ""
# Directory user sync + login (LDAP / Active Directory).
#ldap:
# url: "ldaps://dc1.example.net:636"
# bindDn: "cn=svc-northplane,ou=service,dc=example,dc=net"
# bindPassword: "…" # or NORTHPLANE_LDAP_BIND_PASSWORD
# baseDn: "dc=example,dc=net"
# userFilter: "(&(objectClass=person)(mail=*))"
# userAttr: mail # AD: userPrincipalName
# idAttr: "" # AD: objectGUID, OpenLDAP: entryUUID (stable across DN moves)
# groupAttr: memberOf
# adminGroup: "cn=northplane-admins,ou=groups,dc=example,dc=net"
# syncInterval: 15m
# defaultRoles: [viewer]
# disableMissing: true
# Connect this instance to a main instance (customer-site edge mode).
#federation:
# mode: edge
# mainUrl: "https://main.example.net"
# token: "np_…" # minted on main, scope sites:connect
# site: "customer-a"
# interval: 60s
ai:
provider: none # anthropic | azure-openai | openai-compat | none
#endpoint: "https://api.anthropic.com"
#apiKeyEnv: ANTHROPIC_API_KEY
#model: claude-sonnet-4-6
#modelDeep: claude-opus-4-8
#maxMonthlyTokens: 50000000
backup:
target: "" # directory for continuous backup; empty = disabled
interval: 5m
#deadManUrl: "https://hc-ping.com/" # SPEC §14.2 dead-man switch
```
Note
The comment `directory for continuous backup` on `backup.target` is aspirational: no continuous backup runs. Schedule `northplaned backup` yourself (see [Storage → Backup](/docs/administration/storage/#backup)). The `keyLine` is omitted when `init` is given an empty key path; `serve` then self-provisions `/secret.key`.
## Typical configurations
[Section titled “Typical configurations”](#typical-configurations)
Three minimal, complete variants. Each is valid on its own; combine with the template above as needed.
**Direct TLS on all interfaces** (the server terminates TLS itself):
config.yaml
```yaml
listen: ":8443"
baseUrl: "https://monitoring.example.net:8443"
dataDir: "/var/lib/northplane"
secretKeyFile: "/etc/northplane/secret.key"
tls:
certFile: "/etc/northplane/tls/fullchain.pem"
keyFile: "/etc/northplane/tls/privkey.pem"
```
**Behind a TLS-terminating reverse proxy** (container style, environment only — this is what the Compose stacks set):
```bash
NORTHPLANE_LISTEN=:8443
NORTHPLANE_TRUST_PROXY=true
NORTHPLANE_BASE_URL=https://monitoring.example.net
NORTHPLANE_DATA_DIR=/var/lib/northplane
NORTHPLANE_SECRET_KEY_FILE=/etc/northplane/secret.key
```
**PostgreSQL instead of SQLite** (the NP-TSDB and event segments still live under `dataDir`):
config.yaml
```yaml
storage:
dsn: "postgres://np:@db.internal:5432/northplane?sslmode=require"
eventRetentionMonths: 24
```
Restart `northplaned` after any change — there is no configuration reload.
# Observability
> Health and readiness endpoints, system health and info, the Prometheus /metrics families, logging, the audit log, the dead-man switch, the Admin → System health tab, background workers and the request timeout.
Northplane exposes its own health through a handful of HTTP endpoints, an OpenMetrics exposition, structured logs on stderr and a hash-chained audit log. This page covers all of them, plus the background workers you will see in those logs and the one request deadline that affects every API client.
## Endpoints at a glance
[Section titled “Endpoints at a glance”](#endpoints-at-a-glance)
| Endpoint | Auth | Purpose |
| -------------------------------------------------------------------------------- | ---------------- | -------------------------------------------------------------------------- |
| `GET /healthz` | none | liveness: `200` with body `ok` as soon as the listener is up |
| `GET /readyz` | none | readiness: JSON per subsystem, `503` when any is not ok |
| [`GET /api/v1/system/health`](/docs/reference/api/operations/get_system_health/) | none (anonymous) | queue depths, scheduler/pipeline/alerting/notify/TSDB counters |
| [`GET /api/v1/system/info`](/docs/reference/api/operations/get_system_info/) | none (anonymous) | version, Go version, goroutines, heap, uptime, storage dialect, AI enabled |
| `GET /metrics` | none | OpenMetrics text for Prometheus |
| `GET /api/v1/overview` | `objects:read` | the UI’s overview numbers (state summary, open alerts, incidents, queues) |
| `GET /api/docs`, `GET /api/openapi.json` | none | Swagger UI and the OpenAPI 3.1 document |
Anonymous by design
`/metrics`, `/api/v1/system/health` and `/api/v1/system/info` need no credentials so that scrapers and probes work without an API token; they reveal the version, goroutine/heap numbers and queue depths. Restrict them at the proxy or firewall if that matters to you — see [Security](/docs/administration/security/#unauthenticated-endpoints). Do not send an `Authorization: Bearer np_…` header from probes: an invalid `np_` token is answered `401` on every path served by the API handler, including `/healthz`.
## Liveness and readiness
[Section titled “Liveness and readiness”](#liveness-and-readiness)
`/healthz` performs no checks — it answers `ok` as soon as the HTTP server accepts connections, which makes it the right probe for a proxy health check (Caddy: `health_uri /healthz`) and for the CI deploy verification.
`/readyz` aggregates subsystems:
```json
{"ready":true,"subsystems":[{"name":"storage","ok":true,"info":"sqlite"},{"name":"eventbus","ok":true},{"name":"scheduler","ok":true}]}
```
| Subsystem | Criterion |
| ----------- | ---------------------------------------------------------------------------------- |
| `storage` | database ping succeeds; `info` is `sqlite` or `postgres` |
| `eventbus` | results queue depth below 8000 (a saturated pipeline makes the instance not-ready) |
| `scheduler` | always `true` in this version |
Any `false` turns the response into HTTP `503`. Use `/readyz` for orchestrator readiness gates and for `make dev`, which waits for it after each rebuild.
## System health and info
[Section titled “System health and info”](#system-health-and-info)
`GET /api/v1/system/health` returns the live counters of every subsystem:
```json
{
"queues": {"resultsDepth":0,"eventsDepth":0,"notifyDepth":0,"aiDepth":0,"subscribers":3,"droppedAi":0,"droppedSubscriberMessages":0},
"scheduler": {"scheduled":142,"queueDepth":0,"dispatched":8841,"maxLagMs":12},
"pipeline": {"processed":8830,"workingSet":142},
"alerting": {"rules":6,"pending":0,"matched":19,"opened":4},
"notify": {"sent":12,"failed":1,"dead":0,"dropped":0},
"tsdb": {"series":318,"samplesIngested":105220,"samplesDropped":0,"seriesDropped":0,"blocks":36,"walBytes":41250},
"catalog": 142,
"sse": 3
}
```
`GET /api/v1/system/info`:
```json
{"version":"main-daa6dc518a2b","goVersion":"go1.25.14","goroutines":44,"heapMB":134,"startedAt":"2026-08-23T08:50:57Z","uptime":"22m57s","storage":"sqlite","aiEnabled":false}
```
`np doctor` prints both documents (`--- system/info ---`, `--- system/health ---`) and fails with `server unreachable` when the server does not answer — a quick first check from any machine with `NP_SERVER`/`NP_TOKEN` set ([np CLI](/docs/reference/cli-np/)). The version string is also returned by `northplaned version`, in the OpenAPI `info.version`, in the MCP server implementation, in the footer of the login/setup/register pages and in every federation heartbeat (see [Upgrades](/docs/administration/upgrades/#how-versions-are-identified)).
## Prometheus metrics
[Section titled “Prometheus metrics”](#prometheus-metrics)
`GET /metrics` serves `application/openmetrics-text; version=1.0.0; charset=utf-8`, terminated by `# EOF`, from a dependency-free in-process registry. It exports **server self-metrics only** — no per-object perfdata; monitored metrics live in the NP-TSDB and are queried through `POST /api/v1/metrics/query` (see [Metrics and NP-TSDB](/docs/monitoring/metrics-and-tsdb/)).
| Family | Type | Labels | Meaning |
| ---------------------------------- | --------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `np_http_requests_total` | counter | `method`, `status`, `route` | API requests under `/api/`; `route` is the matched mux pattern (custom-verb routes appear in their internal form, e.g. `/api/v1/alerts/{__seg}`; empty for 404s) |
| `np_http_request_duration_seconds` | histogram (`_bucket`, `_sum`, `_count`) | `method`, `status`, `route` | Prometheus default buckets 0.005 … 10 s + `+Inf` |
| `np_queue_results_depth` | gauge | — | check results waiting for the pipeline |
| `np_queue_events_depth` | gauge | — | events waiting on the bus |
| `np_queue_notifications_depth` | gauge | — | notifications waiting for the outbox worker |
| `np_sse_clients` | gauge | — | connected SSE subscribers |
| `np_scheduler_objects` | gauge | — | objects currently scheduled |
| `np_scheduler_lag_ms_max` | gauge | — | worst scheduling lag in the last window |
| `np_checks_dispatched_total` | gauge (see note) | — | checks handed to the executor since start |
| `np_results_processed_total` | gauge (see note) | — | results processed by the pipeline since start |
| `np_alert_rules` | gauge | — | compiled alert rules |
| `np_alerts_opened_total` | gauge (see note) | — | alerts opened since start |
| `np_notifications_total` | gauge (see note) | `result` = `sent` \| `failed` \| `dead` | notification outcomes since start |
| `np_events_dropped_total` | gauge / counter | `source` = `notify` \| `api` | events that could not be persisted |
| `np_tsdb_series` | gauge | — | series in the NP-TSDB registry |
| `np_tsdb_samples_total` | gauge (see note) | — | samples ingested since start |
| `np_tsdb_wal_bytes` | gauge | — | size of the TSDB WAL |
| `np_catalog_objects` | gauge | — | objects in the in-memory catalog |
| `np_ingress_events_total` | counter | `type` = `webhook` \| `alertmanager` \| `sms` | accepted ingest events |
| `np_ingress_dropped_total` | counter | `reason` = `rate` | ingest requests rejected by the per-source rate limit |
Note
The `*_total` families that are collected at scrape time from subsystem statistics (`np_checks_dispatched_total`, `np_results_processed_total`, `np_alerts_opened_total`, `np_notifications_total`, `np_tsdb_samples_total`, `np_events_dropped_total{source="notify"}`) are exposed with `# TYPE … gauge` although they are monotonically increasing. `rate()` still works on them; strict OpenMetrics parsers may warn. Counters reset when the process restarts.
A minimal scrape job:
prometheus.yml
```yaml
scrape_configs:
- job_name: northplane
scheme: https
metrics_path: /metrics
static_configs:
- targets: ["monitoring.example.net:443"]
```
Useful alerts: `np_queue_results_depth` growing (pipeline stalled — the same condition that turns `/readyz` red at 8000 and pauses the dead-man ping at 7000), `rate(np_notifications_total{result="failed"}[10m])`, `np_ingress_dropped_total` increasing (a source needs a higher `rateLimit`/`burst`), `np_tsdb_series` approaching the 100 000 cap.
## Logs
[Section titled “Logs”](#logs)
* Structured logging via Go `slog`, always to **stderr**. Format `json` (default) or `text` (`logFormat` / `NORTHPLANE_LOG_FORMAT`); level `debug`/`info`/`warn`/`error` (`logLevel` / `NORTHPLANE_LOG_LEVEL`, default `info`). `northplaned mcp` forces text because stdout belongs to the MCP transport.
* There is no log file and no rotation inside Northplane: under systemd read them with `journalctl -u northplaned -f`; in containers with `docker compose logs -f northplane`.
* Lines worth knowing (message field):
| Message | Meaning |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `storage: applying migration` (`version`, `name`) | a schema migration is running at start |
| `server: generated secret-store master key` / `server: configured secretKeyFile unusable — falling back to the data directory` / `server: secret store disabled (no usable master key)` | secret-store key provisioning; see [Secrets](/docs/administration/secrets/) |
| `seeded default admin with a GENERATED password — save it now, it is not recoverable` (WARN, with `email`, `password`) / `seeded default admin — CHANGE THE PASSWORD` | break-glass admin created at start; see [Authentication](/docs/administration/authentication/) |
| `server: serving plaintext HTTP (loopback/dev or behind a TLS-terminating proxy — A-15.10 requires TLS in production)` | no certificate configured; expected behind Caddy |
| `northplane: listening` (`addr`, `scheme`, `storage`, `objects`, `ai`) | the server is up |
| `first run: open /setup to create your admin account` (WARN) | the `/setup` gate is open (only when no local user and no API token exist) |
| `federation: edge mode` | edge federation active |
| `demo: user ready`, `demo: hint`, `demo: environment seeded` | demo seeding; see [Demo mode](/docs/getting-started/demo-mode/) |
| `NORTHPLANE_DEMO is set but this database already holds real (non-demo) hosts — skipping demo seeding …` (WARN) | the real-data guard refused to seed |
| `server: background worker panicked; restarting` (`worker`, `panic`, `stack`) | a supervised worker crashed and will restart after 1 s |
| `deadman: skipping ping, results queue saturated` (WARN) | dead-man ping withheld because the pipeline is stalled |
| `janitor: event segments dropped` (`segments`) | nightly retention removed old event months |
| `northplane: shutting down` → `northplane: background workers drained` or `northplane: shutdown budget elapsed, workers still running` | graceful stop (30 s budget) |
Every API response also carries an `X-Request-Id` (UUIDv7); the same id is stored in audit entries, which lets you correlate a client-side error with the server-side record.
## Audit log
[Section titled “Audit log”](#audit-log)
Every mutation performed through the API is recorded in an append-only, hash-chained audit log — object and config document changes (`host.create`, `service.update`, `template.delete`, `alert-rule.create`, …), alert operations (`alert.ack`, `alert.resolve`, `alert.snooze`, `alert.raise`), maintenance (`downtime.create`, `silence.delete`), administration (`user.create`, `token.create`, `token.rotate`, `secret.put` — without the value, `role.update`, `tenant.create`, `bundle.apply`, `branding.update`), logins (`login.local`, `login.ldap`, `setup.admin`, `user.register`), federation (`federation.apply`) and all AI actions (`ai.*`). Reads are not audited; neither are OIDC logins, failed logins or logouts.
Each entry: `seq`, `ts` (RFC 3339 UTC), `tenantId`, `actorType` (`user` | `token` | `ai_agent` | `system`), `actorId`, `action`, `resource`, `sourceIp` (TCP peer — the proxy’s address behind a reverse proxy), `requestId`, `before`, `after` (JSON snapshots), `prevHash`, `hash`. The hash is SHA-256 over the previous hash and the fields in fixed order; the genesis `prevHash` is 64 zeros. Verification re-walks the whole table and reports the first break.
| Operation | How |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Browse | **Admin → Audit log (Audit-Log)**; [`GET /api/v1/audit`](/docs/reference/api/operations/get_audit/) (`admin:audit`) with filters `actorId`, `actorType`, `action` (prefix), `resource`, `limit` (default 200, max 5000), `afterSeq` (newest first) |
| Export to a SIEM | [`GET /api/v1/audit:export`](/docs/reference/api/operations/get_audit_export/) — NDJSON, ascending, the whole tenant; UI link “NDJSON (SIEM)” |
| Verify the chain | [`POST /api/v1/audit:verify`](/docs/reference/api/operations/post_audit_verify/) → `{"intact":true,"verified":N}` or `{"intact":false,"verified":N,"error":"audit chain broken at seq N: …"}`; CLI `np audit verify` prints `audit chain intact (N entries verified)` or exits non-zero with `AUDIT CHAIN BROKEN after N entries: …` |
| Tail | `np audit tail` — the last 30 entries as a table |
Things to be aware of: the audit log is **never purged** (plan disk accordingly or export and truncate manually); `audit:export` is **not** on the list of streaming paths, so a very large export can hit the 30 s request deadline — page with `GET /api/v1/audit?afterSeq=…` in that case; on PostgreSQL the `jsonb` normalisation can make verification report a false break (see [Storage](/docs/administration/storage/#known-postgresql-caveat)). Verification walks **all** tenants; search and export are per tenant.
## Dead-man switch
[Section titled “Dead-man switch”](#dead-man-switch)
Northplane can prove that it is alive to an external monitor: set `deadManUrl` (env `NORTHPLANE_DEADMAN_URL`) to a healthchecks.io-compatible ping URL and the `dead-man` worker issues a `GET` every `deadManInterval` (default `1m`, values `<= 0` mean `1m`) with a 10 s client timeout. The ping is **skipped** (with the warning above) while the results queue holds more than 7000 items, so a stalled check pipeline makes the external monitor fire even though the process is running.
config.yaml
```yaml
deadManUrl: "https://hc-ping.com/"
deadManInterval: 1m
```
Do not confuse this outgoing ping with the **Heartbeat resource**, which is the inbound dead-man input for your own cron jobs and integrations (`POST /api/v1/heartbeats/{name}/beat`, `heartbeat_missed` events). That one is documented in [Heartbeats](/docs/monitoring/heartbeats/).
## Admin → System health tab
[Section titled “Admin → System health tab”](#admin--system-health-tab)
**Admin → System health (System-Health)** shows two cards: the raw JSON of `system/info` (version, runtime) and `system/health` (refreshed every 10 s), with an **OpenMetrics** link that opens `/metrics`. It is the place to look when the Overview feels stale: growing `queues.resultsDepth` or `scheduler.maxLagMs`, `notify.failed`/`dead` counters, `tsdb.seriesDropped`. Dead letters themselves live in **Admin → Dead letters (Dead-Letters)** ([Reliability](/docs/alarming/reliability/)).

## Background workers and supervision
[Section titled “Background workers and supervision”](#background-workers-and-supervision)
`serve` runs every subsystem as a supervised goroutine: a panic is recovered, logged with its stack, and the worker restarts after 1 s (`workerRestartBackoff`), so one misbehaving integration cannot take the process down. Worker names as they appear in logs:
| Worker | Role |
| --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `scheduler`, `executor`, `pipeline` | schedule checks, run them, turn results into state and events |
| `alerting`, `correlator`, `escalation`, `notify` | rules → alerts, incident correlation, escalation timers, outbox delivery |
| `traps`, `mailin`, `mqttin`, `espa`, `agi` | SNMP trap receiver, IMAP poller, MQTT subscriber, ESPA/ESPA-X listeners, FastAGI listener |
| `api-janitor` | periodic maintenance (table below) |
| `webhook-dispatcher` | outgoing webhook subscriptions |
| `report-scheduler` | scheduled reports (first tick 10 s after start, then every minute) |
| `dead-man` | the outgoing ping above |
| `ldap-sync` (when LDAP is configured), `federation-edge` (edge mode), `ai` (AI service) | conditional workers |
The janitor’s schedule:
| Cadence | Work |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| every 30 s | recompute downtime depths and trigger flexible downtimes for every tenant |
| every 10 min | delete expired sessions and idempotency rows older than 24 h |
| hourly | flush closed TSDB windows; between 02:00 and 03:59 local time (at most once per 20 h) run full TSDB maintenance (downsample + retention) and event retention |
Alert auto-close (`autoCloseAfter`) and snooze wake-ups run inside the alerting engine loop; heartbeat misses are swept every 5 s.
On SIGINT/SIGTERM the HTTP server stops accepting connections and all workers get a shared 30 s budget to drain; the store and TSDB are closed afterwards (`close failed` is logged if that fails).
## The 30 s request timeout
[Section titled “The 30 s request timeout”](#the-30-s-request-timeout)
Every ordinary request is wrapped in a 30 s response deadline (`http.TimeoutHandler`): when the handler has not finished, the client receives HTTP `503` with the plain-text body `request timeout` (not a problem+json document). Exempt — and therefore unbounded — are the streaming paths `/api/v1/stream` (SSE), `/api/v1/events:export` (NDJSON), `/api/v1/ai/chat` (agent chat) and `/mcp`, `/mcp/*`. Long synchronous operations that can approach the limit on large tenants: `audit:export`, `config/bundles:export`, large `objects:batch` calls and report rendering. The other server timeouts (`ReadHeaderTimeout` 10 s, `ReadTimeout` 60 s, `IdleTimeout` 120 s) are listed in [Configuration → Not configurable](/docs/administration/configuration/#not-configurable); proxy settings that match them are in [TLS and reverse proxies](/docs/administration/tls-and-proxy/#timeouts-a-proxy-should-respect).
# Secrets
> The encrypted secret store — AES-256-GCM SecretBox, the secret.key master key and its provisioning, the $SECRET:name$ reference syntax and where it is accepted, the secrets API and Admin tab, backup and rotation advice, and what happens when the key is lost.
Northplane keeps credentials that its own configuration needs — SMTP passwords, Twilio auth tokens, ticket-system API keys, webhook HMAC secrets, agent bearer tokens — in a **write-only secret store**. Values are encrypted at rest with a master key, are never returned by the API, and are referenced from configuration by name. Do not confuse this with [API tokens](/docs/administration/api-tokens/), which are credentials *for* Northplane; secrets are credentials Northplane uses *towards* other systems.
## How the store works
[Section titled “How the store works”](#how-the-store-works)
| Property | Value |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Cipher | **AES-256-GCM**; each value is sealed as `nonce ‖ ciphertext` with a fresh random nonce |
| Master key | 32 bytes, stored **hex-encoded (64 characters)** in a key file with mode `0600` |
| Storage | Table `secrets(tenant_id, name, ciphertext, updated_by, updated_at)`, primary key `(tenant_id, name)` — secrets are **tenant-scoped** by name |
| Visibility | Write-only: `GET /api/v1/secrets` returns names only; audit entries for `secret.put` carry no value; the agent check pull does not expand references |
| Consumers | Notification channels, event sources (ingest auth, IMAP and MQTT passwords), telephony, outgoing webhooks, check-command macros, AI provider connections (see below) |
A wrong master key makes decryption fail with `secret decryption failed (wrong master key?)` — the value is then treated as missing.
## The master key (`secret.key`)
[Section titled “The master key (secret.key)”](#the-master-key-secretkey)
The key file location is `secretKeyFile` in `config.yaml` (env `NORTHPLANE_SECRET_KEY_FILE`). `northplaned init` writes `/secret.key` (as root: `/etc/northplane/secret.key`) with mode `0600` and references it from the generated `config.yaml`; its summary prints “secret key: … (0600 — back this up!)”.
At start the server resolves the key (**self-provisioning**):
1. If `secretKeyFile` is set and the file does **not exist**, a fresh key is generated there (log `server: generated secret-store master key`); then the file is loaded.
2. If the configured path is **unusable** — read-only mount, a directory left behind by a missing Docker bind-mount source, bad contents — the server logs `server: configured secretKeyFile unusable — falling back to the data directory` and uses `/secret.key` (generated if missing). A file with garbage content is never overwritten.
3. If no path is configured, `/secret.key` is used directly (generated if missing).
4. If even that fails: `server: secret store disabled (no usable master key)` — the server still starts, but `PUT /api/v1/secrets/{name}` answers `503 np:secrets/nokey` and everything that needs a sealed value (secrets, AI provider keys, MQTT credentials) is unavailable.
The file must hold exactly 64 hex characters (plus optional whitespace); anything else is `secret key file must hold 64 hex chars (32 bytes)`.
The fallback is loud for a reason
If a deployment silently falls back to `/secret.key`, values sealed afterwards are bound to *that* key. Later “fixing” the bind mount swaps the key and makes those values unreadable. Watch the start-up log for the fallback warning and keep exactly one key per data set.
**Where the key lives per deployment variant:**
| Variant | Key file |
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Binary + `northplaned init` | `/etc/northplane/secret.key` (root) or `/secret.key`; referenced by `secretKeyFile` |
| Root `docker-compose.yml` (Caddy bundle) | No explicit key: self-provisioned at `/var/lib/northplane/secret.key` inside the `northplane-data` volume |
| `deploy/docker-compose.yml` and `deploy/docker-compose.vm.yml` | `./secret.key` on the host, bind-mounted read-only at `/etc/northplane/secret.key` and set via `NORTHPLANE_SECRET_KEY_FILE`. The provisioning script creates it with `openssl rand -hex 32`, owned by uid/gid **65532** (the container user), mode `0600`. It survives image swaps and demo/real switches. |
A key you create yourself is just `openssl rand -hex 32 > secret.key && chmod 600 secret.key` (owned by the user the server runs as — uid 65532 in the container images).
## Store a secret
[Section titled “Store a secret”](#store-a-secret)
**UI:** **Admin → Secrets** shows Name and Referenz (`$SECRET:name$`) for every secret of the active tenant, with a delete action per row. **Anlegen / Create** asks for Name (e.g. `smtp-password`) and Wert (Value); the dialog warns “Wird nie wieder angezeigt / Never shown again”. There is no edit — to change a value, create it again under the same name (upsert).
**API** (all `admin:secrets`, tenant = the request’s tenant):
| Endpoint | Behaviour |
| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| [`PUT /api/v1/secrets/{name}`](/docs/reference/api/operations/put_secrets_name/) | Body `{"value":"…"}` → `204` (create or overwrite). `503 np:secrets/nokey` without a master key. Audit `secret.put` (no value). |
| [`GET /api/v1/secrets`](/docs/reference/api/operations/get_secrets/) | `["smtp-password","twilio-auth"]` — a plain JSON array of **names** |
| [`DELETE /api/v1/secrets/{name}`](/docs/reference/api/operations/delete_secrets_name/) | `204`. Audit `secret.delete`. |
```bash
curl -s -X PUT https://monitoring.example.net/api/v1/secrets/smtp-password \
-H "Authorization: Bearer np_<48 hex>" -H "Content-Type: application/json" \
-d '{"value":""}'
```
Names are free-form strings; keep them URL-safe (they are path segments) and descriptive. A name is unique per tenant — two tenants may both have `smtp-password` with different values.
## Reference a secret: `$SECRET:name$`
[Section titled “Reference a secret: $SECRET:name$”](#reference-a-secret-secretname)
Wherever a configuration field would otherwise hold a credential, write `$SECRET:name$` instead. The value is resolved at use time, in the **tenant of the document** that holds the reference, and never written back into the document or returned by the API.
| Where | How the reference is resolved |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Notification channel** config fields (`password`, `apiKey`, `secretAccessKey`, `sessionToken`, `authToken`, `apiKeySecret`, `token`, `secret`, `apiToken`, `fcmServiceAccount`, `apnsKey`, Asterisk AMI `secret`, MQTT `password`, …) | **Whole-value**: the field must be exactly `$SECRET:name$`. An unresolvable reference becomes the empty string (the delivery then fails with an auth error). The Channels dialog shows the hint “Value or `$SECRET:name$` reference”. See [Channels](/docs/alarming/channels/). |
| **Check commands** (named check commands, `exec:` plugins, `agent:exec:` commands, builtin args) | As a **macro** like `$ARG1$`: `$SECRET:name$` may appear anywhere inside an argument, e.g. `--token $SECRET:agent-token$` for the builtin `agent` check. Resolved by the executor at run time in the object’s tenant; an unresolvable reference is left **verbatim** in the argument and reported as an unknown macro by `POST /api/v1/check-commands:test`. **Not** expanded in `GET /api/v1/agent/checks` (the pulled args keep `$SECRET:…$` literally). See [Plugins and Nagios](/docs/monitoring/plugins-and-nagios/). |
| **Telephony event sources** (`twilioAuthToken` and similar config keys of `voice-inbound` / `sms-inbound`) | Whole-value, placeholder `$SECRET:twilio-auth$` in the dialog. See [Voice and IVR](/docs/alarming/voice-and-ivr/). |
| **Outgoing webhook subscriptions** (`secret`) | `$SECRET:name$` **or** a literal; resolved at delivery to compute `X-Northplane-Signature: sha256=`. See [Outgoing webhooks](/docs/alarming/webhooks-out/). |
Some resources reference secrets **by name without the `$SECRET:` wrapper** (the field is a secret *name*):
| Field | Resource |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `secretRef` | Event sources — the token / HMAC key / basic-auth password for `authMode: token`, `hmac`, `basic` on `POST /api/v1/ingest/{source}` |
| `passwordSecretRef` | IMAP (`email`/`imap`) event sources and MQTT event sources — re-read from the store on every (re)connect |
| AI provider connection keys | Sealed with the same box when you save a connection in **Admin → AI providers** ([Agent chat](/docs/ai/agent-chat/)) |
The Event sources dialog has a “secret ref” field next to the auth mode — see [Event sources](/docs/alarming/event-sources/).
SNMPv3 trap passphrases are not secret references
The Event sources dialog writes `v3AuthSecretRef` / `v3PrivSecretRef` for `snmp-trap` sources, but the trap listener only reads the inline keys `v3AuthPass` / `v3PrivPass` and never consults the secret store. Provide SNMPv3 passphrases inline (API, or the “Weitere Einstellungen” key/value editor) — see [SNMP](/docs/monitoring/snmp/).
bundle excerpt — channel and event source using secrets
```yaml
kind: Channel
metadata: {name: ops-mail}
spec:
type: email
config:
provider: smtp
host: smtp.example.net
port: "587"
username: northplane@example.net
password: $SECRET:smtp-password$
---
kind: EventSource
metadata: {name: grafana}
spec:
type: webhook
authMode: token
secretRef: grafana-ingest # name of a secret, no $SECRET:…$ wrapper
```
Secrets themselves are **not** a bundle kind: create them on every instance (main and each edge) with the API or the UI before applying a bundle that references them. A federation edge resolves references against its **own** store.
## Tenancy
[Section titled “Tenancy”](#tenancy)
Secrets are stored per tenant. A channel in tenant *A* can only resolve `$SECRET:x$` from tenant *A*’s store; a central admin creates a customer’s secrets by sending `X-Northplane-Tenant: ` with the `PUT` (see [Tenants and sites](/docs/administration/tenants-and-sites/)). The master key, however, is one per instance.
## Backup and rotation
[Section titled “Backup and rotation”](#backup-and-rotation)
* **Back up `secret.key`** together with the database. `northplaned backup` copies `core.db`, the event segments and the TSDB — **not** the key file, even when it lives in the data directory — yet the key is what makes the `secrets` table readable. The deployment scripts keep it outside the data volume (`/opt/northplane/secret.key`) precisely so that it is backed up as a separate file; see [Storage](/docs/administration/storage/) and [Operations](/docs/deployment/operations/).
* File permissions: `0600`, owned by the service user (uid 65532 in containers). Never commit it, never put it in an image.
* **There is no key-rotation command.** Rotating the master key means: generate a new key file, point `secretKeyFile` at it (or replace the file), restart, and **re-enter every secret** (`PUT` each name again) plus re-save AI provider connections. Until you do, the old values cannot be decrypted. Keep an inventory of secret names (`GET /api/v1/secrets` per tenant) before you start.
* Rotating an individual *secret value* is just another `PUT` under the same name; the next delivery picks it up.
## If the key is lost
[Section titled “If the key is lost”](#if-the-key-is-lost)
With the key gone (or replaced by a different one) every sealed value is unrecoverable: `secret decryption failed (wrong master key?)`. Symptoms are notification failures (empty password / token → provider rejects), inbound webhooks rejected (`401`, because the source’s `secretRef` resolves to nothing), agent checks with `$SECRET:…$` macros failing, and AI connections that no longer authenticate. Recovery is the rotation procedure above: provide a key, restart, re-create every secret and AI connection. Names survive (they are stored in clear), so `GET /api/v1/secrets` gives you the checklist.
## Errors
[Section titled “Errors”](#errors)
| Code | HTTP | When |
| ------------------- | ---- | --------------------------------------------------------------------- |
| `np:secrets/nokey` | 503 | `PUT /api/v1/secrets/{name}` while the store has no usable master key |
| `np:auth/forbidden` | 403 | Caller lacks `admin:secrets` |
The hardening checklist in [Security](/docs/administration/security/) covers key handling as well.
# Security
> Hardening checklist for a Northplane instance — transport, accounts and tokens, secrets, ingest authentication, browser protections, audit — plus the list of unauthenticated endpoints and the known gaps to be aware of.
Northplane ships with safe defaults — loopback listener, no plaintext on the network without an explicit decision, argon2id everywhere, encrypted secrets, a hash-chained audit log, strict CSP — but a production instance still needs a few deliberate choices. This page is the checklist; each item links to the page that explains the mechanism.
## Hardening checklist
[Section titled “Hardening checklist”](#hardening-checklist)
1. **Terminate TLS** — either a certificate pair in `tls.*` or a trusted reverse proxy with `trustProxy: true`; never `tls.insecure` outside development. Set `baseUrl` to the public `https://` URL. → [TLS and reverse proxies](/docs/administration/tls-and-proxy/)
2. **Harden host SSH by identity, not source IP** — `deploy/harden-access.sh` (key-only auth, rescue sshd on 2222, opt-in port-based firewall with auto-rollback) is described in [Provisioning](/docs/deployment/provisioning/#host-ssh-hardening-anti-lockout).
3. **Expose only 443** — keep 8443 (the listener) reachable from the proxy only; open 9162/udp, 2023, 8123 or 4573 only when you actually run the trap receiver, ESPA, ESPA-X or FastAGI listeners, and only towards the devices that need them. → [Deployment overview](/docs/deployment/overview/)
4. **Decide how the first admin is created** — default-admin seeding with `NP_DEFAULT_ADMIN_EMAIL`/`NP_DEFAULT_ADMIN_PASSWORD` (and rotate that password after first login), or `NP_DEFAULT_ADMIN_DISABLED=1` and the interactive `/setup`, or `northplaned bootstrap-admin` headless. Never leave a generated password only in the logs. → [Authentication](/docs/administration/authentication/)
5. **Keep self-service signup off** unless you want it: `allowSignup: false` is the default; the production instance in [Environments](/docs/deployment/environments/) follows the repo variable `NORTHPLANE_ALLOW_SIGNUP` (default off). Self-registered users only get `viewer`.
6. **Use SSO or directory login** for people (OIDC with `adminGroup`/`idpGroups` mapping, or LDAP with `disableMissing: true`) and keep one local break-glass admin. → [Authentication](/docs/administration/authentication/), [Users, roles and permissions](/docs/administration/users-roles-permissions/)
7. **Scope API tokens** to the permissions they need, set `expiresAt`, use `ipBind` where the caller address is stable (remember it is the TCP peer, i.e. the proxy’s address behind a proxy), rotate with `:rotate`, and never reuse the `*:*` bootstrap token for integrations. Agents need `objects:write` only; MCP tokens get the `aiAgent` flag. → [API tokens](/docs/administration/api-tokens/)
8. **Protect and back up `secret.key`** — mode `0600`, owned by the service user (uid 65532 in containers), copied to a safe place; without it every sealed secret is lost. Put credentials into the secret store and reference them as `$SECRET:name$` instead of pasting them into channel or source configs. → [Secrets](/docs/administration/secrets/)
9. **Authenticate every event source** — `authMode: token` (header, not query string) or `hmac`; `none` only for sources that are reachable from trusted networks alone. Set the Twilio auth token for inbound telephony so signatures are verified, and `allowFrom` for caller allow-lists. → [Event sources](/docs/alarming/event-sources/), [Voice and IVR](/docs/alarming/voice-and-ivr/)
10. **Sign outgoing webhooks** — give webhook subscriptions a `secret` and verify `X-Northplane-Signature` on the receiving side. → [Outgoing webhooks](/docs/alarming/webhooks-out/)
11. **Restrict the anonymous endpoints** at the proxy if your threat model requires it — `/metrics`, `/api/v1/system/health`, `/api/v1/system/info`, `/api/docs`, `/status/default`. See [Unauthenticated endpoints](#unauthenticated-endpoints).
12. **Keep the audit chain verifiable** — run `np audit verify` (or `POST /api/v1/audit:verify`) on a schedule and export the log to your SIEM with `GET /api/v1/audit:export`. → [Observability](/docs/administration/observability/#audit-log)
13. **Back up regularly** — `northplaned backup` plus `secret.key` and `config.yaml`; there is no built-in schedule. → [Storage → Backup](/docs/administration/storage/#backup)
14. **Upgrade deliberately** — pin image tags, read release notes, back up first. → [Upgrades](/docs/administration/upgrades/)
15. **Review the known gaps** below and decide whether they matter for your deployment.
## Transport
[Section titled “Transport”](#transport)
* Plaintext on a non-loopback listener is refused unless `trustProxy` or `tls.insecure` is set; with a certificate pair the minimum protocol is TLS 1.2 and an unloadable pair is fatal (no silent fallback).
* `trustProxy` trusts **only** `X-Forwarded-Proto` (first value). It must be enabled only when the proxy is the sole path to the listener and overwrites inbound forwarded headers — otherwise a client could obtain `Secure` cookies and HSTS over plaintext by sending the header itself.
* HSTS (`max-age=31536000; includeSubDomains`) is sent on HTTPS responses; `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin` always; a strict CSP on the UI and pages; `/api/*` carries no CSP. Details and the verbatim policies: [Security headers](/docs/administration/tls-and-proxy/#security-headers).
* Cloudflare or another CDN in front is fine: Caddy should declare it as a trusted proxy for its own logs; Northplane does not read client IPs from headers at all.
## Accounts and credentials
[Section titled “Accounts and credentials”](#accounts-and-credentials)
* Passwords: argon2id (`time=1`, 64 MiB, 4 threads, 32-byte key, 16-byte salt), minimum 12 characters, constant-time verification against a dummy hash for unknown accounts; login attempts are rate-limited per client IP (burst 8, one per 15 s). A disabled account cannot log in or keep using a session.
* Sessions: server-side rows, `np_session` cookie `HttpOnly` + `SameSite=Lax` + `Secure` (on HTTPS), 12 h or 30 days with “remember me”; logout deletes the row. Password changes do **not** invalidate other sessions — disable the user or wait for expiry if a session must die now.
* The break-glass admin is re-seeded on every start when no enabled local admin exists; the last enabled local admin cannot be disabled or deleted (`409 np:users/last-admin`).
* Roles: `operator` and `viewer` hold no `admin:*`; `config:write` (templates, rules, channels, bundles) is admin-only among the built-ins; custom roles can nest (`includes`) and map IdP groups (`idpGroups`). Token permissions = scopes ∪ role permissions.
* API tokens: `np_` + 48 hex characters, stored as prefix + argon2id hash, shown once; `expiresAt`, `ipBind`, `aiAgent`, `lastUsedAt`; `:rotate` issues a new secret and deletes the old one immediately. MCP over HTTP and stdio both authenticate with these tokens and inherit exactly their permissions.
* Secrets: AES-256-GCM under the 32-byte master key, write-only through the API (`GET /api/v1/secrets` returns names only), tenant-scoped, never logged (audit `secret.put` carries no value). The agent check pull (`GET /api/v1/agent/checks`) does not expand `$SECRET` references.
## Ingest and integrations
[Section titled “Ingest and integrations”](#ingest-and-integrations)
* Generic webhook ingest (`POST /api/v1/ingest/{source}`) authenticates per source: `token` (default — `Authorization: Bearer ` **or** `?token=`; the query form leaks into access logs, prefer the header or HMAC), `hmac` (`X-Northplane-Signature` or `X-Hub-Signature-256`, HMAC-SHA256 over the raw body, hex, optional `sha256=` prefix), `basic` (password compared, username ignored), `none`. An empty secret makes `token`/`hmac`/`basic` fail closed; a disabled secret store (no master key) has the same effect. Rate limits are per source (`rateLimit` default 50/s, `burst` 200).
* Avoid ingest secrets starting with `np_` — the API middleware would treat them as API tokens and answer `401`.
* Source names are resolved across all tenants for ingest URLs (first match by slug order), so treat event-source names as globally unique.
* The Alertmanager receiver (`…/alertmanager`) does **not** check the source’s `enabled` flag (only its auth) — disable by removing the secret or the source if you need to stop it.
* Inbound telephony: Twilio signatures are verified only when `config.twilioAuthToken` is set (as `$SECRET:…$`); `allowFrom` restricts callers (`403 np:ingress/caller`). `baseUrl` must match the public URL or signatures fail.
* Ack links (`GET /api/v1/ack/{token}`) are HMAC-signed with a server secret, valid 24 h, act only on open alerts and remain valid until expiry (re-clicking shows the same confirmation); the DTMF callback uses the same token. Do not forward notification e-mails containing ack links to untrusted recipients.
* Outgoing webhooks are signed with `X-Northplane-Signature: sha256=` when the subscription has a `secret`; deliveries go through the outbox with retries and a dead-letter queue ([Reliability](/docs/alarming/reliability/)).
* Bundle apply tokens (`ap_…`) expire after 10 minutes and are single-use; bundle bodies are capped at 8 MiB.
* Discovery scans refuse loopback, link-local and multicast ranges and anything larger than a /20.
## Browser protections
[Section titled “Browser protections”](#browser-protections)
* The SPA is gated server-side: unauthenticated document navigations redirect to `/login`; API calls never redirect (they get `401` problem documents).
* CSRF: session-cookie requests whose browser sends `Sec-Fetch-Site: cross-site` are rejected (`403 np:auth/csrf`); cookies are `SameSite=Lax`; there is **no CORS** — the API cannot be called from another origin with the user’s cookie, and token clients are expected to be server-side. Raw routes (ingest, ack link, health) are not CSRF-checked because they do not use sessions.
* `frame-ancestors 'none'` / `X-Frame-Options: DENY` prevent clickjacking; the only third-party origin in the CSP is `app.stepped.ai` (the embedded assistant widget).
* Every API response carries `X-Request-Id`, also stored in audit entries — useful for incident forensics.
## Unauthenticated endpoints
[Section titled “Unauthenticated endpoints”](#unauthenticated-endpoints)
Reachable without any credential (restrict at the proxy if needed):
| Endpoint | What it exposes |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET /healthz`, `GET /readyz` | liveness/readiness; `/readyz` names the storage dialect |
| `GET /metrics` | server self-metrics (request counts, queue depths, TSDB stats) |
| `GET /api/v1/system/health` | queue depths and subsystem counters |
| `GET /api/v1/system/info` | **version**, Go version, goroutines, heap, uptime, storage dialect, `aiEnabled` |
| `GET /api/openapi.json`, `GET /api/docs`, `GET /api/docs/{asset}` | the API specification and Swagger UI |
| `GET /docs/` | this documentation |
| `GET /status/default` (and any `/status/{slug}` configured as public) | a public status page for the default tenant: business-service root names with a coarse state, or an aggregate “Infrastruktur” row; German text, `Cache-Control: max-age=30`. Non-public pages require `?token=`; there is no API or UI to configure status pages in this version, so only `default` exists unless the `kv` entry `statuspage/` is written directly |
| `GET/POST /login`, `/setup` (while open), `/register` (when `allowSignup`), `/auth/oidc`, `/auth/callback`, `/auth/logout` | authentication pages |
| `POST /api/v1/ingest/{source}`, `POST /api/v1/ingest/{source}/alertmanager`, `POST /api/v1/voice/inbound/{source}[/menu\|/transcription]`, `POST /api/v1/sms/inbound/{source}` | per-source authentication (see above), not platform RBAC |
| `GET /api/v1/ack/{token}`, `POST /api/v1/voice/gather/{token}` | signed tokens |
Requires a token but no session: `/mcp` (Bearer `np_…`, 401 with `WWW-Authenticate: Bearer resource_metadata="/api/v1/whoami"` otherwise).
A Caddy snippet that keeps the operational endpoints internal:
Caddyfile (excerpt)
```text
monitoring.example.net {
@internal {
path /metrics /api/v1/system/* /api/docs* /api/openapi.json /status/*
not remote_ip 10.0.0.0/8 192.168.0.0/16
}
respond @internal 403
reverse_proxy 10.10.10.11:8443
}
```
Do not block `/healthz` if the proxy (or CI) probes it; the deploy workflow probes `http://localhost:8443/healthz` on the VM directly.
## Audit
[Section titled “Audit”](#audit)
Mutations, logins (`login.local`, `login.ldap`, `setup.admin`, `user.register`), token and secret operations, bundle applies, AI actions and federation applies are recorded with actor, tenant, source IP (TCP peer), request id and before/after snapshots in a SHA-256 hash chain. Verify with `np audit verify`; export NDJSON for long-term retention — the table itself is never purged. Not audited: reads, OIDC logins, failed logins, logouts. Full description: [Observability → Audit log](/docs/administration/observability/#audit-log).
## Known gaps — be aware
[Section titled “Known gaps — be aware”](#known-gaps--be-aware)
These are real behaviours of this version, documented so you can compensate; they are tracked in [Roadmap and known issues](/docs/project/roadmap-and-known-issues/).
| Area | Gap | Mitigation |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| RBAC | Role `scope.folder` / `scope.selector` / `scope.tenantId` are stored and editable but **not enforced** — a role’s permissions apply to the whole tenant. | Use separate tenants for isolation; do not rely on folder scopes. |
| RBAC | System roles (`admin`, `operator`, `viewer`, `ai-agent`) are marked immutable in the UI but can be modified or deleted via `PUT`/`DELETE /api/v1/roles/{name}` with `admin:write`. | Restrict `admin:write`; watch `role.update`/`role.delete` audit entries. |
| Tenancy | `GET /api/v1/users` lists users of **all** tenants; e-mail addresses are globally unique. | Grant `admin:users` only to instance administrators. |
| Tenancy | `POST /api/v1/alerts/{id}:ack` ignores `X-Northplane-Tenant` (uses the caller’s home tenant); `:resolve`/`:snooze` honour it. | Cross-tenant operators ack from a token/user in the customer tenant. |
| Proxy | `X-Forwarded-For` is not used: audit `sourceIp`, token `ipBind`, the login rate limiter and site heartbeat `sourceIp` see the proxy address. | Keep client-IP logs at the proxy; bind tokens to the proxy or omit `ipBind`. |
| Audit | No retention/purge; failed logins are not audited (OIDC logins and logouts are, as `login.oidc`/`logout`); on PostgreSQL chain verification can report false breaks (`jsonb`). `audit:export` streams without the 30 s request deadline. | Export regularly and page with `afterSeq`. |
| Sessions | Password change or admin reset does not invalidate existing sessions; no IdP (RP-initiated) logout. | Disable the user to cut access immediately. |
| Setup | Default-admin seeding closes `/setup` on default installs; the generated password is printed to the logs once. | Set `NP_DEFAULT_ADMIN_PASSWORD` or `NP_DEFAULT_ADMIN_DISABLED=1` explicitly. |
| Ingest | Alertmanager receiver ignores `enabled`; `?token=` accepted for `token` sources; ingest source names are global. | Remove the source/secret to stop a receiver; use headers or HMAC. |
| Tokens | `ipBind` is not evaluated for `northplaned mcp` (stdio) — only expiry. | Give MCP tokens narrow scopes and short expiry. |
| UI | The Admin page renders all 21 tabs regardless of permissions (non-admins see tabs whose calls return 403); the login/setup/register pages and the status page are German-only and unbranded. | Cosmetic; permissions are enforced server-side. |
| Status page | `/status/default` is public by default and cannot be configured or disabled through the API/UI. | Block `/status/*` at the proxy if you do not want a public page. |
| Web push | Web push subscription flow is incomplete in the UI (server side is ready). | Use FCM/APNs through the alarm app ([Mobile push](/docs/alarming/mobile-push/)). |
# Storage
> SQLite versus PostgreSQL, the data directory layout, schema migrations, moving between backends, backup and restore, retention and sizing.
Northplane keeps three kinds of state: the **relational core** (objects, state, alerts, config documents, users, tokens, secrets, audit log, outbox), the **event log** (append-only, partitioned by month) and the **NP-TSDB** (perfdata time series, plain files). The relational core and the event log live in SQLite by default or in PostgreSQL; the TSDB always lives as files under `dataDir`, regardless of backend.
Configuration keys: `storage.dsn`, `storage.eventRetentionMonths`, `dataDir` — see the [Configuration reference](/docs/administration/configuration/#storage).
## Backends at a glance
[Section titled “Backends at a glance”](#backends-at-a-glance)
| Aspect | SQLite (default) | PostgreSQL |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| Selected by | `storage.dsn` empty (⇒ `/core.db`) or a file path | `storage.dsn` starting with `postgres://` or `postgresql://` |
| Driver | `modernc.org/sqlite` (pure Go, no CGO) | `github.com/jackc/pgx/v5` (`pgx` stdlib driver) |
| Core database | one file `core.db` (+ `core.db-wal`, `core.db-shm`) | one database, tables created by migrations |
| Events | monthly segment files `events-YYYYMM.db` in `dataDir` | parent table `events` partitioned by range on `ts`, child partitions `events_YYYYMM` created on demand |
| Journal / durability | `PRAGMA journal_mode=WAL` set once on the file; per connection `synchronous(NORMAL)`, `busy_timeout(5000)`, `foreign_keys(ON)` | server-side |
| Connection pool | 16 open, 16 idle, no idle/lifetime expiry (so the per-connection pragmas are not re-run under load) | 16 open, 8 idle, `ConnMaxLifetime` 30 min, `ConnMaxIdleTime` 5 min |
| Write concurrency | all writes serialised through one in-process mutex + `BEGIN`/`COMMIT`; WAL lets readers run concurrently | server-side MVCC |
| Types | timestamps as RFC 3339 text (UTC), JSON as text, booleans as integers | `timestamptz`, `jsonb`, `boolean`, `bytea`, `bigint` identity columns |
| Backup | `northplaned backup` (`VACUUM INTO` + segment copies + TSDB) | your PITR/`pg_dump` job for the relational part; `northplaned backup` still copies the TSDB and writes a manifest |
| Test status | the shipped default, fully green in CI | storage test suite runs against PostgreSQL 16 in CI, non-blocking (see caveat below) |
Which to choose: SQLite is the tested, zero-dependency default and is what the production instance in [Environments](/docs/deployment/environments/) runs. Choose PostgreSQL when you already operate it, need its backup/replication tooling, or want the relational data off the local disk. Northplane has no multi-node/HA mode; a PostgreSQL backend does not change that.
## SQLite
[Section titled “SQLite”](#sqlite)
* The core database path is always `/core.db` when `storage.dsn` is empty. A non-URL, non-empty `storage.dsn` is treated as a SQLite **file path** — useful for tests or for placing `core.db` on a different filesystem than the TSDB.
* The directory of the database file is created (`0750`) if missing.
* WAL mode is persistent in the file header; you will always see `core.db-wal` and `core.db-shm` next to the database while the server runs. Do not delete them.
* `busy_timeout` is 5 s: a second process holding a long write lock (for example a manual `sqlite3` session in a write transaction) makes requests wait up to 5 s and then fail. Use read-only tools while the server runs, or stop it first.
* Event segments are separate databases (own handle, 4 connections each, same pragmas) so that dropping a month is a file deletion, not a `DELETE`.
### Known PostgreSQL caveat
[Section titled “Known PostgreSQL caveat”](#known-postgresql-caveat)
Audit chain verification on PostgreSQL
`before`/`after` snapshots of audit entries are stored as `jsonb` on PostgreSQL, which normalises JSON text, while the row hash is computed over the original text; timestamps also round-trip at microsecond precision. As a result `POST /api/v1/audit:verify` (and `np audit verify`) can report a broken chain on PostgreSQL even though nothing was tampered with. This is a known, pre-existing failure (`TestAuditChain`) and the reason the PostgreSQL CI job is non-blocking. SQLite is unaffected.
## PostgreSQL
[Section titled “PostgreSQL”](#postgresql)
config.yaml
```yaml
storage:
dsn: "postgres://np:@db.internal:5432/northplane?sslmode=require"
```
or `NORTHPLANE_STORAGE_DSN=postgres://…` in a container (the Compose files carry a commented `postgres:16` service and DSN for exactly this).
* The database and the role must exist; migrations create all tables on first open (including the `events` parent table and its monthly partitions).
* The pool is bounded to 16 connections and actively recycles idle ones, which keeps it healthy behind pgbouncer or a load balancer with idle timeouts.
* Event partitions `events_YYYYMM` are created on demand by the event store and dropped by retention (`DROP TABLE`), discovered via `pg_tables LIKE 'events_2%'`.
* `dataDir` is still required: the NP-TSDB (`/tsdb/`), the fallback `secret.key`, artifacts and plugins live there.
## Data directory layout
[Section titled “Data directory layout”](#data-directory-layout)
`dataDir` defaults to `/var/lib/northplane` as root (and in the container), `~/.local/share/northplane` / `$XDG_DATA_HOME/northplane` on Linux as a user, `~/Library/Application Support/northplane` on macOS.
| Path | Content |
| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `core.db`, `core.db-wal`, `core.db-shm` | SQLite core database (SQLite mode only) |
| `events-YYYYMM.db` (+ `-wal`, `-shm`) | one event segment per month (SQLite mode only); the current month’s segment is created at open |
| `tsdb/series.jsonl` | NP-TSDB series registry (append-only JSONL with in-place threshold updates and `deleted` tombstones) |
| `tsdb/wal.log` | NP-TSDB write-ahead log (25-byte records, fsync batched every 1 s, rewritten after each flush) |
| `tsdb/blocks/block-.npb` | immutable 2-hour raw blocks (Gorilla-compressed) |
| `tsdb/agg/agg--5m.npa`, `tsdb/agg/agg--1h.npa` | daily 5-minute and 1-hour downsampled tiers |
| `secret.key` | AES master key (64 hex characters) — **fallback location** used when `secretKeyFile` is unset or unusable; `northplaned init` writes the key to the config directory instead (`/etc/northplane/secret.key`) |
| `artifacts/` | check artifacts directory (reserved for E2E check artefacts) |
| `plugins/` | last candidate of the plugin-directory auto-detection (see [Configuration](/docs/administration/configuration/#load-order-and-precedence)) |
Not files: the Web Push VAPID key pair and the ack-link signing secret are stored in the `kv` table of the core database (keys `vapid`, `ack_secret`), as are site status records and the AI tool policy.
Ownership: the systemd unit written by `init` uses `StateDirectory=northplane` and `ReadWritePaths=`; the container runs as uid 65532 (distroless `nonroot`) and expects the volume at `/var/lib/northplane` to be writable by that uid.
## Schema migrations
[Section titled “Schema migrations”](#schema-migrations)
Migrations are embedded in the binary, forward-only, and applied automatically by **every** command that opens the store — `serve`, `migrate`, `storage migrate`, `backup`, `mcp`, `bootstrap-admin`. Each migration runs in its own transaction and is recorded in `schema_version (version, name, applied_at)`; pending ones are logged as `storage: applying migration version=N name=…`. A failing migration aborts the command (`storage: migration N "name": …`) and the server does not start.
| # | Name | Content |
| - | --------------------- | --------------------------------------------------------------------------------------------------- |
| 1 | `core` | all base tables |
| 2 | `seed` | default tenant `Default`/`default` and the built-in roles `admin`, `operator`, `viewer`, `ai-agent` |
| 3 | `user_roles` | `users.roles` JSON column |
| 4 | `report_archive_slot` | `report_archive` recreated with a `slot` column |
| 5 | `alert_ticket` | `alerts.ticket_url`, `alerts.ticket_meta` |
| 6 | `hotpath_indices` | partial index on problem states, alert indexes by object and rule |
| 7 | `user_tenant` | `users.tenant_id` (home tenant, defaults to the Default tenant) |
| 8 | `ai_agent_chat` | `ai_provider_connections`, `ai_chats`, `ai_chat_messages` |
| 9 | `alert_snooze` | `alerts.snoozed_until` + partial index |
`northplaned migrate -config ` opens the store, applies whatever is pending and prints `migrations applied — schema is current`. Use it as a pre-flight step during [upgrades](/docs/administration/upgrades/) when you want the schema change to happen before the service restarts. There are no down-migrations; the migration runner simply skips versions it already sees in `schema_version`, so an **older** binary started against a newer database does not fail the migration step — whether it runs correctly depends on the change. Restore from backup for a clean rollback.
## Moving between backends
[Section titled “Moving between backends”](#moving-between-backends)
`northplaned storage migrate --to ` copies the relational data from the backend named in your config to another one. It is an **offline** operation: the downtime equals the copy time.
1. Stop `northplaned` (and any `np-agent` push traffic can simply wait — results are retried).
2. Make sure the target exists (an empty PostgreSQL database, or a path for a new SQLite file). Migrations are applied to the target automatically.
3. Run the copy with the **current** config (it defines the source):
```bash
northplaned storage migrate -config /etc/northplane/config.yaml --to 'postgres://np:@db.internal:5432/northplane?sslmode=require'
```
What is copied, in order: the 23 generic tables (`tenants, users, objects, object_labels, check_state, alerts, incidents, resources, downtimes, silences, heartbeats, api_tokens, sessions, secrets, idempotency, escalations, outbox, ai_actions, ai_conversations, ai_usage, push_subscriptions, report_archive, kv`) with `INSERT … ON CONFLICT DO NOTHING` (the target’s seeded default tenant/roles are kept), then `audit_log` with explicit sequence numbers so the hash chain stays intact, then all events per tenant oldest-first in pages of 1000 into the target’s partitioning. Booleans and timestamps are converted between the dialects. On success it prints `copied rows. Point storage.dsn at the target and restart (NP-TSDB unaffected).`
4. Set `storage.dsn` (or `NORTHPLANE_STORAGE_DSN`) to the target DSN and start the server.
Notes:
* The NP-TSDB is not touched — it is backend-independent and stays under `dataDir`.
* `--to` may also be a SQLite file path. In that case the target’s event segments are written under `-migrated/`, because SQLite segments are placed relative to a data directory, not the DSN; move them into the active `dataDir` before switching.
* Secrets are copied as ciphertext; the target instance needs the **same** `secret.key`.
* The command applies target migrations itself; you do not need to run `migrate` separately.
## Retention
[Section titled “Retention”](#retention)
| Data | Retention | Enforced by |
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Events | `storage.eventRetentionMonths`, default **12**; `0` = keep all. Whole months are dropped (SQLite: segment file + `-wal`/`-shm` deleted; PostgreSQL: partition dropped) when the month key is older than now − N months | janitor, nightly window 02:00–03:59 local time, at most once per 20 h |
| NP-TSDB raw samples | 30 days (fixed) | nightly `TSDB.Maintain` (flush, downsample, delete expired files by window start) |
| NP-TSDB 5-minute aggregates | 400 days (fixed) | same |
| NP-TSDB 1-hour aggregates | 5 years (fixed) | same |
| NP-TSDB series | cap 100 000 series; new series beyond the cap are dropped and counted (`seriesDropped`) | at ingest |
| Sessions | deleted once expired | janitor, every 10 min |
| Idempotency keys | 24 h | janitor, every 10 min |
| Report archive | `keep` distinct slots per report, default 12 | on insert |
| Alerts, incidents, objects, config documents | kept until deleted/resolved by you or a rule (`autoCloseAfter` expires alerts) | — |
| Audit log | **never purged** | — |
| Outbox | rows deleted on successful delivery; dead letters stay until replayed/deleted | notify worker |
The TSDB retention values are not configurable in this version — see [Configuration → Not configurable](/docs/administration/configuration/#not-configurable). Formats, downsampling and the query API are described in [Metrics and NP-TSDB](/docs/monitoring/metrics-and-tsdb/).
## Backup
[Section titled “Backup”](#backup)
### `northplaned backup`
[Section titled “northplaned backup”](#northplaned-backup)
Set `backup.target` (or `NORTHPLANE_BACKUP_TARGET`) to a directory and run the command; it may run while the server is serving traffic:
/var/backups/northplane/northplane-20260823-020000/manifest.json
```bash
NORTHPLANE_BACKUP_TARGET=/var/backups/northplane northplaned backup -config /etc/northplane/config.yaml
```
It creates `/northplane-/` (UTC timestamp, mode `0750`) containing:
| File | Included | Notes |
| ------------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `core.db` | SQLite mode | produced with `VACUUM INTO` — transaction-consistent without stopping writers |
| `events-YYYYMM.db` | SQLite mode | every segment copied; the hot current-month segment last (worst case: the last seconds of events are missing) |
| `tsdb/` | always | whole tree copied; blocks and aggregates are immutable, WAL and series journal are replay-safe |
| `manifest.json` | always | `{"format":"northplane-backup/1","version":"","createdAt":"","storage":"sqlite"\|"postgres", …}` plus `eventSegments` (SQLite) or `schemaVersion` + a note (PostgreSQL) |
**Not** included — back these up separately:
* `secret.key` — without it every sealed value (secrets, AI provider keys, MQTT/IMAP passwords) is unrecoverable. See [Secrets](/docs/administration/secrets/).
* `config.yaml` and your TLS files.
* The relational data on PostgreSQL (use `pg_dump`/PITR; the manifest records the `schemaVersion` so you can validate a restore).
* `/artifacts/` (the doc comment mentions artefacts, the code does not copy them).
No periodic backup loop
`backup.interval` is parsed but not used: the server never runs backups on its own. Schedule `northplaned backup` with cron/systemd timers (or snapshot the volume/VM at the platform level) and ship the result off-host. A minimal cron line:
```text
15 2 * * * northplane NORTHPLANE_BACKUP_TARGET=/var/backups/northplane /usr/local/bin/northplaned backup -config /etc/northplane/config.yaml
```
In the container (distroless, no shell) run the binary directly with the same data volume and an extra environment variable:
```bash
docker compose exec -e NORTHPLANE_BACKUP_TARGET=/var/lib/northplane/backups northplane northplaned backup
```
and copy `/var/lib/northplane/backups/…` out of the volume afterwards. The alternative is a volume-level snapshot; with SQLite in WAL mode, stop the container first or accept a crash-consistent copy.
### Restore
[Section titled “Restore”](#restore)
There is no restore command; a restore is a file operation:
1. Stop `northplaned`.
2. Put `core.db` and the `events-*.db` segments from the backup into `dataDir` (remove stale `core.db-wal`/`core.db-shm` files left by the stopped instance). On PostgreSQL restore the database with your tooling, then check that its `schema_version` matches the manifest’s `schemaVersion`.
3. Replace `/tsdb/` with the `tsdb/` tree from the backup.
4. Make sure the **same** `secret.key` is in place (config `secretKeyFile` or `/secret.key`), otherwise sealed values fail to decrypt.
5. Start the server (or run `northplaned migrate` first if the backup is from an older version — migrations are applied automatically either way) and verify with `/readyz`, the Objects page and `np audit verify`.
## Sizing notes
[Section titled “Sizing notes”](#sizing-notes)
* SQLite runs the reference production instance (agent fleet, SNMP polling, traps, alarming pipelines) on a 4 vCPU / 8 GB VM; the shipped defaults are tuned for it (pool warm and non-expiring, 250 ms pipeline flushes, `busy_timeout` 5 s).
* Event volume is the main disk driver on the relational side: one row per state change, notification, ingress event, ack, config change … per month file. Lower `storage.eventRetentionMonths` if disk is tight; the NDJSON export (`GET /api/v1/events:export`) lets you archive before dropping.
* TSDB growth is bounded by retention and the 100 000-series cap; each check result appends one sample per perfdata label plus `np_exec_time`. Raw blocks are compact (Gorilla encoding) and downsampled tiers are small.
* The outbox and escalation tables are small and self-cleaning; the audit log grows forever (plan for it, or export and truncate manually — there is no built-in purge).
* Keep `dataDir` on local or low-latency storage: SQLite WAL and the TSDB WAL fsync frequently.
# Tenants and sites
> Administering multi-tenancy (tenants, the X-Northplane-Tenant header, per-tenant roles, users and tokens, isolation guarantees) and federation sites (creating a site with its config bundle, the sites:connect token, edge configuration, monitoring connected edges) — with the VM104 worked example.
Northplane separates customers with **tenants** (data partitions inside one instance) and connects remote, customer-site installations as **sites** (federation edges that pull their configuration from a main instance). Both are administered under **Admin (Administration)**. For the models behind them read [Tenancy and RBAC](/docs/concepts/tenancy-rbac/) and [Federation](/docs/concepts/federation/) first; this page is the operator’s how-to and reference.
## Tenants
[Section titled “Tenants”](#tenants)
### The Default tenant
[Section titled “The Default tenant”](#the-default-tenant)
Every instance has the tenant `Default` (slug `default`, id `00000000-0000-7000-8000-000000000001`), created by migration. Single-tenant installs never see another one: all users, tokens, objects and config documents live there. Sessions for OIDC and LDAP users, `/setup`, `/register` and default-admin seeding always land in the Default tenant.
### Creating a tenant
[Section titled “Creating a tenant”](#creating-a-tenant)
Tenants are **create-only** in this version: there is no update, rename, disable or delete (the `disabled` flag exists in the record but is never read for access control). The UI says so — **Admin → Tenants (Mandanten)** lists Name, Slug, Status and ID, and the **Anlegen** dialog takes Name and Slug (“URL-tauglicher Kurzname”) with the note “Mandanten können derzeit nicht gelöscht werden”.
| Endpoint | Permission | Behaviour |
| ---------------------------------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------- |
| [`GET /api/v1/tenants`](/docs/reference/api/operations/get_tenants/) | `admin:tenants` | All tenants (`{items: [{id, name, slug, disabled, version, createdAt, updatedAt}]}`) |
| [`POST /api/v1/tenants`](/docs/reference/api/operations/post_tenants/) | `admin:tenants` | `{name, slug}` — both required (`422 np:validation/tenant`), slug unique → `201 {"id": "…"}`; audit `tenant.create` |
```bash
curl -s -X POST https://monitoring.example.net/api/v1/tenants \
-H "Authorization: Bearer np_<48 hex>" -H "Content-Type: application/json" \
-d '{"name":"MyFoxIT","slug":"myfoxit"}'
```
Creating a tenant seeds the four built-in roles (`admin`, `operator`, `viewer`, `ai-agent`) into it in the same transaction, so role names resolve in every tenant from the start.
### Acting on another tenant: `X-Northplane-Tenant`
[Section titled “Acting on another tenant: X-Northplane-Tenant”](#acting-on-another-tenant-x-northplane-tenant)
Every API handler scopes its reads and writes by the request’s tenant, resolved as:
* the value of the `X-Northplane-Tenant` header — the tenant **id** (UUID), not the slug — **if** the principal holds `admin:tenants` (or a wildcard implying it);
* otherwise the principal’s own tenant (token tenant or session tenant). The header is silently ignored for everyone else.
```bash
# as a central admin: list the hosts of tenant MyFoxIT
curl -s https://monitoring.example.net/api/v1/hosts \
-H "Authorization: Bearer np_<48 hex>" \
-H "X-Northplane-Tenant: "
```
Mutations made with the header are **audited under the acted-on tenant** with the operator’s actor id; `GET /api/v1/whoami` keeps reporting the principal’s home tenant. Cross-tenant reads of objects return `404 np:not-found` (not 403), and listings never leak rows from other tenants.
Handlers that do not honour the header
* `POST /api/v1/alerts/{id}:ack` uses the principal’s **home** tenant (while `:resolve` and `:snooze` honour the header) — a cross-tenant operator acking a customer’s alert gets 404. Use the customer’s own credentials or the ack link instead.
* `GET /api/v1/users` lists **all users of the instance** regardless of tenant.
* `GET`/`PUT /api/v1/branding` is always the instance document under the Default tenant.
* Inbound webhooks, telephony callbacks and ack links resolve their tenant from the event source or alert id across all tenants (see [Event sources](/docs/alarming/event-sources/)).
### Tenant-scoped users, roles and tokens
[Section titled “Tenant-scoped users, roles and tokens”](#tenant-scoped-users-roles-and-tokens)
* **Users** have a home tenant (`tenantId`); `POST /api/v1/users` creates the account in the request’s tenant, so a central admin provisions a customer login by sending `X-Northplane-Tenant`. A local login lands in the home tenant. E-mail addresses are unique instance-wide.
* **Roles** live per tenant. `admin:tenants` holders see and edit other tenants’ roles only through the header. Role names in sessions and tokens are resolved in the session’s/token’s tenant.
* **API tokens** belong to the tenant they were minted in (the creator’s active tenant at creation) and resolve their `roles` there — see [API tokens](/docs/administration/api-tokens/).
* **Secrets** are stored per `(tenant, name)` — see [Secrets](/docs/administration/secrets/).
* **Preferences** are stored per (tenant, actor).
**Worked example — provisioning a customer administrator.** This is how the tenant *MyFoxIT* on the reference instance was set up: a custom role that can do everything inside the tenant except leave it.
```bash
NP=https://monitoring.example.net; TOK=np_
TENANT=$(curl -s -X POST $NP/api/v1/tenants -H "Authorization: Bearer $TOK" \
-H "Content-Type: application/json" -d '{"name":"MyFoxIT","slug":"myfoxit"}' | jq -r .id)
# 1. a tenant-admin role inside the tenant: everything except admin:tenants
curl -s -X POST $NP/api/v1/roles -H "Authorization: Bearer $TOK" -H "X-Northplane-Tenant: $TENANT" \
-H "Content-Type: application/json" -d '{
"name": "tenant-admin",
"permissions": ["objects:*","config:write","checks:run","alerts:*","incidents:*",
"downtimes:write","silences:write","events:read","metrics:read","oncall:*",
"reports:render","admin:read","admin:write","admin:users","admin:tokens",
"admin:secrets","admin:audit","admin:ai"],
"scope": {"tenantId": "'"$TENANT"'"}
}'
# 2. the customer's first login, created IN the tenant
curl -s -X POST $NP/api/v1/users -H "Authorization: Bearer $TOK" -H "X-Northplane-Tenant: $TENANT" \
-H "Content-Type: application/json" \
-d '{"name":"Alexander Hoehne","email":"info@myfoxit.com","password":"","roles":["tenant-admin"]}'
```
The customer logs in at `/login`, lands in tenant MyFoxIT and never sees the tenant switcher (no `admin:tenants`). Because `admin:users` is instance-global for listing, that tenant admin still *sees* every account’s name and e-mail in **Admin → Users** — keep that in mind when you hand out `admin:users`.
### What is isolated and what is not
[Section titled “What is isolated and what is not”](#what-is-isolated-and-what-is-not)
| Tenant-scoped (by `tenant_id`) | Instance-wide |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Hosts, services, check state, alerts, incidents, events (and the SSE stream), downtimes, silences, heartbeats | The user **list** (`GET /api/v1/users`) and e-mail uniqueness |
| All config documents: templates, check commands, time periods, rules, policies, channels, event sources, contacts, schedules, dashboards, reports, webhooks, IVR menus, **roles**, **sites** | Branding (theme and mode) |
| API tokens, secrets, sessions, preferences, idempotency keys | `config.yaml`: TLS, OIDC, LDAP, federation, `allowSignup`, demo mode, `secret.key` |
| Audit search and export (`GET /api/v1/audit`, `:export`) | Audit chain verification (`POST /api/v1/audit:verify` walks the whole table) |
| | Push subscriptions (keyed by actor id) |
| | Event-source ingest URLs (`/api/v1/ingest/{source}` is looked up across all tenants by id or name — first match in slug order wins) and ack links |
### The tenant switcher in the UI
[Section titled “The tenant switcher in the UI”](#the-tenant-switcher-in-the-ui)
The sidebar shows a tenant switcher only when `whoami.permissions` implies `admin:tenants`. It lists “Eigener Mandant / Your tenant · ``” plus all tenants from `GET /api/v1/tenants`; selecting a customer stores the id in `localStorage` (`np.activeTenant`), clears the query cache, navigates to the overview, tints the sidebar accent and labels it “Aktiver Kunde / Active customer: ``”. From then on every API call from the UI sends `X-Northplane-Tenant`. Branding is not re-fetched on a switch (it is instance-wide anyway).
Known UI gap
The Admin page renders all 21 tabs regardless of permissions. A tenant user without `admin:tenants` still sees the **Tenants** tab (its create button answers 403) and the page issues `GET /roles`, `/tenants` and `/ai/policy` requests that 403 for roles without `admin:*`. Only the tenant switcher and the **Appearance** controls are permission-gated client-side. Tracked in [Roadmap and known issues](/docs/project/roadmap-and-known-issues/).
## Sites (federation edges)
[Section titled “Sites (federation edges)”](#sites-federation-edges)
A **site** is a tenant-scoped document on the *main* instance that describes one remote edge installation and embeds the [config bundle](/docs/administration/config-bundles/) that edge should run. The edge is a full `northplaned` (its own scheduler, plugins, agents, notifications, users, tokens and secrets) that **dials out only**: every tick it pulls its bundle and posts a status heartbeat. Nothing on the main instance connects inbound to the customer network.
### Create a site
[Section titled “Create a site”](#create-a-site)
**Admin → Sites (Standorte)** lists Name, Status (Verbunden / Getrennt), zuletzt gesehen, Version, Hosts/Services, offene Alarme and Konfiguration (Angewendet / Apply-Fehler). The dialog has Name, Beschreibung, **Config-Bundle (YAML)** (“Wird von der Edge-Instanz gezogen und angewendet; Validierung beim Speichern.”) and the checkbox **Deaktiviert (Edge-Zugriffe ablehnen)**.
The API is the generic config-document CRUD at [`/api/v1/sites`](/docs/reference/api/operations/get_sites/) (`objects:read` for GET, `config:write` for POST/PUT/DELETE; `PUT` requires `If-Match`):
```bash
curl -s -X POST https://main.example.net/api/v1/sites \
-H "Authorization: Bearer np_<48 hex>" -H "X-Northplane-Tenant: " \
-H "Content-Type: application/json" -d @- <<'EOF'
{
"name": "customer-a",
"description": "Edge in the customer A data centre",
"labels": {"region": "eu-central"},
"bundle": "kind: Host\nmetadata:\n name: edge-gw\nspec:\n address: 10.20.0.1\n checkCommand: builtin:icmp\n---\nkind: Service\nmetadata:\n host: edge-gw\n name: https\nspec:\n checkCommand: builtin:http -S -p 443\n"
}
EOF
```
| Field | Meaning |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `name` | Site name; the edge references it as `federation.site` |
| `description`, `labels` | Free text / key-value labels for the overview |
| `bundle` | Multi-document YAML bundle, **parsed and validated on save** (`422` when invalid). May be empty (“nothing managed centrally yet”). |
| `disabled` | When `true`, the edge’s heartbeat and pull are refused with `403 np:sites/disabled` |
### Mint the edge token
[Section titled “Mint the edge token”](#mint-the-edge-token)
The edge authenticates with an ordinary [API token](/docs/administration/api-tokens/) minted **on the main instance, in the site’s tenant**, with the single scope `sites:connect`:
```bash
curl -s -X POST https://main.example.net/api/v1/api-tokens \
-H "Authorization: Bearer np_<48 hex>" -H "X-Northplane-Tenant: " \
-H "Content-Type: application/json" \
-d '{"name":"site-customer-a","scopes":["sites:connect"]}'
```
The UI hint under the Sites table says the same: “Edge connection: create a token with scope `sites:connect` and add it to the customer instance in config.yaml”. A `sites:connect` token can heartbeat and pull **any** site in its tenant — there is no per-site binding — so mint one token per site and keep sites of different customers in different tenants.
### Configure the edge instance
[Section titled “Configure the edge instance”](#configure-the-edge-instance)
On the edge, set the `federation:` section of `config.yaml` (or the environment equivalents) and restart. The full key reference is in [Configuration](/docs/administration/configuration/).
| Key | Default | Env | Meaning |
| ------------------------------- | ----------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `federation.mode` | `""` (standalone) | `NORTHPLANE_FEDERATION_MODE` | Only `""` or `edge`. There is no `main` mode — a main instance is just an instance that has sites and tokens. |
| `federation.mainUrl` | — | `NORTHPLANE_FEDERATION_MAIN_URL` | `http(s)://…` of the main instance (required in edge mode) |
| `federation.token` | — | `NORTHPLANE_FEDERATION_TOKEN` | The `np_…` token with `sites:connect` (required) |
| `federation.site` | — | `NORTHPLANE_FEDERATION_SITE` | The site name registered on main (required) |
| `federation.interval` | `1m` | — | Tick interval (≤ 0 → 1 m) |
| `federation.insecureSkipVerify` | `false` | — | Skip TLS verification towards main |
| `federation.applyConfig` | `true` | — | `false` = heartbeat only, never pull/apply the bundle |
config.yaml (edge)
```yaml
federation:
mode: edge
mainUrl: "https://main.example.net"
token: "np_…" # minted on main, scope sites:connect
site: "customer-a"
interval: 60s
```
The edge logs `federation: edge mode` at start. Misconfiguration is caught at load time: `federation.mode edge requires federation.token (mint on the main instance, scope sites:connect)`, `… requires federation.site …`, `federation.mainUrl "…": must be an http(s) URL`.
Each tick the `federation-edge` worker does, in this order:
1. `GET {mainUrl}/api/v1/sites/{site}:pull` with `Authorization: Bearer ` and `If-None-Match: `. `304` → nothing to do. `200` → the body (≤ 8 MiB, `application/yaml`) is applied into the edge’s **Default tenant** through the same applier as `np apply` / `bundles:apply`, **without prune**. On success the ETag advances and an audit entry `federation.apply` (actor `system` / `federation`, resource = site name) is written; on failure the old ETag is kept, the error is reported as `applyError` and the pull is retried every tick until a new revision applies. An **empty** bundle is remembered but not applied.
2. `POST {mainUrl}/api/v1/sites/{site}:heartbeat` with `{version, bundleEtag, applyError, stats: {hosts, services, alertsOpen}}` (counted in the edge’s Default tenant). A non-2xx answer is logged as a warning.
The HTTP client timeout is 30 s. If main is unreachable the edge keeps running with its last applied configuration and logs a warning per tick.
### Monitor sites
[Section titled “Monitor sites”](#monitor-sites)
[`GET /api/v1/sites:overview`](/docs/reference/api/operations/get_sites_overview/) (`objects:read`) returns every site of the request’s tenant merged with its last status:
```json
{"items":[{"name":"customer-a","description":"…","labels":{},"bundle":"…","disabled":false,"version":3,
"connected":true,
"status":{"lastSeenAt":"2026-08-23T08:51:12Z","version":"main-daa6dc518a2b","bundleEtag":"\"3f9a…\"",
"applyError":"","stats":{"hosts":2,"services":7,"alertsOpen":0},"sourceIp":"10.10.10.14"}}]}
```
`connected` is `true` when the last heartbeat is younger than **5 minutes**. The status is stored in the key-value store (`site_status::`), not versioned, with `sourceIp` = the TCP peer address of the heartbeat (the proxy’s address when main sits behind one). The Sites tab renders the same data.
The two edge-facing endpoints need `sites:connect`: [`POST /api/v1/sites/{name}:heartbeat`](/docs/reference/api/operations/post_sites_name_heartbeat/) (site must exist in the token’s tenant → 404 otherwise; disabled → `403 np:sites/disabled`; `204`) and [`GET /api/v1/sites/{name}:pull`](/docs/reference/api/operations/get_sites_name_pull/) (`ETag` = quoted hex of the first 16 bytes of SHA-256 of the bundle; `If-None-Match` equal → `304`; otherwise `200 application/yaml`).
### Update a site’s bundle
[Section titled “Update a site’s bundle”](#update-a-sites-bundle)
Change the document on main — in the dialog, or with `PUT /api/v1/sites/{name}` and the current `If-Match` — and the edge picks it up on its next tick (≤ `federation.interval`). Because the edge applies **without prune**, documents you remove from the bundle stay on the edge until someone deletes them there. `Tenant` and `Heartbeat` are not applied by the bundle applier (warning `unsupported kind`); users, tokens, secrets and sites themselves are not bundle kinds at all — provision those on the edge directly.
### Disable a site
[Section titled “Disable a site”](#disable-a-site)
Tick **Deaktiviert (Edge-Zugriffe ablehnen)** or set `"disabled": true`. The edge’s pull and heartbeat then get `403 np:sites/disabled`; the edge keeps its last configuration and keeps running locally. Deleting the site (`DELETE /api/v1/sites/{name}`) makes the edge’s calls 404 with the same local effect. Revoke the site’s token as well if the edge is decommissioned.
### What flows where
[Section titled “What flows where”](#what-flows-where)
| Direction | Content | Not transported |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| Main → edge | The site bundle: hosts, services, templates, check commands, time periods, rules, alert groups, policies, channels, contacts, contact groups, schedules, IVR menus, event sources, business services, dashboards, reports, webhooks, saved filters, static groups, roles | Secrets, users, tokens, sites, tenants, heartbeat definitions |
| Edge → main | Heartbeat: edge version, bundle ETag, apply error, counters (hosts, services, open alerts), source IP | Check results, alerts, events, metrics, notifications — the edge alerts locally with its own channels |
Agents at the customer site talk to the **edge** (`server: https://`) with a token minted on the edge (`objects:write`); see [Agent](/docs/monitoring/agent/). Nothing in federation provisions edge credentials or secrets.
### Worked example: the VM104 edge of doktrace.com
[Section titled “Worked example: the VM104 edge of doktrace.com”](#worked-example-the-vm104-edge-of-doktracecom)
The reference production instance ([Environments](/docs/deployment/environments/)) runs this setup: **main** is `https://doktrace.com` (np-01); the **edge** is the VM `np-staging` (VM104, 10.10.10.14 on the same Proxmox host, see [Proxmox VM deployment](/docs/deployment/proxmox-vm/)) running its own `northplaned` container with its own local admin.
1. On main, tenant **MyFoxIT** (slug `myfoxit`) was created and a site **`vm104-edge`** registered in that tenant (all calls with `X-Northplane-Tenant: `). Its bundle holds the hosts `np-staging` and `lab-web`, passive agent services for them, a channel `ntfy-edge` (ntfy.sh topic), an escalation policy `edge-alarm` and a contact `edge-ops`.
2. On main, an API token `site-vm104-edge` with scope `sites:connect` was minted in tenant MyFoxIT.
3. On VM104, `/opt/northplane/config.yaml` (bind-mounted into the container) got `federation: {mode: edge, mainUrl: https://doktrace.com, token: np_…, site: vm104-edge, interval: 60s}`. The mounted file must be readable by the container user **uid 65532** — a `0600 root:root` file fails with “permission denied”; `chown 65532 config.yaml && chmod 640 config.yaml` fixes it.
4. The edge pulled and applied the bundle into its Default tenant and started heartbeating; `GET /api/v1/sites:overview` on main (with the tenant header) shows `connected: true` with the host/service counters.
5. The `np-agent` on VM104 pushes to the **edge** (`server: https://localhost:8443`, `insecure: true`, token `np-agent-local` minted on the edge, hostname `np-staging`), which fills the passive services from the bundle.
To change what the edge monitors, edit the `vm104-edge` site on main (`PUT` with `If-Match`); the edge applies it within 60 s.
# TLS and reverse proxies
> How northplaned decides between TLS and plaintext, how to terminate TLS directly or behind Caddy/nginx, what trustProxy really does, and the security headers every response carries.
Northplane serves a single HTTP listener (`listen`, default `127.0.0.1:8443`) for the API, the UI, SSE streams, MCP and the docs. It can terminate TLS itself from a certificate/key pair, or sit behind a TLS-terminating reverse proxy. What it will **not** do is silently serve plaintext on a network interface: that combination is refused at start-up unless you tell it that a trusted proxy is in front.
## How the listener decides
[Section titled “How the listener decides”](#how-the-listener-decides)
The decision is made once, when `serve` opens the listener (there is no certificate hot-reload and no ACME/autocert — use Caddy for automatic certificates):
| Configuration | Result |
| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tls.certFile` **and** `tls.keyFile` set | TLS with the loaded pair, minimum TLS 1.2, scheme `https`. If the pair cannot be loaded the server **refuses to start** (`TLS cert load failed, refusing to start insecure: …`) — it never falls back to plaintext. |
| no certificate, and `tls.insecure: true` **or** `trustProxy: true` **or** the bound address is loopback | Plaintext HTTP with the warning `server: serving plaintext HTTP (loopback/dev or behind a TLS-terminating proxy — A-15.10 requires TLS in production)`. |
| no certificate, non-loopback listener (`:8443`, `0.0.0.0:8443`, `[::]:8443`, a LAN address) | Fatal: `no TLS configured on a non-loopback listener — set tls.certFile/keyFile, or trustProxy behind a TLS-terminating proxy, or tls.insecure for dev`. |
“Loopback” is determined from the **bound** address: `127.0.0.1:8443` and `[::1]:8443` are loopback; `:8443`, `0.0.0.0:8443` and `[::]:8443` are not, even on a single-host machine. Setting only one of `tls.certFile`/`tls.keyFile` is rejected already at [config validation](/docs/administration/configuration/#validation-errors).
Docker images listen on all interfaces
The container image sets `NORTHPLANE_LISTEN=:8443`. It therefore refuses to start until you provide `NORTHPLANE_TLS_CERT_FILE`/`NORTHPLANE_TLS_KEY_FILE`, or `NORTHPLANE_TRUST_PROXY=true` (behind Caddy, the Compose default), or `NORTHPLANE_TLS_INSECURE=true` (trial only). See [Installation](/docs/getting-started/installation/).
## Terminating TLS in northplaned
[Section titled “Terminating TLS in northplaned”](#terminating-tls-in-northplaned)
Use this when nothing sits in front of the server — a small site, an edge instance on a customer LAN, an appliance.
1. Obtain a PEM certificate chain and key (from your CA, or a self-signed pair for a LAN). The files must be readable by the user running `northplaned` (the systemd unit written by `init` runs as user `northplane` with `ProtectSystem=strict`; keep the files outside the data directory or add them to `ReadWritePaths`/make them world-readable as appropriate).
2. Configure the listener and the pair:
/etc/northplane/config.yaml
```yaml
listen: ":8443"
baseUrl: "https://monitoring.example.net:8443"
tls:
certFile: "/etc/northplane/tls/fullchain.pem"
keyFile: "/etc/northplane/tls/privkey.pem"
```
or, in a container, `NORTHPLANE_TLS_CERT_FILE=/certs/fullchain.pem` and `NORTHPLANE_TLS_KEY_FILE=/certs/privkey.pem` with the files bind-mounted.
3. Restart. The log line `northplane: listening addr=:8443 scheme=https …` confirms TLS is active.
4. Renewals: the pair is read once at start. After replacing the files, restart `northplaned` (`systemctl restart northplaned`).
With direct TLS, `r.TLS` is set on every request, so `Secure` cookies and HSTS are emitted without any further configuration, and `trustProxy` must stay `false`.
## Behind a TLS-terminating reverse proxy
[Section titled “Behind a TLS-terminating reverse proxy”](#behind-a-tls-terminating-reverse-proxy)
This is the reference deployment: Caddy (or nginx, Traefik, Cloudflare + Caddy …) terminates TLS on 443 and forwards plaintext HTTP to `northplaned` on 8443. Configure Northplane with:
config.yaml
```yaml
listen: ":8443" # or 127.0.0.1:8443 if the proxy runs on the same host
baseUrl: "https://monitoring.example.net"
trustProxy: true
```
(or `NORTHPLANE_LISTEN=:8443`, `NORTHPLANE_TRUST_PROXY=true`, `NORTHPLANE_BASE_URL=https://…` as the Compose stacks do).
### What trustProxy does — and does not do
[Section titled “What trustProxy does — and does not do”](#what-trustproxy-does--and-does-not-do)
`trustProxy: true` changes exactly one thing: `auth.RequestIsHTTPS` treats a request as HTTPS when the **first** value of `X-Forwarded-Proto` equals `https` (case-insensitive), in addition to a real TLS connection. That flag drives:
* the `Secure` attribute on the session cookie `np_session` and the OIDC state/verifier cookies;
* the `Strict-Transport-Security` header;
* the start-up rule above (plaintext on a non-loopback listener is allowed).
It does **not**:
* read `X-Forwarded-For`, `X-Real-IP` or `Forwarded`. The client address used for audit `sourceIp`, API-token `ipBind`, the login rate limiter and site heartbeat `sourceIp` is always the TCP peer (`RemoteAddr`) — behind a proxy that is the proxy’s address. Consequences: token IP binding must target the proxy’s address (or be omitted), the login rate limit is shared by everyone behind the same proxy, and audit entries show the proxy IP. (The config comment mentions `X-Forwarded-For`; the implementation does not use it.)
* rewrite URLs. Links in notifications, ack links and the OIDC redirect come from `baseUrl`, so set it to the public URL.
Enable `trustProxy` **only** when the proxy is the sole path to the listener and strips/overwrites inbound `X-Forwarded-Proto`; otherwise a client could claim `https` and receive `Secure` cookies over plaintext. Bind the listener to the proxy-facing interface or firewall 8443 so that only the proxy reaches it.
### Caddy
[Section titled “Caddy”](#caddy)
The bundled Compose stack uses this two-line `Caddyfile`: with `DOMAIN` unset Caddy issues an internal self-signed certificate for `localhost`; with a public DNS name it obtains a Let’s Encrypt certificate automatically.
caddy/Caddyfile
```text
# DOMAIN=localhost (default) → Caddy issues an internal self-signed cert.
# DOMAIN=monitoring.example.net (public DNS → this host) → automatic Let's Encrypt.
{$DOMAIN:localhost} {
reverse_proxy northplane:8443
}
```
A stand-alone Caddy in front of a VM (the production pattern described in [Proxmox VM deployment](/docs/deployment/proxmox-vm/)) adds compression and an active health check against `/healthz`:
/etc/caddy/sites/monitoring.caddy
```text
monitoring.example.net {
encode zstd gzip
reverse_proxy 10.10.10.11:8443 {
health_uri /healthz
health_interval 30s
health_timeout 5s
}
}
```
Caddy sets `X-Forwarded-Proto` and `X-Forwarded-For` by default and streams responses without buffering, so SSE (`/api/v1/stream`), the NDJSON export and MCP work unchanged. If Cloudflare or another proxy sits in front of Caddy, declare it with `servers { trusted_proxies static }` so Caddy keeps the original client address in its own logs (Northplane itself does not use it).
### nginx
[Section titled “nginx”](#nginx)
A minimal server block. The important parts are the forwarded-proto header, disabled buffering and a long read timeout for the streaming paths, and a request-body limit large enough for bundle uploads (8 MiB):
/etc/nginx/conf.d/northplane.conf
```text
server {
listen 443 ssl http2;
server_name monitoring.example.net;
ssl_certificate /etc/letsencrypt/live/monitoring.example.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/monitoring.example.net/privkey.pem;
client_max_body_size 9m; # bundles are capped at 8 MiB by the server
location / {
proxy_pass http://127.0.0.1:8443;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
# long-lived responses: SSE stream, NDJSON export, agent chat, MCP
proxy_buffering off;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}
}
```
The SSE hub sends a `: ping` comment every 15 s, which keeps idle proxies from closing the stream; if your proxy has a shorter idle timeout, raise it or the UI’s live updates (and `curl -N …/api/v1/stream`) will reconnect constantly.
### Health checks through the proxy
[Section titled “Health checks through the proxy”](#health-checks-through-the-proxy)
`/healthz` (plain `ok`) and `/readyz` (JSON, 503 when a subsystem is down) need no credentials and are the right probes for proxies and orchestrators. Do not send an `Authorization: Bearer np_…` header from a probe: an invalid `np_` token is rejected with 401 on every path served by the API handler, including `/healthz`. See [Observability](/docs/administration/observability/).
## Listen address examples
[Section titled “Listen address examples”](#listen-address-examples)
| `listen` | Meaning | Plaintext allowed without TLS? |
| ---------------------------- | ----------------------------- | ---------------------------------------------- |
| `127.0.0.1:8443` (default) | IPv4 loopback only | yes |
| `[::1]:8443` | IPv6 loopback only | yes |
| `:8443` | all interfaces, IPv4 and IPv6 | no — needs TLS, `trustProxy` or `tls.insecure` |
| `0.0.0.0:8443` / `[::]:8443` | all interfaces | no |
| `10.10.10.11:8443` | one interface | no |
| `:https` | named port (443) | no |
| `127.0.0.1:0` | kernel-assigned port (tests) | yes |
Ports below 1024 need `CAP_NET_BIND_SERVICE` or root; the reference deployments keep 8443 and let the proxy own 80/443. The other network ports Northplane may open (trap receiver 9162/udp, ESPA 2023, ESPA-X 8123, FastAGI 4573) are unrelated to the HTTP listener and are configured on the respective event sources — see [Deployment overview](/docs/deployment/overview/).
## Security headers
[Section titled “Security headers”](#security-headers)
Every response carries hardening headers; they are fixed in code and cannot be configured:
| Header | Value | When |
| --------------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------- |
| `X-Content-Type-Options` | `nosniff` | always |
| `X-Frame-Options` | `DENY` | always |
| `Referrer-Policy` | `same-origin` | always |
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | only when the request is HTTPS (direct TLS, or `trustProxy` + `X-Forwarded-Proto: https`) |
| `Content-Security-Policy` | see below | all paths except `/api/*` (which carry no CSP); `/docs/*` has its own policy |
The SPA / server-rendered pages policy (verbatim):
```text
default-src 'self'; img-src 'self' data: https://app.stepped.ai; style-src 'self' 'unsafe-inline'; script-src 'self' https://app.stepped.ai 'sha256-HlAiISfjqhgIiTh24Wt2L3bd5wG1TYbHlnpS0PMuIA8='; connect-src 'self' https://app.stepped.ai wss://app.stepped.ai; frame-src 'self' https://app.stepped.ai; frame-ancestors 'none'; base-uri 'self'
```
The `app.stepped.ai` origin and the script hash exist for the embedded Stept assistant (chat widget and product tours) loaded by the SPA and by the login/setup/register pages. Everything else is locked to the own origin; `frame-ancestors 'none'` prevents embedding the UI in another site.
The embedded documentation under `/docs/` uses a separate policy, because Starlight needs inline bootstrap scripts and its search runs WebAssembly:
```text
default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
```
If your proxy adds its own security headers, make sure it does not duplicate or contradict these (two `Content-Security-Policy` headers are combined restrictively by browsers).
## Cookies
[Section titled “Cookies”](#cookies)
| Cookie | Attributes | Lifetime |
| ----------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------- |
| `np_session` | `Path=/`, `HttpOnly`, `SameSite=Lax`, `Secure` iff the request is HTTPS | 12 h; 30 days with “remember me” (`Max-Age` = TTL) |
| `np_oidc_state`, `np_oidc_verifier` | `Path=/auth`, `HttpOnly`, `SameSite=Lax`, `Secure` iff HTTPS | 600 s |
Behind a proxy without `trustProxy`, the `Secure` flag is missing and HSTS is not sent even though users connect over HTTPS — the usual symptom of a forgotten `trustProxy: true`. Session-cookie API requests with `Sec-Fetch-Site: cross-site` are rejected (403 `np:auth/csrf`); there is no CORS support, so browser calls must come from the same origin. Details in [Authentication](/docs/administration/authentication/) and [API overview](/docs/reference/api-overview/).
## Timeouts a proxy should respect
[Section titled “Timeouts a proxy should respect”](#timeouts-a-proxy-should-respect)
The server itself uses `ReadHeaderTimeout` 10 s, `ReadTimeout` 60 s, `IdleTimeout` 120 s and a 30 s response deadline for ordinary requests; the streaming paths `/api/v1/stream`, `/api/v1/events:export`, `/api/v1/ai/chat`, `/mcp` and `/mcp/*` have **no** deadline and can stay open indefinitely. Configure proxy read timeouts accordingly (see the nginx example) and keep response buffering off for those paths. The full list of constants is in [Configuration → Not configurable](/docs/administration/configuration/#not-configurable).
## Troubleshooting
[Section titled “Troubleshooting”](#troubleshooting)
| Symptom | Cause / fix |
| ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `northplaned: serve: no TLS configured on a non-loopback listener …` | You set `listen` to a non-loopback address without a certificate pair. Add `tls.certFile`/`tls.keyFile`, set `trustProxy: true` behind a proxy, or (dev only) `tls.insecure: true`. |
| `TLS cert load failed, refusing to start insecure: …` | Unreadable or mismatched PEM files. Check paths, permissions of the `northplane` user, and that cert and key belong together. |
| `config invalid: tls.certFile set without tls.keyFile` | Both keys of the pair are required. |
| Users are logged out after a browser restart / cookies lack `Secure`, no HSTS header | `trustProxy` is `false` behind a TLS-terminating proxy, or the proxy does not send `X-Forwarded-Proto: https`. |
| SSO redirect goes to `http://…` or the wrong host | `baseUrl` is unset or wrong; it must be the public `https://` URL. |
| Live updates stall behind the proxy | Proxy buffering or idle timeout on `/api/v1/stream`; disable buffering and raise read timeouts. |
| Bundle upload returns 413 from the proxy | Raise the proxy body limit to at least 8 MiB (`client_max_body_size 9m` in nginx). |
| Audit log shows the proxy’s IP | Expected — `X-Forwarded-For` is not evaluated. |
# Upgrades
> How to upgrade Northplane per deployment variant, what migrations do, how to roll back, compatibility notes and the version endpoints to verify with.
Northplane is one binary (or one container image) with the UI, the docs and the schema migrations embedded. Upgrading means replacing that artefact and restarting; migrations run automatically on the first start. The steps differ slightly per deployment variant.
## Before you upgrade
[Section titled “Before you upgrade”](#before-you-upgrade)
1. **Back up**: run `northplaned backup` (or snapshot the data volume) and make sure `secret.key` and `config.yaml` are safe — see [Storage → Backup](/docs/administration/storage/#backup). Schema migrations are forward-only; the backup is your rollback path for data.
2. Note the running version (`GET /api/v1/system/info` or **Admin → System health**) so you can roll back to exactly that artefact.
3. Read the release notes of the target version (GitHub Releases for tagged versions; commit history for `main-` images).
4. Plan a short outage: a restart takes seconds, migrations on the shipped schema take well under a minute, but active checks, SSE clients and agents reconnect afterwards (agents retry pushes; scheduled checks resume from the catalog).
## How versions are identified
[Section titled “How versions are identified”](#how-versions-are-identified)
| Where | What you see |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `northplaned version` | `northplaned ` (also in the `help` header) |
| `GET /api/v1/system/info` (anonymous) | `"version":"…"` together with `goVersion`, `uptime`, `storage` |
| **Admin → System health (System-Health)** | the `system/info` card |
| Login, setup and register pages | version in the footer |
| `GET /api/openapi.json` / `northplaned openapi` | `info.version` |
| MCP (stdio and `/mcp`) | server implementation version |
| Federation | each edge reports its version in the heartbeat; **Admin → Sites (Standorte)** shows it per site |
| Backup manifest | `version` field |
| `np --version` | `np `; the usage text (`np help`) carries it too |
The string is injected at build time (`-ldflags -X main.version=…`): `1.0.0-dev` for local builds, the git tag without the `v` for releases (`1.2.0`), `main-<12-char sha>` for images built from `main`, `docker` for an untagged local `docker build`.
## Upgrade per variant
[Section titled “Upgrade per variant”](#upgrade-per-variant)
### Single binary (systemd)
[Section titled “Single binary (systemd)”](#single-binary-systemd)
1. Install the new binaries. Re-running the installer fetches the **latest release** and replaces `northplaned`, `np` and `np-agent` in place (`install -m 0755`), verifying the SHA-256 checksums:
```bash
curl -fsSL https://raw.githubusercontent.com/myfoxit/northplane/main/install.sh | sh
```
or download a specific `northplane___.tar.gz` + `checksums.txt` from the release page, verify, and copy the three binaries to `/usr/local/bin`. (`NP_VERSION=vX.Y.Z` pins the release — see [Installation](/docs/getting-started/installation/#installsh).)
2. Optional pre-flight: `sudo -u northplane northplaned migrate -config /etc/northplane/config.yaml` applies pending migrations while the old service is still running and prints `migrations applied — schema is current`. The old binary keeps working with the newer schema in the usual case (migrations are additive), so this shortens the restart window.
3. `systemctl restart northplaned`, then watch `journalctl -u northplaned -f` for `storage: applying migration …` lines and `northplane: listening`.
4. Upgrade agents on the hosts (`np-agent` from the same tarball), then restart them (`systemctl restart np-agent`). Agents and server ship from the same release; the push/pull protocol is plain JSON over `/api/v1/results` and `/api/v1/agent/checks` with no version handshake, so upgrading them in either order is fine ([Agent](/docs/monitoring/agent/)).
### Docker
[Section titled “Docker”](#docker)
```bash
docker pull ghcr.io/myfoxit/northplane:latest # or a specific tag
docker stop northplane && docker rm northplane
docker run -d --name northplane -v northplane-data:/var/lib/northplane \
ghcr.io/myfoxit/northplane:latest
```
State lives in the volume (`/var/lib/northplane`: `core.db`, event segments, `tsdb/`, and `secret.key` unless you mounted one), so recreating the container is safe.
### Docker Compose
[Section titled “Docker Compose”](#docker-compose)
```bash
cd /opt/northplane # or wherever the stack lives
docker compose pull
docker compose up -d
docker compose logs -f northplane
```
* The root `docker-compose.yml` references `ghcr.io/myfoxit/northplane:latest`; `pull` fetches whatever `latest` is now.
* The `deploy/` stacks read the image from `NORTHPLANE_IMAGE` in `.env` — pin it to an exact tag (`ghcr.io/myfoxit/northplane:main-daa6dc518a2b` or `:1.2.0`) and change that line to upgrade. Keep the previous value (the CI keeps it as `.env.previous`) for rollback.
* Caddy is upgraded the same way (`caddy:2-alpine`); certificates persist in the `caddy-data` volume.
Details of the stacks: [Docker Compose deployment](/docs/deployment/docker-compose/).
### CI-driven production
[Section titled “CI-driven production”](#ci-driven-production)
On the reference production instance nothing is done by hand: a merge to `main` triggers CI, and a green CI run triggers the Deploy workflow, which builds and pushes `ghcr.io/myfoxit/northplane:main-` (+ `latest`), renders a fresh `.env` on the server (keeping the old one as `.env.previous`), runs `docker compose pull && docker compose up -d --remove-orphans`, and then verifies for up to 12 × 5 s that the container runs the wanted image **and** `curl http://localhost:8443/healthz` answers `ok`. If verification fails it restores `.env.previous` and brings the previous image back up — an automatic rollback. Manual runs (`workflow_dispatch`) can also flip demo mode. The whole chain, the GitHub variables/secrets and how to read a red run are documented in [CI/CD](/docs/deployment/ci-cd/); the current state of each environment is in [Environments](/docs/deployment/environments/).
Tagged releases (`v*`) additionally produce the tarballs, the Windows zip (`np`/`np-agent` only) and semver image tags ([Release process](/docs/development/release-process/)).
## Schema migrations
[Section titled “Schema migrations”](#schema-migrations)
* Migrations are applied automatically by the first command that opens the store after the upgrade — normally `serve` — inside one transaction per migration, and logged as `storage: applying migration version=N name=…`. `northplaned migrate` does the same without starting the server.
* The migration list is embedded in the binary (9 migrations as of this version: `core`, `seed`, `user_roles`, `report_archive_slot`, `alert_ticket`, `hotpath_indices`, `user_tenant`, `ai_agent_chat`, `alert_snooze`) and tracked in the `schema_version` table; see [Storage → Schema migrations](/docs/administration/storage/#schema-migrations).
* A failing migration stops the start (`northplaned: storage: migration N "name": …`); the database is left at the last successfully committed migration. Fix the cause (disk, permissions, a PostgreSQL privilege) and start again.
* Boot also reconciles built-in roles additively (for example the `operator` role gains `alerts:write` if missing) and seeds the break-glass admin if no enabled local admin exists — both are idempotent and logged.
* The NP-TSDB has no migration step; its block and aggregate files carry format headers (`NPBLOCK1`, `NPAGGR1`) and are opened in place.
## Rollback
[Section titled “Rollback”](#rollback)
Northplane has no down-migrations, so the cleanest rollback restores the pre-upgrade backup together with the previous artefact:
| Variant | Artefact rollback | Data rollback |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Compose (deploy stacks) | `mv .env.previous .env && docker compose up -d`, or set `NORTHPLANE_IMAGE` back to the previous tag | restore `core.db`, `events-*.db`, `tsdb/` from the backup into the volume while the container is stopped ([Restore](/docs/administration/storage/#restore)) |
| Compose (root stack, `:latest`) | `docker compose pull` cannot go back by itself — set `image:` to an explicit older tag and `up -d` | same |
| Docker | run the older tag | same |
| Single binary | reinstall the previous tarball’s binaries, `systemctl restart northplaned` | restore the data directory from the backup |
| CI-driven | re-run the Deploy workflow for the previous green commit, or repoint `NORTHPLANE_IMAGE` on the server and `up -d`; the workflow rolls back automatically when verification fails | restore the volume on the VM |
Because the migration runner only applies versions it knows and ignores higher ones, an older binary usually **starts** against a newer schema (the added columns/tables are simply unused). That is convenient for a quick revert after a bad deploy, but it is not a supported state to run in for long — restore the backup or move forward again.
## Compatibility notes
[Section titled “Compatibility notes”](#compatibility-notes)
* **UI and docs are embedded** in the binary/image, so they are always exactly in sync with the API — there is nothing to clear or redeploy separately. Browsers pick up the new assets on reload (`/assets/*` are content-hashed and cached immutably; `index.html` is `no-cache`).
* **API**: all routes live under `/api/v1`; responses are RFC 9457 problem documents; the OpenAPI document is generated from the route registry and the TypeScript client types are drift-checked in CI (`make types-check`), so the UI cannot silently lag behind the API. External clients should tolerate new fields in JSON responses.
* **Tokens, sessions, secrets** survive upgrades; sessions are stored in the database, tokens are hashed rows, secrets are sealed with `secret.key`. Never change `secret.key` as part of an upgrade.
* **Federation**: main and edge instances are independent full installations; each reports its version in the heartbeat, and **Admin → Sites** shows it. Upgrade them independently; the pull/heartbeat protocol (`sites:pull` with ETag, `sites:heartbeat` JSON) has no version negotiation, so keep both on the same major version.
* **MCP clients** connect with the same API tokens; tool lists may grow between versions.
* **Event retention, TSDB retention** and other constants may change between versions — re-read [Configuration → Not configurable](/docs/administration/configuration/#not-configurable) after major upgrades.
## Verifying an upgrade
[Section titled “Verifying an upgrade”](#verifying-an-upgrade)
1. `curl -fsS https:///healthz` → `ok`; `curl -fsS https:///readyz` → `"ready":true`.
2. `curl -fsS https:///api/v1/system/info` → the expected `version`.
3. Logs show the expected migration lines (or none) and no `background worker panicked` messages.
4. Log in, open **Overview** and **Admin → System health**; queue depths near zero, `scheduler.scheduled` equals your object count.
5. Trigger a check (`np check-now `) and a test notification (`POST /api/v1/channels/{name}:test-notification`) to confirm the pipeline and the outbox.
6. `np audit verify` → `audit chain intact (N entries verified)`.
7. If you run agents or an edge, check **Admin → Agents** / **Admin → Sites** for fresh heartbeats.
# Users, roles and permissions
> Reference for Northplane RBAC — user accounts and their sources, built-in and custom roles, the permission syntax and wildcard semantics, the complete permission list, and which permission every API route checks.
Authorization in Northplane is role-based: a **user** holds role names, a **role** holds permissions (and may include other roles), and every API route checks one **permission** string. API tokens carry permissions directly as scopes and/or through roles. This page is the reference; the conceptual overview lives in [Tenancy and RBAC](/docs/concepts/tenancy-rbac/), and how credentials are obtained in [Authentication](/docs/administration/authentication/).
## Users
[Section titled “Users”](#users)
### The user record
[Section titled “The user record”](#the-user-record)
| Field | Meaning |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | UUIDv7 |
| `name`, `email` | Display name and login e-mail. E-mail is unique **across the whole instance**, not per tenant. |
| `subject` | Identity-provider subject: `issuer\|sub` for OIDC users, `ldap\|` for directory users; empty for local users |
| `tenantId` | Home tenant (empty = Default). A local login lands in this tenant; OIDC and LDAP users always get the Default tenant. |
| `local` | `true` = password account (created by `/setup`, `/register`, `POST /users` or default-admin seeding); `false` = OIDC (just-in-time provisioned) or LDAP-synced |
| `roles` | Role names. Authoritative for local and LDAP users (LDAP sync writes them); OIDC users get their roles recomputed from IdP groups at every login and usually have an empty list here. |
| `disabled` | A disabled user cannot log in, and existing sessions are rejected on the next request |
| `lastSeenAt` | Stamped at most once per minute while the user is active |
| `version`, `createdAt`, `updatedAt` | |
`passHash` (argon2id) is never returned by the API.
### Managing users in the UI
[Section titled “Managing users in the UI”](#managing-users-in-the-ui)
**Admin → Users (Benutzer)** lists every account with Name (plus an LDAP/OIDC badge for non-local accounts), E-Mail, Rollen, Status and “zuletzt gesehen”. **Benutzer anlegen** opens a dialog with Name, E-Mail, Passwort (≥ 12 characters), Rollen (with suggestions from the roles list) and a **Deaktiviert** switch. Each row offers **Passwort setzen** (local users only), **Bearbeiten** and **Löschen**. Two cards at the bottom handle the LDAP directory sync (when configured) and **Mein Passwort ändern** for the signed-in user.
### User endpoints
[Section titled “User endpoints”](#user-endpoints)
All routes need `admin:users` unless stated otherwise. New users are created in the caller’s active tenant (the `X-Northplane-Tenant` header is honoured for `admin:tenants` holders — this is how a central admin provisions a customer login).
| Endpoint | Behaviour |
| -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`GET /api/v1/users`](/docs/reference/api/operations/get_users/) | Lists the users of the **effective tenant** (home tenant; cross-tenant admins select one via `X-Northplane-Tenant`), ordered by name |
| [`GET /api/v1/users/{id}`](/docs/reference/api/operations/get_users_id/) | One user |
| [`POST /api/v1/users`](/docs/reference/api/operations/post_users/) | `{name, email, password?, roles?, disabled?}` → `201 User`. `password` is optional (≥ 12 characters when given); without it the account can only log in via OIDC until an admin sets one. Duplicate e-mail → `409 np:users/email-in-use`. Audit `user.create`. |
| [`PUT /api/v1/users/{id}`](/docs/reference/api/operations/put_users_id/) | Partial update `{name?, email?, roles?, disabled?}` — absent fields stay unchanged. Audit `user.update` with before/after. |
| [`POST /api/v1/users/{id}:set-password`](/docs/reference/api/operations/post_users_id_set_password/) | `{password}` (≥ 12); an empty password clears it (OIDC-only account). Audit `user.set-password` (no values logged). |
| [`POST /api/v1/users/me:change-password`](/docs/reference/api/operations/post_users_me_change_password/) | No permission, but only for a **session** principal (tokens get 401). `{oldPassword, newPassword}` → 204; wrong current password → `403 np:auth/bad-password`. Audit `user.change-password`. |
| [`DELETE /api/v1/users/{id}`](/docs/reference/api/operations/delete_users_id/) | 204. Audit `user.delete`. |
```bash
curl -s -X POST https://monitoring.example.net/api/v1/users \
-H "Authorization: Bearer np_<48 hex>" -H "Content-Type: application/json" \
-d '{"name":"Jane Doe","email":"jane@example.net","password":"","roles":["operator"]}'
```
Last-admin guard
`PUT` (disabling or removing `admin`) and `DELETE` refuse to remove the **last enabled local admin** with `409 np:users/last-admin`. Only *local, enabled* users holding `admin` count — SSO admins are not considered durable break-glass accounts.
Role names in `roles` are not validated against existing roles: an unknown name simply contributes no permissions.
### User preferences
[Section titled “User preferences”](#user-preferences)
Each actor has one preferences document: `{refreshIntervalMs?: int, extra?: map[string]string}`. `refreshIntervalMs` is `0` for “off” or `1000`–`86400000` (otherwise 422). [`GET`](/docs/reference/api/operations/get_users_id_preferences/)/[`PUT /api/v1/users/{id}/preferences`](/docs/reference/api/operations/put_users_id_preferences/) — `{id}` may be `me` or your own actor id without any permission; another id requires `admin:users`. `PUT` replaces the whole document (audit `preferences.update`). The UI uses it for the refresh presets (5 s / 10 s / 30 s / 60 s / off, default 30 s).
Two things that look like preferences but are not: the UI **language** follows `navigator.language` (German for `de*`, otherwise English) and is not stored; the colour **theme and mode** are instance-wide branding, not per user — see [Branding and themes](/docs/administration/branding-and-themes/).
## Roles
[Section titled “Roles”](#roles)
### Built-in roles
[Section titled “Built-in roles”](#built-in-roles)
Four system roles (`system: true`) are seeded into the Default tenant by migration and into every new tenant on creation:
| Role | Permissions |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `admin` | `*:*` |
| `operator` | `objects:read`, `objects:write`, `checks:run`, `alerts:read`, `alerts:ack`, `alerts:write`, `incidents:read`, `incidents:write`, `downtimes:write`, `silences:write`, `events:read`, `metrics:read`, `oncall:read`, `oncall:write`, `dashboards:read`, `dashboards:write`, `reports:read`, `reports:render` |
| `viewer` | `objects:read`, `alerts:read`, `incidents:read`, `events:read`, `metrics:read`, `oncall:read`, `dashboards:read`, `reports:read` |
| `ai-agent` | `objects:read`, `alerts:read`, `alerts:ack`, `incidents:read`, `incidents:write`, `events:read`, `metrics:read`, `oncall:read`, `checks:run`, `downtimes:write`, `silences:write`, `config:propose`, `reports:render` |
Consequences worth knowing:
* `operator` and `viewer` hold no `admin:*` permission — they cannot list roles, users, tokens, secrets, the audit log or tenants.
* `operator` holds no `config:write`: an operator manages hosts and services but cannot edit templates, check commands, alert rules, channels, event sources, dashboards or reports. Among the built-ins only `admin` can.
* A “tenant-admin” style role (everything except `admin:tenants`) is a **custom** role; see the example in [Tenants and sites](/docs/administration/tenants-and-sites/).
* At boot the server reconciles the system role `operator` to include `alerts:write` in every tenant (only roles with `system: true` are touched).
### Custom roles
[Section titled “Custom roles”](#custom-roles)
A role is a tenant-scoped document (`kind: role`) at [`/api/v1/roles`](/docs/reference/api/operations/get_roles/):
```json
{
"name": "noc-l1",
"permissions": ["objects:read", "alerts:read", "alerts:ack", "admin:read"],
"includes": ["viewer"],
"idpGroups": ["np-noc-l1", "cn=noc-l1,ou=groups,dc=example,dc=net"],
"scope": { "tenantId": "", "folder": "/", "selector": "" },
"system": false
}
```
| Field | Meaning |
| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | Unique per tenant; used in user role lists, token `roles` and `includes` |
| `permissions` | Permission strings (see below) |
| `includes` | Names of roles whose permissions are added — expanded recursively, depth ≤ 8, cycle-safe. Unknown names are ignored. |
| `idpGroups` | Group identifiers from the IdP or directory that map onto this role at OIDC login or LDAP sync. OIDC matching is an exact string compare; LDAP matching is lower-cased and accepts the full group DN or its first RDN value (`cn`). Only roles in the **Default tenant** are consulted for mapping. |
| `scope.tenantId`, `scope.folder`, `scope.selector` | Stored and editable, **not enforced** (see below) |
| `system` | Built-in marker; the UI hides edit/delete for system roles |
Endpoints: `GET /api/v1/roles` (`?q=&cursor=&limit=`, default 500) and `GET /api/v1/roles/{name}` need `admin:read`; [`POST /api/v1/roles`](/docs/reference/api/operations/post_roles/), [`PUT /api/v1/roles/{name}`](/docs/reference/api/operations/put_roles_name/) (with `If-Match`) and [`DELETE /api/v1/roles/{name}`](/docs/reference/api/operations/delete_roles_name/) need `admin:write`. Mutations are audited as `role.create|update|delete`. `Role` is also a [config bundle](/docs/administration/config-bundles/) kind for apply, but bundle **export** skips roles.
**Admin → Roles (Rollen)** shows Name (with a “System” badge), Berechtigungen, Erbt von (includes) and IdP-Gruppen; the dialog edits Name, the permission list, Inherits, IdP groups and the scope fields.
One gap in this version
* **Folder and selector scope are not implemented.** `scope.folder`, `scope.selector` and `scope.tenantId` are persisted, but the authenticator never populates a folder scope on the principal, so the check on host/service create/update always passes and the selector is never evaluated. Tenant isolation comes from the principal’s tenant, not from `scope.tenantId`. Treat a role as tenant-wide.
System roles are immutable through the API: `PUT`/`DELETE /api/v1/roles/{name}` on a `system: true` role returns `403 np:rbac/system-role` (the seed/reconcile paths write through the store directly). To vary a built-in, create a custom role — optionally with `includes` — instead.
## Permission model
[Section titled “Permission model”](#permission-model)
A permission is a string `resource:action`. A held permission **implies** a wanted one when:
* they are equal, or the held one is `*:*` or `*`;
* otherwise both contain a colon and the resource part matches (`*` or equal) **and** the action part matches (`*` or equal).
| Held | Wanted | Result |
| -------------------- | --------------- | ------------------------------------------------------------- |
| `*:*` or `*` | anything | allowed |
| `admin:*` | `admin:users` | allowed |
| `*:read` | `objects:read` | allowed |
| `objects:read` | `objects:write` | denied |
| `objects` (no colon) | `objects:read` | denied — a malformed permission only matches itself literally |
The same logic is ported to the UI (`web/src/permissions.ts`) for hiding controls; the server decides.
### How permissions are resolved per request
[Section titled “How permissions are resolved per request”](#how-permissions-are-resolved-per-request)
* **Session principal**: the role names stored in the session at login are expanded (including `includes`) in the session’s tenant on **every** request. Editing a role’s permissions therefore applies immediately; changing a user’s role list applies at the next login.
* **Token principal**: `scopes` ∪ permissions of the token’s `roles`, roles resolved in the **token’s** tenant.
* The `X-Northplane-Tenant` header changes which tenant a request acts on (for `admin:tenants` holders) but never which permissions the principal has.
## Permission reference
[Section titled “Permission reference”](#permission-reference)
Every permission string that any route or AI/MCP tool checks:
| Permission | What it allows |
| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `objects:read` | Read hosts, services, objects, problems, overview, effective config, the builtin check list; list downtimes, silences, heartbeats, discovery scans; business-service tree/impact/SLA; report archive; bundle plan and export; sites overview; agent check pull (`GET /api/v1/agent/checks`); **read of all config-document kinds** (templates, check commands, time periods, alert rules, alert groups, escalation policies, channels, event sources, business services, dashboards, reports, saved filters, webhooks, IVR menus, sites) |
| `objects:write` | Create/update/delete hosts and services, `POST /objects:batch`, passive results `POST /results`, heartbeat beats |
| `config:write` | Create/update/delete all config-document kinds listed above, heartbeat definitions, bundle apply, branding, discovery scan start, channel test-notification, report `:run`, approve AI actions |
| `checks:run` | `check-now`, `POST /check-commands:test` |
| `alerts:read` | List/get alerts, dead letters, AI action queue, alert-rule tests and escalation-policy simulation |
| `alerts:ack` | Ack/resolve/snooze alerts, dead-letter replay, deny AI actions |
| `alerts:write` | Raise alerts manually |
| `incidents:read` | List/get incidents |
| `incidents:write` | Create/update/resolve/merge/summarize incidents |
| `downtimes:write` | Create/cancel downtimes |
| `silences:write` | Create/expire silences |
| `events:read` | Event search/export, SSE stream, and all AI chat endpoints (`/ai/conversations`, `/ai/chats`, `/ai/connections`, `/ai/tools`, `/ai/providers`, `/ai/chat`) |
| `metrics:read` | Metrics query, object metric series |
| `oncall:read` | On-call now/timeline/ICS/overrides/stats; `GET` of schedules, contacts, contact groups |
| `oncall:write` | `POST/PUT/DELETE` of schedules, contacts, contact groups; schedule overrides |
| `reports:render` | `POST /reports/{name}:render` |
| `admin:read` | List/get roles |
| `admin:write` | Create/update/delete roles |
| `admin:users` | Users CRUD, set-password, directory status/sync, other users’ preferences |
| `admin:tokens` | API tokens create/list/revoke/rotate |
| `admin:secrets` | Secrets put/list/delete |
| `admin:audit` | Audit search/export/verify, contact GDPR data export |
| `admin:tenants` | List/create tenants **and** the right to act on another tenant via `X-Northplane-Tenant` |
| `admin:ai` | AI tool policy get/put |
| `sites:connect` | Federation edge heartbeat and bundle pull |
| `dashboards:read`, `dashboards:write`, `reports:read` | Granted by built-in roles but **checked by no route** — dashboard and report CRUD use `objects:read` / `config:write` |
| `config:propose` | In the built-in `ai-agent` role; checked by no route and no AI tool (the `propose_config_change` tool requires `config:write`) |
| `maintenance:write` | Appears in the MCP tab’s “Read + operate” scope preset; checked by no route (downtimes and silences use `downtimes:write` / `silences:write`) |
AI and MCP tools check the same permission names with the same wildcard logic — see [Agent chat](/docs/ai/agent-chat/) and [MCP server](/docs/ai/mcp-server/).
## Route-to-permission table
[Section titled “Route-to-permission table”](#route-to-permission-table)
Every API operation publishes its permission as `x-required-permission` in `/api/openapi.json`, and the generated [REST API reference](/docs/reference/api/) shows it per operation. `—` means no permission check (the handler may still require a login).
| Method and path | Permission |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| `GET /api/v1/whoami` | — (401 if anonymous) |
| `GET /api/v1/tenants`, `POST /api/v1/tenants` | `admin:tenants` |
| `GET /api/v1/roles`, `GET /api/v1/roles/{name}` | `admin:read` |
| `POST /api/v1/roles`, `PUT/DELETE /api/v1/roles/{name}` | `admin:write` |
| `POST/GET /api/v1/api-tokens`, `DELETE /api/v1/api-tokens/{id}`, `POST /api/v1/api-tokens/{id}:rotate` | `admin:tokens` |
| `PUT /api/v1/secrets/{name}`, `GET /api/v1/secrets`, `DELETE /api/v1/secrets/{name}` | `admin:secrets` |
| `GET /api/v1/audit`, `GET /api/v1/audit:export`, `POST /api/v1/audit:verify`, `GET /api/v1/contacts/{name}:data-export` | `admin:audit` |
| `GET /api/v1/notifications/dead-letters` / `POST …/{id}:replay` | `alerts:read` / `alerts:ack` |
| `POST/DELETE /api/v1/push-subscriptions` | — (principal required) |
| `GET /api/v1/users`, `GET /api/v1/users/{id}`, `POST /api/v1/users`, `PUT /api/v1/users/{id}`, `POST /api/v1/users/{id}:set-password`, `DELETE /api/v1/users/{id}` | `admin:users` |
| `POST /api/v1/users/me:change-password` | — (session user only) |
| `GET/PUT /api/v1/users/{id}/preferences` | — for `me`/own id; `admin:users` for others |
| `GET /api/v1/branding` / `PUT /api/v1/branding` | — (login required) / `config:write` |
| `GET /api/v1/directory/status`, `POST /api/v1/directory:sync` | `admin:users` |
| `GET /api/v1/objects`, `/hosts`, `/services`, `GET /api/v1/objects/{id}`, `GET …/effective-config`, `GET /api/v1/problems`, `GET /api/v1/check-commands:builtins`, `GET /api/v1/overview` | `objects:read` |
| `POST /api/v1/hosts`, `POST /api/v1/services`, `PUT/DELETE /api/v1/objects/{id}`, `POST /api/v1/objects:batch` | `objects:write` |
| `POST /api/v1/objects/{id}/check-now`, `POST /api/v1/check-commands:test` | `checks:run` |
| CRUD of `templates`, `check-commands`, `time-periods`, `alert-rules`, `alert-groups`, `escalation-policies`, `channels`, `event-sources`, `business-services`, `dashboards`, `reports`, `saved-filters`, `webhooks`, `ivr-menus`, `sites` | `objects:read` (GET) / `config:write` (POST/PUT/DELETE) |
| CRUD of `schedules`, `contacts`, `contact-groups` | `oncall:read` (GET) / `oncall:write` (POST/PUT/DELETE) |
| `GET /api/v1/alerts`, `GET /api/v1/alerts/{id}` | `alerts:read` |
| `POST /api/v1/alerts` | `alerts:write` |
| `POST /api/v1/alerts/{id}:ack`, `:resolve`, `:snooze` | `alerts:ack` |
| `GET /api/v1/incidents`, `GET /api/v1/incidents/{id}` | `incidents:read` |
| `POST /api/v1/incidents`, `PUT /api/v1/incidents/{id}`, `POST …/{id}:resolve`, `:merge`, `:summarize` | `incidents:write` |
| `POST /api/v1/alert-rules:test`, `POST /api/v1/alert-rules/{name}:test`, `POST /api/v1/escalation-policies/{name}:simulate` | `alerts:read` |
| `POST /api/v1/downtimes`, `DELETE /api/v1/downtimes/{id}` / `GET /api/v1/downtimes` | `downtimes:write` / `objects:read` |
| `POST /api/v1/silences`, `DELETE /api/v1/silences/{id}` / `GET /api/v1/silences` | `silences:write` / `objects:read` |
| `GET /api/v1/oncall/now`, `GET /api/v1/schedules/{name}/timeline`, `/ics`, `/overrides`, `/stats` | `oncall:read` |
| `POST /api/v1/schedules/{name}/overrides`, `DELETE …/overrides/{id}` | `oncall:write` |
| `POST /api/v1/channels/{name}:test-notification` | `config:write` |
| `GET /api/v1/events`, `GET /api/v1/events:export`, `GET /api/v1/stream` | `events:read` |
| `POST /api/v1/metrics/query`, `GET /api/v1/objects/{id}/metrics` | `metrics:read` |
| `POST /api/v1/results` | `objects:write` |
| `GET /api/v1/heartbeats` / `POST /api/v1/heartbeats`, `DELETE /api/v1/heartbeats/{name}` / `GET`/`POST /api/v1/heartbeats/{name}/beat` | `objects:read` / `config:write` / `objects:write` |
| `POST /api/v1/config/bundles:plan`, `GET /api/v1/config/bundles:export` / `POST /api/v1/config/bundles:apply` | `objects:read` / `config:write` |
| `GET /api/v1/business-services:tree`, `GET /api/v1/objects/{id}/impact`, `GET /api/v1/business-services/{name}/sla` | `objects:read` |
| `POST /api/v1/reports/{name}:render` / `GET …/archive`, `GET …/archive/{id}` / `POST …:run` | `reports:render` / `objects:read` / `config:write` |
| `POST /api/v1/discovery/scans` / `GET /api/v1/discovery/scans`, `GET …/{id}` | `config:write` / `objects:read` |
| `GET /api/v1/agent/checks` | `objects:read` |
| `GET /api/v1/system/health`, `GET /api/v1/system/info` | — (anonymous) |
| `GET /api/v1/sites:overview` | `objects:read` |
| `POST /api/v1/sites/{name}:heartbeat`, `GET /api/v1/sites/{name}:pull` | `sites:connect` |
| `/api/v1/ai/conversations`, `/ai/providers`, `/ai/connections` (+ `:test`, `/models`), `/ai/tools`, `/ai/chats` (+ messages), `POST /api/v1/ai/chat` | `events:read` |
| `GET /api/v1/ai/actions` / `POST …/{id}:approve` / `POST …/{id}:deny` | `alerts:read` / `config:write` / `alerts:ack` |
| `GET/PUT /api/v1/ai/policy` | `admin:ai` |
| Raw routes with their own auth: `POST /api/v1/ingest/{source}` (+ `/alertmanager`), `GET /api/v1/ack/{token}`, `POST /api/v1/voice/gather/{token}`, `POST /api/v1/voice/inbound/{source}` (+ `/menu`, `/transcription`), `POST /api/v1/sms/inbound/{source}`, `GET /api/openapi.json`, `GET /api/docs`, `GET /healthz`, `GET /readyz`, `GET /metrics` | — |
The full per-operation list, including request and response schemas, is in the [REST API reference](/docs/reference/api/).
# AI agent chat
> The built-in AI agent — provider connections, tool policy, the 22 tools and their approval gates, chats versus the legacy assistant sidebar, incident summaries, the stream protocol, redaction, audit, RBAC and configuration.
Northplane ships an AI agent that can read and — with human approval — operate your monitoring through the same tools the REST API exposes. The agent is a **privilege-less client**: every tool call is checked against the calling user’s permissions, mutating actions ride an approval queue, and everything is audited. The language model comes from a provider you connect (Anthropic, OpenAI, Google, a local Ollama, …); Northplane never ships a model and never sends data anywhere until you configure a provider.

## The three surfaces
[Section titled “The three surfaces”](#the-three-surfaces)

| Surface | Where | Model comes from | Persisted as | Needs server-level `ai.provider`? |
| ------------------------------ | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------ |
| **Agent chat page** | `/agent` (nav entry **AI agent** / *KI-Agent*) — streaming chat with tool cards, approvals inline | a **provider connection** (personal or shared per tenant) with a SecretBox-sealed API key | `ai_chats` + per-message `ai_chat_messages` | no |
| **Assistant sidebar** (legacy) | header button **Assistant (⌘I)** or Ctrl/⌘+I — non-streaming, one reply with “action cards” | the **server-level** `ai:` config (`ai.provider`, `ai.model`, …) | `ai_conversations` (one transcript blob) | yes — otherwise it shows `AI provider not configured (ai.provider=none)` |
| **MCP server** | `/mcp` (Streamable HTTP) and `northplaned mcp` (stdio) — your MCP client’s model | the MCP client | only the approval queue + audit | no |
All three funnel tool execution through one gate: **tenant tool policy → RBAC of the calling principal → propose/approve for mutating tools → execute → audit**. The MCP surface is documented on [MCP server](/docs/ai/mcp-server/).
What still depends on the server-level provider
`Service.Enabled()` is true only when `ai.provider` in `config.yaml` is not `none`. It gates the legacy sidebar, `POST /incidents/{id}:summarize` and background incident summaries. The agent chat, MCP and the execution of approved actions do not need it.
## Providers
[Section titled “Providers”](#providers)
`GET /api/v1/ai/providers` ([get\_ai\_providers](/docs/reference/api/operations/get_ai_providers/), `events:read`) returns the catalog in the order the UI shows it. Every provider speaks one of two wire dialects: the native Anthropic Messages API (`/v1/messages`, SSE) or the OpenAI Chat Completions SSE dialect (`/chat/completions`).
| id | Label | Dialect | Default endpoint | API key | Curated fallback models (first = suggested default) | Quirks handled |
| --------------- | ----------------------------------- | --------- | --------------------------------------------------------- | -------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `anthropic` | Anthropic Claude | anthropic | `https://api.anthropic.com` | required | `claude-opus-4-8`, `claude-fable-5`, `claude-sonnet-5`, `claude-haiku-4-5` | adaptive thinking for current model families |
| `openai` | OpenAI | openai | `https://api.openai.com/v1` | required | `gpt-5.6`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.2` | `reasoning_effort`; `max_completion_tokens` |
| `google` | Google Gemini | openai | `https://generativelanguage.googleapis.com/v1beta/openai` | required | `gemini-3.5-flash`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite` | `reasoning_effort`; `max_completion_tokens`; `models/` prefix stripped in listings |
| `xai` | xAI Grok | openai | `https://api.x.ai/v1` | required | `grok-4.5`, `grok-4.3` | `reasoning_effort`; `max_completion_tokens` |
| `mistral` | Mistral | openai | `https://api.mistral.ai/v1` | required | `mistral-large-latest`, `mistral-medium-latest`, `mistral-small-latest` | — |
| `deepseek` | DeepSeek | openai | `https://api.deepseek.com/v1` | required | `deepseek-v4-pro`, `deepseek-v4-flash` | `reasoning_effort`; `reasoning_content` echoed back in tool loops |
| `groq` | Groq | openai | `https://api.groq.com/openai/v1` | required | `openai/gpt-oss-120b`, `llama-3.3-70b-versatile`, `llama-3.1-8b-instant` | — |
| `openrouter` | OpenRouter | openai | `https://openrouter.ai/api/v1` | required | `openrouter/auto`, `anthropic/claude-sonnet-5`, `openai/gpt-5.6-luna` | `reasoning_details` echoed; `HTTP-Referer` (= `baseUrl` or the GitHub URL) + `X-Title: Northplane` |
| `ollama` | Ollama (local) | openai | `http://localhost:11434/v1` | **none** | none — purely live via `/v1/models` | — |
| `openai-compat` | OpenAI-compatible (custom endpoint) | openai | **none — you must supply one** | none | none | — |
Curated lists are dated fallbacks (July 2026 in the code) used when the live model listing fails. Azure OpenAI is **not** a connection provider; it only exists in the legacy server-level config. All provider traffic is outbound HTTPS from `northplaned` itself — the browser only ever talks to its own origin — so allow the endpoints above (or your custom ones) in egress firewalls.
## Provider connections
[Section titled “Provider connections”](#provider-connections)
A connection = provider + endpoint + sealed API key + default model, owned by a user (personal) or by the tenant (**shared**, `userId` empty). Wire shape:
```json
{
"id": "0199…", "shared": false, "name": "My Anthropic account",
"provider": "anthropic", "endpoint": "",
"keyHint": "…abcd", "hasKey": true,
"defaultModel": "claude-sonnet-5", "extra": {}, "disabled": false,
"version": 1, "createdAt": "…", "updatedAt": "…"
}
```
The API key is never returned — only its last four characters as `keyHint`. Keys are sealed with the platform SecretBox (AES-256-GCM, master key from `secretKeyFile`); without a usable master key, creating a keyed connection fails with `secret store disabled — configure secretKeyFile to store provider keys` (see [Secrets](/docs/administration/secrets/)). Keyless providers (Ollama, openai-compat without key) work without a SecretBox.
| Method + path | Permission | Notes |
| ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/v1/ai/connections` ([get\_ai\_connections](/docs/reference/api/operations/get_ai_connections/)) | `events:read` | own connections first, then shared, each group by name |
| `POST /api/v1/ai/connections` ([post\_ai\_connections](/docs/reference/api/operations/post_ai_connections/)) | `events:read`; plus `admin:ai` when `shared: true` | 201 with the connection |
| `PUT /api/v1/ai/connections/{id}` ([put\_ai\_connections\_id](/docs/reference/api/operations/put_ai_connections_id/)) | `events:read`; plus `admin:ai` for shared | body must carry `"shared": true` to edit a shared one; `provider` is immutable |
| `DELETE /api/v1/ai/connections/{id}?shared=true` ([delete\_ai\_connections\_id](/docs/reference/api/operations/delete_ai_connections_id/)) | `events:read`; plus `admin:ai` for shared | the query parameter selects the shared record |
| `POST /api/v1/ai/connections/{id}:test` ([post\_ai\_connections\_id\_test](/docs/reference/api/operations/post_ai_connections_id_test/)) | `events:read` | lists models; `{"status":"ok","models":}` or `400 np:ai/invalid` with the provider’s error text |
| `GET /api/v1/ai/connections/{id}/models` ([get\_ai\_connections\_id\_models](/docs/reference/api/operations/get_ai_connections_id_models/)) | `events:read` | `{"items":[{"id","label"?,"curated"?}],"note":""}` — curated first, then live models sorted by id; if the live listing fails but curated models exist the call still succeeds with a `note` |
Create/update body:
```json
{
"name": "Team OpenRouter",
"provider": "openrouter",
"endpoint": "",
"apiKey": "sk-or-v1-…",
"defaultModel": "anthropic/claude-sonnet-5",
"extra": {},
"shared": true,
"disabled": false
}
```
Validation rules:
* `name` is required; an unknown `provider` → `unknown provider "x"`; a duplicate name → `409 np:conflict`.
* `endpoint` is trimmed and a trailing `/` removed. If it differs from the catalog default it must start with `http://` or `https://` **and the caller needs `config:write`** (`custom endpoints require config:write`) — because it makes the server POST to an arbitrary URL. `openai-compat` has no default, so an endpoint is mandatory there.
* `apiKey`: on create required when the provider needs one (`provider "x" requires an API key`); on update omitted/`null` = keep, `""` = clear, non-empty = rotate.
* A disabled connection cannot be used for chats (`connection "x" is disabled`).
* Audit: `ai.connection.create`, `ai.connection.update` (payload says whether the key was rotated), `ai.connection.delete`.
In the UI, personal connections live in the **AI providers** dialog of the agent page (name, provider, key hint, default model, **Test** button → “N models” or “Test failed”); shared ones are managed under **Admin → AI providers** and appear read-only with a **Shared** badge for other users.
## Tool policy
[Section titled “Tool policy”](#tool-policy)
The tenant-wide policy decides which tools exist, which mutating tools skip approval, and how many tool rounds a turn may take. Stored per tenant; zero value = defaults.
```json
{
"disabled": ["delete_config_resource", "apply_config_change"],
"autoApprove": ["create_silence"],
"maxRounds": 12,
"version": 3
}
```
| Field | Semantics |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled[]` | tools neither advertised to the model (agent chat, legacy sidebar, MCP) nor executable — execution is refused with `tool "x" is disabled by policy` and audited as `ai.disabled.`. |
| `autoApprove[]` | mutating tools that skip the approval queue (still RBAC-checked and audited). Only valid for mutating tools: `tool "x" is read-only — autoApprove applies to mutating tools`. |
| `maxRounds` | agent-loop cap per user turn: `0` = default **10**, maximum **24** (`maxRounds must be between 0 (default) and 24`). Not applied to the legacy sidebar (fixed 8). |
| `version` | incremented on every save |
Endpoints: `GET /api/v1/ai/policy` ([get\_ai\_policy](/docs/reference/api/operations/get_ai_policy/)) and `PUT /api/v1/ai/policy` ([put\_ai\_policy](/docs/reference/api/operations/put_ai_policy/)), both **`admin:ai`** (implied by `admin:*` and `*:*`); unknown tool names → `unknown tool "x"`; audit `ai.policy.update` with the full policy. `PUT` does not enforce `If-Match` — last write wins. `GET /api/v1/ai/tools` ([get\_ai\_tools](/docs/reference/api/operations/get_ai_tools/), `events:read`) returns the catalog with the effective policy state: `items: [{name, description, mutating, autoOk, disabled, autoApprove}]`, sorted by name.
There are **no** per-connection allow-lists, per-tenant token budgets or per-tool argument limits. The only hard per-tool limit is the downtime cap: `create_downtime` accepts at most **4 hours**. The only budget is the instance-wide `ai.maxMonthlyTokens` ([Budget and usage](#budget-and-usage)).
## Tools
[Section titled “Tools”](#tools)
The registry holds **22 tools**, shared by the agent chat, the legacy sidebar and MCP. Input schemas are JSON Schema reflected from typed structs (required inputs are **bold** below). Gate classes:
* **read** — executes directly; audit `ai.read.`.
* **mutating, auto** — executes immediately; audit `ai.execute.`.
* **mutating, approval** — queued as a *proposal* unless the policy’s `autoApprove` lists the tool; the model receives `{"status":"proposed","actionId":"…","note":"queued for human approval (POST /api/v1/ai/actions/:approve)"}`; audit `ai.propose.`.
### Read tools
[Section titled “Read tools”](#read-tools)
| Tool | Permission | Input | Result |
| ----------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `get_overview` | `events:read` | — | `{summary, openAlerts, openIncidents}` (open incidents, max 10) |
| `search_objects` | `objects:read` | `selector` (label selector, e.g. `env=prod,role!=db`), `query` (free text), `kind` (`host`/`service`), `limit` (default 50, max 100) | `[{id, name, kind, labels, state}]` (`PENDING` without state) |
| `get_object` | `objects:read` | **`id`** | `{object, state, effectiveConfig, templateChain, metrics}` |
| `query_metrics` | `metrics:read` | **`objectId`**, `metric` (empty = all), `fromHoursAgo` (default 24), `agg` (`avg/min/max/sum/last/count`) | NP-TSDB query, at most 100 points |
| `get_alerts` | `alerts:read` | `status` (`open/acked/resolved/expired`, default open + acked), `limit` | alert list |
| `analyze_metric` | `metrics:read` | **`objectId`**, `metric`, `hours` (default 168) | deterministic (no LLM): seasonal baseline (hour × weekday EWMA) + MAD anomaly detection → `currentValue, baselineMean/StdDev/Mad, seasonalExpected, deviationSigma, anomalous, anomalousRunLen, totalAnomalyCount` |
| `forecast_capacity` | `metrics:read` | **`objectId`**, **`threshold`**, `metric`, `horizonHours` (default 168) | least-squares trend (needs ≥ 10 samples): `slopePerHour, projectedValue, confidenceR2, projectedExhaustionAt, hoursToThreshold` (null + `note` when not within a year) |
| `suggest_thresholds` | `metrics:read` | **`objectId`**, `metric`, `hours` (default 168) | needs ≥ 20 samples: `suggestedWarn` = P98, `suggestedCrit` = P99.5 |
| `get_incidents` | `incidents:read` | `open` (bool) | up to 50 incidents |
| `who_is_oncall` | `oncall:read` | `schedule` (name) | `{scheduleName: [contact names]}`, overrides resolved at “now” |
| `explain_alert` | `alerts:read` | **`alertId`** | `{alert, topology{object, kind, host, hostState, parents}, recentConfigChanges (audit, 72 h, 10), recentStateChanges (24 h, 20), similarPastAlerts (same rule, resolved, 5)}` |
| `render_report` | `reports:render` | **`name`** | renders a stored report as JSON |
| `list_config_resources` | per-kind read permission (table below) | **`kind`**, `query` (name substring), `limit` (default 100, max 500) | `{kind, count, items[]}` |
| `get_config_resource` | per-kind read permission | **`kind`**, **`name`** | the document including `version` |
The three statistics tools read the series with average aggregation and up to 10 000 points; with an empty `metric` the first series (lowest metric name) is used.
### Mutating tools
[Section titled “Mutating tools”](#mutating-tools)
| Tool | Permission | Gate | Input | Effect |
| ------------------------ | ----------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `run_check_now` | `checks:run` | auto | **`objectId`** | enqueues an immediate recheck; `{"status":"queued"}` |
| `acknowledge_alert` | `alerts:ack` | auto | **`alertId`**, `comment` | acknowledges as `ai:`, stops the escalation chain; `{"status":"acked","alert":""}` |
| `create_downtime` | `downtimes:write` | approval | `objectId` **or** `selector`, `hours` (default 2, **max 4**), **`comment`** | fixed downtime from now; `{"status":"scheduled","id"}`; `createdBy: ai:` |
| `create_silence` | `silences:write` | approval | **`selector`**, `hours` (default 1), **`comment`** | `{"status":"silenced","id"}` |
| `propose_config_change` | `config:write` | approval | **`bundleYaml`** | bundle dry-run plan — but it is registered as mutating-without-auto, so even the plan is computed only after approval (unless `autoApprove` lists it) |
| `apply_config_change` | `config:write` | approval | **`bundleYaml`** | applies the bundle after approval |
| `upsert_config_resource` | per-kind **write** permission | approval | **`kind`**, **`name`**, **`doc`** (the same JSON the REST API accepts), `expectedVersion` (0 = unconditional) | validated like the REST route |
| `delete_config_resource` | per-kind write permission | approval | **`kind`**, **`name`** | `{deleted, kind}` |
### Configuration kinds and their permissions
[Section titled “Configuration kinds and their permissions”](#configuration-kinds-and-their-permissions)
`list_config_resources`, `get_config_resource`, `upsert_config_resource` and `delete_config_resource` accept these `kind` values; the required permission mirrors the REST route (parity is pinned by a test):
| `kind` | read | write |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | -------------- |
| `template`, `check-command`, `time-period`, `alert-rule`, `alert-group`, `escalation-policy`, `channel`, `event-source`, `business-service`, `dashboard`, `report`, `saved-filter`, `webhook-subscription`, `static-group` | `objects:read` | `config:write` |
| `schedule`, `contact`, `contact-group` | `oncall:read` | `oncall:write` |
| `role` | `admin:read` | `admin:write` |
| `preference` | `admin:users` | `admin:users` |
Not reachable through the AI/MCP tools: `override`, `site`, `ivr-menu`, `branding`. An unknown kind is rejected with `unsupported resource kind "x" (one of: …)`.
## Approvals
[Section titled “Approvals”](#approvals)
Mutating tools without auto-execution create an **AI action** in the approval queue:
```json
{ "id":"0199…", "tenantId":"…", "conversationId":"", "tool":"create_downtime",
"args":{"objectId":"0199…","hours":2,"comment":"patching"},
"summary":"create_downtime {\"objectId\":…}", "status":"proposed",
"actor":"mcp-agent", "result":null, "decidedBy":"", "decidedAt":null, "createdAt":"…" }
```
Status lifecycle: `proposed` → `approved` → `executed` or `failed`; or `proposed` → `denied`. `summary` is the tool name plus the first 200 characters of the arguments; `actor` is the proposing principal (token name or user).
| Method + path | Permission | Behaviour |
| ------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/v1/ai/actions?status=` ([get\_ai\_actions](/docs/reference/api/operations/get_ai_actions/)) | `alerts:read` | newest first, max 100, optional status filter |
| `POST /api/v1/ai/actions/{id}:approve` ([post\_ai\_actions\_id\_approve](/docs/reference/api/operations/post_ai_actions_id_approve/)) | **`config:write`** | marks `approved` (only from `proposed`, otherwise 404), audit `ai.action.approve`; then executes the tool under the **approver’s** permissions → `{"status":"executed","result":…}` or `502 np:ai/execute` (`approved but execution failed`, status `failed`). Execution needs no server-level `ai.provider` — approvals from the agent chat and MCP run too. |
| `POST /api/v1/ai/actions/{id}:deny` ([post\_ai\_actions\_id\_deny](/docs/reference/api/operations/post_ai_actions_id_deny/)) | `alerts:ack` | status `denied`, audit `ai.action.deny` |
Execution re-evaluates the tool’s required permission (including the per-kind permission derived from the stored arguments) against the **approver**; if the approver lacks it the action fails with `approver lacks permission X required by tool` (audit `ai.execute.denied.`). The tool then runs as a synthetic `ai_agent` principal (`actorId: ai-approved`, name = the original proposer, permissions = the approver’s) and the result is stored on the action.
Approve executes only with a server-level provider
Because `:approve` calls the executor only when `ai.provider` is not `none`, proposals coming from the agent chat or from MCP clients cannot be executed through the UI or API on an instance whose `config.yaml` has `ai.provider: none`. Set `ai.provider` (for example `anthropic` with `apiKeyEnv`) if you want approvals to execute; the `ai:` block is described under [Configuration](#configuration).
Where approvals appear in the UI: **Admin → AI approvals** (*AI-Freigaben*) lists actions with status badge, tool, arguments, actor and time, with **Approve**/**Deny** for proposed ones (auto-refresh every 15 s). Tool cards in the agent chat show the badge **Approval required** (*Freigabe nötig*) with inline approve/deny and switch to **Approved & executed** or **Denied**; the legacy sidebar shows the same on its action cards.
## Chats and the legacy conversations
[Section titled “Chats and the legacy conversations”](#chats-and-the-legacy-conversations)
**Chats** are the agent-page workspace: per-message rows, any connection/model, switchable mid-chat. **Conversations** are the legacy sidebar’s single-blob transcripts bound to the server-level provider. Both are per user and tenant.
| Method + path | Permission | Notes |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET /api/v1/ai/chats` ([get\_ai\_chats](/docs/reference/api/operations/get_ai_chats/)) | `events:read` | own chats, newest first, max 100 |
| `POST /api/v1/ai/chats` ([post\_ai\_chats](/docs/reference/api/operations/post_ai_chats/)) | `events:read` | `{title?, connectionId?, model?, settings?}` → 201 |
| `GET /api/v1/ai/chats/{id}` ([get\_ai\_chats\_id](/docs/reference/api/operations/get_ai_chats_id/)) | `events:read` | `{chat, messages[]}`; ownership (tenant + user) enforced |
| `PUT /api/v1/ai/chats/{id}` ([put\_ai\_chats\_id](/docs/reference/api/operations/put_ai_chats_id/)) | `events:read` | partial update of `title`, `connectionId`, `model`, `settings` (no `If-Match`) |
| `DELETE /api/v1/ai/chats/{id}` ([delete\_ai\_chats\_id](/docs/reference/api/operations/delete_ai_chats_id/)) | `events:read` | cascades messages; audit `ai.chat.delete` |
| `DELETE /api/v1/ai/chats/{id}/messages/{msgId}` | `events:read` | audit `ai.chat.message.delete` |
| `POST /api/v1/ai/chat` ([post\_ai\_chat](/docs/reference/api/operations/post_ai_chat/)) | `events:read` | the streaming turn (below) |
| `POST /api/v1/ai/conversations` ([post\_ai\_conversations](/docs/reference/api/operations/post_ai_conversations/)) | `events:read` | legacy: `{"conversationId":"","message":"…"}` → `{"conversationId","reply","actions":[{tool,input,proposed,actionId?,result?,error?}]}`; `503 np:ai/disabled` without server-level provider, `502 np:ai/provider` on provider errors; max 8 rounds, context = last 40 messages |
| `GET /api/v1/ai/conversations`, `GET …/{id}` ([get\_ai\_conversations](/docs/reference/api/operations/get_ai_conversations/), [get\_ai\_conversations\_id](/docs/reference/api/operations/get_ai_conversations_id/)) | `events:read` | last 50 `{id,title,createdAt,updatedAt}`; transcript `{id,title,messages}` |
Chat JSON: `{id, title, connectionId, model, settings, version, createdAt, updatedAt}`. Message JSON: `{id, chatId, role: user|assistant, parts[], model, usage{inputTokens,outputTokens,stopReason}, createdAt}`; message ids are UUIDv7, so insertion order is id order. Per-chat `settings`:
```json
{ "toolsEnabled": true, "allowedTools": ["get_alerts", "explain_alert"], "effort": "high", "maxTokens": 8000 }
```
* `toolsEnabled` null/true = tools on; false = no tool definitions are sent.
* `allowedTools` can only **narrow** the policy-filtered set.
* `effort`: `low`/`medium`/`high` in the UI (“Reasoning effort” / *Denkaufwand*); Anthropic additionally accepts `xhigh`/`max`, mapped to adaptive thinking where the model supports it; the OpenAI dialect sends `reasoning_effort` only for providers that accept it (openai, google, xai, deepseek).
* `maxTokens`: Anthropic defaults to 16000 when unset; the OpenAI dialect sends nothing when unset.
Turn request (`POST /api/v1/ai/chat`):
```json
{ "chatId": "0199…", "message": "What is going on with web01?", "trigger": "submit-message" }
```
```json
{ "chatId": "0199…", "trigger": "regenerate-message", "messageId": "" }
```
Rules: `chatId` is required and the chat must have a `connectionId` (`np:ai/no-connection`); `submit-message` (the default) needs a non-empty message of at most **32 KiB**, appends it and sets the chat title from the first 80 characters if empty; `regenerate-message` deletes the given assistant message **and everything after it**, then re-answers; the last stored message must be a user message (`np:ai/no-user-message`); an unknown trigger → 422; a second concurrent stream on the same chat → `409 np:ai/busy`.
Loop behaviour per turn: budget pre-check; model = `chat.model` → `connection.defaultModel` → first curated model (else `no model selected`); tool definitions = policy filter ∩ chat allow-list; the full history is replayed (no compaction) with a redacted copy sent to the provider; up to `maxRounds` provider rounds, each audited as `ai.chat.round` (model, tokens in/out, tool calls); every tool call goes through the gate; the model sees at most **16 KiB** of a tool result (`…(truncated)`), the persisted part keeps up to **64 KiB** (bigger results are stored as `{"truncated":true,"sizeBytes":n,"preview":""}`); after the last allowed round with tool calls the turn stops with `stopReason: "max-rounds"` and an error chunk `agent stopped after N tool rounds`. Persistence always happens — also on client abort (`stopReason: "aborted"`) or provider error after partial output. Provider messages are always derived from the stored UI parts, so switching provider/model mid-chat is lossless.
The system prompt tells the model that it is the Northplane monitoring agent, to answer in the user’s language (German or English), that event texts are **untrusted data**, to format answers in Markdown, to link alerts as `/alerts/`, and which tenant and user it acts for.
## Stream protocol
[Section titled “Stream protocol”](#stream-protocol)
`POST /api/v1/ai/chat` answers with a Server-Sent-Events stream in the **Vercel AI SDK UI-message-stream v1** format: headers `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`, `X-Accel-Buffering: no`, `x-vercel-ai-ui-message-stream: v1`; each chunk is `data: {json}` followed by a blank line; the stream ends with `data: [DONE]`. The route is exempt from the 30 s request deadline and has no server-side keepalive beyond the chunks.
| Chunk `type` | Fields |
| ------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `start` | `messageId` (the pre-assigned, persisted assistant message id) |
| `start-step` / `finish-step` | — (one provider round) |
| `text-start` / `text-delta` / `text-end` | `id`, `delta` |
| `reasoning-start` / `reasoning-delta` / `reasoning-end` | `id`, `delta` |
| `tool-input-start` | `toolCallId`, `toolName`, `dynamic: true` |
| `tool-input-delta` | `toolCallId`, `inputTextDelta` |
| `tool-input-available` | `toolCallId`, `toolName`, `input`, `dynamic: true` |
| `tool-output-available` | `toolCallId`, `output`, `dynamic: true`, optional `toolMetadata: {proposed: true, actionId}` |
| `tool-output-error` | `toolCallId`, `errorText`, `dynamic: true` |
| `error` | `errorText` |
| `finish` | `finishReason`, `messageMetadata: {inputTokens, outputTokens, stopReason}` |
`finishReason` values: `stop`, `tool-calls`, `length`, `content-filter`, `error`, `other`. A failure before anything streamed still emits `error` + `finish{finishReason:"error"}` and `[DONE]`.
```text
data: {"type":"start","messageId":"0199…"}
data: {"type":"start-step"}
data: {"type":"tool-input-start","toolCallId":"toolu_01","toolName":"get_alerts","dynamic":true}
data: {"type":"tool-input-available","toolCallId":"toolu_01","toolName":"get_alerts","input":{"status":"open"},"dynamic":true}
data: {"type":"tool-output-available","toolCallId":"toolu_01","output":[…],"dynamic":true}
data: {"type":"finish-step"}
data: {"type":"start-step"}
data: {"type":"text-start","id":"blk_0"}
data: {"type":"text-delta","id":"blk_0","delta":"2 open alerts …"}
data: {"type":"text-end","id":"blk_0"}
data: {"type":"finish-step"}
data: {"type":"finish","finishReason":"stop","messageMetadata":{"inputTokens":1234,"outputTokens":88,"stopReason":"end_turn"}}
data: [DONE]
```
Persisted part types: `step-start`, `text`, `reasoning`, `dynamic-tool` (with `state` ∈ `input-available | output-available | output-error`, plus `proposed` and `actionId`).
## Incident summaries
[Section titled “Incident summaries”](#incident-summaries)
`POST /api/v1/incidents/{id}:summarize` ([post\_incidents\_id\_summarize](/docs/reference/api/operations/post_incidents_id_summarize/), `incidents:write`) asks the **server-level** provider for a 2–3 sentence summary (what is affected, likely common cause with confidence, scope — the prompt asks for a **German** reply), using `ai.modelDeep` when set, with redaction applied; the text is stored as `incident.summary` and audited as `incident.summarize`. Without a provider: `503 np:ai/disabled` (`AI provider not configured — set ai.provider in config.yaml`). The correlation engine also enqueues background summaries for incidents it creates (a 256-slot queue; dropped under load and counted in `droppedAi` of `/system/health`).
## Budget and usage
[Section titled “Budget and usage”](#budget-and-usage)
* Every provider round (legacy, agent chat, summaries) adds input/output tokens to a per-month counter (`YYYY-MM`, UTC).
* `ai.maxMonthlyTokens` greater than 0 is a **hard stop** before every round: `monthly AI token budget exhausted (n/max) — hard stop per policy`; mid-turn in the agent loop this arrives as an `error` chunk with `stopReason: "budget"`.
* Crossing **80 %** emits one system event `AI token budget at N% (x of y)` (severity warning, default tenant).
* There is no per-tenant, per-user or per-connection budget, no monetary cost computation and no `/ai/usage` endpoint; usage is visible per message (`usage`) and in the audit trail (`ai.completion`, `ai.chat.round`).
## Redaction
[Section titled “Redaction”](#redaction)
Before every provider call a **copy** of the messages and tool results is redacted (persisted transcripts stay unredacted). Always-on patterns: Northplane tokens (`np_…`), `password`/`passwd`/`secret`/`api key`/`token` followed by `:`/`=` and a value, PEM private keys, e-mail addresses, IPv4 addresses and MAC addresses → `[REDACTED:<6-hex tag>]`. `ai.redaction.customPatterns` adds regexes replaced by `[REDACTED]`; `ai.redaction.hostnames: pseudonymize` replaces dotted hostnames with stable `host-0001`-style pseudonyms.
## Audit
[Section titled “Audit”](#audit)
Actor type `ai_agent`. Actions emitted: `ai.connection.create|update|delete`, `ai.policy.update`, `ai.disabled.`, `ai.denied.`, `ai.propose.`, `ai.execute.`, `ai.read.` (every read tool call too), `ai.execute.denied.`, `ai.completion` (legacy, with prompt hash and a ≤ 500-char redacted prompt), `ai.chat.round`; on the REST side `ai.action.approve`, `ai.action.deny`, `ai.chat.delete`, `ai.chat.message.delete`, `incident.summarize`. Read the trail under **Admin → Audit log** or via `np audit tail` ([Observability](/docs/administration/observability/)).
## RBAC interplay
[Section titled “RBAC interplay”](#rbac-interplay)
* Every tool checks `principal.Allow()` with the same permission as the equivalent REST route; denial → `permission denied: required` (audit `ai.denied.`). Wildcards `admin:*`, `*:*`, `*` apply.
* Built-in role `ai-agent`: `objects:read, alerts:read, alerts:ack, incidents:read, incidents:write, events:read, metrics:read, oncall:read, checks:run, downtimes:write, silences:write, config:propose, reports:render`. Note that `config:propose` is consumed by **no** tool — `propose_config_change` requires `config:write`, so a principal with only the `ai-agent` role cannot call it.
* API tokens flagged `aiAgent: true` authenticate as actor type `ai_agent`; see [API tokens](/docs/administration/api-tokens/).
* Multi-tenant admins: the agent chat honours `X-Northplane-Tenant` for callers with `admin:tenants` (the SPA sends it); the MCP server always uses the token’s own tenant.
* Policy and shared connections need `admin:ai`; custom endpoints need `config:write`; approving needs `config:write`, denying `alerts:ack`. Full permission reference: [Users, roles and permissions](/docs/administration/users-roles-permissions/).
## Configuration
[Section titled “Configuration”](#configuration)
The `ai:` block in `config.yaml` configures the **legacy, server-level** provider — needed for the assistant sidebar, incident summaries and the execution step of approvals — plus the budget and redaction that apply to everything. The template written by `northplaned init`:
config.yaml
```yaml
ai:
provider: none # anthropic | azure-openai | openai-compat | none
#endpoint: "https://api.anthropic.com"
#apiKeyEnv: ANTHROPIC_API_KEY
#model: claude-sonnet-4-6
#modelDeep: claude-opus-4-8
#maxMonthlyTokens: 50000000
```
| Key | Env override | Default | Meaning |
| ----------------------------- | --------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ai.provider` | `NORTHPLANE_AI_PROVIDER` | `none` | `anthropic`, `azure-openai`, `openai-compat` or `none`; any other value fails validation (`ai.provider "": must be one of none\|anthropic\|azure-openai\|openai-compat`) |
| `ai.endpoint` | `NORTHPLANE_AI_ENDPOINT` | anthropic `https://api.anthropic.com` | anthropic: requests go to `/v1/messages`; openai-compat: **no default**, requests to `/v1/chat/completions`; azure-openai: used **verbatim** as the full deployment URL |
| `ai.apiKeyEnv` | `NORTHPLANE_AI_API_KEY_ENV` | — | **name** of the environment variable holding the key; wins over `apiKey` when set and non-empty |
| `ai.apiKey` | — | — | static key (discouraged; for gateways) |
| `ai.model` | `NORTHPLANE_AI_MODEL` | `claude-sonnet-4-6` (anthropic), `gpt-4o` (openai-compat/azure) | default model |
| `ai.modelDeep` | — | = `model` | model for “deep” calls (incident summaries) |
| `ai.maxMonthlyTokens` | — | `0` = unlimited | hard monthly budget (input + output tokens), instance-wide |
| `ai.redaction.hostnames` | — | `""` | `""` or `pseudonymize` |
| `ai.redaction.customPatterns` | — | none | extra regexes replaced by `[REDACTED]` |
Auth headers used by the legacy provider: anthropic `x-api-key` + `anthropic-version: 2023-06-01`; openai-compat `Authorization: Bearer`; azure `api-key`. Legacy calls are non-streaming with `max_tokens: 2048` and a 120 s HTTP timeout. Provider connections for the agent chat are **not** configured in `config.yaml` — they are API/UI resources; they do need `secretKeyFile` for keyed providers. The complete key table lives in [Configuration](/docs/administration/configuration/).
## UI
[Section titled “UI”](#ui)
| Surface | What you see |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **AI agent** page (`/agent`) | left: chat list (title, age, hover-delete), **New chat**, **AI providers** button; centre: Markdown answers, collapsible **Reasoning** (*Überlegung*) parts, tool cards (name, approval badge, expandable input/output JSON, approve/deny), per-message delete, regenerate on the last assistant message, model id on hover; composer: connection picker, model picker (from `/connections/{id}/models`, cached 5 min), **Tools** (*Werkzeuge*) popover (**Tools enabled** switch, **Reasoning effort** (*Denkaufwand*) Standard/low/medium/high), Stop while streaming, textarea (Enter sends, Shift+Enter newline). The first send auto-creates the chat with the chosen connection/model. Empty state: “Chat with your infrastructure: the agent operates the Northplane tools under your permissions. Mutating actions require human approval.” with a **Connect a provider** (*Provider verbinden*) call to action |
| **AI providers** dialog | personal connections with **Test**, edit/delete; shared ones read-only with a **Shared** badge; form: Name, Provider (create only), API key (password field, link to the provider’s key page, placeholder `Key stored …abcd` on edit), Endpoint (placeholder = catalog default), Default model |
| **Assistant** sidebar (⌘I) | legacy non-streaming chat with action cards (approve/deny or “executed (audited)”); shows `⚠ … AI provider not configured (ai.provider=none)` without a server-level provider |
| **Admin → AI providers** (*KI-Provider*) | card **Shared connections (for all tenant users)** (*Geteilte Verbindungen*); card **Agent policy** (*Agent-Richtlinie*): table of all tools (name, *mutating* badge, description) with switches **Active** (→ `disabled[]`) and **Auto-approve** (mutating non-auto tools only → `autoApprove[]`), numeric **Max tool rounds per message** (0–24), Save → `PUT /ai/policy` |
| **Admin → AI approvals** (*AI-Freigaben*) | the approval queue |
| **Admin → MCP** | token minting and client snippets — [MCP server](/docs/ai/mcp-server/) |
The admin tabs are also summarised on [Admin](/docs/ui/admin/).
## Limits
[Section titled “Limits”](#limits)
| Item | Value |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------- |
| User message | ≤ 32 KiB |
| Tool result to the model / persisted | 16 KiB / 64 KiB (agent chat); 8 KiB (legacy sidebar) |
| Agent rounds per turn | policy `maxRounds`, default 10, max 24; legacy 8 |
| Context management | none beyond: legacy keeps the last 40 messages; agent chat replays the full history |
| Default max output tokens | Anthropic 16000 (chat) / 2048 (legacy); OpenAI dialect unset (chat) / 2048 (legacy) |
| Streaming provider client | no total timeout; response-header timeout 60 s, idle 90 s; cancelled when the browser aborts |
| Model listing / connection test / legacy calls | 120 s timeout |
| Retries to providers | none |
| Downtime via AI | ≤ 4 h |
| Route deadline | `/api/v1/ai/chat` is exempt from the 30 s deadline; other AI routes are not |
## Known gaps
[Section titled “Known gaps”](#known-gaps)
* Approve does not execute with `ai.provider: none` (see [Approvals](#approvals)).
* `propose_config_change` rides the approval queue even though it is a dry-run.
* The `ai-agent` role’s `config:propose` is consumed by no tool.
* Policy-disabled tools are still advertised to the legacy sidebar model (execution is blocked).
* `PUT /ai/policy`, `PUT /ai/connections/{id}` and `PUT /ai/chats/{id}` do not enforce `If-Match` (last write wins).
* No per-tenant or per-user budget, no cost computation, no usage endpoint.
* No retry/backoff towards providers; no context compaction for long chats.
All of these are also tracked on [Roadmap and known issues](/docs/project/roadmap-and-known-issues/).
# MCP server
> Northplane as a Model Context Protocol server — Streamable HTTP at /mcp and stdio via northplaned mcp, the 22 tools and 3 prompts with their annotations, RBAC mapping, token minting in Admin → MCP, and ready-to-paste client configs for Claude Code, Claude Desktop, Cursor, VS Code, Windsurf, Codex and Gemini.
Every Northplane instance speaks the **Model Context Protocol** (MCP), so any MCP client — Claude Code, Claude Desktop, Cursor, VS Code, Windsurf, Codex CLI, Gemini CLI, your own agent — can read, operate and (through the approval queue) configure monitoring with *its* model. Northplane exposes only tools and prompts; the language model is the client’s. Authentication is an ordinary Northplane API token, so the MCP session is a privilege-less, audited API client with exactly the token’s permissions. The tools are the same 22 tools the [AI agent chat](/docs/ai/agent-chat/) uses, and mutating tools go through the same [approval queue](/docs/ai/agent-chat/#approvals).
Implementation: the official Go SDK (`github.com/modelcontextprotocol/go-sdk`), server implementation info `name: northplane`, `title: Northplane Monitoring`, `version` = the `northplaned` version. One server instance is built **per principal/session**; tools disabled by the tenant [tool policy](/docs/ai/agent-chat/#tool-policy) are not advertised. Resources are not offered (tools and prompts only).
## Transports
[Section titled “Transports”](#transports)
### Streamable HTTP at /mcp
[Section titled “Streamable HTTP at /mcp”](#streamable-http-at-mcp)
The running server mounts `/mcp` (and `/mcp/*`) next to the API — always in `northplaned serve`, same origin, same TLS, no extra port.
| Item | Detail |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| URL | `https:///mcp` |
| Authentication | per request through the normal authenticator: `Authorization: Bearer np_…` (an `np_session` cookie also works technically, but clients use tokens). Missing or invalid credential → **401** with `WWW-Authenticate: Bearer resource_metadata="/api/v1/whoami"` and the text body `MCP requires a Northplane API token`. Token expiry and IP binding (`ipBind`, compared with the TCP peer address) are enforced like everywhere else. |
| Sessions | stateful SDK sessions via the `Mcp-Session-Id` header (managed by the client library after `initialize`). The server binds each session id to the authenticating actor: another token reusing the id gets **403** `session belongs to another token`; a `DELETE` drops the binding. |
| Timeouts | `/mcp` and `/mcp/*` are exempt from the 30 s per-request deadline; the HTTP server has `ReadHeaderTimeout` 10 s, `ReadTimeout` 60 s, `IdleTimeout` 120 s and no `WriteTimeout` |
| Tenant | the token’s own tenant; `X-Northplane-Tenant` is **not** honoured on `/mcp` |
| Behind a proxy | plain HTTPS, no WebSockets; a reverse proxy that forwards the whole site (such as the bundled Caddy’s `reverse_proxy northplane:8443`) forwards `/mcp` too ([TLS and reverse proxy](/docs/administration/tls-and-proxy/)) |
Quick check without a client:
```bash
curl -si https://np.example.com/mcp # → 401, WWW-Authenticate: Bearer resource_metadata="/api/v1/whoami"
curl -s https://np.example.com/api/v1/whoami -H "Authorization: Bearer np_…" # shows the token's permissions = what the MCP session may do
```
### stdio: northplaned mcp
[Section titled “stdio: northplaned mcp”](#stdio-northplaned-mcp)
For a client on the **same host** as the server, `northplaned mcp` serves MCP on stdin/stdout:
```bash
export NORTHPLANE_TOKEN=np_…
northplaned mcp -config /etc/northplane/config.yaml
```
| Item | Detail |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Authentication | `NORTHPLANE_TOKEN` must be a valid API token; missing → `northplaned: set NORTHPLANE_TOKEN to a Northplane API token (np_…)`; invalid → `northplaned: token: …`. The session inherits exactly the token’s scopes. |
| What it opens | the store (SQLite data dir or PostgreSQL DSN from the config), the NP-TSDB and the object catalog **directly** — it does not talk to a running server, so it needs the same config file and access to the same data |
| Logging | forces `logFormat: text` on stderr (stdout belongs to the transport); logs `mcp: serving on stdio` with the actor name |
| Lifetime | runs until the client closes stdin or SIGINT/SIGTERM |
Reduced tool set on stdio
The stdio process wires no scheduler, escalation engine, bundle planner, report renderer or resource administration. Read tools, the statistics tools and the prompts work. `list_config_resources`, `get_config_resource`, `upsert_config_resource` and `delete_config_resource` return `resource administration is not wired in this deployment`; `propose_config_change` and `apply_config_change` return `bundle planner not wired`; `render_report` returns `report renderer not wired`; `run_check_now` and `acknowledge_alert` dereference components that do not exist in this mode and must not be used there. Use Streamable HTTP against the running server for anything beyond read-only analysis. Full command reference: [northplaned CLI](/docs/reference/cli-northplaned/#mcp).
## Tokens and RBAC
[Section titled “Tokens and RBAC”](#tokens-and-rbac)
The MCP session is the token. Each tool call is checked against the token’s permissions with the same permission the equivalent REST route uses; a denial comes back as an MCP tool error `error: permission denied: required` and is audited as `ai.denied.`. Tool execution then follows the shared gate: tenant policy → RBAC → proposal for mutating tools (unless the policy auto-approves them) → execute → audit (`ai.read.`, `ai.execute.`, `ai.propose.`). Proposals land in **Admin → AI approvals**, where a human with `config:write` approves or denies them — see [Approvals](/docs/ai/agent-chat/#approvals); approval executes the tool directly (no server-level `ai.provider` needed).
Recommended token shape: minted with `aiAgent: true` (audits as actor type `ai_agent`), scoped to exactly what the agent should do, with an expiry — see [API tokens](/docs/administration/api-tokens/). The built-in role `ai-agent` is a reasonable starting point (`objects:read, alerts:read, alerts:ack, incidents:read, incidents:write, events:read, metrics:read, oncall:read, checks:run, downtimes:write, silences:write, config:propose, reports:render`); note that its `config:propose` is consumed by no tool — the config tools need `config:write`.
## Tools
[Section titled “Tools”](#tools)
Tool descriptions get a suffix on MCP: mutating tools without auto-execution `(mutating: returns a proposal that requires human approval)`, auto-executing ones `(mutating: executes immediately, audited)`. Annotations are MCP 2025-06-18 hints derived from the registry so they cannot drift: `readOnlyHint` = not mutating; for mutating tools `destructiveHint` is `true` for `apply_config_change`, `create_downtime` and `create_silence` and `false` otherwise; `idempotentHint` is `true` for the two auto-executing tools. Results are `TextContent` with pretty-printed JSON; errors are returned with `isError: true` and the text `error: …`; proposals come back as `{"status":"proposed","actionId":"…","note":"queued for human approval (POST /api/v1/ai/actions/:approve)"}`.
| Tool | Permission | Kind | Annotations |
| ------------------------ | ------------------------- | ------------------------------------------------ | ----------------------------- |
| `get_overview` | `events:read` | read | readOnly |
| `search_objects` | `objects:read` | read | readOnly |
| `get_object` | `objects:read` | read | readOnly |
| `query_metrics` | `metrics:read` | read | readOnly |
| `get_alerts` | `alerts:read` | read | readOnly |
| `analyze_metric` | `metrics:read` | read (deterministic statistics) | readOnly |
| `forecast_capacity` | `metrics:read` | read (deterministic statistics) | readOnly |
| `suggest_thresholds` | `metrics:read` | read (deterministic statistics) | readOnly |
| `get_incidents` | `incidents:read` | read | readOnly |
| `who_is_oncall` | `oncall:read` | read | readOnly |
| `explain_alert` | `alerts:read` | read | readOnly |
| `render_report` | `reports:render` | read | readOnly |
| `list_config_resources` | per-kind read permission | read | readOnly |
| `get_config_resource` | per-kind read permission | read | readOnly |
| `run_check_now` | `checks:run` | mutating, executes immediately | destructive false, idempotent |
| `acknowledge_alert` | `alerts:ack` | mutating, executes immediately | destructive false, idempotent |
| `create_downtime` | `downtimes:write` | mutating, proposal (max 4 h) | **destructive** |
| `create_silence` | `silences:write` | mutating, proposal | **destructive** |
| `propose_config_change` | `config:write` | mutating, proposal (dry-run plan after approval) | destructive false |
| `apply_config_change` | `config:write` | mutating, proposal | **destructive** |
| `upsert_config_resource` | per-kind write permission | mutating, proposal | destructive false |
| `delete_config_resource` | per-kind write permission | mutating, proposal | destructive false |
Inputs, outputs and the per-kind permission table are documented once, on [AI agent chat → Tools](/docs/ai/agent-chat/#tools). Tools listed in the tenant policy’s `disabled[]` are not advertised (and are refused on execution as defence in depth); tools in `autoApprove[]` execute without a proposal.
## Prompts
[Section titled “Prompts”](#prompts)
| Prompt | Description | Returned user message |
| ------------------ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `morning-briefing` | Summarise the overnight state: new problems, incidents, SLA risks. | “Use get\_overview, get\_alerts and get\_incidents to compile a concise morning briefing: what broke since yesterday evening, what is still open, who is on call today (who\_is\_oncall). End with the top 3 action items.” |
| `incident-triage` | Triage the currently open incidents: cluster, name, rank by impact. | “List open incidents (get\_incidents) and their alerts. For each: name the likely common cause (use explain\_alert on a representative alert), the blast radius, and whether it can be acknowledged or needs escalation.” |
| `config-review` | Review monitoring coverage: untemplated objects, missing checks, noisy rules. | “Search objects (search\_objects) and review the monitoring configuration: objects without templates, hosts without services, rules that opened the most alerts (get\_alerts). Propose concrete improvements as bundle fragments via propose\_config\_change.” |
Each prompt returns one `user` message with the fixed text; no arguments.
## Minting a token: Admin → MCP
[Section titled “Minting a token: Admin → MCP”](#minting-a-token-admin--mcp)
**Admin → MCP** shows the instance’s MCP URL (`/mcp`), mints a token with `POST /api/v1/api-tokens` `{name, scopes, aiAgent: true}` (needs `admin:tokens`; the secret is shown **once**) and renders the per-client snippets below with the token filled in. Scope presets:

| Preset | Label (EN / DE) | Scopes |
| ----------- | ---------------------------------------- | --------------------------------------------------------------------------------------------- |
| `read` | Read only / Nur lesen | `objects:read,alerts:read,incidents:read,events:read,oncall:read,metrics:read,reports:render` |
| `operate` | Read + operate / Lesen + Bedienen | read set + `alerts:ack,checks:run,downtimes:write,silences:write` |
| `configure` | Read + configure / Lesen + Konfigurieren | read set + `config:write,oncall:write` |
The `operate` scopes match what the operate tools actually check: `acknowledge_alert` (`alerts:ack`), `run_check_now` (`checks:run`), `create_downtime` (`downtimes:write`) and `create_silence` (`silences:write`).
The tab’s footnote reminds you of the local alternative: “Locally on the same host, stdio works too: `northplaned mcp` with `NORTHPLANE_TOKEN` in the environment.”
## Client configuration
[Section titled “Client configuration”](#client-configuration)
Replace `https://np.example.com/mcp` with your instance URL and `np_` with the minted token. The **Streamable HTTP** snippets are exactly what **Admin → MCP** generates; the **stdio** variants are for a client running on the server host (binary path and config path as installed by `northplaned init`). The flag and key syntax of the stdio forms is each client’s own — check the client’s documentation if it has moved.
* Claude Code
**Streamable HTTP** — one command in the terminal:
```bash
claude mcp add --transport http northplane https://np.example.com/mcp --header "Authorization: Bearer np_"
```
**stdio** — on the server host:
```bash
claude mcp add northplane --env NORTHPLANE_TOKEN=np_ -- /usr/local/bin/northplaned mcp -config /etc/northplane/config.yaml
```
* Claude Desktop
**Streamable HTTP** — `claude_desktop_config.json` → `"mcpServers"` (Settings → Developer → Edit Config); Claude Desktop bridges HTTP through `mcp-remote`:
```json
{
"mcpServers": {
"northplane": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://np.example.com/mcp", "--header", "Authorization: Bearer np_"]
}
}
}
```
**stdio** — same file, on the server host:
```json
{
"mcpServers": {
"northplane": {
"command": "/usr/local/bin/northplaned",
"args": ["mcp", "-config", "/etc/northplane/config.yaml"],
"env": { "NORTHPLANE_TOKEN": "np_" }
}
}
}
```
* Cursor
**Streamable HTTP** — `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
```json
{
"mcpServers": {
"northplane": { "url": "https://np.example.com/mcp", "headers": { "Authorization": "Bearer np_" } }
}
}
```
**stdio** — same file, on the server host:
```json
{
"mcpServers": {
"northplane": {
"command": "/usr/local/bin/northplaned",
"args": ["mcp", "-config", "/etc/northplane/config.yaml"],
"env": { "NORTHPLANE_TOKEN": "np_" }
}
}
}
```
* VS Code
**Streamable HTTP** — one command in the terminal (or the equivalent entry in `.vscode/mcp.json`):
```bash
code --add-mcp '{"name":"northplane","type":"http","url":"https://np.example.com/mcp","headers":{"Authorization":"Bearer np_"}}'
```
**stdio** — on the server host:
```bash
code --add-mcp '{"name":"northplane","type":"stdio","command":"/usr/local/bin/northplaned","args":["mcp","-config","/etc/northplane/config.yaml"],"env":{"NORTHPLANE_TOKEN":"np_"}}'
```
* Windsurf
**Streamable HTTP** — `~/.codeium/windsurf/mcp_config.json` → `"mcpServers"`:
```json
{ "mcpServers": { "northplane": { "serverUrl": "https://np.example.com/mcp", "headers": { "Authorization": "Bearer np_" } } } }
```
**stdio** — same file, on the server host:
```json
{
"mcpServers": {
"northplane": {
"command": "/usr/local/bin/northplaned",
"args": ["mcp", "-config", "/etc/northplane/config.yaml"],
"env": { "NORTHPLANE_TOKEN": "np_" }
}
}
}
```
* Codex CLI
**Streamable HTTP** — `~/.codex/config.toml` (bridged through `mcp-remote`):
```toml
[mcp_servers.northplane]
command = "npx"
args = ["-y", "mcp-remote", "https://np.example.com/mcp", "--header", "Authorization: Bearer np_"]
```
**stdio** — same file, on the server host:
```toml
[mcp_servers.northplane]
command = "/usr/local/bin/northplaned"
args = ["mcp", "-config", "/etc/northplane/config.yaml"]
env = { NORTHPLANE_TOKEN = "np_" }
```
* Gemini CLI
**Streamable HTTP** — `~/.gemini/settings.json` → `"mcpServers"`:
```json
{ "mcpServers": { "northplane": { "httpUrl": "https://np.example.com/mcp", "headers": { "Authorization": "Bearer np_" } } } }
```
**stdio** — same file, on the server host:
```json
{
"mcpServers": {
"northplane": {
"command": "/usr/local/bin/northplaned",
"args": ["mcp", "-config", "/etc/northplane/config.yaml"],
"env": { "NORTHPLANE_TOKEN": "np_" }
}
}
}
```
After connecting, ask the client to list tools: you should see the tools above (minus policy-disabled ones) and the three prompts. A good first prompt is `morning-briefing`.
## Security notes
[Section titled “Security notes”](#security-notes)
* **The token is the blast radius.** Mint a dedicated token per client with the least scopes, an expiry and — where the client has a fixed egress address — `ipBind`. Rotate it with `POST /api-tokens/{id}:rotate`; revoke with `DELETE`. Tokens are shown once and stored hashed.
* **Mutations are gated twice:** RBAC on the token, then the approval queue for every mutating tool except `run_check_now` and `acknowledge_alert` (unless the tenant policy auto-approves more). Disable tools you never want an agent to touch in **Admin → AI providers → Agent policy** (`disabled[]`); disabled tools are neither advertised nor executable.
* **Tool results are untrusted data for the model** — event texts, outputs and labels can contain anything an attacker could feed into your monitoring. That is the reason the approval queue exists; keep it on for configuration changes.
* **Audit:** every tool call (reads included) is recorded as actor type `ai_agent` with the token name; review under **Admin → Audit log** or `np audit tail`.
* **Network:** `/mcp` is served by the same listener as the UI/API. Exposing it on the internet is the same decision as exposing the API; `/mcp` sits outside the API’s CSRF wrapper and has no rate limiting. Keep TLS on ([TLS and reverse proxy](/docs/administration/tls-and-proxy/)).
* **stdio means local data access:** `northplaned mcp` opens the database and TSDB directly, so whoever can run it with a readable config can read the data directory anyway — the token still decides what the MCP session returns, but protect config and data-dir permissions accordingly.
* **No tenant switching over MCP:** a token acts in its own tenant only.
* General hardening guidance: [Security](/docs/administration/security/).
# Acknowledge and snooze
> Every way to acknowledge, resolve or snooze an alert — UI, np CLI, API, ack links, SMS keyword, IVR, DTMF, the alarm app, AI — and exactly what each path records.
Acknowledging an alert says “someone owns this”: the alert moves from `open` to `acked`, every pending escalation step and repeat is cancelled, and the acknowledgement is recorded as an `ack` event plus an audit entry. Resolving closes the alert. Snoozing acknowledges with a deadline — if the alert is still unresolved at that time it re-opens and the escalation chain starts again from step 0. All paths below end in the same storage transitions, so it does not matter whether the on-call engineer taps a link on the phone, presses a key during the call, answers an SMS, or uses the UI.
## Status transitions
[Section titled “Status transitions”](#status-transitions)
```text
ack / snooze resolve (any path) · clear event · incident resolve
open ──────────────────────────▶ acked ─────────────────────────────────────────────▶ resolved
│ ▲ │ snooze deadline reached → back to open, chain restarts
│ └──┘
└──────────── resolve ───────────────────────────────────────────────────────────▶ resolved
open | acked ── rule.autoCloseAfter elapsed ───────────────────────────────────────▶ expired
```
* `ack` is only possible from `open` (an already acked alert answers `404 np:not-found`); `resolve` from `open` or `acked`; `snooze` from `open` or `acked` (so you can put a wake-up on an alert that was acked without one).
* Every ack and resolve calls `StopChain`: all `escalations` rows of the alert are marked done. A step that is already mid-delivery in the outbox still goes out; a delivery whose alert turned out to be resolved or expired is dropped silently.
* Acks and snoozes are **not** suppression: an acked alert can be refreshed by further matching events (severity may rise, title/payload update) without re-opening, and a clear event resolves it.
## Acknowledgement paths
[Section titled “Acknowledgement paths”](#acknowledgement-paths)
| Path | Who | What it does | Recorded as |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| **UI** — **Alerts (Alarme)** row → **Acknowledge (Quittieren)**, or **Problems (Probleme)** row when an open alert exists; dialog with optional comment (“Running escalations will be stopped”) | logged-in user with `alerts:ack` | `POST /api/v1/alerts/{id}:ack {"comment"}` | audit `alert.ack {"comment"}`, event `ack {alertId, by, comment}` |
| **CLI** — `np ack [-m comment]` → prints `acknowledged: ` | API token with `alerts:ack` | same endpoint | same |
| **API** — [`POST /api/v1/alerts/{id}:ack`](/docs/reference/api/operations/post_alerts_id_ack/), body optional `{"comment":"…"}` → `200` Alert | `alerts:ack` | ack, stop chain, mirror a sticky ack onto the object when the alert has an `objectId` | same |
| **Ack link** — `GET /api/v1/ack/{token}` in e-mail, SMS (`ack: `), ntfy action, Slack/Teams **Acknowledge** button, push `ackUrl` | anyone holding the link (no login) | acks only if still `open`; actor = contact name; answers an HTML page “✓ Quittiert — Der Alarm wurde übernommen. Die Eskalationskette ist gestoppt.” even when the alert was already acked; invalid/expired → `403`, unknown alert → `404` | audit `alert.ack {"via":"ack-link"}`, event `ack {alertId, via:"ack-link"}` |
| **Outbound call, DTMF** — during a voice notification press **4** | the called contact | `POST /api/v1/voice/gather/{token}` (Twilio); 4 = ack (open only), 6 = resolve (open or acked); other digits: “Not acknowledged. Goodbye.” | audit `alert.ack` / `alert.resolve {"via":"voice-dtmf"}`, event `ack {alertId, via:"ack-link"}` / `alert_resolved` |
| **SMS keyword** — text `ACK` (or the source’s `ackKeyword`) to an `sms-inbound` number | a phone number that matches a contact’s `phone` | acks the **newest open alert** of the tenant; reply `Acknowledged: `; unknown numbers get “Unknown number — not acknowledged.” | audit `alert.ack {"via":"sms"}`, event `ack {alertId, via:"ack-link"}` |
| **IVR (inbound call)** — `ack-alert` / `resolve-alert` option of a `voice-inbound` menu | caller (after PIN gate, if any) | one open alert → acted on immediately; several → choose by digit (1–9, newest five) | audit `alert.ack {"via":"voice-inbound"}`, event `ack {alertId, via:"ack-link"}` |
| **Asterisk AGI** — the same menu over FastAGI | caller | ack/resolve via the server’s internal path; also sets the object’s sticky ack (`acknowledged via asterisk-agi`) | audit `alert.ack {"via":"asterisk-agi"}`, event `ack {alertId, by, via:"asterisk-agi"}` |
| **Alarm app / any client** | API token with `alerts:ack` | `POST /api/v1/alerts/{id}:ack`, `:resolve`, `:snooze` | as API |
| **AI agent / MCP** — tool `acknowledge_alert` | principal with `alerts:ack` (auto-approved mutating tool) | acks as `ai:`, stops the chain; no `ack` event and no object ack | audit `ai.execute.acknowledge_alert` |
Details of the telephony paths (menus, PIN, keyword, languages) are in [Voice and IVR](/docs/alarming/voice-and-ivr/); how the app registers and what it receives is in [Mobile push](/docs/alarming/mobile-push/).
Ack links
The link is `/api/v1/ack/...`, signed with a server-generated secret and valid for **24 hours** (the DTMF gather callback uses the same token). It is rendered into notifications only when `baseUrl` (`NORTHPLANE_BASE_URL`) is configured. The token is not consumed on first use — re-clicking is a no-op — and it always acknowledges, never resolves. Object notifications (per-object contact routing) carry no ack link.
`:ack` ignores the tenant header
`:ack`, `:resolve` and `:snooze` all honour `X-Northplane-Tenant` for `admin:tenants` operators (the ack verb used to act on the principal’s own tenant only).
## Comments
[Section titled “Comments”](#comments)
The optional ack comment is not stored on the alert itself. It lands in three places: the `ack` event payload (`comment`), the audit entry (`after: {"comment":…}`), and — when the alert belongs to a host or service — the object’s sticky acknowledgement (`ackedBy`/`ackComment` in the object’s state, shown on the Problems page as “acknowledged: ”). That sticky object ack suppresses further object-level notifications until the next hard recovery clears it; it does not suppress rule-based alerts. A snooze writes `snoozed until