Self-hosted operations¶
Teams that run CVE Radar on internal servers, Kubernetes, or Docker need more than the default single-user browser workflow. This chapter documents production-oriented capabilities that ship in the same MIT-licensed codebase: structured audit logs, secret file mounts, RBAC, PostgreSQL multi-tenancy, Prometheus metrics, air-gapped mirrors, and Kubernetes stack discovery.
There is no enterprise tier or license gate. Environment variables only toggle operational behaviour (airgap, optional Postgres, metrics protection). For day-two troubleshooting and rate limits, see Operations. For baseline env vars, see Configuration.
flowchart TB
classDef ui fill:#e9edf5,stroke:#00baba,color:#253343
classDef api fill:#f3fcfc,stroke:#008c8c,color:#253343
classDef data fill:#fff7ed,stroke:#eda232,color:#253343
classDef ext fill:#f5f5f5,stroke:#666,color:#253343
subgraph deploy["Self-hosted deployment"]
Browser[Browser SPA]:::ui
API[Express API]:::api
PG[(PostgreSQL optional)]:::data
Metrics["GET /metrics"]:::api
Audit[stdout audit JSON]:::ext
end
Browser --> API
API --> PG
API --> Metrics
API --> Audit
API --> Feeds[NVD / OSV / mirrors]:::ext
API --> Notify[Slack / SMTP / webhooks]:::ext
The diagram shows optional Postgres for multi-tenancy, Prometheus scraping on /metrics, structured audit lines on stdout, and outbound notification channels after watch runs. Air-gapped installs replace public feeds with local mirrors (see Air-gapped deployment).
Audit logging¶
CVE Radar can emit one JSON line per audit event on stdout for ingestion by ELK, Loki, Splunk, or Docker's json-file driver. This gives security and platform teams a tamper-friendly trail of who triggered scans without logging CVE titles or secrets.
| Action | When logged |
|---|---|
scan |
After every POST /api/scan (success, partial source failure, or hard error) |
watch |
After every POST /api/watch |
health |
Only when AUDIT_HEALTH=true and GET /api/health?detailed=true |
export |
Not server-side yet (export is browser-only) |
Each line is a single JSON object with "audit": true:
{
"audit": true,
"ts": "2026-06-06T14:30:00.000Z",
"action": "scan",
"ip": "10.0.0.42",
"stack": ["redis", "nginx"],
"duration_ms": 8420,
"sources_failed": ["NVD"],
"result_count": 12,
"mode": "full"
}
Set TRUST_PROXY_HOPS when behind a reverse proxy so ip reflects the client (same as rate limiting). Filter Docker logs:
docker logs cve-radar 2>&1 | grep '"audit":true'
Audit events never include vulnerability titles, webhook URLs, or API keys — only stack tool names and aggregate counts.
Secrets and production keys¶
Development may use a local .env file. Production should mount secrets as files or platform vault sync — never bake keys into image layers or commit them in Compose.
| Direct env | File env (*_FILE) |
|---|---|
NVD_API_KEY |
NVD_API_KEY_FILE |
GITHUB_TOKEN |
GITHUB_TOKEN_FILE |
DEEPL_API_KEY |
DEEPL_API_KEY_FILE |
ALERT_WEBHOOK_URL |
ALERT_WEBHOOK_URL_FILE |
NOTIFICATION_SLACK_WEBHOOK_URL |
— |
NOTIFICATION_DISCORD_WEBHOOK_URL |
— |
NOTIFICATION_TELEGRAM_BOT_TOKEN |
— |
NOTIFICATION_SMTP_PASS |
mount via env file in Compose |
API_SECRET |
API_SECRET_FILE |
When *_FILE is set, the server reads the path (trimmed UTF-8) and hydrates the direct env var before routes load. Example with Docker Compose: docker-compose.secrets.example.yml.
mkdir -p secrets
printf '%s' 'your-nvd-key' > secrets/nvd_api_key.txt
chmod 600 secrets/*.txt
docker compose -f docker-compose.secrets.example.yml up -d
On Kubernetes, prefer mounting a Secret as files and setting NVD_API_KEY_FILE=/etc/cve-radar-secrets/nvd_api_key. Use External Secrets Operator to sync from Vault or cloud secret stores. Verify images with docker history --no-trunc — only non-secret defaults should appear in Config.Env.
RBAC and API authentication¶
When API_SECRET is set, all /api/* routes except GET /api/health and GET /api/v1/health require X-Api-Key or Authorization: Bearer. Set API_ROLE on the server process to one of:
| Role | Permissions |
|---|---|
| admin | Settings, tenant stack CRUD, scan/watch, translate, read meta and history |
| scanner | Run scan/watch/validate, translate, read meta and history |
| viewer | Read meta, translate, scan history — cannot run scans |
| auditor | Read scan history/trends and meta — cannot scan or change stacks |
Default role is admin for backward compatibility. A viewer calling POST /api/scan receives 403 { "code": "FORBIDDEN" }. For a single internal team, one shared secret with admin is typical; shared platforms should combine RBAC with multi-tenancy and network policy.
The browser may use build-time VITE_API_KEY matching API_SECRET — suitable only for trusted single-tenant installs.
Watch notifications¶
Self-hosted operators often need out-of-band alerts when scheduled watch runs detect new CVEs. CVE Radar dispatches server-side notifications asynchronously after POST /api/watch returns a non-empty newVulns array — the HTTP response is unchanged and dispatch never blocks the handler.
| Channel | Primary env vars |
|---|---|
| Slack | NOTIFICATION_SLACK_WEBHOOK_URL or legacy ALERT_WEBHOOK_URL |
| Discord | NOTIFICATION_DISCORD_WEBHOOK_URL |
| Telegram | NOTIFICATION_TELEGRAM_BOT_TOKEN, NOTIFICATION_TELEGRAM_CHAT_ID |
| Email (SMTP) | NOTIFICATION_SMTP_HOST, NOTIFICATION_SMTP_FROM, NOTIFICATION_SMTP_TO; optional NOTIFICATION_SMTP_PORT, NOTIFICATION_SMTP_USER, NOTIFICATION_SMTP_PASS |
| Generic webhook | NOTIFICATION_WEBHOOK_URL |
| Control | Env | Default |
|---|---|---|
| Minimum severity | NOTIFICATION_MIN_SEVERITY or legacy ALERT_MIN_SEVERITY |
HIGH |
| Dedup window (ms) | NOTIFICATION_DEDUP_MS |
900000 (15 min) |
| Slack payload format | ALERT_WEBHOOK_FORMAT |
slack or generic |
The legacy single Slack webhook (ALERT_WEBHOOK_URL, ALERT_MIN_SEVERITY) still works — server/services/alerts.ts delegates to NotificationService for backward compatibility. Repeat notifications for the same CVE batch and channel are suppressed within the dedup window.
Unlike NVD or API keys, NOTIFICATION_* vars support *_FILE mounts (see Secrets) — or inject via platform env or a gitignored Compose env file. Verify with GET /api/health?detailed=true → alerts.webhookConfigured. Details: Alerts and docs/self-hosted/NOTIFICATIONS.md.
Multi-tenancy (PostgreSQL)¶
Optional PostgreSQL-backed tenant isolation supports shared CVE Radar deployments. Without DATABASE_URL, the app stays single-tenant (browser localStorage only).
DATABASE_URL=postgres://cve_radar:cve_radar@127.0.0.1:5432/cve_radar
Migrations run automatically on first pool connect; each applied file is recorded in _schema_migrations so restarts skip already-applied DDL. Schema definitions in server/db/schema.ts (Drizzle ORM) support incremental ORM migration; tenant/stack and scan-history runtime queries use Drizzle via getDb() (shared pg pool). A default tenant is seeded for existing installs. Send the tenant slug on v1 API calls:
X-Tenant-Id: arvancloud-sre
Omit the header to use default. Key v1 routes:
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/tenants |
Create tenant { slug, name } |
GET |
/api/v1/tenants/stacks |
List stacks for current tenant |
POST |
/api/v1/tenants/stacks |
Create stack { name, tools, settings? } |
GET |
/api/v1/tenants/stacks/:id |
Get saved stack by UUID |
PUT |
/api/v1/tenants/stacks/:id |
Update saved stack |
DELETE |
/api/v1/tenants/stacks/:id |
Delete saved stack |
GET |
/api/v1/scans/history |
Scan history scoped to tenant |
GET |
/api/v1/scans/trends |
Trends scoped to tenant |
Legacy /api/* routes persist history under default when Postgres is enabled. SQL queries always filter WHERE tenant_id = … so tenant A cannot read tenant B rows.
Prometheus metrics and Grafana¶
Metrics are exposed at GET /metrics (root path, not under /api).
| Variable | Default | Description |
|---|---|---|
METRICS_ENABLED |
true |
Set false to return 404 |
METRICS_PROTECT |
false |
Set true to require API_SECRET on /metrics |
| Metric | Type | Labels | Description |
|---|---|---|---|
cve_radar_scans_total |
counter | mode, status |
Scan/watch requests |
cve_radar_vulns_found |
gauge | severity, source |
Counts from last successful scan |
cve_radar_scan_duration_seconds |
histogram | mode |
Handler duration |
cve_radar_source_reachable |
gauge | source |
Upstream reachability |
cve_radar_cache_entries_total |
gauge | — | Cache size |
For in-cluster scraping, leave METRICS_PROTECT off and restrict with NetworkPolicy. Import the dashboard JSON from docs/self-hosted/grafana/cve-radar-dashboard.json.
Example PromQL:
sum(rate(cve_radar_scans_total{status="success"}[5m]))
/ sum(rate(cve_radar_scans_total[5m]))
Air-gapped deployment¶
CVE Radar can run without outbound access to public NVD, OSV, or CISA endpoints when local mirrors or an OSV bulk directory are provided.
AIRGAPPED=true
NVD_MIRROR_URL=http://internal-mirror/nvd
KEV_MIRROR_URL=http://internal-mirror/kev/catalog.json
# OSV — mirror API or offline bulk:
OSV_MIRROR_URL=http://internal-mirror/osv
# OSV_BULK_PATH=/data/osv/extracted
When OSV_BULK_PATH is set, OSV package lookups read the local JSON tree (from scripts/sync-osv-bulk.sh / make sync-osv-bulk) instead of calling the OSV query API.
When AIRGAPPED=true, GitHub Advisories, RSS, and external translation upstreams are skipped. Missing NVD/KEV mirror env vars cause scan to fail closed (AIRGAPPED mode requires …) rather than returning silent empty results. Check status:
curl -s 'http://localhost:3001/api/health?detailed=true' | jq .airgap
Use scripts/sync-mirrors.sh for NVD/KEV mirror layout and scripts/sync-osv-bulk.sh for OSV bulk sync in your sync zone.
Kubernetes stack discovery¶
Opt-in discovery maps Deployment container images to preset stack tools via GET /api/v1/discovery/kubernetes.
K8S_DISCOVERY_ENABLED=true
# K8S_DISCOVERY_NAMESPACES=production,staging
# K8S_KUBECONFIG=/path/to/kubeconfig
Requires authentication in production (API_SECRET; roles admin or scanner). When disabled, the endpoint returns 503 { "code": "K8S_DISCOVERY_DISABLED" }. Response example:
{
"enabled": true,
"images": ["haproxy", "nginx", "redis"],
"tools": ["HAProxy", "Nginx", "Redis"],
"unmapped": ["my-sidecar"]
}
Grant the discovery ServiceAccount read-only list/get on deployments only — no secrets or write verbs. Combine with tenant isolation on shared clusters. When features.k8sDiscovery is true in /api/capabilities, the Stack tab can import discovered tools.
Quick reference¶
| Topic | Key env vars |
|---|---|
| Audit | AUDIT_HEALTH, TRUST_PROXY_HOPS |
| Secrets | *_FILE mounts |
| Auth / RBAC | API_SECRET, API_ROLE |
| Notifications | NOTIFICATION_*, legacy ALERT_WEBHOOK_URL |
| Tenants | DATABASE_URL, X-Tenant-Id |
| Metrics | METRICS_ENABLED, METRICS_PROTECT |
| Airgap | AIRGAPPED, *_MIRROR_URL, OSV_BULK_PATH |
| K8s discovery | K8S_DISCOVERY_ENABLED, K8S_DISCOVERY_NAMESPACES |
| Enrichment | EPSS_ENABLED, COMPLIANCE_ENABLED (default on; disable for airgap) |
Maintainer copies of these guides also live under docs/self-hosted/ in the repository (English, synced with this chapter).
Back to home · Previous: Operations