Skip to main content
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

ResourcePurpose
StatefulSetSingle-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.
ServiceAccountStable 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:
  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:
    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.
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 Pendingno 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).