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

# Deploy Qodo using Helm

> Install and configure Qodo on Kubernetes using Helm.

This guide walks you through deploying the Qodo platform on Kubernetes using the Helm chart.

For a complete list of Helm values and configuration options, see the [Helm chart configuration reference](on-prem-helm-chart-reference).

This chart installs the Qodo platform into a single Kubernetes namespace: the
web portal, the API, the code-review agents, the code-intelligence engine, the
git gateway, and their supporting datastores.

Configuration is **two values files**:

| Layer | File               | Who owns it                                                                                                                     |
| ----- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| 1     | `onprem-base.yaml` | **Qodo.** Do not edit — it carries the component set and the settings common to every installation, and is replaced on upgrade. |
| 2     | your own file      | **You.** Everything specific to your environment.                                                                               |

The complete list of values you can set is
[Helm chart configuration reference](on-prem-helm-chart-reference), with a ready-to-edit skeleton in
`values-customer-template.yaml`. Both are
generated from the chart, so they cannot drift from what it actually accepts.

### Where the chart comes from

Two options. They install the same chart; pick one and follow it through, since
they differ in one prerequisite.

|                   | **A — this bundle**                           | **B — the Qodo registry**                                                  |
| ----------------- | --------------------------------------------- | -------------------------------------------------------------------------- |
| Source            | the `qodo-onprem/` directory beside this file | `oci://artifacts-self-hosted.qodo.ai/codium-stack/qodo-onprem/qodo-onprem` |
| Auth              | none — the chart is already here              | your Qodo licence, via `helm registry login`                               |
| Image pull Secret | **you create `qodo-pull`** (step 4)           | **created for you** from your licence                                      |
| Upgrades          | Qodo sends you a new bundle                   | `helm pull` a newer `--version` yourself                                   |
| Air-gapped        | yes                                           | no — needs egress to `artifacts-self-hosted.qodo.ai`                       |

**Option B**, once per machine:

```bash theme={null}
helm registry login artifacts-self-hosted.qodo.ai \
  --username '<your-qodo-account-email>' \
  --password '<your-qodo-licence-id>'
```

Same credentials as the image registry — one licence covers the chart and the
images. Then use the `oci://` URL wherever this page says `./qodo-onprem`, and
**skip Secret 1 in step 4**: a chart pulled this way carries your licence, and
the chart turns it into the image-pull Secrets every workload needs. Creating
`qodo-pull` as well does no harm, it is simply unused.

To see available versions: `helm show chart oci://artifacts-self-hosted.qodo.ai/codium-stack/qodo-onprem/qodo-onprem`.

## 1. Prerequisites

Before working through this section, run `./qodo-check.sh -n <namespace>
-f <your-values.yaml>` and read `READINESS.md`: they verify these prerequisites
for you and cover the backup, rollback and test-account items this guide does
not. Run the same command again after installing, before opening traffic — it
detects that Qodo is now installed and adds the post-installation checks.

* Kubernetes 1.25+ and Helm 3, with a namespace you can install into.
* An **ingress controller** (any: ingress-nginx, GCE, Traefik, …) and a DNS
  record you control — see step 2.
* **PostgreSQL.** Nothing to prepare by default: the chart runs Postgres 16
  with `pgvector` in-cluster, creates its own databases and generates its own
  admin password. To use your own database server instead — PostgreSQL 15 or 16
  with the `pgvector` extension available — set `externalDatabase.enabled: true`
  and see step 5.
