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
API_SECRET_PREVIOUS API_SECRET_PREVIOUS_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 and/or OIDC_ISSUER is set, all /api/* routes except GET /api/health and GET /api/v1/health require authentication:

Method Client Role source
X-Api-Key Automation / legacy Server API_ROLE (default admin)
Authorization: Bearer (shared secret) Same as API key API_ROLE
Authorization: Bearer (JWT) OIDC / SSO OIDC_ROLE_MAPPING from IdP groups

Set API_ROLE on the server process for shared-secret clients. For JWT clients, configure OIDC_ISSUER, OIDC_ROLE_MAPPING, and optionally OIDC_GROUPS_CLAIM (Keycloak: realm_access.roles). See SECURITY.md.

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.

Security audit pack

Enterprise and GRC reviews (#259) should use:

Artifact Purpose
docs/security/THREAT_MODEL.md STRIDE-lite threats and mitigations
docs/security/DEPENDENCY_AUDIT.md How to snapshot npm audit / Trivy for auditors
docs/self-hosted/SECRETS.md Zero-downtime API_SECRET + API_SECRET_PREVIOUS rotation
SECURITY.md OIDC SSO and SAML via IdP→OIDC federation (no native SAML SP; ADR 007)

During secret rotation, set the new value in API_SECRET / API_SECRET_FILE and keep the old value in API_SECRET_PREVIOUS / API_SECRET_PREVIOUS_FILE until every client is updated, then remove the previous pair.

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.

If X-Tenant-Id is sent but malformed, the API returns 400 TENANT_SLUG_INVALID — it does not silently fall back to default.

Multi-tenancy checklist

Shared deployments should verify each concern before go-live:

Concern Behaviour
Data isolation Tenant-scoped SQL uses WHERE tenant_id = … for stacks, scan history, and notification prefs.
Header validation Valid slug when header is present; unknown slug → 404 TENANT_NOT_FOUND.
Reserved slugs default, system, admin, root, postgres, public cannot be used when creating tenants (409 TENANT_SLUG_RESERVED).
Auth / RBAC API_SECRET and optional OIDC; tenant admin routes require admin role.
Rate limits Scan/watch buckets are per IP + tenant on v1 routes.
Audit Scan/watch audit lines include tenant; GET /api/v1/audit is scoped to the current tenant.
Notifications Per-tenant prefs and webhook secrets when X-Tenant-Id is set; dedup keys include tenant.
Legacy routes /api/* without version uses default tenant for history.
Health GET /api/health?detailed=truedatabase.multiTenant; capabilities → features.multiTenant.

Docker Compose with PostgreSQL: docker compose --profile postgres up -d and set DATABASE_URL (see docs/self-hosted/TENANTS.md).

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_mirror_age_days gauge mirror Offline mirror age (nvd, osv, kev, osv_bulk)
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
# MIRROR_MANIFEST_PATH=/data/mirrors/.mirror-manifest.json
# MIRROR_STALE_DAYS=7

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.mirrorHealth'

The sidebar shows a stale mirror warning when any feed age exceeds MIRROR_STALE_DAYS (default 7). Prometheus exposes cve_radar_mirror_age_days.

Mirror sync (internet zone)

Run npm run mirrors:sync (or make mirrors-sync) in a connected zone to download CISA KEV, NVD API 2.0 pages (incremental lastMod window via MIRROR_NVD_DAYS), and OSV bulk with resume + SHA256 manifest (.mirror-manifest.json). Copy artifacts to the air-gapped host and set MIRROR_MANIFEST_PATH. See docs/self-hosted/AIRGAP.md for RPO/RTO guidance and disk sizing.

Skip flag Effect
MIRROR_SKIP_NVD=true KEV + OSV only
MIRROR_SKIP_OSV=true NVD + KEV only
MIRROR_SKIP_KEV=true NVD + OSV only

CI validates mirror JSON fixtures against schemas/airgap/*.json via npm run check:airgap-schemas.

Helm chart

Official chart: charts/cve-radar/. Renders Deployment, Service, optional Ingress, bundled Redis, optional Postgres, PVC for /app/data, and Kubernetes Secret mounts via *_FILE env vars (same pattern as Secrets).

helm install cve-radar ./charts/cve-radar -n cve-radar --create-namespace

See docs/self-hosted/HELM.md.

Kubernetes Operator (CVEScanStack)

Optional v0 operator reconciles CVEScanStack custom resources into agent and watch CronJob objects. CRD group cve-radar.io/v1alpha1; manifests under deploy/k8s/operator/. Run in-cluster via npm run operator:run (see docs/self-hosted/OPERATOR.md).

kubectl apply -f deploy/k8s/operator/crd-cvescanstack.yaml
kubectl apply -f deploy/k8s/operator/rbac.yaml
kubectl apply -f deploy/k8s/operator/deployment.yaml

Each CVEScanStack spec lists preset stack tools, apiKeySecretRef, optional tenantId, and cron schedules for Trivy import (/api/v1/scans/import) and watch polling (/api/v1/watch). Tracked in #254.

Scan job worker (BullMQ)

When REDIS_URL is set, run a separate worker alongside the API: npm run worker:run (production image: node dist-server/server/worker/run.js). The API accepts POST /api/v1/scan/queue; clients poll GET /api/v1/jobs/:id and fetch paginated results from GET /api/v1/jobs/:id/results. The Web UI switches to queue + poll when /api/capabilities reports features.jobQueue: true. Without Redis, scans stay synchronous (POST /api/v1/scan/stream). See #257.

REDIS_URL=redis://127.0.0.1:6379 npm run worker:run

Scan agent (Trivy / Grype)

Import local or cluster scanner output via POST /api/v1/scans/import. Local CLI: npm run agent:scan -- --target ./. Example CronJob: deploy/k8s/agent-scan-cronjob.example.yaml. Maintainer guide: docs/self-hosted/AGENT.md.

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, OIDC_*
Notifications NOTIFICATION_*, legacy ALERT_WEBHOOK_URL
Tenants DATABASE_URL, X-Tenant-Id
Metrics METRICS_ENABLED, METRICS_PROTECT
Airgap AIRGAPPED, *_MIRROR_URL, OSV_BULK_PATH, MIRROR_MANIFEST_PATH, MIRROR_STALE_DAYS
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