Install on Kubernetes
A complete, start-to-finish install of the Octo platform on any Kubernetes cluster, with no credential in a values file.
This is the whole install, in order, on a cluster you already have, with no cloud provider assumed and no Terraform involved. It ends with a working editor on your own hostname, TLS, and every credential held in a Kubernetes Secret rather than in a values file.
Helm Chart is the reference for what each value means. This page is the path through it.
Before you start
You need:
- A Kubernetes cluster, 1.26 or newer, and
kubectlpointing at it. - Helm 3.8 or newer; earlier versions cannot pull an OCI chart.
- An ingress controller and a hostname you can point at it. Any controller
works; the chart writes an ordinary
Ingress. - A default StorageClass, for the Postgres volume.
kubectl get storageclassshould show one marked(default). - Roughly 4 GB of allocatable memory free across the cluster for the bundled stack (the editor, orchestrator, observability service, Postgres, NATS and Redis), plus whatever your integrations need.
Optional, and worth deciding now rather than later:
- cert-manager, if you want the chart to get certificates for you. Without it you supply a TLS Secret yourself, or terminate TLS upstream.
- A wildcard DNS record, if you want each deployed integration published at its own public hostname. See Publish your integrations.
Trying Octo rather than deploying it? The standalone editor runs from a single image with no cluster at all (see Running from Docker), and Local Cluster (k3d) brings the full platform up on a laptop in one command.
The install
Choose a namespace and create it
The credentials have to exist before the chart that reads them, and a Secret is only readable by workloads in its own namespace, so the namespace comes first:
kubectl create namespace octoEverything below assumes octo. Use whatever name you like.
Mint the credentials
Three secrets, generated once, on your machine. Nothing about them comes from Octo: they are random bytes with specific shapes. (The auth and embeddings Secrets come later, in the steps that turn those features on.)
# Postgres password for the bundled database
kubectl -n octo create secret generic octo-db-password \
--from-literal=postgres-password="$(openssl rand -hex 24)"
# AES-256 key encrypting KV secret namespaces at rest: base64 of 32 raw bytes
kubectl -n octo create secret generic octo-kv-key \
--from-literal=kv-encryption-key="$(openssl rand -base64 32)"
# HMAC key deriving every dev run's identity and public hostname
kubectl -n octo create secret generic octo-devrun-key \
--from-literal=dev-run-hash-secret="$(openssl rand -base64 32)"The names matter. They must not be the ones the chart uses for Secrets of
its own: {release}-postgres, {release}-auth, {release}-kv,
{release}-devruns, {release}-embeddings. {release}-postgres collides on
install, since the chart creates it whatever the password's source (it also
carries the username and database name). The others collide on migration,
deleting the Secret you just pointed the release at. The names used here are
clear of all five.
Back up the KV key and the dev-run key before you go any further, and never rotate either one.
A new kv-encryption-key makes every value already written to a secret KV
namespace permanently unreadable. A new dev-run-hash-secret re-labels every
exposed dev run, so a webhook registered against the old hostname silently
stops being delivered, with no error anywhere. The Postgres password is the
ordinary kind and can be rotated; the other two cannot. Put them somewhere
durable now, while there is nothing to lose.
Write a values file
One file, and it holds no credentials: only the names of the Secrets you just created.
ingress:
# The hostname you will point at your ingress controller.
host: octo.example.com
# The controller that should claim this Ingress. Omit it only if your cluster
# marks one IngressClass default (`kubectl get ingressclass` says).
className: nginx
tls:
# cert-manager issues a certificate per host over HTTP-01. See step 5 for
# the alternatives.
mode: cert-manager
clusterIssuer: letsencrypt-prod
secretName: octo-tls
postgres:
auth:
existingSecret: octo-db-password
existingSecretPasswordKey: postgres-password
kv:
existingSecret: octo-kv-key
existingSecretKey: kv-encryption-key
orchestrator:
devRuns:
existingSecret: octo-devrun-key
existingSecretKey: dev-run-hash-secretThat is a complete install. NATS, Redis, the observability service, the retention sweep and the schema job are on by default and need nothing said about them.
References, rather than the values themselves. Helm keeps every value it is
given in the release history, so a password written into this file lands in the
release Secret in the cluster and stays there for every retained revision.
--set and --set-file are the same value by another route. A reference is a
name: the chart never sees the value, and the pod reads it from your Secret at
start-up.
Install the chart
helm install octo oci://ghcr.io/juancavallotti/charts/octo \--version 0.11.7 \--namespace octo \--values octo-values.yamlNo --create-namespace: you made it in step 1, and Helm creating it would mean
the Secrets had nowhere to go.
The chart pulls its images from Docker Hub by default, at the version of the chart
itself: image.tag falls back to appVersion, so a chart installs the images of
its own release rather than drifting onto latest. To pull from your own registry,
add image.registry and, for a private one, defaults.imagePullSecrets.
Watch it come up:
kubectl -n octo get pods -wSix pods reach Running, plus a schema Job that runs once and completes. If
Postgres stays Pending, its volume has nowhere to come from; check
kubectl get storageclass and kubectl -n octo describe pvc.
Point DNS at it, and settle TLS
Find the address your controller published, and create an A (or CNAME) record
for ingress.host pointing at it:
kubectl -n octo get ingressTLS is one of five modes, and which one you want is a property of your cluster rather than of Octo:
ingress.tls.mode | What it does | You need |
|---|---|---|
cert-manager | Annotates the Ingress with a ClusterIssuer; cert-manager issues per host over HTTP-01 | cert-manager, a ClusterIssuer, and DNS already resolving |
secret | References a TLS Secret that already exists | To create that Secret yourself |
gke-managed-cert | Creates a ManagedCertificate for the GCE ingress controller | GKE |
acm | Terminates at an ALB against a certificate by ARN | EKS with the AWS Load Balancer Controller |
none | No TLS on the Ingress; something upstream terminates it | A load balancer or mesh that does |
With cert-manager, DNS must resolve before the certificate can be issued,
since the HTTP-01 challenge is a request to your own hostname. The first minute after
DNS lands may serve the controller's default certificate; watch the real one
arrive with:
kubectl -n octo get certificateOpen the editor
Visit https://octo.example.com in a browser.
The editor is open to anyone who can reach it at this point; step 7 is how
you close it. If the host resolves to nothing, the usual cause is that no
controller claimed the Ingress. kubectl -n octo describe ingress shows which
class it asked for; kubectl get ingressclass shows what the cluster has.
Configure single sign-on
Signing in is how anybody reaches the editor — there is no open mode, and the chart refuses to render without the settings below. Any provider that speaks the authorization-code flow works. Register a client with yours, with this redirect URI:
https://octo.example.com/api/auth/callback/oidcThen create the auth Secret, holding both credentials, because the chart reads them from one reference:
kubectl -n octo create secret generic octo-auth-creds \
--from-literal=oidc-client-secret='<the client secret your provider issued>' \
--from-literal=auth-secret="$(openssl rand -base64 32)"and add to your values file:
auth:
oidc:
issuer: https://id.example.com
clientId: octo
# Optional: what the sign-in button calls your provider.
providerName: Acme SSO
existingSecret: octo-auth-creds
# Optional: restrict writes to these roles. Empty means every role except
# platform:monitor.
writeRoles: ""auth-secret signs session cookies. Unlike the two keys in step 2 it is
recoverable: rotating it signs everyone out and nothing worse.
Upgrade to apply it:
helm upgrade octo oci://ghcr.io/juancavallotti/charts/octo \--version 0.11.7 --namespace octo --values octo-values.yamlMCP clients need a provider that supports Dynamic Client Registration. Without
it they cannot reach /mcp at all — it accepts only access tokens the provider
minted. See MCP authentication.
Publish your integrations
Each integration you deploy from the editor can get its own public hostname under a domain you nominate. Add:
orchestrator:
# Integrations are published at {slug}.apps.example.com.
baseDomain: apps.example.com
# The controller that claims the Ingress the orchestrator creates per
# deployment. Usually the same one as ingress.className, and it is also what
# the branded 502/503 page is selected from, so name it even where the cluster
# has a default IngressClass.
ingressClass: nginx
wildcardTLS:
# One *.apps.example.com certificate rather than one per subdomain.
enabled: true
clusterIssuer: letsencrypt-dns
secretName: octo-wildcard-tlsThen point *.apps.example.com at the same ingress address.
Prefer the wildcard over per-host certificates, especially with dev runs on.
A per-host certificate puts every hostname you deploy into public Certificate
Transparency logs; one wildcard publishes the domain and nothing under it. The
wildcard needs a DNS-01 ClusterIssuer, because HTTP-01 cannot issue a wildcard,
which means giving cert-manager credentials for the zone.
Leaving baseDomain unset is supported: integrations still deploy and work,
reachable in-cluster, with no external address.
Turn on semantic memory search (optional)
With an embedding server, searching agent memory ranks by meaning; without one it ranks by matching text. Both are supported.
kubectl -n octo create secret generic octo-embeddings-key \
--from-literal=apiKey='<your provider key>'embeddings:
enabled: true
connectorType: llm-openai # or llm-gemini, llm-openrouter
model: text-embedding-3-small
dimensions: 1536
existingSecret: octo-embeddings-key
existingSecretKey: apiKeyThe key is held in this one pod, rather than in every integration's pod.
dimensions must match the vector(N) columns in the schema, which are indexed
and fixed at schema time. And do not change model on an installation that has
embedded anything: vectors carry no record of which model produced them, so a
store holding two models' vectors cannot be ranked coherently. Changing it means
re-embedding everything, at whatever your provider charges.
What you have now
| Component | What it is | Notes |
|---|---|---|
| Platform | The editor UI, at ingress.host | Proxies the orchestrator and logs APIs through its BFF |
| Orchestrator | Deploys integrations through the Kubernetes API | Never exposed directly; reached through the editor |
| Observability service | Consumes logs and traces into Postgres; serves pod stats from Redis | Serves /platform/logs, /platform/traces and /platform/metrics |
| Postgres | The bundled database, on a PVC | Retain policy, so helm uninstall does not delete it |
| NATS | Pub-sub between components | |
| Redis | Shared state across replicas | Required; the observability service will not start without one |
| Schema Job | Applies sql/schema.sql | Re-runs idempotently on every upgrade |
| Retention CronJob | Deletes logs and traces past their policy | Nightly |
Verify end-to-end by building a flow in the editor and deploying it. Your first flow is the shortest path.
Production shape
The install above is already the right shape. What separates it from a long-running deployment is mostly what you point it at.
A managed database. The bundled Postgres is one replica with no backups you did not arrange. Point the chart at RDS, Cloud SQL or your own cluster instead:
postgres:
enabled: false
externalDatabase:
host: octo.abcdef.us-east-1.rds.amazonaws.com
database: octo
user: octo
sslmode: require
existingSecret: rds-octo
existingSecretPasswordKey: passwordThe schema Job then runs against that host. postgres.enabled: false skips the
StatefulSet, its Service and its Secret entirely.
A managed Redis. Same idea. A Redis with a password must arrive as a Secret, not a URL in a values file, because the URL contains the credential:
kubectl -n octo create secret generic octo-redis \
--from-literal=redis-url='rediss://default:<password>@cache.example:6380'redis:
enabled: false
externalRedis:
existingSecret: octo-redis
existingSecretKey: redis-urlResources and replicas. The chart's defaults are unopinionated. Set them
once, for every workload, in defaults, and override per component:
defaults:
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { memory: 1Gi }
postgres:
resources:
requests: { cpu: 250m, memory: 1Gi }
platform:
replicas: 2
orchestrator:
replicas: 2A restricted security context, if your cluster enforces Pod Security
Standards. defaults.podSecurityContext and defaults.securityContext apply to
every workload at once. The GKE and EKS profiles set the restricted baseline and
are worth reading as examples.
Upgrading
helm upgrade octo oci://ghcr.io/juancavallotti/charts/octo \--version 0.11.7 --namespace octo --values octo-values.yamlKeep passing --values: --reuse-values and a values file do not compose, and
the file is the record of what this release is.
A chart-version bump moves the images with it, so the Deployments roll, the schema Job re-runs idempotently, and Postgres and its volume are untouched. Integrations already deployed keep running on the runtime image they were deployed with, so redeploy them from the editor to pick up a newer one. See Releases and Upgrades.
Rotating a credential
Every credential is a Secret you own, so rotation does not involve Helm:
kubectl -n octo create secret generic octo-db-password \
--from-literal=postgres-password='<new password>' \
--dry-run=client -o yaml | kubectl apply -f -
kubectl -n octo rollout restart deploy,statefulsetTwo exceptions, from step 2: never rotate kv-encryption-key or
dev-run-hash-secret. Changing the first destroys every value in a secret KV
namespace; changing the second breaks every webhook registered against a dev-run
hostname.
Uninstalling
helm uninstall octo --namespace octoThe database survives on purpose: the StatefulSet sets
persistentVolumeClaimRetentionPolicy: Retain, so the claim outlives the release
and the next install adopts it. Deleting the namespace deletes the claim, the
volume and your Secrets. That is the irreversible step:
kubectl delete namespace octoWhen something is wrong
| Symptom | Where to look |
|---|---|
Pods Pending | kubectl -n octo describe pod; usually no StorageClass, or not enough allocatable memory |
| Hostname resolves to nothing | kubectl -n octo describe ingress, then kubectl get ingressclass. An Ingress no controller claims is not an error anywhere |
| Certificate never appears | kubectl -n octo describe certificate. HTTP-01 needs DNS resolving first; a wildcard needs DNS-01 |
| Editor loads, sign-in fails at the provider | The redirect URI must match {auth.url}/api/auth/callback/oidc exactly, scheme included. Set auth.url explicitly if the chart cannot know your scheme |
| Writes to a secret KV namespace rejected | No kv.existingSecret (or kv.encryptionKey). Plain KV keeps working, which is why this is quiet |
| Editor's Run says dev runs unavailable | orchestrator.devRuns needs its Secret; the chart refuses to render without one, so this means the feature was turned off |
| A deployed integration's hostname 404s | orchestrator.baseDomain and the wildcard DNS record; the catch-all page only renders for a controller the chart can identify |