> ## Documentation Index
> Fetch the complete documentation index at: https://allhandsai-docs-agent-canvas-helm-chart.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Kubernetes (Helm)

> Install Agent Canvas into a Kubernetes cluster with the official Helm chart.

The official Helm chart deploys the all-in-one Agent Canvas image (frontend + agent-server + automation) on Kubernetes as a `StatefulSet` with a `PersistentVolumeClaim` for durable state, a `Service`, and an optional `Ingress` and RBAC layer. It's the recommended way to run Agent Canvas as a shared, always-on backend that survives pod restarts and image upgrades.

<Tip>
  **Turn this into an internal vibecoding platform.** Once Agent Canvas runs in your cluster with [RBAC enabled](#rbac), you can give it a skill that teaches the agent how to deploy the small web apps it builds straight into the cluster. From that point on, **anyone with access to the Agent Canvas UI can build and ship code into the cluster — and save it to GitHub — with just a prompt.** No pipelines, no manual `kubectl`, no hand-written manifests: the agent scaffolds the app, applies the manifests, and gives back a live URL.

  The "save to GitHub" half of that loop requires the **GitHub MCP server** to be enabled so the agent can create repos and push commits on the user's behalf. See the [generic app-deployment skill](#skill-deploying-apps-into-the-cluster) below for a ready-to-adapt version, and [RBAC](#rbac) for the permissions it needs.
</Tip>

<Warning>
  The agent server can read and write the pod filesystem, execute shell commands, and — when RBAC is enabled — mutate the Kubernetes cluster it runs in. Treat the release namespace as trusted infrastructure, put it behind an authenticated ingress before exposing it to the internet, and only turn on `rbac.clusterAdmin` when you truly need cluster-wide access.
</Warning>

## Prerequisites

* Kubernetes **1.24 or later** (required by the chart's `kubeVersion` constraint).
* [Helm **3.x**](https://helm.sh/docs/intro/install/).
* A working `kubectl` context with permission to create resources in the target namespace.
* A `StorageClass` that supports `ReadWriteOnce` volumes. On GKE this is usually `standard-rwo` on older node pools or `hyperdisk-balanced` on `c4` / `n4` node pools. On EKS it's `gp3`. On DigitalOcean/Linode it's `do-block-storage` / `linode-block-storage`.
* An ingress controller (nginx, Traefik, cloud-provider ingress, etc.) if you want to reach Agent Canvas from outside the cluster.

## Get the Chart

The chart lives alongside the source in the `OpenHands/agent-canvas` repo. Clone it and install from the local path:

```bash theme={null}
git clone https://github.com/OpenHands/agent-canvas.git
cd agent-canvas
helm install agent-canvas ./helm/agent-canvas \
  --namespace agent-canvas --create-namespace
```

That single command deploys everything below. Agent Canvas is now reachable inside the cluster at `http://agent-canvas.agent-canvas.svc.cluster.local:8000`. See [Access It](#access-it) for how to reach it from a browser.

## What Gets Deployed

| Resource                          | Purpose                                                                                                                                                                                        |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `StatefulSet`                     | Single-replica pod running the all-in-one image.                                                                                                                                               |
| `PersistentVolumeClaim` (per pod) | Backs `~/.openhands` and `~/workspace` (both mounted from the same PVC via `subPath`): settings, encrypted secrets, conversation history, automation SQLite DB, cloned repos, generated files. |
| `Service` (`ClusterIP`)           | Cluster-internal endpoint on port 8000.                                                                                                                                                        |
| `Service` (headless)              | Required by the `StatefulSet` for stable pod DNS.                                                                                                                                              |
| `ServiceAccount`                  | Stable identity the pod runs under.                                                                                                                                                            |
| `Ingress` (optional)              | External HTTP(S) entry point.                                                                                                                                                                  |
| `RoleBinding` (per namespace)     | Created when `rbac.enabled=true`, one per entry in `rbac.namespaces`.                                                                                                                          |
| `ClusterRoleBinding` (optional)   | Created when `rbac.clusterAdmin=true`.                                                                                                                                                         |

## Persistence

The chart provisions **one** PVC and mounts it at multiple well-known subdirectories of the openhands user's HOME via `subPath`. That preserves the pristine `/home/openhands` the base image ships (dotfiles like `~/.bashrc` and `~/.profile`) while persisting the directories that actually contain state:

* `~/.openhands` — agent-server settings and encrypted secrets, conversation history and event stores, automation SQLite database (unless you point at external Postgres — see [External Database](#external-database)), the `OH_SECRET_KEY` and session API key auto-generated on first boot
* `~/workspace` — the agent's default working directory: cloned repos, worktrees, anything the agent writes when it treats `~` as the workspace root

Both paths share the same underlying disk. Add more entries to `persistence.mounts` if you want other subtrees persisted (e.g. `~/.cache`, `~/.config`).

Defaults:

```yaml theme={null}
persistence:
  enabled: true
  mounts:
    - mountPath: /home/openhands/.openhands
      subPath: openhands
    - mountPath: /home/openhands/workspace
      subPath: workspace
  size: 20Gi
  # storageClassName: ""       # empty → cluster default
  accessModes:
    - ReadWriteOnce
```

<Note>
  The pod runs as `openhands` (UID 10001) from the upstream image. The chart sets `podSecurityContext.fsGroup: 10001` so the kubelet chowns the PVC on mount and the process can write to it. If you override `securityContext` or `podSecurityContext`, make sure UID/GID/fsGroup all point at the same user or `openhands` won't be able to write to the volume.
</Note>

<Tip>
  On GKE clusters using `c4` or `n4` node pools, the default `standard-rwo` StorageClass will fail to attach because those machine types require `hyperdisk-balanced`. Set `persistence.storageClassName: hyperdisk-balanced` explicitly.
</Tip>

### Bring Your Own PVC

If you already manage the volume out of band, point the chart at it and it will skip the `volumeClaimTemplates` path:

```yaml theme={null}
persistence:
  enabled: true
  existingClaim: my-agent-canvas-pvc
```

## Ingress

Ingress is off by default. Enable it and provide the standard knobs — the chart supports `className`, `annotations`, multiple `hosts` with per-path routing, and TLS.

```yaml theme={null}
ingress:
  enabled: true
  className: nginx
  annotations:
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-body-size: "50m"
    cert-manager.io/cluster-issuer: letsencrypt-prod
  hosts:
    - host: agent-canvas.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - hosts:
        - agent-canvas.example.com
      secretName: agent-canvas-tls
```

<Note>
  The read/send timeout annotations matter — Agent Canvas holds long-lived WebSocket connections for streaming agent events. Without generous timeouts, the ingress controller will close idle streams and the UI will drop reconnects mid-turn. Nginx defaults to 60 seconds.
</Note>

## RBAC

RBAC is **off by default**. The pod runs under its own ServiceAccount but has no in-cluster permissions. Turn it on when the agent needs to inspect or mutate Kubernetes resources (e.g. to deploy things it builds).

Two independent switches:

```yaml theme={null}
rbac:
  enabled: true
  # Full access to all resources in these namespaces (bound to the
  # built-in `admin` ClusterRole via one RoleBinding per namespace).
  # Each namespace must already exist in the cluster.
  namespaces:
    - default
    - agent-sandbox
  # Optionally grant cluster-admin. OFF by default. Very broad — enable
  # only when the agent truly needs to manage the whole cluster.
  clusterAdmin: false
```

<Warning>
  `rbac.clusterAdmin: true` grants `cluster-admin` — the highest privilege level in Kubernetes. An agent with a compromised prompt (or a bad LLM response) could delete every resource in the cluster. Prefer scoping to specific namespaces with `rbac.namespaces` whenever possible.
</Warning>

## Skill: Deploying Apps Into the Cluster

To unlock the internal vibecoding platform described at the top of this page, give the agent a [skill](/overview/skills/creating) that teaches it the conventions for shipping the apps it builds into a namespace of your cluster. Drop the markdown below into `.openhands/skills/deploy-app/SKILL.md` (or your workspace's skills directory), adjust the placeholders (`<namespace>`, `<domain>`, GitHub org), and the agent will scaffold, deploy, and — with the **GitHub MCP server enabled** — push each app to its own repo on request.

This is a generic version of the skill the OpenHands team runs internally. It assumes the backend was installed with [`rbac.enabled=true`](#rbac) and a `rbac.namespaces` entry for the target namespace, so the pod's ServiceAccount can `kubectl apply` there directly.

````markdown theme={null}
# Deploy apps into the cluster

Use this skill to create and manage the small web apps you build, serving each
one at `https://<name>.<domain>` from the `<namespace>` namespace of the
cluster Agent Canvas runs in.

## Platform conventions

Every app follows the same pattern:

- **Namespace:** `<namespace>`. The agent runs under a ServiceAccount that has
  admin in this namespace (granted via the Helm chart's `rbac.namespaces`), so
  `kubectl apply` works directly with no extra credentials.
- **Content:** static files (HTML/JS/CSS) served by an `nginx:*-alpine` pod.
  The files live in a **ConfigMap** (`<name>-web`) mounted at
  `/usr/share/nginx/html`. Apps that need a backend add their own container.
- **Objects per app:** `Deployment` + `Service` (ClusterIP, port 80) +
  `Ingress`. Apps that need scheduled work add a `CronJob`.
- **Host:** `<name>.<domain>`.
- **TLS:** if cert-manager is installed, add the
  `cert-manager.io/cluster-issuer: <issuer>` annotation and a `tls` block with
  `secretName: <name>-tls`; the cert is issued automatically.
- **Auth:** put shared apps behind your ingress's authentication (oauth2-proxy,
  a forward-auth middleware, Cloudflare Access, etc.) so they aren't exposed
  unauthenticated. Reference your cluster's auth middleware/annotation here.
- **Resources:** keep them tiny (requests `10m`/`16Mi`, limits `100m`/`64Mi`).

### Ingress template

```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: <name>
  namespace: <namespace>
  labels:
    app: <name>
  annotations:
    cert-manager.io/cluster-issuer: <issuer>
    # Add your cluster's auth middleware/annotation here so the app is
    # not exposed unauthenticated.
spec:
  ingressClassName: <ingress-class>   # e.g. nginx or traefik
  rules:
    - host: <name>.<domain>
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: <name>
                port:
                  number: 80
  tls:
    - hosts:
        - <name>.<domain>
      secretName: <name>-tls
```

### Deployment + Service template

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: <name>
  namespace: <namespace>
  labels:
    app: <name>
spec:
  replicas: 1
  selector:
    matchLabels:
      app: <name>
  template:
    metadata:
      labels:
        app: <name>
    spec:
      containers:
        - name: web
          image: nginx:1.27-alpine
          ports:
            - containerPort: 80
          resources:
            requests:
              cpu: 10m
              memory: 16Mi
            limits:
              cpu: 100m
              memory: 64Mi
          volumeMounts:
            - name: web
              mountPath: /usr/share/nginx/html
      volumes:
        - name: web
          configMap:
            name: <name>-web
---
apiVersion: v1
kind: Service
metadata:
  name: <name>
  namespace: <namespace>
  labels:
    app: <name>
spec:
  selector:
    app: <name>
  ports:
    - port: 80
      targetPort: 80
```

## Secrets (never commit them)

If an app needs credentials at runtime, store them in a Kubernetes `Secret`
created out of band — never in a ConfigMap, the git repo, or the manifest, and
never print their values. Create the Secret from environment variables so the
plaintext never appears in a command line or file:

```bash
kubectl create secret generic <name>-<purpose> -n <namespace> \
  --from-literal=<key>="$SOME_ENV_VAR" \
  --dry-run=client -o yaml | kubectl apply -f -
```

Consume it in the Deployment via `env` + `secretKeyRef`. Document the one-off
`kubectl create secret ...` command in the app's `README.md` — keep it out of
`deploy.sh` and the committed manifest.

## Source layout & GitHub

- Keep each app's source under `~/workspace/<project>`.
- Give each app its own GitHub repo (e.g. `<github-org>/app-<project>`).
  **This requires the GitHub MCP server to be enabled** so the agent can create
  the repo and push commits. Create it if it doesn't exist, then push.
- Standard repo layout:
  - `README.md` — what the app is, its URL, how to deploy, any one-off secrets.
  - `k8s/<project>.yaml` — all Kubernetes objects (Deployment+Service+Ingress).
  - `web/` — static assets served via the ConfigMap.
  - `deploy.sh` — regenerates the ConfigMap from `web/`, applies `k8s/`, and
    rolls the Deployment.

### Typical `deploy.sh`

```bash
#!/usr/bin/env bash
set -euo pipefail
NS=<namespace>
DIR="$(cd "$(dirname "$0")" && pwd)"

kubectl create configmap <name>-web --namespace "$NS" \
  --from-file="$DIR/web/" \
  --dry-run=client -o yaml | kubectl apply -f -

kubectl apply -f "$DIR/k8s/<name>.yaml"
kubectl rollout restart deployment/<name> -n "$NS"
kubectl rollout status deployment/<name> -n "$NS"
```

## Creating a new app

1. `mkdir -p ~/workspace/<project>/{k8s,web}` and add `web/index.html`,
   `k8s/<project>.yaml` (from the templates above), `deploy.sh`, and a
   `README.md`.
2. If GitHub MCP is enabled, create/verify `<github-org>/app-<project>`,
   commit, and push.
3. Deploy: `./deploy.sh`.
4. If cert-manager is used, wait for the cert:
   `kubectl get certificate <name>-tls -n <namespace>` should become
   `READY=True`. Confirm the Ingress has an address.
5. Report the live URL back to the user.

## Updating an app

Edit files under `~/workspace/<project>`, commit + push (via GitHub MCP), then
re-run `./deploy.sh` (which restarts the Deployment so nginx reloads the
ConfigMap).

## Deleting an app

1. `kubectl delete -f ~/workspace/<project>/k8s/<project>.yaml` and delete the
   `<name>-web` ConfigMap. Deleting the Ingress lets cert-manager clean up the
   TLS secret; delete any credential secrets explicitly.
2. Optionally archive/delete the GitHub repo and remove
   `~/workspace/<project>`.

## Verifying access

```bash
kubectl get deploy,svc,ingress,certificate -n <namespace> -l app=<name>
```
````

## Common Configurations

### Minimal (defaults + ingress)

```yaml theme={null}
# values.yaml
ingress:
  enabled: true
  className: nginx
  hosts:
    - host: agent-canvas.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - hosts: [agent-canvas.example.com]
      secretName: agent-canvas-tls
```

### With LLM Credentials from a Secret

Rather than typing your LLM key into the UI on every reinstall, pass it in through the chart. Create the secret separately, then reference it via `config.extraEnv`:

```bash theme={null}
kubectl -n agent-canvas create secret generic llm \
  --from-literal=api-key=sk-...
```

```yaml theme={null}
# values.yaml
config:
  extraEnv:
    - name: LLM_MODEL
      value: "openhands/claude-sonnet-4-5-20250929"
    - name: LLM_API_KEY
      valueFrom:
        secretKeyRef:
          name: llm
          key: api-key
```

### Agent That Manages a Sandbox Namespace

```yaml theme={null}
# values.yaml
rbac:
  enabled: true
  namespaces:
    - agent-sandbox
```

Create the sandbox namespace before installing (`kubectl create namespace agent-sandbox`). Then the pod can `kubectl apply` / `kubectl delete` anything inside `agent-sandbox` but nothing else.

### External Database

The automation subsystem uses a SQLite database on the PVC by default. For higher-volume deployments, point it at Postgres:

```yaml theme={null}
# values.yaml
config:
  automationDbUrl: "postgresql+asyncpg://user:pass@postgres.databases.svc.cluster.local/agent_canvas"
```

Store the actual credentials in a Kubernetes Secret and reference them via `config.extraEnv` rather than putting the password in `values.yaml`.

## Install and Upgrade

```bash theme={null}
# First install
helm install agent-canvas ./helm/agent-canvas \
  --namespace agent-canvas --create-namespace \
  -f values.yaml

# Later upgrades
helm upgrade agent-canvas ./helm/agent-canvas \
  -n agent-canvas -f values.yaml

# Check rollout
kubectl -n agent-canvas rollout status statefulset/agent-canvas
kubectl -n agent-canvas get pvc,pod,svc,ingress
```

To pin a specific image (e.g. a PR preview or a build newer than the chart's `appVersion`):

```bash theme={null}
helm upgrade agent-canvas ./helm/agent-canvas \
  -n agent-canvas -f values.yaml \
  --set image.tag=sha-<git-sha>
```

## Access It

The chart's default `Service` is `ClusterIP`. Three common ways to reach the UI:

1. **Ingress** — configure the `ingress:` block as shown above. This is the production path.
2. **Port-forward** — for quick access from your laptop without touching DNS or ingress:

   ```bash theme={null}
   kubectl -n agent-canvas port-forward svc/agent-canvas 8000:8000
   ```

   Then open `http://localhost:8000`.
3. **LoadBalancer** — set `service.type: LoadBalancer` if your cloud provisions cloud load balancers for you. Cheaper than ingress for one-off installs, but skips TLS and auth.

<Warning>
  The agent server accepts any request with the right `LOCAL_BACKEND_API_KEY`, so exposing it via a bare LoadBalancer means anyone on the internet who can guess the key can drive the agent. Prefer the Ingress path with an authenticated proxy (oauth2-proxy, Cloudflare Access, tailscale-serve, ngrok OAuth, etc.) in front of it.
</Warning>

## Uninstall

```bash theme={null}
helm uninstall agent-canvas -n agent-canvas
```

The PVC created by the `StatefulSet` is **retained** on uninstall so a reinstall picks up where you left off. Delete it explicitly if you want a fully clean slate:

```bash theme={null}
kubectl -n agent-canvas delete pvc -l app.kubernetes.io/instance=agent-canvas
```

## Troubleshooting

### `FailedAttachVolume: pd-balanced disk type cannot be used by c4-standard-8 machine type`

The default StorageClass on your cluster is provisioning a disk type your nodes can't attach. On GKE `c4` / `n4` node pools, use `hyperdisk-balanced`:

```yaml theme={null}
persistence:
  storageClassName: hyperdisk-balanced
```

Because `volumeClaimTemplates` on an existing `StatefulSet` are immutable, changing the StorageClass requires deleting the STS and PVC first:

```bash theme={null}
kubectl -n agent-canvas delete statefulset agent-canvas
kubectl -n agent-canvas delete pvc -l app.kubernetes.io/instance=agent-canvas
helm upgrade agent-canvas ./helm/agent-canvas -n agent-canvas -f values.yaml
```

### `ErrImagePull` on `ghcr.io/openhands/agent-canvas:<tag>`

Verify the tag exists on GHCR — the chart's `appVersion` pins the default. To pull an image built from a specific commit, use `--set image.tag=sha-<short-sha>`. See [GHCR](https://github.com/OpenHands/agent-canvas/pkgs/container/agent-canvas) for the tag list.

### WebSocket disconnects every minute

Your ingress is closing idle streams. Bump the timeout annotations on the `Ingress`:

```yaml theme={null}
ingress:
  annotations:
    # nginx
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
    # Traefik
    traefik.ingress.kubernetes.io/router.middlewares: ""  # keep this in mind if you also add auth middlewares
```

### Pod stuck in `Pending` — `no persistent volumes available`

Either no `StorageClass` exists on the cluster, or the one you set doesn't provision on demand. Run `kubectl get storageclass` and set `persistence.storageClassName` to one that shows `VOLUMEBINDINGMODE=WaitForFirstConsumer` (Immediate is fine too).

## Related Guides

* [Docker Backend](/openhands/usage/agent-canvas/backend-setup/docker) — single-container equivalent for laptops and single-host VMs.
* [VM / Self-Hosted Installation](/openhands/usage/agent-canvas/backend-setup/vm) — install directly on a Linux VM without Kubernetes.
* [Connect and Manage Backends](/openhands/usage/agent-canvas/backends) — point a local Agent Canvas UI at a remote backend.
