# Configuration as code Your whole setup — settings, tags, notification channels, monitors, status pages and maintenance windows — can be exported as one readable YAML (or JSON) document, kept in git, reviewed in pull requests and applied back, with a dry-run plan first. History, users and API tokens are not part of it (use a [database backup](#backups-vs-config) for those). ## Three ways to use it | Where | Export | Import | |---|---|---| | **UI** — Settings → Backup | *Configuration as code → Export configuration* (YAML/JSON, optional secrets) | *Import configuration*: upload or paste, choose merge/replace, **Preview changes**, then **Apply** | | **API** | `GET /api/v1/config` | `POST /api/v1/config` (`?dry_run=1`, `?mode=replace&confirm=1`) | | **GitOps** — `VV_CONFIG_FILE` | – | The file is merged into the database on every start | Export needs the **editor** role; secrets in the export and every import need **admin**. ### API ```bash TOKEN=vv_xxxxxxxxxxxxxxxx # Settings → API tokens (a write token of an admin for imports) # Export (YAML by default; format=json for JSON; secrets=1 to include secrets, admins only) curl -H "Authorization: Bearer $TOKEN" "https://status.example.com/api/v1/config" > vigilant.yml # What would change? (nothing is written) curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/yaml" \ --data-binary @vigilant.yml "https://status.example.com/api/v1/config?dry_run=1" # Apply (merge) curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/yaml" \ --data-binary @vigilant.yml "https://status.example.com/api/v1/config" # Replace: also deletes objects the document doesn't list (in the sections it contains) curl -X POST ... "https://status.example.com/api/v1/config?mode=replace&dry_run=1" curl -X POST ... "https://status.example.com/api/v1/config?mode=replace&confirm=1" ``` The body is YAML or JSON (detected automatically; JSON if it starts with `{`), UTF-8, at most 2 MB. The response is the plan: ```json { "applied": false, "mode": "merge", "summary": {"create": 2, "update": 1, "delete": 0, "unchanged": 14, "skip": 0}, "items": [ {"kind": "monitor", "key": "Website", "action": "update", "changes": ["interval_seconds"]}, {"kind": "tag", "key": "staging", "action": "create"} ], "errors": [], "warnings": [] } ``` If anything is invalid you get `422` with every error (`fields` keyed as `kind.key.field`) and **nothing is written** — an import is one transaction, all or nothing. ### GitOps with `VV_CONFIG_FILE` ```yaml services: vigilant-vole: volumes: - ./data:/data - ./vigilant.yml:/config/vigilant.yml:ro environment: - VV_CONFIG_FILE=/config/vigilant.yml ``` On every start the file is merged in the background (always **merge**, never replace). Results and validation errors go to the log (`VV_CONFIG_FILE applied … created=… updated=… unchanged=…`); if any entry is invalid nothing is applied and the server keeps running with its current configuration. Changes made in the UI in the meantime stay until the next restart re-applies the file's values for the objects it lists. ## Merge vs replace - **merge** (default) creates what's new and updates what changed. Nothing is deleted. - **replace** additionally **deletes** objects of a section *present in the document* that the document doesn't list. Sections left out of the document are never touched, in either mode. Docker-discovered monitors are never deleted. The API requires `confirm=1` (or `dry_run=1`) for replace. Within an object, fields you leave out take their **defaults** — the document describes the full desired state of each object it lists. Two exceptions: a monitor's `tags`/`notifications` and a status page's `sections` keep their current value when the key is missing (write `[]` to clear them). ## Keys and references Objects are matched by stable, human-readable keys instead of database ids, so a document can be applied to a fresh instance: | Section | Key | |---|---| | `tags` | `name` | | `notification_channels` | `name` | | `monitors` | `key` (defaults to `name`) | | `status_pages` | `slug` | | `maintenance` | `title` | Keys are compared case-insensitively. Monitors created by an import get `source: config` with their key as `external_id`, so renaming such a monitor in the document (same `key`) updates it instead of creating a new one — give monitors an explicit `key` if you expect to rename them. Duplicate names on export are disambiguated as `"Name #id"`. References use these keys: a monitor's `parent` (a group's key), `tags`, `notifications`; a status page section's `monitors`; a maintenance window's `monitors` and `status_pages` (slugs). ## Secrets Exports mask secrets as `••••••` unless an admin asks for them (`secrets=1` / *Include secrets*): channel credentials, monitors' `basic_auth_pass`, sensitive HTTP header values (names containing `authorization`, `cookie`, `token`, `secret`, `key`, `password`, `auth`, `session`, `signature`) and push tokens. **Importing a masked value keeps the value already stored**, so a masked export round-trips safely and can live in git. For a new object a masked secret is an error — supply the real value (e.g. by templating the file in CI). ## Document reference ```yaml version: 1 # format version (optional; currently 1) settings: { … } # every section is optional tags: [ … ] notification_channels: [ … ] monitors: [ … ] status_pages: [ … ] maintenance: [ … ] ``` Unknown keys are rejected (except inside a channel's `config`, where unknown keys are dropped). ### `settings` Missing keys keep their current value. | Key | Type | Notes | |---|---|---| | `site_name` | string | Shown in the header and page titles. | | `base_url` | string | Public URL, e.g. `https://status.example.com`; used in notification links, push URLs and agent snippets. `""` clears it. | | `timezone` | string | IANA name, e.g. `Europe/Oslo`. | | `language` | string | `en` or `nb`. | | `retention_days` | int | 1–3650. Raw heartbeats older than this are deleted; daily rollups are kept. | | `badges_public` | bool | Badges for every monitor without auth (otherwise only monitors on a published status page). | | `metrics_enabled` | bool | Enables `/metrics`. | | `discovery_enabled` | bool | Docker label discovery. | | `discovery_docker_host` | string | Empty = local socket. `""` clears it. | | `agent_confirmation_default` | bool | New monitors start with *Confirm outages with agents* on. | ### `tags` | Key | Type | Default | Notes | |---|---|---|---| | `name` | string | – | Required, unique. | | `color` | string | `blue` | Palette name: `red`, `orange`, `yellow`, `green`, `mint`, `teal`, `cyan`, `blue`, `indigo`, `purple`, `pink`, `brown`, `gray`. Hex values map to the nearest. | ### `notification_channels` | Key | Type | Default | Notes | |---|---|---|---| | `name` | string | – | Required, unique, < 100 characters. | | `kind` | string | – | Provider, see below. | | `active` | bool | `true` | | | `default` | bool | `false` | Attach automatically to new monitors. | | `config` | object | `{}` | Provider settings (required keys in **bold**, secrets marked 🔒). | | `kind` | `config` keys | |---|---| | `discord` | **`url`** 🔒, `username`, `avatar_url`, `thread_id` | | `slack` | **`url`** 🔒, `channel`, `username`, `icon_emoji` | | `teams` | **`url`** 🔒 (Workflows webhook) | | `googlechat` | **`url`** 🔒 | | `mattermost` | **`url`** 🔒, `channel`, `username`, `icon_url` | | `rocketchat` | **`url`** 🔒, `channel`, `username`, `emoji` | | `matrix` | **`homeserver`**, **`room_id`**, **`access_token`** 🔒 | | `webex` | **`token`** 🔒, `room_id`, `person_email` | | `feishu` | **`url`** 🔒, `secret` 🔒 | | `dingtalk` | **`url`** 🔒, `secret` 🔒 | | `telegram` | **`bot_token`** 🔒, **`chat_id`**, `message_thread_id`, `silent`, `server_url` | | `signal` | **`url`** (signal-cli REST API), **`number`**, **`recipients`** | | `twilio` | **`account_sid`**, **`auth_token`** 🔒, **`from`**, **`to`** | | `ntfy` | `server`, **`topic`**, `tags`, `access_token` 🔒, `username`, `password` 🔒 | | `pushover` | **`user_key`** 🔒, **`app_token`** 🔒, `device`, `down_priority`, `priority`, `sound`, `retry`, `expire` | | `gotify` | **`server`**, **`app_token`** 🔒, `priority` | | `pushbullet` | **`access_token`** 🔒, `device_iden`, `channel_tag` | | `bark` | `server`, **`device_key`** 🔒, `group`, `sound`, `critical` | | `email` | **`host`**, `port`, `security` (`starttls` default, `tls`, `none`), `username`, `password` 🔒, **`from`**, **`to`**, `cc`, `bcc`, `subject_prefix`, `ignore_tls` | | `pagerduty` | **`routing_key`** 🔒, `region` | | `opsgenie` | **`api_key`** 🔒, `region`, `tags` | | `webhook` | **`url`**, `method`, `content_type`, `headers` 🔒, `body_template` | | `apprise` | **`url`** (Apprise API server), `config_key`, `urls` 🔒, `tag` | | `homeassistant` | **`url`**, **`token`** 🔒, `service` | `GET /api/v1/notification-providers` returns the same list with field types, labels and select options. The webhook `body_template` supports `{{monitor_name}}`, `{{monitor_id}}`, `{{monitor_type}}`, `{{target}}`, `{{status}}`, `{{event}}`, `{{message}}`, `{{summary}}`, `{{headline}}`, `{{url}}`, `{{downtime}}`, `{{downtime_seconds}}`, `{{expires_in_days}}`, `{{occurred_at}}`. ### `monitors` | Key | Type | Default | Notes | |---|---|---|---| | `key` | string | `name` | Stable identity, see above. | | `name` | string | – | Required. | | `monitor_type` | string | – | `http`, `keyword`, `json_query`, `tcp`, `ping`, `dns`, `push`, `docker`, `websocket`, `domain_expiry`, `ntp`, `steam`, `group`. | | `target` | string | `""` | URL (`http`, `keyword`, `json_query`; `ws://`/`wss://` for `websocket`), `host:port` (`tcp`, `steam`; `ntp` port optional), host (`ping`, `dns`), domain (`domain_expiry`), container name or id (`docker`); empty for `push` and `group`. | | `parent` | string | – | Key of a `group` monitor. | | `tags` | list | keep | `["production", {name: env, value: prod}]`. Unknown tag names are created. | | `notifications` | list | keep (new: default channels) | Channel names: `["Ops Discord", {name: PagerDuty, escalation: true}]`. Escalation channels are only used after `escalation_minutes`. | | `push_token` | string | generated | Push monitors only; missing or masked keeps the current token. | | `description` | string | `""` | Up to 2,000 characters. | | `active` | bool | `true` | `false` = paused. | | `interval_seconds` | int | `60` | 10–86400. | | `retry_interval_seconds` | int\|null | `null` | Interval while failing; `null` = same as `interval_seconds`. 10–86400. | | `timeout_seconds` | int | `10` | 1–300, and not more than the interval. | | `retries` | int | `0` | 0–20 failed checks (recorded as *pending*) before *down*. | | `upside_down` | bool | `false` | Invert up/down. | | `http_method` | string | `GET` | HTTP family. | | `http_headers` | object\|string | – | `{Authorization: "Bearer …"}` or JSON text. | | `http_body` | string | – | Up to 64 KB. | | `http_body_encoding` | string | `json` | `json`, `form`, `raw`. | | `accepted_status_codes` | string | `200-399` | e.g. `200-299,301,2xx`. | | `max_redirects` | int | `10` | 0–30. | | `ignore_tls` | bool | `false` | Don't verify certificates. | | `basic_auth_user` / `basic_auth_pass` | string | – | Password is a secret. | | `proxy_url` | string | – | `http://`, `https://`, `socks5://` or `socks5h://` proxy. | | `keyword` | string | – | `keyword` type: text to find (case-sensitive). | | `keyword_invert` | bool | `false` | Down when the keyword *is* found. | | `json_path` | string | – | `json_query`: RFC 9535 JSONPath, e.g. `$.status`. | | `json_operator` | string | `==` | `==`, `!=`, `contains`, `<`, `<=`, `>`, `>=`. | | `json_expected` | string | – | Value to compare with. | | `dns_record_type` | string | `A` | `A`, `AAAA`, `CNAME`, `MX`, `NS`, `TXT`, `SOA`, `SRV`, `CAA`. | | `dns_resolver` | string | system | e.g. `1.1.1.1` or `1.1.1.1:53`. | | `dns_expected` | string | – | Optional value the answer must contain. | | `docker_host` | string | local | `unix:///var/run/docker.sock`, `tcp://host:2375`, … | | `expiry_notify` | bool | `true` | Warn before TLS certificates (HTTPS) and domains (`domain_expiry`) expire. | | `expiry_days` | int | `14` | 1–365; further warnings at 7, 3, 1 and 0 days. | | `resend_interval` | int | `0` | Reminder every N down checks; 0 = off. | | `escalation_minutes` | int | `0` | Notify escalation channels after N minutes down; 0 = off. | | `flap_detection` | bool | `true` | Mute alerts while a monitor flaps (≥ 4 changes in 10 checks). | | `run_on_agents` | bool | `false` | Remote agents run this check too. | | `confirm_with_agents` | bool | `false` | Only alert when agents confirm the outage. | | `sort_order` | int | `0` | Dashboard order. | ### `status_pages` | Key | Type | Default | Notes | |---|---|---|---| | `slug` | string | – | Required; public at `/status/`. | | `title` | string | – | Required. | | `description` | string | `""` | Shown under the title. | | `custom_domain` | string | – | Serve the page at `/` on this host (point the DNS at Vigilant Vole / your proxy). | | `published` | bool | `true` | `false` = draft (not public). | | `theme` | string | `auto` | `auto`, `light`, `dark`. | | `show_tags` | bool | `false` | | | `show_uptime` | bool | `true` | 90-day uptime bars and percentages. | | `show_response` | bool | `true` | Response times. | | `show_powered_by` | bool | `true` | "Powered by Vigilant Vole" footer. | | `footer_text` | string | – | Markdown. | | `custom_css` | string | – | Added to the page (`