> ## Documentation Index
> Fetch the complete documentation index at: https://docs.controlplane.com/llms.txt
> Use this file to discover all available pages before exploring further.

# CPLN Advisor

> Deploy CPLN Advisor on Control Plane using the Template Catalog. It scores your workloads from their own metrics, logs and billed cost and turns them into LLM tuning suggestions, with an optional Autopilot. Covers the prerequisite secrets, the single-location rule, and migrating from 1.0.0.

<Warning>
  **This chart does not create a GVC — it deploys into one you already have.** An earlier build of 1.0.0 did create one, **named after the GVC you installed into**, so Helm adopted your GVC and has owned it since. The current chart stops declaring it, and Helm deletes what a chart stops declaring. Install as a new release instead: [If you installed the earlier 1.0.0](#if-you-installed-the-earlier-1-0-0).

  One values key also changed, a clean break with no fallback: `gvc.locations` (a list) became the top-level `location` (a single name). `worker.resources` and `redis.resources` still use `maxCpu` / `maxMemory`.
</Warning>

## Overview

CPLN Advisor watches the workloads in the org it runs in, tracks CPU, memory, replica counts, error rates and **billed** cost, and turns what it finds into concrete tuning suggestions — memory limits, autoscaling thresholds, replica counts — generated by an LLM grounded in Control Plane runbooks. Suggestions appear in a dashboard, and **Autopilot** can apply the qualifying ones for you, each with a one-click revert.

It reports on the org it runs in: Control Plane injects `CPLN_ORG` into every container, so the advisor knows where it lives without being told.

No credential passes through this chart. It creates no credential secret and takes none as a value — it reads two [dictionary secrets](/guides/create-secret/dictionary) you create first, and the advisor's own Control Plane token is entered in the dashboard after install.

### What Gets Created

* **Dashboard Workload** (`RELEASE_NAME-web`) — The Next.js UI on port 3000, a `serverless` workload scaling 1–3. **The only public workload**, and the only one holding a session key.
* **API Workload** (`RELEASE_NAME-api`) — FastAPI backend on port 8000, `standard`, pinned to one replica. Verifies the login, runs the database migration at startup, and is reachable only by the dashboard.
* **Worker Workload** (`RELEASE_NAME-worker`) — Runs the scans, applies Autopilot changes, sends the Slack digest. One replica, no inbound traffic.
* **Scheduler Workload** (`RELEASE_NAME-scheduler`) — Fires the crons that *create* the scans. One replica, no inbound traffic.
* **Redis Workload** (`RELEASE_NAME-redis`) — Task broker and cache on port 6379. Not persistent and not authenticated; its firewall admits exactly the API, worker and scheduler.
* **Bundled Postgres** (`RELEASE_NAME-postgres` plus `-pg-vs`, `-pg-identity`, `-pg-policy`) — The database, from the catalog's [Postgres](/template-catalog/templates/postgres) template pinned at `3.4.1`: a `stateful` workload on its own volume set, with a `pg_isready` probe and scheduled backups behind a switch. It creates no secret — it reads your database credentials secret.
* **Startup Script Secret** (`RELEASE_NAME-startup`) — The API's boot script. Holds no credential.
* **Identity & Policies** (`RELEASE_NAME-identity`, `-policy`, `-gvc-policy`) — `reveal` on exactly two secrets (the advisor credentials you created and the boot script), plus `view` on exactly one GVC, the one you installed into.

<Note>
  **This template does not create a GVC.** It deploys into a GVC you already have — every resource lands in the GVC you install into, so `cpln workload exec`, `cpln logs` and uninstalling all work against that GVC, and uninstalling can never delete it. The `location` you configure must already be a location of that GVC, and a GVC location this release did not ask for starts nothing.
</Note>

### The Single Location Requirement

**Install the advisor into a GVC with exactly one location.** One location is not a simplification: scale bounds are per-location, so a second location would mean a second scheduler firing every cron twice, a second Redis splitting the task queue, and a second API running the same startup migration.

Every workload this chart owns is pinned to the configured `location` by `defaultOptions.minScale/maxScale: 0` plus a `localOptions` entry carrying the real replica counts. Measured against a GVC that had a second location added underneath a running release, all five tiers started nothing there and their deployments read:

```text theme={null}
This workload location is deactivated because maxScale is set to 0.
```

That message, not the field, is the signal to look for. The API, worker, scheduler and Redis also refuse to start outside the configured location — a replica forced into another one exits `1` and logs a `FATAL` line naming both locations, while the correctly placed replica keeps serving untouched.

<Warning>
  **The bundled Postgres is the one tier this chart cannot pin.** The `postgres` template at `3.4.1` has no `location` knob, and a subchart's values cannot be templated, so the parent has nothing to hand it. In a GVC with more than one location it runs **one independent, empty database per location**, each on its own billed volume, all answering the same `RELEASE_NAME-postgres.GVC_NAME.cpln.local` service DNS name.

  Measured on a two-location GVC: the west database held the advisor's 8 tables, the east one held 0, and 10 consecutive connections from a client in each location resolved strictly location-locally with no round-robin and no cross-location bleed. So an install stays *correct* today — every advisor tier is pinned, so all three writers keep reaching the same database — but you pay for a second database that nothing uses, and **changing `location` later silently repoints the app at the empty one**, making your scan history and stored credentials appear to be gone.

  The API warns about extra GVC locations in its startup log. It cannot prevent them.
</Warning>

### Workloads the Advisor Currently Skips

**The advisor reads `defaultOptions.autoscaling.maxScale: 0` as "suspended" and skips the workload**, ignoring the `localOptions` entry that actually supplies the replica count — even though the workload is running and serving traffic. Scans log:

```text theme={null}
scan: skipping GVC_NAME/WORKLOAD_NAME — suspended
```

That idiom is exactly how every location-pinned template in the catalog places its workloads, including this one, so the advisor does not currently score its own five tiers. Confirmed by changing one variable and nothing else: patching one tier from `maxScale: 0` to `1` made the very next scan score it. A fix belongs in the advisor backend, not in the chart — there is no chart-side workaround that does not reintroduce the placement defect this version closed.

## Prerequisites

**A GVC with exactly one location, and two `dictionary` secrets that exist before you install.** The advisor's own Control Plane token is set in the dashboard *after* install, not here.

<Warning>
  **A missing prerequisite secret wedges the deployment rather than failing the install.** `cpln helm install` exits 0, the resources are created, and the workloads then never start — so `cpln logs` returns **zero lines**, which reads as a broken platform rather than a missing prerequisite. See [Diagnosing a Stuck Install](#diagnosing-a-stuck-install).
</Warning>

### The Advisor Credentials Secret

The keys are the application's own environment variable names, so a secret built for a hand-applied deployment works here unchanged.

```bash theme={null}
DB_PASS="$(openssl rand -hex 32)"

cpln secret create-dictionary --name advisor-config \
  --entry ADVISOR_API_TOKEN="$(openssl rand -hex 32)" \
  --entry ADVISOR_SECRET_KEY="$(openssl rand -hex 32)" \
  --entry ADVISOR_SESSION_SECRET="$(openssl rand -hex 32)" \
  --entry ADVISOR_USERNAME=admin \
  --entry ADVISOR_PASSWORD='YOUR-STRONG-PASSPHRASE' \
  --entry DATABASE_URL="postgresql+asyncpg://advisor:$DB_PASS@RELEASE_NAME-postgres.GVC_NAME.cpln.local:5432/advisor"
```

| Key                                     | What it is                                                                                                                                                                                        |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ADVISOR_API_TOKEN`                     | Shared between the dashboard and the API. The API **fails closed**: without it, every route except `/health` returns `401`.                                                                       |
| `ADVISOR_SECRET_KEY`                    | Encrypts the AI and Slack credentials stored in the database. **Losing or changing it loses those credentials** — unrecoverably, and the app then reports them as "not set" rather than erroring. |
| `ADVISOR_SESSION_SECRET`                | Signs the dashboard session cookie. Rotating it is safe; it signs everyone out.                                                                                                                   |
| `ADVISOR_USERNAME` / `ADVISOR_PASSWORD` | The web UI login. A human types the password, so prefer a long passphrase.                                                                                                                        |
| `DATABASE_URL`                          | Must start `postgresql+asyncpg://`, **not** `postgres://` — SQLAlchemy needs the driver named. The host is `RELEASE_NAME-postgres.GVC_NAME.cpln.local:5432`, always fully qualified.              |

Set `auth.secretName` to the name you used.

### The Database Credentials Secret

From version `3.4.0` the bundled Postgres template reads its credentials from a secret instead of taking them inline, which is what lets this chart bundle a database and still keep every credential out of values. Exactly three keys:

```bash theme={null}
cpln secret create-dictionary --name advisor-db-credentials \
  --entry username=advisor \
  --entry password="$DB_PASS" \
  --entry database=advisor
```

Set `postgres.config.credentialsSecretName` to the name you used.

<Warning>
  **These three values must match `DATABASE_URL` above, and nothing cross-checks them.** Helm cannot read secret values at render time, so a mismatch is not a render error — it is an authentication failure at runtime, after everything appears to have installed cleanly. Set `DB_PASS` once and use it in both commands, as shown.
</Warning>

Secret names are org-wide, so give each release its own pair of secret names.

### A Service Account for the Advisor

The advisor's Control Plane token is deliberately **not** a value and **not** in either secret. You set it once in the dashboard after signing in, under **Configuration → Control Plane**, where it is stored encrypted in the database.

Create a [service account](/guides/create-service-account) whose token can, at minimum:

| Permission         | Why                                                                                                                     |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| `gvc: view`        | List GVCs and their workloads                                                                                           |
| `workload: view`   | Read specs, deployments and events                                                                                      |
| `org: readLogs`    | Classify errors from workload logs                                                                                      |
| `org: readMetrics` | CPU, memory, latency and request metrics                                                                                |
| `org: readUsage`   | Real billed cost per workload rather than an estimate                                                                   |
| `workload: edit`   | **Only** to apply or revert a suggestion — it patches and redeploys the workload. Omit it to run the advisor read-only. |

<Note>
  **These grants cannot be narrowed with `targetLinks`, and trying produces a silently empty fleet.** An org-scoped policy carrying `targetLinks` is rejected with `org/task policies only support target=all`, and a `workload: view` binding scoped to `//gvc/GVC_NAME/workload/*` is stored happily but returns zero workloads. Grant `target: all` for these two kinds.

  Without `org: readUsage` the scan still succeeds — it logs `billing: charges query failed … falling back to the allocation estimate` and reports estimated rather than billed cost.
</Note>

## Installation

Create both prerequisite secrets first, then install by whichever method you prefer:

<CardGroup cols={2}>
  <Card title="UI" href="/template-catalog/install-manage/ui" icon="laptop">
    Browse, install, and manage templates visually
  </Card>

  <Card title="CLI" href="/template-catalog/install-manage/cli" icon="terminal">
    Manage templates from your terminal
  </Card>

  <Card title="Terraform" href="/template-catalog/install-manage/terraform" icon={<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><g fill-rule="evenodd"><path d="M77.941 44.5v36.836L46.324 62.918V26.082zm0 0" fill="#5c4ee5"/><path d="M81.41 81.336l31.633-18.418V26.082L81.41 44.5zm0 0" fill="#4040b2"/><path d="M11.242 42.36L42.86 60.776V23.941L11.242 5.523zm0 0M77.941 85.375L46.324 66.957v36.82l31.617 18.418zm0 0" fill="#5c4ee5"/></g></svg>}>
    Declare templates in your Terraform configurations
  </Card>

  <Card
    title="Pulumi"
    href="/template-catalog/install-manage/pulumi"
    icon={<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" id="Pulumi-Icon--Streamline-Svg-Logos" height="24" width="24">
    <desc>
        Pulumi Icon Streamline Icon: https://streamlinehq.com
    </desc>
    <path fill="#f26e7e" d="M4.683025 13.3318c0.869125 -0.5018 0.870575 -2.1264 0.003225 -3.62865s-2.27504 -2.313275 -3.1441725 -1.811475C0.672945 8.3935 0.6715 10.0181 1.53885 11.52035c0.86735 1.502275 2.27505 2.313275 3.144175 1.81145Zm0.0052 3.2167c0.86735 1.502275 0.865925 3.126875 -0.003225 3.628675 -0.86915 0.5018 -2.2768275 -0.309225 -3.144175 -1.81145 -0.8673525 -1.50225 -0.8659075 -3.126875 0.003225 -3.628675 0.8691325 -0.5018 2.276825 0.309225 3.144175 1.81145Zm5.922875 3.4243c0.86735 1.50225 0.8659 3.126775 -0.003225 3.62875 -0.869125 0.501775 -2.27685 -0.309325 -3.1442 -1.81155 -0.867325 -1.50225 -0.865875 -3.12685 0.00325 -3.628675 0.869125 -0.5018 2.276825 0.309225 3.144175 1.811475Zm-0.001925 -6.845275c0.86735 1.50225 0.8659 3.12685 -0.003225 3.628675 -0.869125 0.5018 -2.276825 -0.309225 -3.144175 -1.811475 -0.86735 -1.50225 -0.8659 -3.12685 0.003225 -3.62865 0.869125 -0.501825 2.276825 0.3092 3.144175 1.81145Z" stroke-width="0.25"></path>
    <path fill="#8a3391" d="M22.45775 11.524125c0.86725 -1.502225 0.865925 -3.12685 -0.003225 -3.62865 -0.869125 -0.501825 -2.276825 0.3092 -3.144175 1.811475 -0.86735 1.50225 -0.8659 3.126825 0.003225 3.62865 0.869125 0.501825 2.276825 -0.3092 3.144175 -1.811475Zm0.000175 3.2151c0.869075 0.5018 0.870625 2.1264 0.003225 3.62865 -0.86735 1.50225 -2.27505 2.313275 -3.144175 1.81145 -0.869125 -0.5018 -0.870575 -2.126425 -0.003225 -3.62865 0.86735 -1.50225 2.27505 -2.313275 3.144175 -1.81145ZM16.536225 18.157875c0.86915 0.501825 0.8706 2.126425 0.00325 3.628675 -0.86735 1.502125 -2.275075 2.313225 -3.1442 1.81145 -0.869125 -0.50175 -0.870575 -2.126425 -0.003225 -3.62865 0.867375 -1.502275 2.27505 -2.3133 3.144175 -1.811475Zm-0.003325 -6.843775c0.869125 0.5018 0.870575 2.126425 0.003225 3.628675s-2.27505 2.313275 -3.1442 1.811475c-0.869125 -0.501825 -0.870575 -2.126425 -0.003225 -3.628675 0.86735 -1.502275 2.27505 -2.313275 3.1442 -1.811475Z" stroke-width="0.25"></path>
    <path fill="#f7bf2a" d="M15.138225 2.06721c0 1.003615 -1.40625 1.817215 -3.14095 1.817215 -1.7347 0 -3.14095 -0.8136 -3.14095 -1.817215C8.856325 1.06359 10.262575 0.25 11.997275 0.25c1.7347 0 3.14095 0.81359 3.14095 1.81721ZM9.2166 5.482375c0 1.003625 -1.40625 1.8172 -3.14095 1.8172 -1.7347 0 -3.14095 -0.813575 -3.14095 -1.8172s1.40625 -1.817225 3.14095 -1.817225c1.7347 0 3.14095 0.8136 3.14095 1.817225Zm8.71005 1.8172c1.7347 0 3.14095 -0.813575 3.14095 -1.8172s-1.40625 -1.817225 -3.14095 -1.817225c-1.7347 0 -3.14095 0.8136 -3.14095 1.817225s1.40625 1.8172 3.14095 1.8172Zm-2.788425 1.605625c0 1.003625 -1.40625 1.8172 -3.14095 1.8172 -1.7347 0 -3.14095 -0.813575 -3.14095 -1.8172 0 -1.0036 1.40625 -1.8172 3.14095 -1.8172 1.7347 0 3.14095 0.8136 3.14095 1.8172Z" stroke-width="0.25"></path>
    </svg>}
  >
    Declare templates in your Pulumi programs
  </Card>
</CardGroup>

<Note>
  **A fresh install reaches all six workloads ready in about a minute**, but two things look alarming on the way and neither is a misconfiguration:

  * **The API can crash-restart once or twice on a first install.** It has no wait-for-database, so it races Postgres (ready at 42 seconds in a measured install) and `alembic upgrade head` exits `1` with `ConnectionError: unexpected connection_lost()`. The third boot ran the migration and served. `status.versions[].message` keeps showing `Error: exitCode: 1` afterwards on an otherwise healthy deployment.
  * **The internal mesh took about four minutes to converge**, during which the worker and scheduler could not reach Redis (`Cannot send task to the queue`) and four scheduled scans were dropped with every workload still reading `ready: true`. It recovered with no intervention.
</Note>

## If you installed the earlier 1.0.0

An earlier build of 1.0.0 rendered a GVC resource **named after the GVC you installed into**, so Helm adopted your GVC and has owned it since the first install. The current chart does not declare a GVC at all, and Helm deletes what a chart stops declaring.

If you have not installed this template before, none of this section applies — install normally.

<Warning>
  **An in-place upgrade from 1.0.0 destroys that GVC and every workload, volume set and identity inside it — including anything unrelated you keep there — while printing `upgraded successfully`.** The same adoption path deleted a GVC during testing, despite a `helm.sh/resource-policy: keep` annotation on it.

  The chart ships a render-time refusal, and it was verified: a real `cpln helm upgrade` carrying a 1.0.0 values file was rejected before a single API call, and the GVC's object version was **unchanged** afterwards with all six workloads, the volume set and both identities still present.

  ```text theme={null}
  cpln-advisor: the `gvc` values key was REMOVED. … DO NOT `helm upgrade` a release of the
  earlier GVC-creating 1.0.0 onto this chart: it created a GVC NAMED AFTER THE ONE YOU
  INSTALLED INTO, so Helm ADOPTED your
  GVC and owns it.
  ```

  **The guard cannot cover one case: an upgrade run with no values at all.** It sees only the current defaults, has no `gvc` key to find, and the deletion proceeds. Do not run an upgrade of an earlier GVC-creating 1.0.0 release against this chart under any circumstances.
</Warning>

Migrate to a new release instead:

<Steps>
  <Step title="Back up the database">
    If `postgres.backup` is off, turn it on and let one run complete, or take a manual `pg_dumpall`. See [Backups and Restore](#backups-and-restore).
  </Step>

  <Step title="Install as a NEW release into an existing single-location GVC">
    Use a **different release name** — secret names are org-wide, so a same-named release collides with the 1.0.0 one even in another GVC. Create a new database credentials secret for it, and point `DATABASE_URL` at the new release's Postgres host.

    Reuse the **same `ADVISOR_SECRET_KEY`** the old release used. A different one means the AI and Slack credentials in the restored rows cannot be decrypted, and they read as "not set".
  </Step>

  <Step title="Load the dump into the new database">
    Then sign in and confirm the scan history and stored credentials are there.
  </Step>

  <Step title="Uninstall the old release">
    Only then run `cpln helm uninstall`, **against the GVC you originally installed 1.0.0 into**. That is where Helm tracks the release — and be aware it takes the adopted GVC with it.
  </Step>
</Steps>

## Configuration

The default `values.yaml` for this template:

```yaml theme={null}
# The ONE location of your GVC the advisor runs in. It must already be a
# location of that GVC; the API reads the GVC at boot and warns if it is not.
location: aws-us-east-1

# The API, worker and scheduler all run the SAME backend image, different
# commands. Both packages are public, so no pull secret is needed.
images:
  backend: ghcr.io/controlplane-com/cpln-advisor/advisor-backend:latest
  web: ghcr.io/controlplane-com/cpln-advisor/advisor-web:latest

# PREREQUISITE dictionary secret; must EXIST BEFORE INSTALL. Six keys:
# ADVISOR_API_TOKEN, ADVISOR_SECRET_KEY, ADVISOR_SESSION_SECRET,
# ADVISOR_USERNAME, ADVISOR_PASSWORD, DATABASE_URL.
auth:
  secretName: advisor-config

# OPTIONAL override for the dashboard's public URL (Slack links + CORS).
# Leave empty unless you use a custom domain. Never "*".
appUrl: ""

# Idle windows, sliding, under a hard ceiling the app enforces.
session:
  hours: 12
  rememberDays: 30

# INFO logs one line per scan plus notable events. DEBUG is very noisy.
logLevel: INFO

# The dashboard (Next.js). The only public workload.
web:
  replicas:
    min: 1 # set 0 to scale to zero when idle, at the cost of a cold start
    max: 3
  resources: # Capacity AI ON
    minCpu: 75m
    maxCpu: 250m
    minMemory: 128Mi
    maxMemory: 512Mi

# The API (FastAPI). Internal only; runs the startup migration.
api:
  resources: # Capacity AI ON
    minCpu: 75m
    maxCpu: 256m
    minMemory: 128Mi
    maxMemory: 512Mi

# Runs the scans, applies Autopilot changes, sends the Slack digest.
worker:
  resources: # Capacity AI off — a long scan should not be resized underneath itself
    maxCpu: 100m
    maxMemory: 256Mi

# Fires the crons that CREATE the scans. Not optional.
scheduler:
  resources: # Capacity AI ON
    minCpu: 25m
    maxCpu: 50m
    minMemory: 64Mi
    maxMemory: 128Mi

# Task broker and cache. NOT persistent.
redis:
  image: redis:7-alpine
  resources: # Capacity AI off — a broker should not be resized under load
    maxCpu: 50m
    maxMemory: 128Mi
  maxmemory: 100mb # Redis' own cache cap, below `memory` on purpose

# Bundled database, passed to the catalog's postgres chart 3.4.1.
postgres:
  image: postgres:18 # 17+ is required for the backup feature below

  config:
    # SECOND PREREQUISITE dictionary secret; must EXIST BEFORE INSTALL.
    # Exactly three keys: username, password, database — and they must match
    # DATABASE_URL above. Nothing cross-checks them.
    credentialsSecretName: advisor-db-credentials

  resources:
    minCpu: 150m
    minMemory: 256Mi
    maxCpu: 512m
    maxMemory: 512Mi

  volumeset:
    capacity: 10 # GiB — 10 is the minimum
    autoscaling:
      enabled: true
      maxCapacity: 50
      minFreePercentage: 20
      scalingFactor: 1.5

  # `same-gvc` now means anything in YOUR GVC may open port 5432.
  internalAccess:
    type: same-gvc

  # Off: this app holds few connections — three workloads, one replica each.
  pgbouncer:
    enabled: false

  # OFF because it needs a bucket and a cloud account you create first.
  # Turn it on: a volume is not a backup.
  backup:
    enabled: false
    image: ghcr.io/controlplane-com/backup-images/postgres-backup:18.1.0
    schedule: "0 2 * * *" # daily, 02:00 UTC
    provider: aws # options: aws, gcp, minio
    aws:
      bucket: my-backup-bucket
      region: us-east-1
      cloudAccountName: my-backup-cloudaccount
      policyName: my-backup-policy
      prefix: advisor/backups
```

### Location

`global.cpln.gvc` is injected by the platform at install time and is never declared in values. There is no `gvc` key — the chart refuses to render if it finds one.

Nothing validates the other direction: a `location` the GVC does **not** have is accepted, stored, and simply runs nothing anywhere. That is why the API reads the GVC at boot and reports what it found. The read is bounded at roughly 22 seconds and **warns rather than fails** — a failure logs a warning and the dashboard keeps serving, verified by deleting the GVC-read policy and watching three `HTTP 403` retries end in a warning while the API stayed ready with no restarts.

### Images

The API, worker and scheduler run the **same** backend image with different commands — a scan is executed by the worker and served by the API, so they must never drift apart.

<Warning>
  **Both images track `:latest`, so an install is not reproducible.** The tag is resolved when the workload is **deployed**, not continuously, because these workloads set `supportDynamicTags: false`. Two installs a week apart can therefore be running different code, and there is nothing to roll back to. Every build also publishes a `:sha-<commit>` tag — pin the same one on both images before you depend on going back:

  ```bash theme={null}
  --set images.backend=ghcr.io/controlplane-com/cpln-advisor/advisor-backend:sha-COMMIT \
  --set images.web=ghcr.io/controlplane-com/cpln-advisor/advisor-web:sha-COMMIT
  ```
</Warning>

### Login and Sessions

The login name and password are keys in your credentials secret, not values. The login is verified by the **API**, never by the dashboard, so one workload holds the credentials. There is one account for everyone: change history and activity attribute everything to a single user, and there is no per-person audit trail.

`session.hours` is an *idle* window that slides forward while you work, under a hard ceiling the app enforces. It is in force, not merely rendered: a session cookie issued at the default setting carried an `Expires` exactly 12 hours after login. `session.rememberDays` is the equivalent window with "Keep me signed in" selected.

### Access

**The dashboard is public and there is no values knob to close it.** That is not a toggle because it is not really a choice — the dashboard is the only way into this app, and nothing inside the GVC calls it. What stands between the internet and your fleet is the login:

* the session cookie is HMAC-signed with `ADVISOR_SESSION_SECRET`, so it cannot be forged
* the API **fails closed** — verified against the running release, every route except `/health` returned `401` without a credential, a wrong `Authorization: Bearer` token returned `401`, and a wrong password returned `401 Incorrect username or password.`
* both credentials come from a secret you create, so there is no shipped default password to look up

Everything else is closed to the internet, and internal traffic is admitted only from real callers:

| Workload                            | Public  | Reachable inside the GVC by                                  |
| ----------------------------------- | ------- | ------------------------------------------------------------ |
| `RELEASE_NAME-web`                  | **yes** | nothing — it calls out, never in                             |
| `RELEASE_NAME-api`                  | no      | the dashboard only                                           |
| `RELEASE_NAME-redis`                | no      | the API, worker and scheduler only                           |
| `RELEASE_NAME-worker`, `-scheduler` | no      | nothing                                                      |
| `RELEASE_NAME-postgres`             | no      | anything in the GVC (`same-gvc`) — see [Database](#database) |

Those lists are enforced, measured in both directions from inside the GVC: the dashboard reached the API's `/health` (`200`) and a worker-to-API call timed out; the API got `+PONG` from Redis while the dashboard's Redis connection hung and returned nothing.

To narrow the dashboard to an office or VPN range, edit `inboundAllowCIDR` on the `RELEASE_NAME-web` workload after installing. Access changes take roughly 30 seconds to a few minutes to propagate.

### Public URL

`appUrl` is empty by default and should stay that way unless you use a custom domain. The app derives its own public URL from Control Plane's built-in environment variables as `https://{web workload}-{gvc alias}.cpln.app`, and the chart passes it this release's actual dashboard workload name so the derivation resolves to the right workload. Both the Slack "View in Advisor" links and the CORS allowlist come from it. Never set it to `*`: combined with credentials, a wildcard makes the server echo back whichever origin asked.

### Resources

A block that exposes both a floor and a limit names them `minCpu`/`maxCpu` and `minMemory`/`maxMemory`; a block that exposes only the limit uses the API's own field names, bare `cpu`/`memory`. `minCpu` and `minMemory` are the floor [Capacity AI](/reference/workload/capacity) scales up from, and only the three tiers that enable it set one. The worker and Redis run with Capacity AI off — a long scan should not be resized underneath itself, and a broker should not be resized under load — so a floor there would be inert.

The chart checks two limits at render time, because Control Plane enforces both but publishes neither in a schema, so each would otherwise surface as a `400` partway through an install:

* **`maxCpu`/`minCpu` must be strictly under 4:1**, and memory is bounded the same way at 4:1 inclusive.
* **Unit typos.** `cpu` and `memory` are bare strings with no numeric bound, so `512Gi` written for `512Mi` is accepted and the workload then never schedules.

Raising `maxCpu` or `maxMemory` on `web`, `api`, `scheduler` or `postgres` means raising its matching minimum too.

### Database

Values under `postgres` are passed straight through to the bundled [Postgres](/template-catalog/templates/postgres) chart, pinned at `3.4.1`. Memory is deliberately above that chart's defaults, because a fleet scan writes in bursts. Three workloads write this database concurrently — the API serves the dashboard, the worker records scan results and Autopilot changes, and the scheduler writes cron state.

<Warning>
  **`postgres.internalAccess: same-gvc` is wider than it was in 1.0.0.** That version created a dedicated GVC, so `same-gvc` meant "the advisor and nothing else". The GVC is now yours and may hold unrelated workloads, any of which can open port 5432 — measured, the dashboard workload reached the database even though it is not a database client.

  If the GVC is shared, narrow it at install time with the real names. A subchart's values cannot be templated, so the workload links have to be written out:

  ```bash theme={null}
  --set postgres.internalAccess.type=workload-list \
  --set postgres.internalAccess.workloads[0]=//gvc/GVC_NAME/workload/RELEASE_NAME-api \
  --set postgres.internalAccess.workloads[1]=//gvc/GVC_NAME/workload/RELEASE_NAME-worker \
  --set postgres.internalAccess.workloads[2]=//gvc/GVC_NAME/workload/RELEASE_NAME-scheduler
  ```

  Add `RELEASE_NAME-postgres-backup` to that list if you turn backups on.
</Warning>

### Backups and Restore

**Backups are off by default and you should turn them on.** They are off only because they need a bucket and a cloud account you create first — and a volume is not a backup: losing it loses every scan, score and Autopilot record.

```yaml theme={null}
postgres:
  backup:
    enabled: true
    image: ghcr.io/controlplane-com/backup-images/postgres-backup:18.1.0
    schedule: "0 2 * * *"
    provider: aws            # or gcp, minio
    aws:
      bucket: my-backup-bucket
      region: us-east-1
      cloudAccountName: my-backup-cloudaccount
      policyName: my-backup-policy
      prefix: advisor/backups
```

The bucket, [cloud account](/guides/create-cloud-account) and IAM policy steps are the Postgres template's, passed through unchanged — see its [backup prerequisites](/template-catalog/templates/postgres#backup-prerequisites). `provider: minio` needs its own prerequisite dictionary secret holding `accessKey` and `secretKey`.

<Warning>
  **`backup.image` is coupled to `image`.** Tag `18.1.0` backs up Postgres 18 and `17.1.0` backs up Postgres 17. Change one and change the other, or `pg_dump` meets a server it cannot read.
</Warning>

Each run writes one gzipped `pg_dumpall` plain-SQL file, `postgres-<UTC-timestamp>.sql.gz`, under `<bucket>/<prefix>/`. `pg_dumpall` produces a whole-cluster script including `CREATE ROLE` and `CREATE DATABASE`, so it restores into an empty server rather than merging into a running one.

The restore was executed end to end against this template with the `aws` provider: a 90 KB dump containing all 8 advisor tables and their rows, the database dropped outright, then restored with the application working on the restored data.

<Steps>
  <Step title="Stop the writers">
    Scale the API, worker and scheduler to zero, or they will be writing while you restore.
  </Step>

  <Step title="Download the dump">
    Use your own cloud tooling — the Postgres container has neither `aws` nor `gsutil`.
  </Step>

  <Step title="Open a tunnel to the database">
    ```bash theme={null}
    cpln port-forward RELEASE_NAME-postgres 5432:5432 --gvc GVC_NAME
    ```
  </Step>

  <Step title="Load it">
    ```bash theme={null}
    gunzip -c postgres-TIMESTAMP.sql.gz | \
      psql -v ON_ERROR_STOP=1 -h 127.0.0.1 -p 5432 -U USERNAME -d postgres
    ```
  </Step>
</Steps>

<Warning>
  **Use `psql` version 18 or newer, and pass `-v ON_ERROR_STOP=1`.** The chart pins `postgres:18`, and a PostgreSQL 18 `pg_dumpall` wraps its output in the `\restrict` / `\unrestrict` meta-commands, which older clients do not have. Running the restore with an older client printed 11 error lines — `invalid command \restrict`, `ERROR: role "advisor" already exists` — and then **exited 0**, which is precisely the silently-wrong-restore case. `-v ON_ERROR_STOP=1` turns that into a real failure. If you have no matching client locally, run the restore from inside the Postgres container, which already has one.
</Warning>

## Connecting

| What      | Where                                                                                                                                                        |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Dashboard | `https://<canonical-endpoint>` — always public. Read it from `status.canonicalEndpoint` in `cpln workload get RELEASE_NAME-web -o yaml`; never hand-build it |
| API       | `http://RELEASE_NAME-api.GVC_NAME.cpln.local:8000` — internal, reachable only from the dashboard. Plain `http` is correct; the sidecar adds mTLS             |
| Database  | `RELEASE_NAME-postgres.GVC_NAME.cpln.local:5432` — internal; credentials come from your database secret                                                      |

Both internal workloads are also reachable from your machine without opening anything up:

```bash theme={null}
cpln port-forward RELEASE_NAME-api 8123:8000 --gvc GVC_NAME
```

<Warning>
  Use the fully-qualified `.cpln.local` name for internal access. The short workload name does not resolve reliably from every workload type.
</Warning>

## After the First Deploy

<Steps>
  <Step title="Sign in">
    Open the dashboard and use the `ADVISOR_USERNAME` and `ADVISOR_PASSWORD` from your credentials secret.
  </Step>

  <Step title="Connect Control Plane">
    Go to **Configuration → Control Plane**, paste the service account token, and press **Test connection**. A working connection returns the org and the number of GVCs the token can see. The token round-trips encrypted through the database and reads back masked afterwards.
  </Step>

  <Step title="Add an AI provider, and Slack if you want digests">
    An Anthropic or OpenAI key, on the same page. These are stored encrypted using `ADVISOR_SECRET_KEY` — which is why losing that key loses them.
  </Step>

  <Step title="Enroll workloads and scan">
    Enable **Scan** on the workloads you want watched, then **Scan now**. Note that workloads pinned with `maxScale: 0` are currently skipped — see [Workloads the Advisor Currently Skips](#workloads-the-advisor-currently-skips).
  </Step>

  <Step title="Confirm the scheduler is firing">
    Check the API's log for `GVC location check OK`, and the scheduler's for it sending `run_scan`:

    ```bash theme={null}
    cpln logs '{gvc="GVC_NAME", workload="RELEASE_NAME-scheduler"}' --limit 50 --since 10m
    ```
  </Step>
</Steps>

<Note>
  **The scheduler is not optional and is the one people delete.** The worker runs tasks; the scheduler fires the cron that creates them. Without it nothing is ever scanned on a schedule, retention never prunes and Autopilot never runs — while every workload still reports healthy. "Scans stopped but everything is green" is this, every time.
</Note>

<Note>
  Autopilot's apply and revert, the Slack digest, and the AI providers were not exercised during template testing — they need credentials the test environment did not have. What was verified is the scan path end to end: the worker executing a scan, scoring workloads, and the API serving the resulting suggestions, activity and fleet cost.
</Note>

## Diagnosing a Stuck Install

A missing prerequisite secret produces **no log output at all**. The container never starts, so `cpln logs` returns zero lines and the deployment simply looks slow.

```bash theme={null}
cpln workload get-deployments RELEASE_NAME-api --gvc GVC_NAME -o yaml
```

Read `status.versions[].message` — it names the missing secret.

<Warning>
  This is `get-deployments`, not `cpln workload get`. Plain `get` has no `versions` key at all and shows you nothing.
</Warning>

Creating the missing secret repairs the deployment on its own, but slowly — roughly 5.5 to 10.5 minutes across the catalog, so poll rather than time-boxing it. A forced redeployment shortcuts it:

```bash theme={null}
cpln workload force-redeployment RELEASE_NAME-api --gvc GVC_NAME
```

## Important Notes

* **Never `helm upgrade` a release of the earlier GVC-creating 1.0.0 onto this chart.** It deletes the GVC that build adopted and everything in it. See [If you installed the earlier 1.0.0](#if-you-installed-the-earlier-1-0-0).
* **Install into a single-location GVC.** Every advisor workload is pinned to `location`, but the bundled Postgres cannot be, and a second GVC location gives it a second independent database on the same service DNS name — billed, empty, and the one a later `location` change would repoint you at.
* **Create both prerequisite secrets before installing.** A missing one wedges the deployment silently; [Diagnosing a Stuck Install](#diagnosing-a-stuck-install) gives the one command that names it.
* **`DATABASE_URL` and the database credentials secret must agree, and nothing cross-checks them.** A mismatch installs cleanly and then fails to authenticate at runtime.
* **Losing `ADVISOR_SECRET_KEY` loses every credential entered in the UI.** They are unrecoverable and must be re-entered. Keep it somewhere durable before you deploy.
* **The dashboard is public and there is no values knob to close it.** The login is the boundary. Narrow `inboundAllowCIDR` on the `RELEASE_NAME-web` workload after installing if you want it tighter.
* **The bundled database admits any workload in your GVC.** If the GVC is shared, narrow `postgres.internalAccess` — see [Database](#database).
* **Backups are off by default and you should turn them on**, and restore with a `psql` of version 18 or newer — see [Backups and Restore](#backups-and-restore).
* **A Postgres password is first-boot only.** It is read when the data directory initializes; rotating it in the secret afterwards does not change the running server. You must also change it in Postgres itself and update `DATABASE_URL` to match.
* **Rotating any secret requires a forced redeployment.** `cpln://secret/…` references resolve when a replica starts and are never re-resolved while it lives, so a rotated secret keeps working with the old value indefinitely while everything reports healthy.
* **Redis is not persistent and not authenticated.** Everything in it is derived or transient, so a restart at worst skips one scan and repeats one digest. Its firewall admits exactly the three workloads that use it — do not widen it.
* **Both images track `:latest`, so an install is not reproducible.** Pin `:sha-<commit>` on both before this matters to you.
* **Autopilot redeploys your workloads.** Each applied suggestion patches a live workload and restarts it. It is per-workload and off until you enable it.
* **The advisor's token is as powerful as you make it.** Grant `workload: edit` only if you want Autopilot and one-click apply; without it the advisor runs read-only.
* **The first Helm upgrade after an install can re-apply resources** even with identical values — a measured no-op upgrade reported four resources `Updated` with no chart-authored field changed, and the second upgrade was completely clean.
* **`uninstall` deletes the database volume set and the scan history with it.** It does **not** delete the GVC — this chart never owns one — and it does not delete your two prerequisite secrets.

## External References

<CardGroup cols={2}>
  <Card title="CPLN Advisor Template" icon="github" href="https://github.com/controlplane-com/templates/tree/main/cpln-advisor">
    View the source files, default values, and chart definition
  </Card>

  <Card title="Create a Service Account" icon="key" href="https://docs.controlplane.com/guides/create-service-account">
    Issue the token the advisor uses to read and tune your org
  </Card>

  <Card title="Capacity AI" icon="microchip" href="https://docs.controlplane.com/reference/workload/capacity">
    How resource floors and automatic sizing work on Control Plane
  </Card>

  <Card title="Workload Firewall" icon="shield" href="https://docs.controlplane.com/reference/workload/firewall">
    Internal and external access controls used by every workload here
  </Card>
</CardGroup>
