showcase/shell-docs/src/content/docs/premium/self-hosting.mdx
CopilotKit Intelligence — the platform that powers threads, shared state, the inspector, and observability — can be self-hosted on your own Kubernetes cluster using the copilot-intelligence Helm chart. Self-hosting is a licensed deployment mode: you run the control plane and data plane inside your own network boundary, bring your own Postgres and Redis (or run bundled Bitnami subcharts), point the chart at your own OIDC provider, and manage secrets with External Secrets Operator, direct Kubernetes Secrets, or chart-managed credentials. The chart deploys two always-on workloads — app-api (the backend service, port 4201) and app-frontend (the web UI, port 8080) — plus an optional realtime-gateway (a WebSocket service for realtime sync, port 4401), a database-migrations Job, and a thread-culler CronJob. Supporting resources include Services, an Ingress, HPAs, PodDisruptionBudgets, ConfigMaps, and (when ESO is enabled) ExternalSecret resources.
If none of these apply, use Copilot Cloud — it is the fastest path to a working Intelligence deployment and requires no cluster operations.
<Callout type="info" title="Validate locally before committing to a real cluster"> The chart installs the same way against a local Docker Desktop or k3d cluster as it does against a production cluster. Walk this guide end-to-end on your laptop first — the `values-quickstart-local.yaml` overlay shipped in the chart enables bundled in-cluster Postgres, Redis, and (optionally) Keycloak so you can validate without any external dependencies. The chart repo also ships `scripts/local-demo.sh`, which spins up a disposable k3d cluster, installs the released chart from GHCR, and brings up a bundled Keycloak in one command:./scripts/local-demo.sh --version <chart-version>
Use whichever local path you prefer; both follow the same install commands described below. </Callout>
Before starting, make sure the following are in place. The How the Enterprise Intelligence Platform Works page explains the layering in more depth.
License and registry access:
oci://ghcr.io/copilotkit/charts/intelligence (anonymous pulls are allowed for the released chart)<chart-version> placeholder used throughout this guide (e.g. 0.1.0-rc.16).Cluster and tooling:
kubectl configured against the target cluster with an admin-equivalent contextPlatform prerequisites (cluster-wide, installed once):
nginx-ingress or the AWS Load Balancer Controllercert-manager (or a cloud-managed certificate alternative such as AWS ACM) for TLS on the public hostnamesExternal Secrets Operator if you plan to sync secrets from AWS Secrets Manager, HashiCorp Vault, or GCP Secret Manager (recommended for production, but not required — see Secrets)External dependencies (reachable from the cluster):
Optional:
Ensure `kubectl` points to the cluster that will run Intelligence.
```bash title="Terminal"
kubectl config current-context
kubectl auth can-i create namespace --all-namespaces
```
The context shown should be the target cluster, and the permission check should return `yes`. If either is wrong, fix your kubeconfig before proceeding.
These components are cluster-wide and installed once per cluster, independently of the application chart.
<Tabs items={["AWS (EKS)", "On-prem / generic", "Local (Docker Desktop / k3d)"]}>
<Tab value="AWS (EKS)">
```bash title="Terminal"
# AWS Load Balancer Controller (kube-system)
helm repo add eks https://aws.github.io/eks-charts
helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
-n kube-system \
--set clusterName=<YOUR_CLUSTER_NAME>
# cert-manager
helm repo add jetstack https://charts.jetstack.io
helm install cert-manager jetstack/cert-manager \
-n cert-manager --create-namespace \
--set installCRDs=true
# External Secrets Operator (optional — see Secrets step)
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
-n external-secrets --create-namespace
```
</Tab>
<Tab value="On-prem / generic">
```bash title="Terminal"
# NGINX Ingress Controller
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm install ingress-nginx ingress-nginx/ingress-nginx \
-n ingress-nginx --create-namespace
# cert-manager
helm repo add jetstack https://charts.jetstack.io
helm install cert-manager jetstack/cert-manager \
-n cert-manager --create-namespace \
--set installCRDs=true
```
</Tab>
<Tab value="Local (Docker Desktop / k3d)">
```bash title="Terminal"
# NGINX Ingress Controller as ClusterIP — you will reach it via
# `kubectl port-forward` later, so no LoadBalancer service is needed.
helm upgrade --install ingress-nginx ingress-nginx \
--repo https://kubernetes.github.io/ingress-nginx \
--namespace ingress-nginx --create-namespace \
--set controller.service.type=ClusterIP \
--wait
```
cert-manager and External Secrets Operator are not required for a local validation pass — TLS is terminated outside the cluster and secrets are managed by the chart (Path C below) or pre-created by hand (Path B).
</Tab>
</Tabs>
After each controller is running, its pods should be `Ready` in their respective namespaces.
Intelligence needs Postgres, Redis, and an OIDC issuer. You can either point the chart at managed services you already run, or enable the bundled Bitnami subcharts for in-cluster Postgres and Redis (appropriate for evaluation and small self-hosted installs).
**Using managed services (recommended for production):**
- Create a Postgres database and user. Record the host, port (default `5432`), database name, username, and password.
- Create a Redis instance with TLS enabled. Record the host, port (default `6379`), and password.
- Configure an OIDC client in your identity provider. Record the issuer URL, client ID, and client secret.
**Using the bundled in-cluster subcharts:**
Set `postgresql.enabled: true` and `redis-subchart.enabled: true` in your values file (covered in the next step). A matching `StorageClass` must exist in the cluster. The bundled Keycloak subchart is available via `keycloak.enabled: true` if you also need a quick OIDC provider for evaluation; do not use the bundled Keycloak for production workloads. See [Bundled Keycloak (eval only)](#bundled-keycloak-eval-only) for the realm and credentials it creates.
The chart already ships a tested overlay for this shape — `values-quickstart-local.yaml` — which enables bundled Postgres + Redis, sets `migrations.enabled: true`, sizes resources for a laptop, and creates disposable secrets so the install runs end-to-end with no manual prep. Layer your own overlay on top of it (see the next step) to plug in your IdP and ingress.
The released chart ships several example values files for the common deployment shapes. Pick the one closest to your environment and copy it into a working overlay you can edit. Pull and untar the chart so you have local copies to diff against:
```bash title="Terminal"
helm pull oci://ghcr.io/copilotkit/charts/intelligence --version <chart-version> --untar
# AWS-flavored (ALB, IRSA, External Secrets from AWS Secrets Manager)
cp intelligence/values-aws-example.yaml my-values.yaml
# Or on-prem-flavored (nginx, manual Kubernetes Secrets)
cp intelligence/values-onprem-example.yaml my-values.yaml
# Or self-hosted eval (bundled Keycloak + in-cluster Postgres/Redis)
cp intelligence/values-self-hosted-eval.yaml.example my-values.yaml
```
The chart untars into a directory named `intelligence/` (the published chart name on GHCR; the chart's `nameOverride` keeps release-prefixed resources named `cpki-*`).
Edit `my-values.yaml` to set at minimum:
- `database.host`, `database.port`, `database.name` — your Postgres connection (`name` defaults to `intelligence`)
- `redis.host`, `redis.port`, `redis.tls` — your Redis connection (TLS is on by default; managed Redis requires it)
- `auth.issuer` — your OIDC provider's issuer URL
- `auth.existingSecret` — name of the Kubernetes Secret containing `auth-secret`, `auth-client-id`, `auth-client-secret` (or use one of the alternate paths in [Secrets](#create-secrets))
- `ingress.ui.host` — the hostname users will load the Intelligence UI on (for example `intelligence.example.com`)
- `ingress.api.host` — optional dedicated API hostname. When omitted, the `ui.host` rule routes `/api` and `/auth` paths to `app-api`, so a single hostname is fine for most installs.
- `ingress.tls` — TLS configuration for the hosts above
- `migrations.enabled: true` — **required for first install**; defaults to `false`. Without it the database schema is never applied and `app-api` will crashloop. (The eval overlay `values-quickstart-local.yaml` sets this for you when you layer on top of it.)
<Callout type="warn" title="OIDC issuer URL — trailing slash matters">
Some providers (Auth0 in particular) only accept the issuer URL with a trailing slash (e.g. `https://your-tenant.auth0.com/`). A missing or extra slash produces an opaque "issuer mismatch" failure at login time. Match the value exactly to what your provider's discovery endpoint advertises.
</Callout>
See the [Configuration reference](#configuration-reference) section for the full set of values.
The chart supports three paths for secrets management. Pick exactly one.
**Path A — External Secrets Operator (recommended for production):**
1. Ensure your secret backend (AWS Secrets Manager, Vault, etc.) has entries for the database URL, Redis URL, and auth credentials.
2. Create a `ClusterSecretStore` (or `SecretStore`) that references that backend.
3. In `my-values.yaml`, set `externalSecrets.enabled: true`, `externalSecrets.store.kind`, and `externalSecrets.store.name` to match. The chart then generates `ExternalSecret` resources that sync those entries into Kubernetes Secrets at the names `app-api` expects.
**Path B — Direct Kubernetes Secrets (you manage the rotations):**
Leave `externalSecrets.enabled: false` (the default) and create the Secrets manually before installing:
```bash title="Terminal"
kubectl create namespace copilot-intelligence
kubectl create secret generic cpki-db \
--from-literal=database-url='postgresql://user:pass@host:5432/intelligence' \
-n copilot-intelligence
kubectl create secret generic cpki-redis \
--from-literal=redis-url='rediss://:password@host:6379' \
-n copilot-intelligence
kubectl create secret generic cpki-auth \
--from-literal=auth-secret="$(openssl rand -hex 32)" \
--from-literal=auth-client-id='<OIDC client id>' \
--from-literal=auth-client-secret='<OIDC client secret>' \
-n copilot-intelligence
```
Reference these names in your values file via `database.existingSecret`, `redis.existingSecret`, and `auth.existingSecret`. The Secret keys are lowercase-hyphenated (`auth-secret`, `database-url`, `runner-auth-secret`); the workloads consume them as the corresponding uppercase env vars (`AUTH_SECRET`, `DATABASE_URL`, `RUNNER_AUTH_SECRET`).
**Path C — Chart-managed self-hosted secrets (simplest BYOC):**
Useful when you do not run a secret manager and prefer Helm to create the Kubernetes Secrets directly from values you provide at install time. Set `selfHostedSecrets.enabled: true` and supply the credentials inline:
```yaml title="my-values.yaml"
selfHostedSecrets:
enabled: true
db:
url: "postgresql://user:pass@host:5432/intelligence"
redis:
url: "rediss://:password@host:6379"
auth:
# Auto-generated when left empty.
secret: ""
clientId: "<OIDC client id>"
clientSecret: "<OIDC client secret>"
realtimeGateway:
# Auto-generated when left empty.
runnerAuthSecret: ""
secretKeyBase: ""
beam:
# Auto-generated when left empty.
releaseCookie: ""
```
The chart auto-generates `auth.secret`, the realtime-gateway runner/key-base, and the BEAM cookie when those fields are empty, so you only need to provide what you actually have.
The chart can run database schema migrations as a pre-install `Job`. This is **disabled by default** (`migrations.enabled: false`). If you want the chart to apply migrations for you on install, set the following in `my-values.yaml`:
```yaml title="my-values.yaml"
migrations:
enabled: true
```
With this enabled, `helm install` blocks the rollout until the migrations Job reports `Completed`. Leave it disabled if you manage schema migrations out-of-band (for example, via your existing CI/CD or DBA pipeline).
The release can be installed directly from the GHCR OCI registry — no local untar is required for the install itself. Use `helm upgrade --install` so the same command works for first-time installs and upgrades.
```bash title="Terminal"
helm upgrade --install copilot-intelligence \
oci://ghcr.io/copilotkit/charts/intelligence \
--version <chart-version> \
-f my-values.yaml \
-n copilot-intelligence \
--create-namespace \
--wait \
--timeout 10m
```
Layering multiple values files is supported and is the recommended pattern for evaluation: combine the chart's bundled `values-quickstart-local.yaml` (in-cluster Postgres/Redis, eval-sized resources, `migrations.enabled: true`, disposable secrets) with your own overlay (IdP, ingress, anything cluster-specific). Pull the chart first so you have a local copy of `values-quickstart-local.yaml` to reference:
```bash title="Terminal"
helm upgrade --install copilot-intelligence \
oci://ghcr.io/copilotkit/charts/intelligence \
--version <chart-version> \
-f intelligence/values-quickstart-local.yaml \
-f my-values.yaml \
-n copilot-intelligence --create-namespace \
--wait --timeout 10m
```
`--wait` blocks until the `Deployments` report healthy replicas; `--timeout 10m` allows enough time for image pulls and (if you enabled it in the previous step) the initial database migration job. Right-most `-f` files win on conflicts, so put your overlay last.
<Callout type="info" title="When the migrations Job runs">
The migrations Job runs as a **pre-install/pre-upgrade** hook (weight `-5`) when secrets are pre-created (Path A or Path B above), so the schema is ready before app pods start. It runs as a **post-install/post-upgrade** hook (weight `5`) when secrets are managed by Helm (Path C, or when using `postgresql.enabled: true`), because the Secret resources don't exist until Helm has created them.
</Callout>
Check that every pod is `Running` and the ingress is ready:
```bash title="Terminal"
kubectl get pods -n copilot-intelligence
kubectl get ingress -n copilot-intelligence
```
You should see `app-api`, `app-frontend`, and — if enabled — `realtime-gateway` pods running. If you opted into migrations (`migrations.enabled: true`, see the values step above), the migrations `Job` will also appear as `Completed`; if you left migrations disabled, no Job is created.
Confirm the API health check reports `ok`:
```bash title="Terminal"
curl https://<ingress.api.host>/api/health
```
The endpoint returns `200 OK` only when the database is reachable — a failed health check is almost always a database connectivity problem.
Service-specific health endpoints, useful when port-forwarding to an individual pod:
| Service | Path |
|---|---|
| `app-api` | `/api/health` |
| `app-frontend` | `/healthz` |
| `realtime-gateway` | `/health` |
Finally, browse to `https://<ingress.ui.host>` and log in via your OIDC provider. A successful login confirms end-to-end wiring.
<Callout type="info" title="Local validation — port-forward the ingress controller">
On a local cluster (Docker Desktop, k3d) without a public DNS name, port-forward the **ingress controller** rather than the frontend service so the UI host rule still routes `/api` and `/auth` to `app-api`. Set `ingress.ui.host: "localhost"` in your overlay, then leave this terminal open for as long as you're using the app:
```bash title="Terminal"
kubectl -n ingress-nginx port-forward svc/ingress-nginx-controller 8080:80
```
Browse to `http://localhost:8080`. Port-forwarding the `app-frontend` service directly bypasses the ingress and breaks `/api` and `/auth` routing.
</Callout>
**Upgrade** — bump the version in your install command and re-run it. Because the install command already uses `helm upgrade --install`, the same invocation works for both fresh installs and upgrades:
```bash title="Terminal"
helm upgrade --install copilot-intelligence \
oci://ghcr.io/copilotkit/charts/intelligence \
--version <new-chart-version> \
-f my-values.yaml \
-n copilot-intelligence \
--wait
```
Before upgrading, regenerate the example values for the target version (`helm pull ... --version <new-chart-version> --untar`) and diff against your overlay to catch new keys.
**Uninstall** — releases leave PersistentVolumes in place by default if you enabled bundled subcharts; delete them manually if you intend to tear down state.
```bash title="Terminal"
helm uninstall copilot-intelligence -n copilot-intelligence
```
When keycloak.enabled: true, the chart deploys the Bitnami Keycloak subchart with a pre-seeded realm and demo user. This is for evaluation and demos — not production. The realm import creates:
cpk-devcpk-self-hosted with secret cpk-self-hosted-secret (override via auth.keycloakClient.clientId / auth.keycloakClient.clientSecret)engineer / engineer (override via auth.keycloakDemoUser)["*"] for eval flexibility (override via auth.keycloakClient.redirectUris / webOrigins)The chart auto-wires auth.issuer to the in-cluster Keycloak service, so leaving auth.issuer empty when keycloak.enabled: true is intentional.
For production self-hosted deployments, leave keycloak.enabled: false and point auth.issuer at your own IdP.
The tables below summarize the most common values. For every option, see values.yaml in the pulled chart.
| Key | Description | Default |
|---|---|---|
global.imageRegistry | Registry prefix for unqualified image names | "" |
global.intelligenceImageRegistry | Registry prefix specifically for the five Intelligence service images | "" |
global.imagePullSecrets | Image pull secrets for private registries | [] |
global.storageClass | StorageClass override for bundled subcharts | "" |
| Key | Description | Default |
|---|---|---|
database.host | Postgres host | "" (required) |
database.port | Postgres port | 5432 |
database.name | Database name | intelligence |
database.existingSecret | Pre-existing Secret with database-url | "" |
database.secretKeys.url | Key inside the Secret holding the connection string | database-url |
| Key | Description | Default |
|---|---|---|
redis.host | Redis host | "" (required) |
redis.port | Redis port | 6379 |
redis.tls | Require TLS (ElastiCache defaults to on) | true |
redis.existingSecret | Pre-existing Secret with redis-url | "" |
redis.secretKeys.url | Key inside the Secret holding the connection URL | redis-url |
| Key | Description | Default |
|---|---|---|
openSearch.host | OpenSearch domain endpoint | "" |
openSearch.port | Port | 443 |
openSearch.tls | Require TLS | true |
openSearch.existingSecret | Pre-existing Secret with opensearch-url | "" |
| Key | Description | Default |
|---|---|---|
auth.deploymentMode | self-hosted (single org) or hosted (multi-org) | self-hosted |
auth.issuer | OIDC issuer URL (auto-set when keycloak.enabled: true) | "" |
auth.existingSecret | Secret with auth-secret, auth-client-id, auth-client-secret | "" |
auth.defaultOrganizationId | Default organization ID in self-hosted mode | default |
auth.providerId | Stable identifier for the OIDC provider | enterprise-sso |
auth.providerName | Display name shown in the UI | Enterprise SSO |
auth.trustHost | Trust the X-Forwarded-Host header (set behind a reverse proxy) | "true" |
| Key | Description | Default |
|---|---|---|
ingress.enabled | Create Ingress resources | true |
ingress.className | nginx or alb | nginx |
ingress.ui.host | UI hostname; the rule for this host routes /api and /auth to app-api and / to app-frontend | "" (required) |
ingress.api.host | Optional dedicated API hostname. When set, this hostname routes / to app-api. When empty, no separate API rule is created — the UI host already serves the API. | "" |
ingress.realtimePlane.host | Optional dedicated realtime hostname (only used when realtimeGateway.enabled: true) | "" |
ingress.tls | TLS configuration | [] |
ingress.websocket.enabled | Add WebSocket-friendly annotations (auto-enabled when realtime-gateway is enabled with nginx) | false |
ingress.annotations | Additional ingress annotations | {} |
appApi, appFrontend, realtimeGateway)| Key | Description | Default (appApi) | Default (appFrontend) | Default (realtimeGateway) |
|---|---|---|---|---|
<svc>.enabled | Enable the service | true | true | false |
<svc>.replicaCount | Replicas | 2 | 2 | 2 |
<svc>.image.repository | Image repository (published chart fully-qualifies these to ghcr.io/copilotkit/intelligence/<svc>) | intelligence/app-api | intelligence/app-frontend | intelligence/realtime-gateway |
<svc>.image.tag | Image tag (defaults to chart appVersion) | "" | "" | "" |
<svc>.resources | CPU/memory requests | 250m / 512Mi | 100m / 128Mi | 500m / 512Mi |
<svc>.autoscaling.enabled | Enable HPA | true | false | true |
<svc>.autoscaling.minReplicas | HPA minimum | 2 | 2 | 2 |
<svc>.autoscaling.maxReplicas | HPA maximum | 10 | 4 | 10 |
<svc>.serviceAccount.annotations | Annotations on the ServiceAccount (IRSA, workload identity) | {} | {} | {} |
<svc>.podAnnotations | Pod template annotations (e.g. for Stakater Reloader on ESO secret rotation) | {} | n/a | {} |
| Key | Description | Default |
|---|---|---|
realtimeGateway.enabled | Enable the gateway | false |
realtimeGateway.host | PHX_HOST override | "" |
realtimeGateway.existingSecret | Secret containing keys runner-auth-secret and secret-key-base (mapped to env vars RUNNER_AUTH_SECRET / SECRET_KEY_BASE) | "" |
realtimeGateway.beam.clustering.enabled | BEAM clustering across replicas | true |
realtimeGateway.beam.cookieSecret.name | Secret containing the BEAM cookie | cpki-beam-cookie |
Enabling the realtime gateway requires that either realtimeGateway.existingSecret is set, or that externalSecrets.secrets.realtimeGateway.enabled or selfHostedSecrets.enabled is true — the chart fails validation otherwise.
| Key | Description | Default |
|---|---|---|
externalSecrets.enabled | Generate ExternalSecret resources | false |
externalSecrets.store.kind | ClusterSecretStore or SecretStore | ClusterSecretStore |
externalSecrets.store.name | SecretStore name | "" (required when enabled) |
externalSecrets.refreshInterval | How often ESO syncs | 1h |
externalSecrets.secrets.* | Per-secret mappings — see values.yaml | — |
| Key | Description | Default |
|---|---|---|
selfHostedSecrets.enabled | Create Kubernetes Secrets from inline values; auto-generates blank fields | false |
selfHostedSecrets.db.url | Postgres connection URL | "" (required when enabled) |
selfHostedSecrets.redis.url | Redis connection URL | "" (required when enabled) |
selfHostedSecrets.auth.clientId / clientSecret | OIDC client credentials | "" (required when enabled) |
selfHostedSecrets.auth.secret | Internal auth signing secret | auto-generated when empty |
selfHostedSecrets.realtimeGateway.runnerAuthSecret / secretKeyBase | Runtime gateway secrets | auto-generated when empty |
selfHostedSecrets.beam.releaseCookie | BEAM clustering cookie | auto-generated when empty |
| Key | Description | Default |
|---|---|---|
postgresql.enabled | Deploy in-cluster Postgres | false |
postgresql.auth.password | Postgres password (set at deploy time) | "" |
redis-subchart.enabled | Deploy in-cluster Redis (aliased to avoid collision with redis.*) | false |
redis-subchart.auth.password | Redis password | "" |
keycloak.enabled | Deploy bundled Keycloak for quick eval | false |
| Key | Description | Default |
|---|---|---|
objectStorage.enabled | Persist AG-UI events from the realtime gateway to S3-compatible storage | false |
objectStorage.bucket | Bucket name | "" |
objectStorage.region | Bucket region | us-east-1 |
objectStorage.endpoint | S3-compatible endpoint override (e.g. for MinIO) | "" |
objectStorage.forcePathStyle | Force path-style addressing (required for MinIO) | false |
objectStorage.existingSecret | Secret with static access keys (optional if using IRSA) | "" |
| Key | Description | Default |
|---|---|---|
migrations.enabled | Run the migrations Job. Required for first install — defaults to false. | false |
migrations.image.repository | Migrations image repository | intelligence/db-migrations |
migrations.activeDeadlineSeconds | Job deadline | 1800 |
migrations.backoffLimit | Retry count before failing | 3 |
The migrations Job runs as a pre-install/pre-upgrade Helm hook (weight -5) when secrets are pre-created (External Secrets path or manual existingSecret) and as a post-install/post-upgrade hook (weight 5) when secrets are managed by Helm itself (selfHostedSecrets.enabled or postgresql.enabled).
| Key | Description | Default |
|---|---|---|
threadCuller.enabled | Run a CronJob that soft-deletes stale threads in unlicensed deployments | false |
threadCuller.schedule | Cron expression | 0 * * * * |
threadCuller.staleHours | Threads older than this many hours (since last update) are culled | "3" |
threadCuller.batchSize | Maximum threads to cull per run | "1000" |
threadCuller.licenseSecret.existingSecret | Secret containing COPILOTKIT_LICENSE_TOKEN. When set, the CronJob skips culling (licensed install). When empty, it culls. | "" |
| Key | Description | Default |
|---|---|---|
config.logLevel | Log level for all services (trace/debug/info/warn/error/fatal) | info |
config.nodeEnv | Node environment; affects cookie security and runtime defaults | production |
config.appFrontendOrigin | Browser origin allowed to perform authenticated bootstrap writes | "" |
config.publicAppOrigin | Public UI origin used by server-side callbacks when distinct from appFrontendOrigin | "" |
config.allowedOrigins | Additional CORS allowlist (comma-separated). Entries are exact origins (https://app.example.com) or Phoenix-style //host patterns | "" |
Per-service keys podDisruptionBudget, podAntiAffinity, and networkPolicy are available for high-availability and traffic-isolation requirements. See values.yaml for full shapes.