* **A StorageClass that provisions PersistentVolumes** (`ReadWriteOnce` is
  enough). Postgres, Redis, RabbitMQ and the Qodo Git repo cache each claim
  one. If your cluster has no dynamic provisioning at all, see
  [Clusters without persistent volumes](#clusters-without-persistent-volumes).
* Credentials Qodo issued you for the image registry.
* One or more **Entra ID (Azure AD) app registrations** for single sign-on —
  step 3.
* An **LLM endpoint**: an OpenAI-compatible URL and API key. It may be your own
  gateway; no outbound access to `api.openai.com` is required if you provide
  one.

## 2. DNS and TLS

Pick a base domain, e.g. `qodo.example.com`. The chart derives these public
hosts by default:

| Default host              | Full-host override                 | Serves                                         |
| ------------------------- | ---------------------------------- | ---------------------------------------------- |
| `app.<baseDomain>`        | `global.ingress.appHostname`       | the web portal                                 |
| `api.<baseDomain>`        | `global.ingress.apiHostname`       | the API                                        |
| `portal-api.<baseDomain>` | `global.ingress.portalApiHostname` | the portal's backend endpoint                  |
| `auth.<baseDomain>`       | `global.ingress.authHostname`      | the sign-on service (OIDC issuer)              |
| `sdk.<baseDomain>`        | `global.ingress.sdkHostname`       | Qodo Agent Runtime for SDK/CLI agentic reviews |
| `git.<baseDomain>`        | `global.ingress.gitHostname`       | inbound webhooks from your git provider        |

One wildcard DNS record `*.<baseDomain>` pointing at your ingress controller
covers all defaults, and one wildcard certificate covers them in TLS. Set a
complete hostname override when an existing certificate or Kubernetes Gateway
listener only covers another DNS level. Every generated Ingress route, OIDC
issuer, portal URL, platform URL and CORS origin follows the same override.
The six resolved hostnames must remain distinct because they route `/` to
different services.

Two things are easy to get wrong here:

* **The resolved auth hostname must resolve from inside the cluster too.** Pods
  validate sign-on tokens against that public OIDC issuer, so a record that
  only exists on your corporate DNS, or a split-horizon setup that resolves it
  differently inside, will fail after login rather than at install time.
* **The webhook host must be reachable by whatever calls it.** If your git
  provider is cloud-hosted, `git.<baseDomain>` needs to be reachable from the
  internet. You can serve webhooks on the base domain itself instead of a
  subdomain — set `global.ingress.gitHostname` — but the host you choose is
  also the URL handed to the provider when hooks are registered, so it must
  resolve and be covered by the certificate from the caller's point of view.
* **The SDK host defaults to `sdk.<baseDomain>`.** If your DNS or certificate
  policy requires a different hostname, set `global.ingress.sdkHostname`;
  both the QAR Ingress route and the platform's `DYNACONF_SDK__BASE_URL` are
  derived from that one value.

## 3. Entra ID app registration

In **Microsoft Entra admin center → App registrations → New registration**:

1. Note the **Application (client) ID** and **Directory (tenant) ID**.
2. Create a **client secret** and copy its value.
3. Add **both** of these Redirect URIs (type *Web*):

   ```
   https://<resolved-auth-hostname>/ui/login/login/externalidp/callback
   https://<resolved-auth-hostname>/idps/callback
   ```

> **Register both.** The sign-on service uses the first one; the second is
> required by its API. Registering only `/idps/callback` looks correct and
> fails at login with `AADSTS50011: The redirect URI ... does not match the
> redirect URIs configured for the application`.

If you manage app registrations with the Azure CLI, note that
`az ad app update --web-redirect-uris` **replaces** the entire list — read the
existing URIs first and write them all back, or you will silently drop them.

Define an Entra app role with the case-sensitive value
`Qodo-Platform-Admin`; users assigned that role become Qodo organization
owners, while users with any other or missing role remain regular users. If
your app registration already uses another administrator role value, set
`global.entra.adminRoleName` to match it exactly.

### Sovereign Microsoft clouds (for example GCC High)

The default `type: entra` uses Zitadel's native Azure connector and derives a
commercial-cloud issuer on `login.microsoftonline.com`. A sovereign tenant
must instead use the generic OIDC connector and its exact issuer URL:

```yaml theme={null}
global:
  entra:
    type: generic-oidc
    name: "Entra GCC High"
    clientId: "<Application (client) ID>"
    tenantId: ""
    issuerUrl: "https://login.microsoftonline.us/<tenant-id>/v2.0"
    existingSecret: qodo-entra-idp
```

Confirm the tenant discovery document at
`<issuerUrl>/.well-known/openid-configuration` uses the expected sovereign
authorization, token, and JWKS hosts. The Secret contract and callback URIs
are unchanged. Do not reuse the same provider name when changing connector
types: Zitadel refuses to replace an existing native Entra provider with a
generic OIDC provider of the same name.

### Multiple identity providers

Repeat the app-registration steps for every tenant. Give every provider a
unique, stable display name, register both callback URIs in every application,
and use the same administrator app-role value across tenants: Qodo has one
`global.entra.adminRoleName` setting for the shared installation.

Multi-IdP credentials use a different, secret-only contract. Create one
Kubernetes Secret whose
`DYNACONF_ZITADEL_PROVISION__EXTERNAL_IDPS` key contains the complete Dynaconf
TOML list. Every `{ ... }` entry must stay on one logical line:

```bash theme={null}
NAMESPACE=qodo
kubectl create secret generic qodo-entra-idps -n "$NAMESPACE" \
  --from-literal='DYNACONF_ZITADEL_PROVISION__EXTERNAL_IDPS=[{type="entra",name="Corporate",issuer_url="https://login.microsoftonline.com/<tenant-a>/v2.0",client_id="<client-a>",client_secret="<secret-a>"},{type="generic-oidc",name="GCC High",issuer_url="https://login.microsoftonline.us/<tenant-b>/v2.0",client_id="<client-b>",client_secret="<secret-b>"}]'
```

Prefer creating that value through your Vault, External Secrets, CSI, or other
approved secret manager so the completed list never enters shell history. The
list must include every provider the provisioner should manage. Omitted
providers remain in Zitadel until an operator removes them manually. Provider
names are Zitadel's idempotency keys: renaming one creates another provider.

Select list mode in values and leave the legacy fields empty:

```yaml theme={null}
global:
  entra:
    name: ""
    clientId: ""
    tenantId: ""
    externalIdpsExistingSecret: qodo-entra-idps
```

Updating only this Secret does not start a new provisioning Job. After adding
or removing a provider, or after rotating a client secret through Vault,
External Secrets, CSI, or another secret manager, wait for the Kubernetes
Secret to contain the new list and then re-run the same `helm upgrade --install`
command used for the installation. The new Helm release revision starts the
provisioner, applies the provider changes in Zitadel, and rolls the platform to
read the refreshed configuration. The rotation is not active until that upgrade
completes.

The chart refuses list mode together with any legacy `name`, `clientId`,
`tenantId`, or `issuerUrl`. On a one-to-many upgrade, remove those four fields
from your customer values file, keep the existing provider's name and
credentials in the new list, and do not delete `qodo-zitadel-generated`. The
provisioner preserves that Secret, clears the old direct-provider pin, and the
chart rolls the platform after provisioning so the provider picker takes
effect.

## 4. Create the Secrets

Create the Secrets required by the infrastructure choices below. Credentials
never belong in a values file.

> **On option B (the Qodo registry), skip Secret 1.** A chart pulled with your
> licence already carries the registry credential and creates the pull Secrets
> itself.

```bash theme={null}
# Pick your namespace once and reuse it in every command on this page.
NAMESPACE=qodo
kubectl create namespace "$NAMESPACE"

# 1. Image pull credentials (issued by Qodo). The name is fixed.
#    OPTION A ONLY — not needed when installing from the Qodo registry.
kubectl create secret docker-registry qodo-pull -n "$NAMESPACE" \
  --docker-server=artif-reg-self-hosted.codium.ai \
  --docker-username='<your-qodo-account-email>' \
  --docker-password='<your-qodo-license-id>'

# 2. LLM endpoint API key.
kubectl create secret generic qodo-openai -n "$NAMESPACE" \
  --from-literal=OPENAI_API_KEY='<api-key>'

# 3. Single-tenant Entra client secret (the app registration from section 3).
#    For multiple tenants, create qodo-entra-idps as documented above instead.
#    A legacy qodo-entra-idp may remain during migration; list mode ignores it.
kubectl create secret generic qodo-entra-idp -n "$NAMESPACE" \
  --from-literal=IDP_CLIENT_SECRET='<client-secret>'

# 4. YOUR OWN DATABASE SERVER ONLY. Use that server's EXISTING admin
#    password (see step 5). Do not run this for bundled PostgreSQL: Helm
#    generates and owns qodo-db-admin when generatedSecrets.render=true.
kubectl create secret generic qodo-db-admin -n "$NAMESPACE" \
  --from-literal=POSTGRES_PASSWORD='<existing-database-admin-password>'
```

> Do not pre-create `qodo-db-admin` on the bundled-PostgreSQL path. The chart
> renders that Secret as part of the Helm release; an existing Secret without
> Helm ownership metadata makes install fail with `invalid ownership metadata`.
> The exception is the fully pre-provisioned mode
> `global.generatedSecrets.render=false`, where you must create every Secret in
> the generated-credentials inventory before install.

Everything else the platform needs is generated on first install and kept
across upgrades. On platform 2.182.0 and later, Zitadel provisioning also
creates one `scim-<provider-slug>` machine user per configured provider and
checkpoints its one-time PAT as `SCIM_PAT_<PROVIDER_SLUG>` in
`qodo-zitadel-generated`. Keep that Secret backed up and access-restricted;
runtime workloads consume only its OIDC keys, not those PATs.

## 5. Database

**The default is an in-cluster database**: Postgres 16 with `pgvector`, on a
persistent volume sized by `global.storage.sizes.postgres` (20Gi), creating the
four databases it needs. You supply the three routing values below; with
`global.generatedSecrets.render=true`, Helm creates `qodo-db-admin` itself:

```yaml theme={null}
global:
  externalDatabase:
    host: qodo-postgres        # the in-cluster Service — leave as-is
    adminUser: postgres        # in-cluster superuser — correct here
    sslMode: disable           # the bundled server does not enable TLS
    existingSecret: qodo-db-admin
postgres:
  enabled: true                # brings the server up
```

Both halves are needed: `postgres.enabled` runs the server, and
`global.externalDatabase.*` is how every component finds it — including the
migration Jobs, which is why `existingSecret` cannot be empty. The key is named
`externalDatabase` because it is "where the database is"; `enabled: true` under
it is what switches to a server of your own.

### Only if you bring your own database server

Set `externalDatabase.enabled: true` with `host`, `existingSecret` (the
`qodo-db-admin` Secret from step 4) and — importantly — `adminUser`:

```yaml theme={null}
global:
  externalDatabase:
    enabled: true
    host: <your-db-host>
    adminUser: <admin user>          # see the warning below
    sslMode: require                 # encrypted; the managed-server default
    existingSecret: qodo-db-admin
```

The install creates its own databases, so that user needs **CREATEDB**.
`require` prevents plaintext connections, but does not authenticate the
database certificate or hostname; verified TLS modes and custom database CA
plumbing are not yet exposed by this chart.

**Set `adminUser` explicitly.** It defaults to `postgres`, which most managed
database services do not offer — on Google Cloud SQL the equivalent is the
user you created with the `cloudsqlsuperuser` role, on Amazon RDS the master
user. Leaving the default in place fails part-way through the install, in a
setup job, rather than up front.

Use an **empty** database or instance. The install creates databases named
`zitadel`, `litegit`, `rag_db`, `qodo_merge` and `qodo_rules`; if objects with
those names already exist and are owned by a different user, the setup jobs
will fail on permissions.

**`pgvector` must be creatable, not merely installed.** Two of those databases
(`rag_db` and `qodo_rules`) hold embeddings and need the `vector` extension
inside them — extensions are per-database, so one does not cover the other.
Managed services usually make creating this particular extension
**superuser-only**: on Amazon RDS it needs `rds_superuser`, which the install
user should not have. The extension being *available* on the server is not
enough. If your user cannot create it, have an admin run this once, before
installing. The databases must be owned by the configured `adminUser`, not by
the privileged operator running these commands (replace `qodo_admin` below
with that exact role name):

```sql theme={null}
CREATE DATABASE rag_db OWNER qodo_admin;      -- skip if it already exists
CREATE DATABASE qodo_rules OWNER qodo_admin;  -- skip if it already exists
ALTER DATABASE rag_db OWNER TO qodo_admin;
ALTER DATABASE qodo_rules OWNER TO qodo_admin;
\c rag_db
CREATE EXTENSION IF NOT EXISTS vector;
\c qodo_rules
CREATE EXTENSION IF NOT EXISTS vector;
```

The install detects this and stops with the same instructions rather than
failing later inside a migration.

Note the bundled connection pooler
(`advanced.components.pgbouncer.enabled`) is for the in-cluster database only
and is rejected alongside `externalDatabase.enabled: true` — front a managed
database with its own pooler (RDS Proxy, Cloud SQL connectors) instead.

### Clusters without persistent volumes

If your cluster cannot provision PersistentVolumes, add
`qodo-onprem/values-emptydir.yaml` as an extra `-f`, **after** your own values
file. Note the path: it ships inside the chart directory, unlike
`onprem-base.yaml` at the top level.

```bash theme={null}
helm upgrade --install qodo ./qodo-onprem -n qodo \
  -f onprem-base.yaml -f my-values.yaml -f qodo-onprem/values-emptydir.yaml
```

It moves all four datastores onto node-local `emptyDir` storage, so the install
needs no StorageClass and creates no PVCs.

> **This destroys data.** An `emptyDir` lives and dies with its pod. Any
> restart, eviction, node drain, or `helm upgrade` that rolls a pod wipes that
> component — the databases included. Verified, not theoretical: deleting the
> Postgres pod took the database count from 10 back to 5 and dropped a test
> table. Use it for a short-lived evaluation, never for an environment whose
> data matters, and expect to re-run onboarding after any restart.

Switching to real volumes later means a reinstall, not an upgrade: drop the
extra `-f` and reinstall into a clean namespace.

**Pass it on EVERY upgrade, not just the install.** Values files are not
remembered between `helm upgrade` runs, so omitting this `-f` later asks the
chart to move the datastores back onto PersistentVolumes. That upgrade fails
part-way, and the failure is not self-healing:

```
Error: UPGRADE FAILED: cannot patch "qodo-rabbitmq" with kind StatefulSet:
  StatefulSet.apps "qodo-rabbitmq" is invalid: spec: Forbidden: updates to
  statefulset spec for fields other than 'replicas', 'ordinals', 'template',
  'updateStrategy', 'persistentVolumeClaimRetentionPolicy' and
  'minReadySeconds' are forbidden
```

Kubernetes refuses the two StatefulSets (their volume claims are immutable),
but everything else in that run is already patched — including `qodo-gitway`,
which is left mounting a `qodo-gitway-repo-store` PVC that can never bind on a
cluster with no provisioner. The pod goes `Pending` with `pod has unbound
immediate PersistentVolumeClaims`, and re-running the correct three-file
command does **not** fix it: Helm compares its own last-good manifest with the
new one, both `emptyDir`-shaped, so it sees nothing to change and never removes
the stray volume. Recover by deleting the deployment and its orphaned claim,
then upgrading again with all three files:

```bash theme={null}
kubectl delete deploy qodo-gitway -n <namespace>
```

```bash theme={null}
kubectl delete pvc qodo-gitway-repo-store -n <namespace>
```

Nothing durable is lost — that volume is the repository cache, which is
`emptyDir` on this path and re-clones on demand.

### Only if you bring your own Redis

The chart runs Redis in-cluster by default, without a password, and nothing
needs configuring. To point at a managed instance (ElastiCache, Memorystore):

```yaml theme={null}
global:
  externalRedis:
    host: <your-redis-host>
```

**If that instance requires a password**, create a Secret and name it — no extra
`-f` layer, no other change:

```bash theme={null}
kubectl -n qodo create secret generic qodo-redis-auth \
  --from-literal=REDIS_PASSWORD='<password>'
```

```yaml theme={null}
global:
  externalRedis:
    host: <your-redis-host>
    existingSecret: qodo-redis-auth
```

Every component that talks to Redis picks it up from there. Any password works —
`@`, `/`, `:`, `#` and spaces included; each component URL-encodes it.

## 6. Write your values file

If this bundle contains a `values-example.yaml`, start from that — it is a
worked file for your environment, with your hostnames and endpoints already
filled in and the placeholders you must supply marked. Otherwise copy
`values-customer-template.yaml` and uncomment what you need. A minimal file:

```yaml theme={null}
global:
  baseDomain: qodo.example.com
  ingress:
    className: nginx
    annotations:
      cert-manager.io/cluster-issuer: letsencrypt
  entra:
    name: Corporate SSO
    clientId: <application-client-id>
    tenantId: <directory-tenant-id>
  llm:
    openaiBaseUrl: https://llm-gateway.example.internal/v1
```

That is a complete file — the database is in-cluster and needs nothing. To use
your own database server instead, add the block from step 5:

```yaml theme={null}
global:
  externalDatabase:
    enabled: true                    # without this the other keys are ignored
    host: postgres.example.internal
    adminUser: qodo_admin
    existingSecret: qodo-db-admin
```

## 7. Install

Option A — from this bundle:

```bash theme={null}
helm upgrade --install qodo ./qodo-onprem \
  --namespace "$NAMESPACE" \
  -f onprem-base.yaml \
  -f my-values.yaml
```

Option B — from the Qodo registry (after `helm registry login`, section 1).
Fetch and unpack first, because `onprem-base.yaml` lives *inside* the chart and
`-f` only reads local files:

```bash theme={null}
helm pull oci://artifacts-self-hosted.qodo.ai/codium-stack/qodo-onprem/qodo-onprem \
  --version <chart-version> --untar

helm upgrade --install qodo ./qodo-onprem \
  --namespace "$NAMESPACE" \
  -f qodo-onprem/onprem-base.yaml \
  -f my-values.yaml
```

Install from the unpacked directory, **not** the `oci://` URL directly: the
copy you pulled carries your licence, which is what produces the image-pull
Secrets. Pulling again for an upgrade is the same two commands with a new
`--version`.

Order matters: the second `-f` wins where the two overlap.

`$NAMESPACE` is the one you set in step 4 — the Secrets have to live in the
same namespace as the release. Any namespace works; pick whatever fits your
cluster conventions.

The **release** name, though, must stay **`qodo`**. Resource names are fixed to
a `qodo-` prefix in this packaging, so another release name yields `qodo-*`
resources anyway and two releases in one namespace would collide.

First install takes several minutes: setup jobs prepare the databases and
configure sign-on before the applications become ready.

```bash theme={null}
kubectl get pods -n "$NAMESPACE" -w
```

## 8. Verify

```bash theme={null}
# All workloads ready, setup jobs Completed
kubectl get pods -n "$NAMESPACE"

# The ingress has an address
kubectl get ingress -n "$NAMESPACE"
```

Then browse to the resolved portal URL (`https://app.<baseDomain>` by default,
or `https://<global.ingress.appHostname>` when overridden), choose **SSO
(Single Sign-On)**, and sign in with your Entra ID account. Your user is
created automatically on first login.

## Troubleshooting

**Pods stuck in `ImagePullBackOff`** — the `qodo-pull` Secret is missing,
misnamed, or holds the wrong credentials. Confirm with
`kubectl describe pod <pod> -n <namespace>` and check the name is exactly `qodo-pull`.

**The ingress never gets an address** — with the GCE ingress controller, ask
Qodo for the additional values layer it requires; container-native load
balancing needs annotations this chart does not apply by default. On other
controllers, check that `global.ingress.className` matches an IngressClass that
exists (`kubectl get ingressclass`).

**Setup jobs fail with a permissions or role error** — almost always
`global.externalDatabase.adminUser`: see step 5.

**Login fails with `AADSTS50011`** — a Redirect URI is missing from the app
registration: see step 3, and note that both are required.

**The portal loads but stays on a spinner** — check that the pods are ready and
that the resolved portal API hostname (`portal-api.<baseDomain>` or
`global.ingress.portalApiHostname`) resolves and is covered by the certificate;
the portal calls it from the browser.

**A review pod restarts every few minutes and reviews never finish** — read the
restart reason before treating it as a crash or a failed health check, because
those look identical from `kubectl get pods`:

```bash theme={null}
kubectl describe pod <pod> -n <namespace> | grep -A4 'Last State'
```

`Reason: OOMKilled` with `Exit Code: 137` means the container hit its memory
limit, not a bug. The queue worker reserves 10Gi per replica, while the HTTP
agentic-review endpoint requests 4Gi with an 8Gi limit. Very large diffs or a
long `global.merge.settings` metadata list can still exceed those limits. If
both entry points serve reviews in your installation, raise both aliases so
the outcome does not depend on which one handled the review:

```yaml theme={null}
pr-agent-litegit:
  deployments:
    resources:
      limits:
        memory: 16Gi
      requests:
        memory: 16Gi
pr-agent-agentic:
  deployments:
    resources:
      limits:
        memory: 16Gi
      requests:
        memory: 8Gi
```

Each container reserves its `requests.memory`, so multiply by `replicaCount`
before raising it. Six queue-worker containers at 10Gi reserve 60Gi; with the
default 1Gi analytics sidecar in every pod, the six replicas reserve 66Gi of
schedulable memory. Pods stay `Pending` if the nodes cannot satisfy that.

**A pod is `Pending` with `unbound immediate PersistentVolumeClaims`** — on a
cluster without volume provisioning, this is usually an upgrade that omitted
`values-emptydir.yaml`; see "Clusters without persistent volumes" in step 5 for
the recovery, which Helm cannot perform on its own.

**Something else** — collect `kubectl get pods -n <namespace>`,
`kubectl describe ingress -n <namespace>`, and the logs of the failing pod, and
contact Qodo support.

## Upgrades

Replace `onprem-base.yaml` with the new version, keep your own values file, and
re-run the same `helm upgrade --install` command. Your file is never overwritten
by an upgrade.
