Skip to main content
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. 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: 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:
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.
  • 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: 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):
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:
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:
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:
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.
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:
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:
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):
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.
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:
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:
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):
If that instance requires a password, create a Secret and name it — no extra -f layer, no other change:
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:
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:

7. Install

Option A — from this bundle:
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:
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.

8. Verify

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:
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:
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.