Configuration reference
Part of the PerspectiveGraph manual. Every setting the backend reads: its default, the Helm value that sets it, and whether you need it.
Nothing here is required to start. Every setting has a default, and make demo runs
with none of them set. Without credentials, the API and the ingestion endpoint are open and
the log says so on every start. That is what a demo needs, and it is not a deployment: the
next section lists what changes when real findings go into the graph.
Where to set them
Section titled “Where to set them”| You run it with | Where the settings go |
|---|---|
| Docker Compose | A .env file next to docker-compose.yml. Copy .env.example (every key, with the reasons behind it) or, for production, .env.production.example - or let make prod-init write it (production on one machine). docker-compose.yml passes every key on this page to the backend; a variable set in the shell wins over the file. |
| Helm | The chart’s values - the Helm column below. values-production.yaml is the production starting point, and values-ha.yaml adds replicas. |
| The binary | Environment variables. The backend also reads a .env in its working directory and in the parent. |
Secrets. A setting marked secret below also accepts <KEY>_FILE: the path of a file
holding the value, so it never enters the process environment, where docker inspect,
/proc/<pid>/environ and crash dumps can read it. Under Compose,
docker-compose.secrets.yml mounts them; under Helm,
secrets.existingSecret names a Secret you manage. A _FILE that is set but unreadable stops
the backend at startup rather than leaving it without the credential.
Compose recipes. A few variables are read by the compose files themselves rather than by the backend:
| Variable | Read by | What it does |
|---|---|---|
DOMAIN |
docker-compose.vm.yml |
The name Caddy serves and obtains a certificate for; also the default of CORS_ALLOWED_ORIGINS and DASHBOARD_URL (https://<DOMAIN>). Required there. |
BACKUP_UID, BACKUP_GID |
docker-compose.vm.yml |
The user the backup service writes ./backups as. make prod-init sets them to whoever ran it. |
BACKUP_HOURS, BACKUP_KEEP |
docker-compose.vm.yml |
A dump every BACKUP_HOURS (default 24), the newest BACKUP_KEEP kept (default 14). |
AWS_PROFILE, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN |
docker-compose.aws.yml |
Your AWS credentials for the live connector, passed to the AWS SDK as the AWS CLI reads them. |
AWS_CONFIG_DIR |
docker-compose.aws.yml |
The directory mounted read-only as the backend’s ~/.aws (default ~/.aws). |
The minimum for production
Section titled “The minimum for production”PG_ENV=production turns the first three rows into conditions of starting: the backend
refuses to run without them, so an open deployment cannot be reached by forgetting a
setting. The rest are not enforced, and the
pre-production checklist explains each one.
| What | Settings | With PG_ENV=production |
|---|---|---|
| API authentication | API_TOKENS, or OIDC_JWKS_URL + OIDC_ISSUER + OIDC_AUDIENCE |
required |
| Ingest authentication | INGEST_HMAC_SECRET or INGEST_HMAC_SECRETS |
required |
| A database it can reach | POSTGRES_* or POSTGRES_DSN, with Apache AGE |
required: no fallback to an in-memory graph |
| Encryption in transit | TLS_CERT_FILE + TLS_KEY_FILE (or TLS at the proxy), POSTGRES_SSLMODE=verify-full, NATS_TLS_* |
recommended |
| NATS credentials | NATS_USER + NATS_PASSWORD for a NATS you run yourself (the chart’s has them) |
recommended |
| State that survives a restart | GOVERNANCE_BACKEND=postgres, or the *_PATH settings on a volume; AUDIT_LOG_PATH, AUDIT_RETENTION |
recommended |
| Encryption at rest | STORE_ENCRYPTION_KEY |
recommended |
| The edge | CORS_ALLOWED_ORIGINS (your dashboard’s origin), TRUSTED_PROXY_CIDRS (behind a proxy), METRICS_ADDR (off the API port) |
recommended |
| Logs a pipeline can read | LOG_FORMAT=json |
recommended |
Below, Default is what the backend uses when the variable is unset or empty, and Helm is the chart value that sets it.
Deployment profile
Section titled “Deployment profile”| Setting | What it does |
|---|---|
PG_ENVDefault: demoHelm: backend.env |
production makes startup refuse an open API, open ingestion and a missing database (see above). It only ever adds checks, so it is safe anywhere that is not a demo. |
Database: PostgreSQL + Apache AGE
Section titled “Database: PostgreSQL + Apache AGE”The graph lives in PostgreSQL with the Apache AGE extension. Operations says where you can get one; most managed services do not offer AGE.
With postgres.cloudnativepg.enabled, the chart sets these itself: the host is the
operator’s -rw Service, the password comes from the Secret the operator generates, the
mode is verify-full, and libpq’s own PGSSLROOTCERT points the driver at the operator’s CA
(Kubernetes).
| Setting | What it does |
|---|---|
POSTGRES_HOSTDefault: localhostHelm: postgres.externalHost |
The database host. The chart uses its own Postgres unless postgres.enabled=false. |
POSTGRES_PORTDefault: 5432Helm: postgres.externalPort |
The database port. |
POSTGRES_USERDefault: perspectiveHelm: postgres.auth.user |
The database user. Beyond a demo, a role that is not a superuser (Operations). |
POSTGRES_PASSWORDDefault: perspectiveHelm: postgres.auth.password |
Secret. Change it beyond a demo. The chart generates one for its own Postgres and keeps it across upgrades. |
POSTGRES_DBDefault: perspectivegraphHelm: postgres.auth.database |
The database name. |
POSTGRES_SSLMODEDefault: disableHelm: postgres.sslMode |
libpq sslmode. The bundled Postgres has no TLS; for an external one use require, or verify-full to check the server’s certificate too. |
POSTGRES_DSNDefault: built from the keys above Helm: postgres.dsn |
Secret. A full libpq connection string. When set, the six keys above are ignored; use it for what they cannot say, such as sslrootcert. |
AGE_GRAPH_NAMEDefault: perspectiveHelm: postgres.graph |
The AGE graph, created on startup. |
GRAPH_STRICTDefault: falseHelm: backend.graphStrict |
Refuse to start when AGE is unreachable, instead of falling back to an in-memory graph that a restart loses. PG_ENV=production does the same. |
Event bus: NATS JetStream
Section titled “Event bus: NATS JetStream”| Setting | What it does |
|---|---|
NATS_URLDefault: nats://localhost:4222Helm: nats.externalUrl |
The chart uses its own NATS unless nats.enabled=false. Use tls:// for a NATS that speaks TLS. |
NATS_STREAMDefault: PERSPECTIVEHelm: nats.stream |
The JetStream stream the events go through. |
NATS_SUBJECTDefault: perspective.events.*Helm: nats.subject |
The subjects the stream carries. |
NATS_MAX_AGEDefault: 168hHelm: nats.maxAge |
How long an unprocessed or dead-lettered event is kept. A handled event leaves at once, so this bounds a backlog, not a history. |
NATS_USERDefault: none Helm: nats.auth.user |
Credentials for a NATS that requires them. Without them, anything that reaches the bus can publish onto it, past ingest authentication. |
NATS_PASSWORDDefault: none Helm: nats.auth.password |
Secret. The chart generates one for its own NATS and keeps it across upgrades. |
NATS_TLS_CADefault: none Helm: nats.tls.enabled, nats.tls.secretName |
A private CA to trust. Leave the three TLS keys empty inside a service mesh, which encrypts the hop already. |
NATS_TLS_CERTDefault: none Helm: nats.tls.secretName |
A client certificate, for mutual TLS. |
NATS_TLS_KEYDefault: none Helm: nats.tls.secretName |
Its key. |
Servers, TLS and logs
Section titled “Servers, TLS and logs”| Setting | What it does |
|---|---|
API_ADDRDefault: :8080Helm: service.apiPort |
The GraphQL API and the dashboard’s backend. |
INGEST_ADDRDefault: :8081Helm: service.ingestPort |
The ingestion endpoint: the write side of the graph. Never publish it beyond the scanners that post to it. |
TLS_CERT_FILEDefault: none Helm: backend.tls.enabled, backend.tls.secretName |
With TLS_KEY_FILE, both servers speak HTTPS (TLS 1.2 or later) themselves. Empty: plain HTTP, for TLS that ends at a proxy or ingress. |
TLS_KEY_FILEDefault: none Helm: backend.tls.secretName |
The key for TLS_CERT_FILE. |
METRICS_ADDRDefault: none Helm: backend.metricsAddr |
Moves Prometheus /metrics off the API port. Recommended in production: some series carry a tenant label, so on a reachable API port anyone can list the tenants. |
CORS_ALLOWED_ORIGINSDefault: http://localhost:5173,http://localhost:3000Helm: backend.corsAllowedOrigins |
Browser origins allowed to call the API. Set your dashboard’s origin in production; an empty value allows none, * allows any. |
TRUSTED_PROXY_CIDRSDefault: none Helm: backend.trustedProxyCidrs |
The proxies in front of the backend, whose X-Forwarded-For is believed. Set it behind a proxy, or every request looks like the proxy’s and one attacker’s failed logins lock everyone out. Leave it empty without one. |
GRAPHQL_INTROSPECTIONDefault: follows authentication Helm: backend.graphqlIntrospection |
Whether the schema can be queried: on while the API is open, off once it needs a credential. on or off overrides it. |
LOG_LEVELDefault: infoHelm: backend.logLevel |
debug, info, warn or error. |
LOG_FORMATDefault: textHelm: backend.logFormat |
json for a log pipeline. |
Authentication and access
Section titled “Authentication and access”| Setting | What it does |
|---|---|
API_TOKENSDefault: none Helm: auth.apiTokens |
Secret. Bearer tokens, comma-separated, each token:role[:tenant[:expiry[:apps]]]; the role is viewer, operator or admin. A token can be stored as sha256$<hex> so only its hash sits at rest. Security has the details. |
API_ANONYMOUS_ROLEDefault: none Helm: auth.anonymousRole |
viewer publishes the instance read-only: a request with no credential gets that role instead of a 401. Writes stay admin-only. Only for data meant to be public. |
API_RATE_RPSDefault: 60Helm: auth.apiRateRps |
Requests per second per client IP on the API. 0 disables the limit. |
AUTH_LOCKOUT_THRESHOLDDefault: 50Helm: backend.authLockoutThreshold |
Failed logins from one IP within 5 minutes before it is locked out for 15. 0 disables it. |
OIDC_JWKS_URLDefault: none Helm: auth.oidc.jwksUrl |
Turns on SSO: RS256 tokens are verified against these keys. Then OIDC_ISSUER and OIDC_AUDIENCE are required, and the backend refuses to start without them. |
OIDC_ISSUERDefault: none Helm: auth.oidc.issuer |
The issuer the tokens must name. Required with OIDC_JWKS_URL. |
OIDC_AUDIENCEDefault: none Helm: auth.oidc.audience |
The audience the tokens must carry. Required with OIDC_JWKS_URL. |
OIDC_CLIENT_IDDefault: none Helm: auth.oidc.clientId |
With OIDC_AUTHORIZE_URL, shows “Sign in with SSO” on the dashboard. Not a secret. |
OIDC_AUTHORIZE_URLDefault: none Helm: auth.oidc.authorizeUrl |
The IdP’s authorization endpoint, where “Sign in with SSO” sends the browser. |
OIDC_TOKEN_URLDefault: none Helm: auth.oidc.tokenUrl |
The IdP’s token endpoint. When set, the dashboard uses Authorization Code with PKCE; the IdP must allow CORS from the dashboard’s origin. |
OIDC_SCOPESDefault: openid profile emailHelm: auth.oidc.scopes |
The scopes the dashboard asks for at sign-in. |
OIDC_LOGOUT_URLDefault: none Helm: auth.oidc.logoutUrl |
The IdP’s end-session endpoint, so “Sign out” ends the IdP session too. |
OIDC_GROUP_ROLESDefault: none Helm: auth.oidc.groupRoles |
Maps directory groups to roles, e.g. pg-viewers=viewer,pg-admins=admin. Without it or a role claim, a signed-in user gets no access. |
OIDC_DEFAULT_ROLEDefault: none Helm: auth.oidc.defaultRole |
A role for every user the IdP signs in, mapped group or not. A deliberate widening, logged at startup. |
OIDC_ROLE_CLAIMDefault: roleHelm: auth.oidc.roleClaim |
Claim names, for directories that namespace them (Auth0 and Entra often do). |
OIDC_GROUPS_CLAIMDefault: groupsHelm: auth.oidc.groupsClaim |
The claim that lists a user’s groups, read by OIDC_GROUP_ROLES. |
OIDC_TENANT_CLAIMDefault: tenantHelm: auth.oidc.tenantClaim |
The claim naming the user’s tenant. |
OIDC_APPS_CLAIMDefault: appsHelm: auth.oidc.appsClaim |
The claim listing the applications a user may read, for access scoped to them. |
Ingestion
Section titled “Ingestion”| Setting | What it does |
|---|---|
INGEST_HMAC_SECRETDefault: none Helm: ingest.hmacSecret |
Secret. Every ingest request must be signed with it (how to sign). Required with PG_ENV=production. |
INGEST_HMAC_SECRETSDefault: none Helm: ingest.hmacSecrets |
Secret. One secret per tenant, tenant-a:secretA,tenant-b:secretB; the X-Tenant header picks it. |
INGEST_HMAC_ACCEPT_V1Default: trueHelm: ingest.hmacAcceptV1 |
Whether the older signature over the body alone is still accepted. It can be replayed: set false once perspectivegraph_ingest_signatures_total{version="v1"} stays at zero. |
INGEST_RATE_RPSDefault: 30Helm: auth.ingestRateRps |
Requests per second per client IP on ingestion. 0 disables the limit. |
SCRUB_INGESTDefault: trueHelm: scrubIngest |
Redacts credentials that scanner output carries by accident (cloud keys, tokens, private keys) before they reach the graph. |
Data protection and audit
Section titled “Data protection and audit”| Setting | What it does |
|---|---|
STORE_ENCRYPTION_KEYDefault: none Helm: crypto.storeEncryptionKey |
Secret. Encrypts the file-backed stores and the audit log at rest (AES-256-GCM). 64 hex characters are the key itself; anything else is a passphrase. Generate one with openssl rand -hex 32. |
EXPORT_SIGNING_KEYDefault: none Helm: crypto.exportSigningKey |
Secret. Signs the OSCAL and SIEM exports (Ed25519); the public key is at GET /export/pubkey. |
AUDIT_LOG_PATHDefault: none Helm: persistence.enabled |
The tamper-evident audit log: who changed, viewed or exported which attack paths. Empty: kept in memory only. |
AUDIT_RETENTIONDefault: kept forever Helm: auditRetention |
How long audit records are kept, e.g. 2160h (90 days). Applies with GOVERNANCE_BACKEND=postgres; a file-backed log is rotated instead. |
EXFIL_ALERT_THRESHOLDDefault: 0 (off)Helm: backend.exfilAlertThreshold |
Attack-path views or exports by one user within 5 minutes before an exfiltration alert. |
Governance state
Section titled “Governance state”Suppressions, tickets, red-team verdicts and posture history. Each lives in memory unless one of these keeps it.
| Setting | What it does |
|---|---|
GOVERNANCE_BACKENDDefault: fileHelm: governanceBackend |
postgres keeps all of them in the database, shared by every replica, and ignores the paths below. file keeps each in its path: one writer, one replica. |
SUPPRESSIONS_PATHDefault: none Helm: persistence.enabled |
Paths taken off the board, with owner and expiry. |
TICKETS_PATHDefault: none Helm: persistence.enabled |
Remediation tickets, one open per path. |
VALIDATIONS_PATHDefault: none Helm: persistence.enabled |
Red-team and BAS verdicts, the evidence behind the precision and recall the dashboard reports. |
HISTORY_PATHDefault: none Helm: persistence.enabled |
Per-path first and last seen, MTTR and the posture trend. Without it, path ages start over on every restart. |
TICKET_WEBHOOK_URLDefault: none Helm: ticket.webhookUrl |
Secret. Also posts each new ticket to a tracker (Jira, GitHub, a SOAR). |
The graph and the analyzer
Section titled “The graph and the analyzer”| Setting | What it does |
|---|---|
ANALYZER_INTERVALDefault: 30sHelm: backend.analyzerInterval |
How often the attack paths are recomputed. |
ANALYZER_MAX_HOPSDefault: 12Helm: backend.analyzerMaxHops |
The longest path searched for. |
ANALYZER_WORKERSDefault: 0 (one per CPU)Helm: backend.analyzerWorkers |
Parallel searches per pass. The result is the same either way; only the time changes. |
ANALYZER_INCREMENTALDefault: falseHelm: backend.analyzerIncremental |
Keeps the graph in memory and reads only what changed each pass: less database load on a large graph, more memory. |
ANALYZER_DB_PATHSDefault: falseHelm: backend.analyzerDbPaths |
Computes paths in the database with Cypher instead of in process. Only for graphs known to be small: it enumerates paths. |
GRAPH_SWEEPDefault: trueHelm: graph.sweep |
A source that describes a scope in full retracts what it no longer lists: a fixed CVE or a terminated instance leaves the graph at the next scan. |
GRAPH_TTLDefault: 0 (off)Helm: graph.ttl |
Removes what no source has reported within this window, e.g. 168h. Set it to a few scan cycles. |
COVERAGE_STALE_AFTERDefault: 24hHelm: backend.coverageStaleAfter |
How long a source may stay silent before the dashboard reports it as stale, so an empty board is not mistaken for a safe one. |
SEED_IAM_USERSDefault: falseHelm: backend.seedIamUsers |
Also starts paths from IAM users, as if their keys had leaked, beside the paths from the internet. |
ATTACKER_PROFILE_PRIORSDefault: 0.5 / 0.35 / 0.15Helm: backend.attackerProfilePriors |
The weights of the commodity, criminal and APT attacker profiles that each path’s score is averaged over (scoring). |
EPSS_TRAVERSAL_GAMMADefault: 1.0Helm: backend.epssTraversalGamma |
Maps EPSS to the probability of crossing a hop as EPSS to this power; below 1 raises it. |
Connectors: reading your cloud accounts
Section titled “Connectors: reading your cloud accounts”Sources and integrations has the permissions the AWS role needs.
| Setting | What it does |
|---|---|
CONNECTORS_ENABLEDDefault: none Helm: connectors.enabled |
aws, azure, kubernetes, comma-separated. Empty: data arrives only by ingestion. |
CONNECTOR_INTERVALDefault: 15mHelm: connectors.interval |
How often each connector reads. |
CONNECTOR_TIMEOUTDefault: 2mHelm: connectors.timeout |
The longest one read may take. |
CONNECTOR_TENANTDefault: the default tenant Helm: connectors.tenant |
The tenant whose graph the connectors feed. |
AWS_CONNECTOR_MODEDefault: fixturesHelm: connectors.aws.mode |
sdk reads the live account; fixtures reads sample files, with no credentials. |
AWS_REGIONDefault: none Helm: connectors.aws.region |
The region the connector reads. |
AWS_ROLE_ARNDefault: none Helm: connectors.aws.roleArn |
A read-only role to assume, or several, comma-separated, one per account. Without it, the connector uses the standard AWS credential chain directly. |
AWS_FIXTURES_DIRDefault: none Helm: connectors.aws.fixturesDir |
Where fixtures mode reads from. |
AZURE_CONNECTOR_MODEDefault: fixturesHelm: connectors.azure.mode |
Only fixtures is built so far. |
AZURE_FIXTURES_DIRDefault: none Helm: connectors.azure.fixturesDir |
Where fixtures mode reads from. |
K8S_CONNECTOR_MODEDefault: inclusterHelm: connectors.kubernetes.mode |
incluster reads the cluster the backend runs in, with its service account (the chart grants it read access); fixtures reads a kubectl dump from disk. |
K8S_CLUSTER_NAMEDefault: none Helm: connectors.kubernetes.clusterName |
Required for the Kubernetes connector. Names repeat across clusters; on EKS it must be the EKS cluster’s name, so its objects meet the AWS connector’s. |
K8S_AWS_ACCOUNTDefault: none Helm: connectors.kubernetes.awsAccount |
On EKS, the account the nodes run in, so a pod’s escape to its node meets the instance the AWS connector reads. |
K8S_FIXTURES_DIRDefault: none Helm: connectors.kubernetes.fixturesDir |
Where fixtures mode reads k8s-sample.json from. |
Threat intelligence
Section titled “Threat intelligence”| Setting | What it does |
|---|---|
THREATINTELDefault: off Helm: threatIntel.enabled |
Adds CISA KEV (exploited in the wild) and FIRST EPSS (likely to be) to every CVE, and weighs the paths through them accordingly. |
KEV_FEED_URLDefault: the CISA feed Helm: threatIntel.kevFeedUrl |
A mirror, for an installation without internet access. |
EPSS_API_URLDefault: the FIRST API Helm: threatIntel.epssApiUrl |
Likewise. |
KEV_HOLDOUTDefault: off Helm: kevHoldout.enabled |
Needs THREATINTEL. Records a forecast for each CVE not yet exploited and grades it a window later against KEV: a calibration measure the engine cannot mark itself on. |
KEV_HOLDOUT_PATHDefault: none Helm: kevHoldout.path |
Where forecasts wait to be graded. Without it they live in memory, and a restart before the window ends loses them. |
KEV_HOLDOUT_WINDOWDefault: 720hHelm: kevHoldout.window |
How long after a forecast it is graded: 30 days by default, the horizon EPSS itself forecasts over. |
GitHub and GitLab
Section titled “GitHub and GitLab”| Setting | What it does |
|---|---|
GITHUB_TOKENDefault: none Helm: github.token |
Secret. Pull request comments, the merge-gate commit status and fix pull requests. Empty: logged, not posted. |
GITHUB_API_URLDefault: https://api.github.comHelm: github.apiUrl |
For GitHub Enterprise Server. |
GITHUB_DRY_RUNDefault: falseHelm: github.dryRun |
Logs instead of posting, even with a token. |
DASHBOARD_URLDefault: none Helm: github.dashboardUrl |
Links the commit status back to the dashboard. |
GITLAB_TOKENDefault: none Helm: gitlab.token |
Secret. Merge request comments. Empty: logged, not posted. |
GITLAB_API_URLDefault: https://gitlab.com/api/v4Helm: gitlab.apiUrl |
For a self-managed GitLab. |
GITLAB_DRY_RUNDefault: falseHelm: gitlab.dryRun |
Logs instead of posting, even with a token. |
REPO_ALLOWLISTDefault: - (no writes) Helm: repoAllowlist |
The repositories the engine may write to, owner/repo or owner/*. Required for any write: the destination otherwise comes from ingested data, which anyone holding the ingest secret can plant. |
PR_ATTRIBUTIONDefault: diffHelm: prAttribution |
diff counts only the routes a change opened or made likelier; commit every route through what the commit touched. |
Alerts and search
Section titled “Alerts and search”| Setting | What it does |
|---|---|
ALERT_WEBHOOK_URLDefault: none Helm: alert.webhookUrl |
Secret. Posts an alert when a new attack path appears. |
ALERT_WEBHOOK_FORMATDefault: slackHelm: alert.webhookFormat |
slack (Slack, Mattermost) or generic (the raw JSON). |
OPENSEARCH_URLDefault: none Helm: opensearch.url |
Full-text search over the graph. |
AI assistant
Section titled “AI assistant”Off unless a key is set. Each call sends a compacted view of the graph to the provider and is recorded in the audit log (AI assistant and MCP).
| Setting | What it does |
|---|---|
ANTHROPIC_API_KEYDefault: none Helm: ai.apiKey |
Secret. Turns the assistant on with Claude. |
ANTHROPIC_MODELDefault: claude-opus-5Helm: ai.model |
The Claude model. |
ANTHROPIC_BASE_URLDefault: https://api.anthropic.comHelm: ai.baseUrl |
For a proxy. |
AI_MAX_TOKENSDefault: 4096Helm: ai.maxTokens |
The longest answer, in tokens. |
AI_RATE_PER_MINDefault: 10Helm: ai.ratePerMin |
Calls per minute per client, on top of API_RATE_RPS: each one is paid for. |
HF_TOKENDefault: none Helm: ai.hf.token |
Secret. Any OpenAI-compatible provider instead, used only without ANTHROPIC_API_KEY. HUGGINGFACE_API_KEY is accepted as an alias. |
HF_MODELDefault: meta-llama/Llama-3.1-8B-InstructHelm: ai.hf.model |
A chat model the token can reach. |
HF_BASE_URLDefault: https://router.huggingface.co/v1Helm: ai.hf.baseUrl |
Any OpenAI-compatible API: Together, Groq, a local Ollama. |