Configuration reference
Every variable each role reads, with its default and the reasoning beside it — generated from the annotated example files, which remain the authority.
Every role reads its settings from the environment; a .env in the working directory is loaded when present. Two rules hold throughout: no secret has a default, and every timeout's default is also its maximum. Durations are Go durations (90s, 15m, 24h). A variable that is set but empty does not fall back to its default.
The roles
From observability/.env.example.
Shared by every role
LOG_LEVEL
debug | info | warn | error
LOG_FORMAT
json | text. json in anything that ships its logs somewhere; text for reading them in a terminal. Both --log-level and --log-format override these.
AWS credentials — none of the platform's own are set here
serve and work reach AWS as the platform's own identity, then assume each
customer's reader role. That identity comes from the AWS SDK's default
chain, not from a variable in this file: a credential_process in the AWS
config file (IAM Roles Anywhere, for a host outside AWS), or an instance,
task or pod role inside AWS. Both roles log the identity they found as they
start ("this platform's AWS identity"), or that they found none.
AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY would work, because the chain
reads them, and one such key can assume every customer role the platform is
trusted with. They are the last resort, never the default, and every start
warns while one is in use. platform/docs/platform-aws-identity.md has the
three routes; deploy/cloudformation/platform-identity.md sets up the first.
The home cloud — one identity, the others federated from it
PLATFORM_HOME_CLOUD
Which cloud the platform holds its own identity in: aws, gcp or azure. Every other cloud is reached by federating from it, with a token the home issues, never with a second credential; the settings of another home are refused at start. Unset (the default) is aws: the AWS default chain above, exactly as before. Named outright as aws, serve and work refuse to start when that chain finds no credentials. Admin -> Accounts shows the home, read-only. docs/runbook.md, "The home cloud", has the table. Passed to serve, work and notify.
GCP_CERTIFICATE_FILE
A Google home's identity: the host's certificate (PEM, leaf first, then any intermediates) and its private key (PEM), presented to an X.509 workload identity provider (GCP_WORKLOAD_IDENTITY_PROVIDER) over mutual TLS. Read at start — a pair that does not belong together, or a certificate out of date, stops the role — and again at each sign-in, so a renewed certificate is used without a restart. Required with PLATFORM_HOME_CLOUD=gcp; refused under any other home.
GCP_PRIVATE_KEY_FILE
GCP_PLATFORM_SERVICE_ACCOUNT
The service account a Google home mints OIDC ID tokens of, to reach AWS and Azure: name@project.iam.gserviceaccount.com. The pool's principal needs roles/iam.serviceAccountOpenIdTokenCreator on it, and nothing else. Unset: a Google home reaches neither. Google home only.
AWS_PLATFORM_ROLE_ARN
The IAM role a Google or Azure home assumes with its token to reach AWS (AssumeRoleWithWebIdentity): arn:aws:iam::<account>:role/<name>. Every customer's reader role trusts it. Unset: such a home does not reach AWS, and the portal offers no AWS account to connect. Refused under an AWS home.
AZURE_PLATFORM_TENANT_ID
An Azure home's own app, in the operator's directory, by its ids (GUIDs): the app holds the home certificate (AZURE_CERTIFICATE_FILE) and sets the application ID URI api://<client id>; its token reaches AWS and Google Cloud. Needed under an Azure home to reach either; refused under any other.
AZURE_PLATFORM_CLIENT_ID
Google Cloud — through the home identity
GCP_WORKLOAD_IDENTITY_PROVIDER
The workload identity provider serve and work sign in to Google Cloud through, to read and verify the Google Cloud projects registered as accounts: //iam.googleapis.com/projects/<number>/locations/global/workloadIdentityPools/<pool>/providers/<provider>. The platform's home identity is exchanged for a project reader's token; no key file. Under an AWS home it is an AWS provider, under a Google home an X.509 provider (required), under an Azure home an OIDC provider for the directory's issuer. Unset: no Google Cloud project is collected, and the portal offers none to connect (serve's setting decides what it offers).
Azure — through the home identity
AZURE_CERTIFICATE_FILE
Unset (the default): serve and work sign in to Azure as the platform's own AWS identity, through AWS outbound identity federation — STS issues a token for Entra ID's audience at the region's endpoint, and each subscription's app registration trusts it as a federated credential. No secret exists. The AWS account must have outbound identity federation enabled, and the platform's role must be allowed sts:GetWebIdentityToken (docs/runbook.md, "Connecting an Azure subscription"). Under a Google home Azure is reached with GCP_PLATFORM_SERVICE_ACCOUNT's ID token, and this setting is refused. Under an Azure home it is required: the home's own identity. Set, for a deployment off AWS: a PEM file holding a private key and its certificate; the key signs each sign-in, and the customer uploads the certificate to their app registration. Read once, at start: a file whose key and certificate do not belong together stops serve or work. Never a client secret.
Database connections — three URLs, three different roles, on purpose
MIGRATE_DATABASE_URL schema OWNER. Creates and alters tables. Used only by
`panorama migrate`.
DATABASE_URL panorama_app. Row-level security ENFORCED. Holds no
CREATE on schema public, so it cannot migrate — that
is the point, not an oversight.
CONTROL_DATABASE_URL panorama_control. BYPASSRLS, five sanctioned operations:
credential lookup, tenant enumeration, queue
dequeue/reap, bootstrap, and snapshot partition
maintenance.
Collapsing them would mean the application role can create tables, or the
migration role runs the product with isolation disabled. Both roles are
created by deploy/bootstrap-roles.sql, once, before the first migration.
`panorama cleanup` verifies both roles at STARTUP and refuses to run without
them, because it exists to delete: panorama_control holds the same DML grants and
bypasses row-level security, so swapping the two URLs is a working
configuration that quietly deletes across every tenant at once. It has nothing
to serve and nothing to route around, so failing to start is the right answer.
`panorama serve` verifies on every READINESS probe that DATABASE_URL is genuinely
panorama_app — not a superuser, not BYPASSRLS, no role memberships — and that
CONTROL_DATABASE_URL is panorama_control. Swapping them leaves the process live
but never ready, so it receives no traffic. The check is in readiness rather
than startup so that a database outage cannot stop the process answering
/healthz, which would make Kubernetes restart every pod instead of routing
around them.
MIGRATE_DATABASE_URL
Required by every role except `migrate`. Connects as panorama_app. `work` needs it too. The collector *modules* never touch the database — they enumerate AWS and their snapshots reach Postgres only by being POSTed to the API, so every write invariant stays in one process — but the worker around them runs the job queue, and dequeue/complete/fail/reap are SQL. Required by `panorama migrate` only. The role that owns the schema.
DATABASE_URL
CONTROL_DATABASE_URL
The BYPASSRLS connection, as panorama_control. Required by every role except `migrate`, and deliberately with no fallback to DATABASE_URL: falling back would run the control pool with row-level security enforced, so tenant enumeration would quietly return nothing and the scheduler would sit idle looking perfectly healthy. Both roles are created by deploy/bootstrap-roles.sql, once, before the first `panorama migrate`. Migration 0002 refuses to run if they are missing.
DB_MAX_CONNS
Pool size per process. N replicas open N times this many, and Postgres' max_connections is the ceiling every role shares — a generous value here is how one role starves the others. Range [1, 100]. Any pool_* parameter in DATABASE_URL is overridden by this.
serve
LISTEN
host:port. 0.0.0.0 binds every interface; use 127.0.0.1 to bind loopback only.
CORS_ORIGINS
Comma-separated explicit allowlist of origins. "*" is rejected at startup: a wildcard on a credentialed API makes every page a user visits a client of it. Empty means no cross-origin browser access at all. The same list is the browser boundary's: an unsafe request on a session route, or on login/logout, from any origin but this server's own or one listed here is refused with 403 origin_not_trusted. A proxy that rewrites the Host header must preserve it or list the public origin here.
SESSION_TTL
How long a login lasts. Range (0, 12h].
SESSION_MAX_PER_USER
How many sessions one user may hold at once. A login past it ends that user's oldest session. Range [1, 100].
COOKIE_SECURE
Send the session cookie only over HTTPS. Set false ONLY for local plain-HTTP development; it is the difference between a session token that can be read off the wire and one that cannot.
DEPLOYMENT_HOST
The hostname the deployment itself is reached at, when it is a subdomain whose first label is no tenant's — platform.example.com. A login through this exact host never reads that label as a tenant, whatever the tenants table holds, and no tenant can be created with that label as its slug. A port, if given, is ignored; case does not matter. Leave empty for a deployment reached at a bare domain, an address, or only at its tenants' own subdomains.
OPERATOR_MANUAL_PATH
The file GET /manual serves: the operator's manual, to administrators only. Empty unless set, and then the route is 404. The manual describes the operations of the deployment that wrote it — its hosts, its aliases, where each key is kept — so it is not embedded in the binary and not carried in the image: the deployment that owns the manual mounts the file and names it here. A customer's build has nothing to serve and nothing to read out of it. The page is generated into platform/docs/operator-manual.html; mount it and point this at the path inside the container.
OPERATOR_CONTACT
How this deployment's people reach whoever operates it, for help and for access (MILESTONE_1 §7.3): an email address such as ops@example.com, or an https:// address. `serve` tells every signed-in person (GET /auth/me's operatorContact) and the portal's Help menu shows it as "Contact support", an address as a mail link. Empty, Help has no contact. `serve` refuses to start with any other value, because the menu draws it as a link: a value in neither shape would be a link to nowhere, or to a script. The pilot gate requires a real one.
AGENT_INSTALL_PLATFORM_URL
The one-step agent install: Provisioning -> Agents -> Install an agent hands an administrator an AWS CloudFormation quick-create link for one of the tenant's AWS accounts. The stack runs in the customer's account on their credentials; the platform assumes nothing there. Set all three or none; with none the button answers that the deployment does not offer it and the manual install (platform/docs/agent-install.md) is the way. The https address an agent in a customer's account calls this deployment at.
AGENT_INSTALL_IMAGE
The agent image, pinned to a version and a digest — the `pull` of the agent's release record, agent/deploy/releases/<version>.json (agent/RELEASE.md), <repository>:<version>@sha256:<digest>, until the version on main is published and its record kept (0.3.36 is the last published release; its pull is ghcr.io/cloudpanorama/panorama/panorama-agent:0.3.36@sha256:3fefb868e49cab2d3ef97967378a88c598cb5903c55c6bcb45b7594c807e963b and is what to set until then): ghcr.io/cloudpanorama/panorama/panorama-agent:<version>@sha256:<digest> The image serves both linux/amd64 and linux/arm64, so either CPU architecture the install offers runs it.
AGENT_INSTALL_TEMPLATE_URL
The published copy of deploy/cloudformation/agent-runtime.yaml. Quick-create links take templates only from Amazon S3, so an https S3 URL. Each agent release publishes its copy, readable by anyone, at https://cloudpanorama-agent-templates.s3.eu-west-1.amazonaws.com/agent-runtime/<version>/agent-runtime.yaml (agent/RELEASE.md); name the same version as AGENT_INSTALL_IMAGE. The current release: https://cloudpanorama-agent-templates.s3.eu-west-1.amazonaws.com/agent-runtime/0.3.36/agent-runtime.yaml
CLIENT_ADDRESS_SOURCE
How this server works out which client a request came from: `peer` if clients connect to it directly, `proxy` if something sits in front. REQUIRED whenever COOKIE_SECURE is true, and the process refuses to start without it. There is no safe default: this server speaks plain HTTP, so a production deployment has something terminating TLS in front of it, and whether RemoteAddr is a client or that terminator is not something this process can work out. Failed logins are counted per address — guess `peer` behind a proxy and every request in the installation counts against one address, five mistyped passwords from locking out everybody. Omitting it is allowed only with COOKIE_SECURE=false, which is local development, where clients really do connect to this process. Deliberately left EMPTY here rather than filled in. This file describes a production deployment — COOKIE_SECURE is true above — and the whole point of the setting is that the answer depends on something no template can know. Copying a template that had already answered `peer` behind a TLS proxy would reproduce the installation-wide throttle key the setting exists to prevent, and would do it silently, because the configuration would validate. CLIENT_ADDRESS_SOURCE=peer clients connect to this server CLIENT_ADDRESS_SOURCE=proxy something is in front — set TRUSTED_PROXIES
TRUSTED_PROXIES
The proxies whose X-Forwarded-For is believed: CIDRs or bare addresses, comma-separated. Required by CLIENT_ADDRESS_SOURCE=proxy and rejected without it. A list rather than a boolean, deliberately: trusting "whatever set the header" is trusting the caller, since anyone can send one. The header is read from the right and only as far as these hops reach; if every hop in it is one of these, no client address was ever recorded and the per-address limit is dropped rather than applied to our own infrastructure.
INGEST_TOKEN
Bearer token the collectors present to POST /ingest. No default — see rule 1. Generate with: openssl rand -hex 32
INGEST_AUTH_DISABLED
Escape hatch for local development with no token at all. With no token set and this left false, the process exits rather than starting an open ingest endpoint: auth fails closed.
ENTITLEMENTS_UNRESTRICTED
Entitlements fail closed the same way. A tenant with no entitlement row has NO modules until an admin installs a licence: panorama admin install-license acme.license True is the development exception: every tenant with no row has every module and no cap. `serve` refuses it once any licence is installed — the two describe different deployments, and holding both would leave the choice to the code. Read by `serve`, `notify`, `admin show-license` and `admin verify-account`, which all refuse it beside a licence; the scheduler never reads it, because collection is not an entitlement.
work
API_URL
Required, no default. Where the worker POSTs snapshots. http:// or https:// with a host. Collected resource data reaches Postgres only through this API, never through the worker's own database connection, which is for the queue.
INGEST_TOKEN
The same token the server expects. No default — see rule 1.
WORKER_CONCURRENCY
Collector goroutines sharing one dequeue loop. Range [1, 64].
MODULE_TIMEOUT
Budget for one collector module against one account and region. Range (0, 10m]. A run that exceeds it is failed, which closes it against a late batch and a late finalize. Nothing is released: the next run to register claims the next generation regardless.
HTTP_TIMEOUT
Budget for a single outbound HTTP call. Range (0, 1m], and it must not exceed MODULE_TIMEOUT. Zero is rejected: on http.Client a zero timeout means NO timeout, so the most dangerous value is also the one a typo produces.
COST_RESOURCE_MAX_PAGES
The daily per-resource Cost Explorer pull's page bound, per account. Each page is a billed request. A pull that reaches the bound with pages left fails and writes nothing — the previous window stays and the settings page shows the failure — rather than storing a window cut short, which would say the resources it did not reach cost nothing. Range [1, 10000].
PERFORMANCE_METRICS_PER_RUN
The Performance series' metrics per run, per account and region. Each metric is a billed CloudWatch GetMetricData query ($0.01 per thousand). A run with more signals than this takes the next ones in order and the following run resumes where it stopped, so every resource is reached, less often; the Performance page says how old each series is. Range [1, 10000]. 760, measured rather than guessed: the peaks beside the averages (a maximum for every saturation average, p95 and p99 where CloudWatch serves them) take one of each catalogued type from 88 series to 134, so a budget that stayed at 500 would leave the last resources of an account half again as stale as the first.
cleanup
The only role that deletes on a schedule. A pass runs at boot and every 24 hours after it. Eight of the settings below are retention windows, each validated at startup against a floor: too long costs disk, too short costs data, and only one of those is worth refusing to start over. Longer than the default is always accepted. The other two are not windows — CLEANUP_BATCH_SIZE bounds how much one statement deletes, and METRICS_LISTEN is where this role answers a scrape.
CLEANUP_BATCH_SIZE
Rows per delete statement, range [1, 50000]. Each batch is its own transaction, and the loop repeats until a pass is short of the limit — one unbounded DELETE across a backlog holds locks for minutes and queues the ingest path behind it.
CLEANUP_TOMBSTONE_RETENTION
How long a tombstoned resource is kept before the row is physically removed. Minimum 24h. Reads have excluded it since the moment it was tombstoned; this is what stops the table growing forever.
CLEANUP_RUN_RETENTION
Collection run history, for the admin matrix. Minimum 24h. Deleting a run cascades to its run_buckets. serve reads it too — set it for both — to say when the collection run a provisioning request's verification names stops being inspectable on the request's trail.
CLEANUP_SNAPSHOT_RETENTION
Snapshot partitions. Minimum 168h (7 days) — NOT 24h. The floor is enforced inside drop_expired_snapshot_partitions, whose whole job is destroying data that cannot be recovered; a shorter value here would be accepted at startup and then raise from inside Postgres on every nightly pass. The configuration diffs written beside the snapshots follow the same retention: the cleanup pass deletes a tenant's diff rows whose newer observation is older than this, in batches. serve reads it too, for the snapshot a verification names, as it does CLEANUP_RUN_RETENTION.
CLEANUP_STALE_RUN_EXPIRY
How long a run may stay `running` before it is marked failed. Minimum 1h, which is above the 45-minute maximum run duration: reaping below that fails runs that are still collecting. A reaped run keeps ended_at NULL, which is how "the collector vanished" is told from "the collector reported a failure".
CLEANUP_IDEMPOTENCY_RETENTION
Replay protection. Minimum 1h. The window only has to outlast the collector's retry budget — three attempts with backoff — and this is the highest-churn table in the schema.
CLEANUP_JOB_FAILED_RETENTION
Failed queue jobs, kept for the admin matrix rather than forever. Minimum 24h. Validated now; the purge itself arrives with the jobs table.
CLEANUP_COST_RETENTION_MONTHS
Cost history, in months counting the current one: 25 keeps this month and the 24 before it (the owner's decision, 2026-09-11). cost_data is dropped a month partition at a time and the daily rollups and Kubernetes shares derived from it are deleted past the same first-of-month cutoff. Range [14, 120]: the scheduler keeps 13 months of partitions, and a retention inside that would drop what it recreates. History imported from the donor older than this goes too.
CLEANUP_PERFORMANCE_ROLLUP_RETENTION_DAYS
The Performance series' hourly rollup, in days: what the 30-day window reads, and the 30 days before it that its comparison draws, so 90 keeps both and a month to spare. Dropped a day partition at a time. Range [31, 3650]: below 31 the 30-day window would lose its oldest day, and the database's dropper refuses it; below 60 the comparison shows only part of the month before. The five-minute points the rollup is made from are kept 14 days whatever this says. At about 325 bytes an hour a series, 90 days of 1,000 series is about 670 MiB (docs/runbook.md, "How the tables grow").
METRICS_LISTEN
Where this role answers a Prometheus scrape. Each process builds its own registry, so a role with no HTTP surface still needs a listener or its counters are unreadable. It carries no authentication and a scrape discloses how much is being deleted: bind it where only the scraper can reach it. Fail-closed in both directions: the listener binds before the first pass, so a port already in use stops startup rather than surfacing an hour later, and a listener that stops — for any reason, including cleanly — cancels the pass in flight rather than only the next one. A pass runs as long as its backlog takes, so waiting for the next one can be hours of deleting that nobody can see. Stopping the process itself is not that case: a shutdown arrives on the process's own signal and exits with no error — but a listener that had already gone away is still reported, because a shutdown afterwards does not undo it. Every background role — cleanup, work, schedule and notify — takes it, and each answers GET /healthz there too: the probe says the process is up; the *_last_*_timestamp_seconds gauges say its work moves. A source checkout's deploy/monitoring has a scrape configuration, the alerts and a dashboard; platform 0.1.2's /deploy does not carry it.
Not configured here yet
schedule cadence and leader election
notify drain interval; destinations are per tenant in the database,
never a global webhook URL
The compose stack
From observability/deploy/.env.example.
Required — no defaults
POSTGRES_PASSWORD
Owner of the schema. Runs `panorama migrate` and nothing else.
PANORAMA_APP_PASSWORD
The application role. Row-level security ENFORCED; cannot create tables.
PANORAMA_CONTROL_PASSWORD
The control-plane role. BYPASSRLS, for the five sanctioned operations: credential lookup, tenant enumeration, queue dequeue/reap, bootstrap, and snapshot partition maintenance.
Optional — compose supplies these defaults
PANORAMA_IMAGE
The platform image every role runs. An install names the published, signed release by version and digest — the install page's IMAGE, the digest its release record gives — after `cosign verify` has accepted that digest: ghcr.io/cloudpanorama/panorama/panorama:X.Y.Z@sha256:<digest>. The current release is 0.1.2 (2026-10-24), below: one index for linux/amd64 and linux/arm64. Unset, compose runs this checkout's release by version and digest. `make deploy/.env` writes panorama:local for a source checkout, the image `make compose-up` builds.
SEED_DEMO
Seed the demo tenant on `compose up`: three fake accounts, about two hundred fake EC2 instances and a month of history, all labelled as fabricated on every screen. The admin's password is generated and printed once, in `docker compose logs seed`. Re-running is a no-op; `make seed RESET=1` replaces the estate. Leave it false for a real deployment.
ENTITLEMENTS_UNRESTRICTED
The development exception for licences. Compose defaults it to true: the local stack has no licence, and a tenant with no licence row has no modules otherwise. `serve` refuses the flag once a licence is installed, so a stack that has one must set this false. Leave it false for a real deployment.
LICENSE_PUBLIC_KEY
The licence-verifying public key, baked into the image at build time (64 hex characters from `cloudpanorama-license keygen`). Read only by a source build (docker-compose.build.yml): the published image already carries the product's key, and this setting does not change it. Empty builds an image that accepts no licence, which is what the development exception is for. Set it and `make compose-up` to run licences issued with your key locally. `docker compose run --rm serve version` prints the key an image carries.
INGEST_AUTH_DISABLED
Ingest authentication fails closed: with no credential configured and this left false, `serve` exits rather than starting an open ingest endpoint. Compose leaves it false, and `make compose-up` issues the worker token below on the stack's first run, so a stack authenticates ingest without being asked. True is for a laptop that asks for it and wrong everywhere else — it makes the write endpoints accept anything, and those endpoints can tombstone a tenant's whole inventory. With it set, writes are attributed to the single tenant this deployment holds, so `panorama admin bootstrap` has to have run first.
INGEST_TOKEN
The two ways to protect the write path instead. panorama admin create-ingest-token --tenant-slug acme --name 'prod collector' issues a per-tenant credential, which is the general case: it resolves to exactly one tenant, so a leaked collector token cannot write into anybody else's inventory. Nothing is set here for it — the token lives in the database and the collector sends it. INGEST_TOKEN is the single-tenant shortcut: one shared value, set on `serve` and on `work` alike, attributed to the only tenant that exists. It is compared directly rather than looked up, so it has a minimum length, and it must not be the multi-tenant path. Setting it beside INGEST_AUTH_DISABLED is refused: the opt-out accepts anything, so a token next to it is protection that is not there.
WORKER_TOKEN
WORKER_TOKEN is the internal fleet's credential, issued with
panorama admin create-worker-token --name 'eu-west worker fleet'
and set on `work` alone. It names NO tenant: the fleet serves every customer,
and which one a given request is acting for comes from the lease the worker
presents with it. That is what makes one credential usable by a shared pool,
and it is why a row in control.worker_tokens is worth more than one in
control.ingest_tokens — it is not scoped to anybody.
A worker needs EXACTLY ONE of WORKER_TOKEN, INGEST_TOKEN and
INGEST_AUTH_DISABLED=true, and refuses to start otherwise. They are three
deployment shapes rather than three settings: holding two would leave the
choice to the code, and holding none used to start happily and be found out
through repeated 401s — which is precisely what a missing production secret
looks like. `make compose-up` issues this one on the stack's first run, from
the migrated database, and appends it here (deploy/compose-worker-token.sh);
it leaves the file alone when one of the three is already set to a
non-empty value, and refuses (without changing anything) when deploy/.env is
not mode 0600 — `chmod 600 deploy/.env`. By hand:
docker compose -f deploy/docker-compose.yml run --rm -T serve \
admin create-worker-token --name 'compose workers' --raw
then WORKER_TOKEN=<that> here and `docker compose up -d`.
JOB_LEASE
How long a dequeued job belongs to the worker that took it. The dequeue writes now() + JOB_LEASE onto the row, and that stored deadline is what the ingest API and the reaper both read — nothing else configures it. It must be longer than MODULE_TIMEOUT + HTTP_TIMEOUT, and `work` refuses to start otherwise: a lease shorter than the work it was taken for expires mid-collection, the job is handed to a second worker, both write, and the first one's finalize is refused. Unlike the timeouts, this is a floor — too long only means a dead worker's job waits longer to be recovered.
COST_RESOURCE_MAX_PAGES
The daily per-resource Cost Explorer pull's page bound, per account; each page is a billed request. A pull that reaches it with pages left fails and writes nothing — the previous window stays, and the cost settings page shows the failure naming this setting. Raise it for an account that trips it, or switch the account to "report only" once its Cost & Usage Report carries resource ids. Range [1, 10000]; passed to `work`.
PERFORMANCE_METRICS_PER_RUN
The Performance series' metrics per run, per account and region; each is a billed CloudWatch GetMetricData query ($0.01 per thousand). A run with more signals takes the next ones in order and the following run resumes, so every resource is reached, less often. Range [1, 10000]; passed to `work`.
GCP_WORKLOAD_IDENTITY_PROVIDER
The workload identity provider this platform signs in to Google Cloud through, as //iam.googleapis.com/projects/<number>/locations/global/workloadIdentityPools/<pool>/providers/<provider>. Unset: no Google Cloud project is collected, and the portal offers none to connect. Passed to `serve` and `work`.
AZURE_CERTIFICATE_FILE
Azure's sign-in. Unset: the platform's own AWS identity through outbound identity federation, with no secret anywhere. Set only on a deployment off AWS: the path, inside the serve and work containers, of a PEM file with a private key and the certificate the customer's app registration holds. Passed to `serve` and `work`.
PLATFORM_HOME_CLOUD
The home cloud: aws, gcp or azure; unset is aws, as before. Every other cloud is reached by federating from it. The settings below are its identity and its routes to the others; the roles' part of the configuration reference and the runbook's "The home cloud" say which belongs to which home. Paths are inside the containers. Passed to `serve`, `work` and `notify`.
GCP_CERTIFICATE_FILE
GCP_PRIVATE_KEY_FILE
GCP_PLATFORM_SERVICE_ACCOUNT
AWS_PLATFORM_ROLE_ARN
AZURE_PLATFORM_TENANT_ID
AZURE_PLATFORM_CLIENT_ID
TIER_CRITICAL_INTERVAL
Collection cadence per tier. Each must be at least a minute (the scheduler ticks once a minute) and they must be in order: a "critical" tier collected less often than "slow" is two settings swapped, and the symptom is the estate that matters most going stalest. The standard tier is every inventory collector, and ten minutes since 2026-10-03 (an hour before). The slow tier is Cost Explorer, the Cost & Usage Report, Trusted Advisor, Kubernetes attribution, anomalies, budgets and forecasts: daily, never on the ten minutes.
TIER_STANDARD_INTERVAL
TIER_SLOW_INTERVAL
TIER_METRICS_INTERVAL
Performance's CloudWatch reads have a tier of their own, outside the order above: GetMetricData is billed per metric requested, so this is the CloudWatch bill's lever. At least a minute. Each run fetches the window since the last, so a longer interval stores the same points, later.
FRESHNESS_HEADROOM
How late a collection may land — beyond its tier's cadence and the scheduler's jitter — before the resources it keeps are shown as stale. Queue wait plus execution time; the inventory never hides a stale bucket, it marks it. notify reads the tiers and this too, so the weekly team digest's findings call a bucket stale exactly when the Findings page does.
SCHEDULER_JITTER
The fraction of its interval a tier's targets are spread across: 0.1 spreads an hourly tier over six minutes, so three hundred accounts do not call AWS in the same second. Each target keeps a fixed place in that slice and is still collected once per interval — a wider spread is a smaller burst, never a lower frequency. Raise it when the queue backs up on the hour; the freshness window grows with it, so on-time collections still read fresh. Between 0 (every target on the interval's boundary) and 1.
NOTIFY_INTERVAL
How often the transition outbox is drained, and how long one delivery may take. The timeout must be shorter than the interval: the drain holds row locks for the length of the send, so a slow destination would start each cycle before the last one finished and the outbox would grow while nothing looked broken.
NOTIFY_TIMEOUT
AI_REVIEWER_QUEUE_URL
The AI reviewer queue: the one SQS queue URL every `ai-reviewer` notification destination of this deployment sends to, with the platform's own AWS identity (the notify role mounts it as serve and work do; the identity's role needs sqs:SendMessage on that queue alone — the AIReviewerQueueArn parameter of platform-identity.yaml). Unset, the destination kind is refused and nothing is sent. The queue is the operator's: a tenant never names one.
SMTP_URL
The mail relay: the one SMTP server every `email` notification channel of this deployment sends through. The operator's transport, never the tenant's — a channel names addresses only, and nothing a tenant enters can redirect the relay — so it is not under the destination policy that holds tenant-selected HTTPS targets to public addresses on 443: a relay inside your own network is your choice. Unset, the email kind is refused. smtp://host:587 sends with STARTTLS, smtps://host:465 with implicit TLS; no other port and never plaintext — a relay on 587 that does not offer STARTTLS is refused before the password or any address is sent. The relay's certificate is checked against the system roots and its name. No credentials in the URL: they go in SMTP_USERNAME and SMTP_PASSWORD (both or neither), and SMTP_FROM is the plain address every message is sent from. notify sends; serve sends a channel's test message.
SMTP_USERNAME
SMTP_PASSWORD
SMTP_FROM
PORTAL_URL
The portal's public address: the origin people open the portal at, such as https://panorama.example.com — a scheme and a host, a port if it is not the scheme's, and nothing after. notify builds the weekly team digest's links on it: each team's channels, and the inbox of each member who chose the digest, hear the team's spend, budgets, anomalies and alerts once a week (the first cycle after Monday 00:00 UTC), with links back to the Spend pages. Unset, no digest is sent; an organisation without Spend gets none.
COMPOSE_PROFILES
Triage (TRIAGE_SPEC §10 and §11): a decision model that will decide, for every event, whether it goes to the AI reviewer, to people or to nobody (nothing routes by it yet). notify and serve read the six TRIAGE_* settings below and refuse to start on one internal/triage refuses. The `triage` profile starts the Open-Jev sidecar beside the roles: no published port, on an internal network only notify and serve share. Unset, no sidecar runs. Turning Triage on with the sidecar is this line and TRIAGE_PROVIDER=open-jev; the first `up` builds the image from deploy/triage (a 4.6 GB download; deploy/triage/README.md).
TRIAGE_IMAGE
The sidecar image the `triage` profile runs: built locally from deploy/triage under this name unless it names a published one, such as ghcr.io/cloudpanorama/panorama/panorama-triage:<commit>-cpu.
TRIAGE_PROVIDER
open-jev for the sidecar, or typesafe for TypeSafe's hosted Jev, which receives a minimal state (TRIAGE_HOSTED_SHARE) and is paid to TypeSafe directly. Unset turns Triage off for the deployment.
TRIAGE_URL
The provider's base URL. Unset: http://triage:8791 for open-jev (the sidecar above), https://api.typesafe.ai for typesafe. The sidecar has no authentication, so for open-jev it must be a private address or a service name, never a public one: a model on another host (a GPU machine) is named by its private address, and the link to it is yours to protect.
TRIAGE_MODEL
The pinned model asked in every request; an alias such as jev-latest is refused, so a provider's upgrade cannot change decisions silently. For the sidecar it is the name the image answers as — the digest of its checkpoint, which the image refuses to serve under any other. For typesafe, a pinned release such as jev-1.13.0.
TRIAGE_API_KEY_FILE
typesafe only: a file holding TypeSafe's key, mode 0600, readable by the roles' user, at this path inside the notify and serve containers — mount it there with a docker-compose.override.yml. Refused for open-jev, which takes no key.
TRIAGE_TIMEOUT
How long one decision may take, retries included, before the event goes to
people instead. The default holds for the CPU sidecar at its 4 CPUs asked
one decision at a time: on the platform host a decision took 3.4 s
at the median and 4.7 s at p95. The sidecar answers one decision at a time,
so decisions asked together wait for each other — four at once, the last
took 14 s — and fewer CPUs are slower (2 CPUs: 7.3 s at p95). Allow about
twice the p95 times the decisions in flight: 15s for 2 CPUs, 40s when four
are asked together on 4. A larger state is slower too, about 7 ms per input
token on 4 CPUs. The runbook ("The Triage sidecar") has the measurement.
TRIAGE_HOSTED_SHARE
typesafe only: what may leave the deployment beside the minimal state — any of names, tags and ids, comma-separated. Empty sends none of them. Refused for open-jev: the sidecar always receives the full state, and nothing leaves.
OPERATOR_CONTACT
How this deployment's people reach whoever operates it, for help and for access: an email address such as ops@example.com, or an https:// address. The portal's Help menu shows it to everyone signed in, as "Contact support". Unset, Help has no contact. serve refuses to start with anything else, since the menu draws it as a link. A pilot needs a real one.
AGENT_INSTALL_PLATFORM_URL
Provisioning -> Agents -> Install an agent: an AWS CloudFormation quick-create link that runs an agent in a tenant's AWS account. All three or none (the roles' part of the configuration reference says what each is): the platform address agents call (https), the agent image pinned as <repo>:<version>@sha256:<digest>, and the https Amazon S3 address of deploy/cloudformation/agent-runtime.yaml.
AGENT_INSTALL_IMAGE
AGENT_INSTALL_TEMPLATE_URL
SERVE_PORT
Host ports. Both bind to 127.0.0.1 only, and both deliberately avoid the obvious number: the other pillars of this workspace already hold 5432 and 8080-8083, so those defaults would fail on the machine this is built for. Container ports are unchanged.
POSTGRES_PORT
CLEANUP_METRICS_PORT
Where each background role answers a Prometheus scrape. None has an HTTP surface otherwise, and each registry is its own — without a listener its counters are unreadable. Loopback only, like the two above: a scrape discloses queue depth, error rates and how much is being deleted. A source checkout's monitoring directory scrapes these five ports (serve's is SERVE_PORT); platform 0.1.2's copy does not carry it.
WORKER_METRICS_PORT
SCHEDULER_METRICS_PORT
NOTIFY_METRICS_PORT
PLATFORM_AWS_DIR
The directory serve and work mount read-only as the platform's own AWS identity: the Roles Anywhere certificate (aws.crt), its key (aws.key) and an AWS config whose credential_process runs the image's signing helper. Relative to this directory; deploy/platform-aws/ is kept out of git except for its README. Empty, the two roles run with no AWS credentials and say so. deploy/cloudformation/platform-identity.md §3.2.
LOG_LEVEL
text is easier to read while developing; json is what ships.
LOG_FORMAT
COOKIE_SECURE
Plain HTTP locally, so the session cookie cannot be Secure-only.
CORS_ORIGINS
The same list is the browser boundary's: an unsafe request on a session route, or on login/logout, from any origin but this server's own or one listed here is refused with 403 origin_not_trusted. A proxy that rewrites the Host header must preserve it or list the public origin here.
CLIENT_ADDRESS_SOURCE
Who a request comes from, for the per-address login throttle and limits. Compose defaults it to peer: clients connect to serve directly, as on a laptop. Behind a TLS proxy (deploy/proxy/nginx.conf.example), which COOKIE_SECURE=true implies, set proxy here and name the proxy below — with peer, every client is the proxy and one person's failed logins lock out everybody.
TRUSTED_PROXIES
The proxies whose X-Forwarded-For serve believes, as CIDRs or addresses,
comma-separated; required by CLIENT_ADDRESS_SOURCE=proxy and refused beside
peer. It is the address serve sees the proxy connect from: for a proxy on
this host in front of SERVE_PORT, the compose network's gateway —
docker network inspect panorama_default -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
— and for a proxy container on the compose network, that container's address.
A header from anything else is ignored: the request counts as its peer.
Generated from observability/.env.example (c279f8b8c091), observability/deploy/.env.example (ceab99afe864) by www/tools/docs; the value in brackets is a hash of the source's content, so a page changes only when its source does.