The complete list of values you can set is
Helm chart configuration 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.
Option B, once per machine:
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
pgvectorin-cluster, creates its own databases and generates its own admin password. To use your own database server instead — PostgreSQL 15 or 16 with thepgvectorextension available — setexternalDatabase.enabled: trueand see step 5. - A StorageClass that provisions PersistentVolumes (
ReadWriteOnceis 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. - 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.comis 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:
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 — setglobal.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, setglobal.ingress.sdkHostname; both the QAR Ingress route and the platform’sDYNACONF_SDK__BASE_URLare derived from that one value.
3. Entra ID app registration
In Microsoft Entra admin center → App registrations → New registration:- Note the Application (client) ID and Directory (tenant) ID.
- Create a client secret and copy its value.
-
Add both of these Redirect URIs (type Web):
Register both. The sign-on service uses the first one; the second is required by its API. Registering onlyIf you manage app registrations with the Azure CLI, note that/idps/callbacklooks correct and fails at login withAADSTS50011: The redirect URI ... does not match the redirect URIs configured for the application.
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 defaulttype: 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:
<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 oneglobal.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:
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.
Do not pre-createEverything else the platform needs is generated on first install and kept across upgrades. On platform 2.182.0 and later, Zitadel provisioning also creates oneqodo-db-adminon 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 withinvalid ownership metadata. The exception is the fully pre-provisioned modeglobal.generatedSecrets.render=false, where you must create every Secret in the generated-credentials inventory before install.
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 withpgvector, 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:
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
SetexternalDatabase.enabled: true with host, existingSecret (the
qodo-db-admin Secret from step 4) and — importantly — adminUser:
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):
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, addqodo-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.
emptyDir storage, so the install
needs no StorageClass and creates no PVCs.
This destroys data. AnSwitching to real volumes later means a reinstall, not an upgrade: drop the extraemptyDirlives and dies with its pod. Any restart, eviction, node drain, orhelm upgradethat 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.
-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:
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:
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):-f layer, no other change:
@, /, :, # and spaces included; each component URL-encodes it.
6. Write your values file
If this bundle contains avalues-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:
7. Install
Option A — from this bundle:helm registry login, section 1).
Fetch and unpack first, because onprem-base.yaml lives inside the chart and
-f only reads local files:
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.
8. Verify
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 inImagePullBackOff — 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:
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:
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
Replaceonprem-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.