Skip to content

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=truealerts.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