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.
Google Cloud — through the same identity (p7_82)
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 own AWS credentials are exchanged for a project reader's token; no key file. Unset: no Google Cloud project is collected.
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 (p7_18): 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 or an address.
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 (p5_1). 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` and `admin show-license`; 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].
cleanup
The only role that deletes on a schedule. A pass runs at boot and every 24 hours after it. Seven 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.
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 (p8_9) follow the same retention: the cleanup pass deletes a tenant's diff rows whose newer observation is older than this, in batches.
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 in task_31 (D1).
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.
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 (p7_16), and each answers GET /healthz there too: the probe says the process is up; the *_last_*_timestamp_seconds gauges say its work moves. deploy/monitoring has a scrape configuration, the alerts and a dashboard.
Not configured here yet
schedule cadence and leader election -> task_32 (D2)
notify drain interval; destinations are per tenant in the database,
never a global webhook URL -> task_37 (F1)
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
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 (p5_1). 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`). Empty builds an image that accepts no licence, which is what the development exception is for. Set it and `docker compose build` 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. True is right for local development 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. The local stack declares the opt-out, which is why it needs neither token.
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 (p7_82), as //iam.googleapis.com/projects/<number>/locations/global/workloadIdentityPools/<pool>/providers/<provider>. Unset: no Google Cloud project is collected. Passed to `serve` and `work`.
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.
TIER_STANDARD_INTERVAL
TIER_SLOW_INTERVAL
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.
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 (p8_23): 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.
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 (p7_16). 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. deploy/monitoring scrapes these five ports (serve's is SERVE_PORT).
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 (p7_34): 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. platform/docs/platform-aws-identity.md §3.
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 (p7_18): 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.