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.
Turn this into an internal vibecoding platform. Once Agent Canvas runs in your cluster with RBAC enabled, 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 below for a ready-to-adapt version, and RBAC for the permissions it needs.
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.
Prerequisites
- Kubernetes 1.24 or later (required by the chart’s
kubeVersion constraint).
- Helm 3.x.
- 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:
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 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), 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:
persistence:
enabled: true
mounts:
- mountPath: /home/openhands/.openhands
subPath: openhands
- mountPath: /home/openhands/workspace
subPath: workspace
size: 20Gi
# storageClassName: "" # empty → cluster default
accessModes:
- ReadWriteOnce
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.
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.
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:
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.
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
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.
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:
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
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.
Skill: Deploying Apps Into the Cluster
To unlock the internal vibecoding platform described at the top of this page, give the agent a skill 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 and a rbac.namespaces entry for the target namespace, so the pod’s ServiceAccount can kubectl apply there directly.
# 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)
# 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:
kubectl -n agent-canvas create secret generic llm \
--from-literal=api-key=sk-...
# 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
# 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:
# 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
# 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):
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:
-
Ingress — configure the
ingress: block as shown above. This is the production path.
-
Port-forward — for quick access from your laptop without touching DNS or ingress:
kubectl -n agent-canvas port-forward svc/agent-canvas 8000:8000
Then open http://localhost:8000.
-
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.
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.
Uninstall
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:
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:
persistence:
storageClassName: hyperdisk-balanced
Because volumeClaimTemplates on an existing StatefulSet are immutable, changing the StorageClass requires deleting the STS and PVC first:
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 for the tag list.
WebSocket disconnects every minute
Your ingress is closing idle streams. Bump the timeout annotations on the Ingress:
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).