🗄️ Archived project — June 2026. The
claude-webuiapp has been decommissioned (project abandoned) and fully removed from the cluster. This article is kept for historical purposes.
There is something intrinsically comical about asking an AI agent to add a Kubernetes service, watching it edit the repo’s YAML, push to actual-state, and watching ArgoCD deploy the result — knowing that this agent itself runs as a pod on that very same cluster. It deploys, quite literally, itself.
That is exactly the situation I have been in since putting claude-webui into service on my k3s homelab. Here is how it works, what had to be improvised, and why it is both a good idea and slightly unsettling.
actual-state,
et ArgoCD le redéploie — le pod redémarre avec la config qu'il vient d'écrire.
La boucle se referme sur elle-même. The agent edits its own manifests, pushes to actual-state,
and ArgoCD redeploys it — the pod restarts with the config it just wrote.
The loop closes on itself. The app: claude-code-webui
claude-code-webui is an open-source web wrapper around Claude Code, Anthropic’s CLI. It exposes a browser interface that launches Claude Code sessions in a working directory, streams prompts through, and displays the tools being used in real time. No custom API, no prompt engineering — it is literally claude-code-webui --host 0.0.0.0 --port 8080 starting the CLI as a subprocess.
The image runs from ghcr.io/yellowbeanie/claude-webui (private, built via GitHub Actions on ARM64 for the cluster nodes). The interface is exposed at claude.olig-homelab-2025.win, behind a Cloudflare Tunnel and a Cloudflare Access policy restricted to my Gmail address. Without that, anyone reaching the URL would have a Claude Code shell on the repo. Non-negotiable.
The ArgoCD app orchestrating all of this is minimal:
# argocd/apps/claude-webui.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: claude-webui
namespace: argocd
spec:
source:
repoURL: [email protected]:YellowBeanie/k3s-homelab.git
targetRevision: actual-state
path: claude-webui
destination:
server: https://kubernetes.default.svc
namespace: claude-webui
syncPolicy:
automated:
prune: false
selfHeal: true
syncOptions:
- ServerSideApply=true
prune: false is deliberate — I don’t want ArgoCD deleting resources if Claude pushes a malformed manifest. The feedback loop is tense enough as it is.
OAuth Pro: the Anthropic token in a SealedSecret
Claude Code authenticates via an Anthropic token stored in ~/.claude/.credentials.json. For an ephemeral pod, this data has to be injected from the outside — and since the repo is public, it has to be encrypted.
The solution: a SealedSecret. The .credentials.json file is sealed with the public certificate of the cluster’s Sealed Secrets controller, and the ciphertext is committed to the repo safely. Only the controller can decrypt it.
# Seal the OAuth credentials from the local machine
kubectl create secret generic claude-webui-oauth-creds \
--namespace claude-webui \
--from-file=.credentials.json="$HOME/.claude/.credentials.json" \
--dry-run=client -o yaml \
| kubeseal \
--cert docs/sealed-secrets/sealed-secrets-pub.pem \
--format yaml \
> claude-webui/sealedsecret-oauth-creds.yaml
The result is a harmless encrypted blob that goes into the repo:
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: claude-webui-oauth-creds
namespace: claude-webui
spec:
encryptedData:
.credentials.json: AgANgjWsBAA7ZC/NIkSH... # ~3 KB of RSA-OAEP encryption
Pitfall number one: Claude Code refreshes its token automatically and rewrites .credentials.json on every refresh. If the file is mounted directly from a Kubernetes Secret (read-only), the refresh fails and the agent finds itself logged out in the middle of a change. The solution is to use the Secret only as an initial seed, via an init container that copies the file onto a PVC if and only if it does not exist yet. The live token then lives on the persistent volume, not in the Secret.
Rotation takes two commands: claude logout && claude login locally, then resealing the new .credentials.json. It has happened more than once in the repo’s history — one of those times because I had forgotten the old token was still referenced in a chat and had ended up exposed. Preventive rotation.
Persistence: two Longhorn PVCs
Without persistence, every pod restart starts from scratch: no conversation history, no project context, the OAuth token potentially lost. Two Longhorn PVCs cover the two distinct needs:
# 1 GB for ~/.claude — credentials, projects, history
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: claude-webui-home
namespace: claude-webui
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 1Gi
storageClassName: standard-storage
---
# 5 GB for /workspace — the k3s-homelab repo clone
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: claude-webui-workspace
namespace: claude-webui
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 5Gi
storageClassName: standard-storage
Pitfall number two: claude-code-webui reads ~/.claude.json (Claude’s project index) on every /projects request and crashes with ENOENT if the file is missing. It has to be initialised before the main container’s first start. And that’s not all.
Pitfall number three: the web interface filters the projects it displays down to those with a matching folder under ~/.claude/projects/<encoded>/. The encoding turns /, \, :, . and _ into dashes. So the path /workspace/k3s-homelab becomes -workspace-k3s-homelab. If that folder does not exist, the project does not appear in the UI — even if claude.json references it correctly (commits 9a0c2dc and 7196a52).
Both problems are solved in the seed-claude-creds init container:
# Excerpt from the seed-claude-creds init container
if [ ! -f /home/node/.claude/claude.json ]; then
echo '{"projects":{"/workspace/k3s-homelab":{}}}' \
> /home/node/.claude/claude.json
fi
mkdir -p /home/node/.claude/projects/-workspace-k3s-homelab
And in the main container, ~/.claude.json is a symlink to the persistent copy on the PVC:
ln -sf /home/node/.claude/claude.json /home/node/.claude.json
exec claude-code-webui --host 0.0.0.0 --port 8080
SSH to the master: kubectl without a ServiceAccount
For an agent that edits YAML to be genuinely useful, it also has to be able to observe the state of the cluster — check whether a pod is in CrashLoopBackOff, inspect logs, validate that a deployment succeeded. Two options:
- A Kubernetes ServiceAccount with an exhaustive RBAC Role.
- An SSH key to
[email protected](master-1) andsudo kubectl.
I chose the second. The honest reason: writing an RBAC Role covering every verb Claude might need is a long haul, and in a single-user personal homelab, the isolation gained does not offset the maintenance cost. The risk is knowingly accepted.
The SSH key (claude-cluster-runner@claude-webui, ed25519) is sealed like the others:
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: claude-webui-master-ssh-key
namespace: claude-webui
spec:
encryptedData:
id_ed25519: AgCztnTzUzFYl8yX... # encrypted ed25519 key
The SSH configuration in a ConfigMap maps the master-1 alias to the real IP and the correct identity:
# configmap-ssh.yaml
data:
config: |
Host master-1
HostName 10.0.0.11
User admin
IdentityFile /home/node/.ssh/id_master_ed25519
IdentitiesOnly yes
StrictHostKeyChecking yes
UserKnownHostsFile /home/node/.ssh/known_hosts
The clone-repo init container installs both keys (GitHub + master) in an emptyDir mounted at ~/.ssh, with the required 0600 permissions. The main container mounts that same directory read-only — a precaution to stop a rogue Claude Code session from overwriting or exfiltrating the keys through the web interface.
From the UI, Claude can now run:
ssh master-1 sudo kubectl -n argocd get apps
ssh master-1 sudo kubectl -n monitoring logs -l app=grafana --tail=50
GitHub deploy key: pushing without a human token
So that Claude can commit and push the modified manifests to actual-state, a deploy key with write access is registered on the YellowBeanie/k3s-homelab repo. It is sealed and injected the same way as the master key.
The clone-repo init container performs a clone (or a git reset --hard origin/actual-state) on every pod start. The hard reset is intentional: the workspace always restarts from a clean state aligned with GitHub, even if Claude had left uncommitted files during the previous restart.
The Git environment variables in the Deployment sign commits with a recognisable identity:
env:
- name: GIT_AUTHOR_NAME
value: "Claude (homelab)"
- name: GIT_AUTHOR_EMAIL
value: "[email protected]"
The full Deployment
For the overall picture, here is the Deployment with its volumes (slightly abridged):
apiVersion: apps/v1
kind: Deployment
metadata:
name: claude-webui
namespace: claude-webui
spec:
replicas: 1
strategy:
type: Recreate # no rolling update: ReadWriteOnce on the PVCs
template:
spec:
imagePullSecrets:
- name: claude-webui-ghcr # private GHCR
securityContext:
fsGroup: 1000
runAsUser: 1000
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
initContainers:
- name: seed-claude-creds # OAuth seed + claude.json + encoded folder
...
- name: clone-repo # installs the SSH keys, clones/resets the repo
...
containers:
- name: claude-webui
image: ghcr.io/yellowbeanie/claude-webui:latest
command: ["/usr/bin/tini", "--"]
args:
- /bin/sh
- -c
- |
ln -sf /home/node/.claude/claude.json /home/node/.claude.json
exec claude-code-webui --host 0.0.0.0 --port 8080
resources:
requests: {cpu: 250m, memory: 512Mi}
limits: {cpu: "2", memory: 2Gi}
volumeMounts:
- name: workspace
mountPath: /workspace
- name: claude-home
mountPath: /home/node/.claude
- name: ssh-dir
mountPath: /home/node/.ssh
readOnly: true # SSH keys mounted read-only at runtime
volumes:
- name: workspace
persistentVolumeClaim:
claimName: claude-webui-workspace # 5 Gi, k3s-homelab repo
- name: claude-home
persistentVolumeClaim:
claimName: claude-webui-home # 1 Gi, ~/.claude
- name: ssh-dir
emptyDir: {} # filled by the init container
- name: claude-creds-secret
secret:
secretName: claude-webui-oauth-creds
- name: deploy-key-secret
secret:
secretName: claude-webui-deploy-key
- name: master-key-secret
secret:
secretName: claude-webui-master-ssh-key
Private GHCR
The ghcr.io/yellowbeanie/claude-webui image is private (it embeds a custom Dockerfile to adjust paths and dependencies on ARM64). The access pattern is the same as for the cluster’s other namespaces: a claude-webui-ghcr SealedSecret of type kubernetes.io/dockerconfigjson, referenced in imagePullSecrets. The image is pinned by SHA256 digest via the GitHub Actions workflow that automatically commits the digest after each build.
Risks and guardrails
Running an AI agent with write access to the infrastructure repo and sudo on the master is a decision that deserves to be documented honestly.
The concrete risks I have run into:
-
Retry loops. Claude has no notion of “backoff”. If a
kubectl applyfails on a malformed manifest, it can chain attempts without stopping. The solution: branch protections onmain(merges require a human review), and I watch the GitHub notifications for overly frequent pushes toactual-state. -
An expiring OAuth token. The automatic refresh works as long as the process is running. After a pod restart, if the token on the PVC has expired, the agent starts but fails on the first request to the Anthropic API. Symptom: the UI shows a 401 error while streaming. Remedy: reseal.
-
The agent touching its own manifests. It is possible — and it has happened. Claude modified
deployment.yamlon instruction to add an environment variable. ArgoCD deployed the change. The pod restarted with the new config. Everything went fine. But it does mean that a badly phrased instruction can trigger a pod restart mid-conversation. Plan on rephrasing.
The guardrails in place:
- Cloudflare Access on the ingress: only my Gmail can open the UI.
- Branch protections on
main: no direct push, critical PRs go through a human review. prune: falsein the ArgoCD policy: ArgoCD does not delete resources if a manifest disappears.- SSH keys mounted read-only in the runtime container.
automountServiceAccountToken: false: the pod has no ServiceAccount token in memory.
The effect in practice
Since it went into service, roughly 80% of the repo’s infrastructure changes go through the Claude interface. The most frequent use cases: adding an annotation, adjusting a requests/limits resource, investigating a failing pod, writing a SealedSecret for a new namespace, writing a blog article about deploying AI agents in Kubernetes clusters.
That last one is not a joke. Part of this article was written from the claude.olig-homelab-2025.win interface, in the /workspace/k3s-homelab workspace, by a pod running on the cluster described in the article.
The recursion stops there. For now.
See also
Auditing my homelab’s security with an AI agent (Claude Fable 5) — the same logic of guardrails (evidence, barriers, rollback) applied this time to a full security audit of the cluster.