CloudPanorama

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

default INFO

debug | info | warn | error

LOG_FORMAT

default json

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

unset by default

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

default postgres://panorama_owner:CHANGEME@localhost:5432/panorama?sslmode=disable

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

default postgres://panorama_app:CHANGEME@localhost:5432/panorama?sslmode=disable

CONTROL_DATABASE_URL

default postgres://panorama_control:CHANGEME@localhost:5432/panorama?sslmode=disable

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

default 10

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

default 0.0.0.0:8080

host:port. 0.0.0.0 binds every interface; use 127.0.0.1 to bind loopback only.

CORS_ORIGINS

default http://localhost:5173

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

default 12h

How long a login lasts. Range (0, 12h].

SESSION_MAX_PER_USER

default 5

How many sessions one user may hold at once. A login past it ends that user's
oldest session. Range [1, 100].

DEPLOYMENT_HOST

required, no default

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

required, no default

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

required, no default

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

required, no default

Bearer token the collectors present to POST /ingest. No default — see rule 1.
Generate with: openssl rand -hex 32

INGEST_AUTH_DISABLED

default false

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

default false

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

default http://localhost:8080

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

required, no default

The same token the server expects. No default — see rule 1.

WORKER_CONCURRENCY

default 4

Collector goroutines sharing one dequeue loop. Range [1, 64].

MODULE_TIMEOUT

default 10m

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

default 60s

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

default 100

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

default 500

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

default 5000

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

default 720h

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

default 168h

Collection run history, for the admin matrix. Minimum 24h. Deleting a run
cascades to its run_buckets.

CLEANUP_SNAPSHOT_RETENTION

default 720h

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

default 24h

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

default 24h

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

default 168h

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

default 25

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

default 0.0.0.0:9090

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

default GENERATED_BY_MAKE_COMPOSE_UP

Owner of the schema. Runs `panorama migrate` and nothing else.

PANORAMA_APP_PASSWORD

default GENERATED_BY_MAKE_COMPOSE_UP

The application role. Row-level security ENFORCED; cannot create tables.

PANORAMA_CONTROL_PASSWORD

default GENERATED_BY_MAKE_COMPOSE_UP

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

default false

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

default true

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

required, no default

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

default true

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

unset by default

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

unset by default

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

default 15m

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

default 100

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

default 500

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

unset by default

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

default 5m

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

default 1h

TIER_SLOW_INTERVAL

default 24h

FRESHNESS_HEADROOM

default 1h

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

default 0.1

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

default 60s

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

default 10s

AI_REVIEWER_QUEUE_URL

required, no default

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

default 18080

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

default 55432

CLEANUP_METRICS_PORT

default 19090

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

default 19091

SCHEDULER_METRICS_PORT

default 19092

NOTIFY_METRICS_PORT

default 19093

PLATFORM_AWS_DIR

default ./platform-aws

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

default INFO

text is easier to read while developing; json is what ships.

LOG_FORMAT

default text

CORS_ORIGINS

default http://localhost:5173

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.