Run Hermes on another cluster¶
Purpose: run the public hermes-dev image in another homelab, with Docker Compose or plain Kubernetes manifests.
Scope: nothing from this cluster is needed: no Argo CD, 1Password, Longhorn, Kopiur, LiteLLM or Hindsight.
This cluster's setup: Hermes Agent: the AI agent in the cluster.
How the pieces fit: Local AI starter kit.
Image source: homelab-images/images/hermes-dev lists what is installed and how it is built.
flowchart TD
A{Do you run Kubernetes?} -->|no| C[Option A: Docker Compose]
A -->|yes| K[Option B: Kubernetes manifests]
C --> L[Connect and log in]
K --> L
Use a coding agent¶
Paste this prompt into Claude Code or Codex, inside your own cluster repo:
Read https://raw.githubusercontent.com/mitchross/talos-argocd-proxmox/main/docs/domains/ai-gpu/hermes-other-clusters.md.
Deploy Hermes to my Kubernetes cluster with the manifests on that page.
Before you change anything, ask me for: hostname, Gateway API or Ingress, StorageClass, and which model to use
(Claude subscription token, or the URL of my own OpenAI-compatible LLM server).
Keep secrets out of Git. Create the Secret with kubectl and show me the command first.
Review every command the agent proposes before it runs.
What you need¶
- An
amd64machine. The image has noarm64build. - Docker, or a Kubernetes cluster with a default
ReadWriteOnceStorageClass. - For Kubernetes: an HTTPS Gateway (Gateway API) or Ingress controller that passes WebSockets.
- A DNS name for Hermes, for example
hermes.example.com. - One model: a Claude Max/Pro token from
claude setup-tokenon your PC, or an OpenAI-compatible server (vLLM, llama.cpp, Ollama, LiteLLM).
The image is public at ghcr.io/mitchross/hermes-dev. Pin a digest; the main tag moves with each build:
The container must start as root. s6-overlay sets up the volume, then drops the gateway and dashboard to user hermes (uid 10000).
The settings file¶
Hermes reads config.yaml from its data directory (/opt/data). Start with one of these.
Claude subscription (the token goes in CLAUDE_CODE_OAUTH_TOKEN):
model:
provider: anthropic
default: claude-sonnet-5-5
terminal:
backend: local
cwd: /opt/data/workspace
timezone: "America/New_York"
Your own OpenAI-compatible server (the key goes in MY_LLM_API_KEY; use any non-empty value if the server has no key):
model:
provider: custom
default: my-model
base_url: http://my-llm.example.svc.cluster.local:8000/v1
api_key: ${MY_LLM_API_KEY}
terminal:
backend: local
cwd: /opt/data/workspace
timezone: "America/New_York"
Change default, base_url and timezone to yours. You can also switch models later in the dashboard's model picker.
Option A: Docker Compose¶
- Make a folder with a
data/directory in it, and save one of the settings files above asdata/config.yaml. -
Generate two secrets. Run this twice and keep both values:
-
Save this as
compose.yamlnext todata/:services: hermes: image: ghcr.io/mitchross/hermes-dev:main@sha256:<digest> command: ["gateway", "run"] restart: unless-stopped ports: - "9119:9119" environment: HERMES_UID: "1000" # your user id (`id -u`), so you can edit files in ./data HERMES_GID: "1000" # your group id (`id -g`) HERMES_DASHBOARD: "1" HERMES_DASHBOARD_HOST: 0.0.0.0 HERMES_DASHBOARD_PUBLIC_URL: http://<server-ip>:9119 HERMES_DASHBOARD_BASIC_AUTH_USERNAME: admin HERMES_DASHBOARD_BASIC_AUTH_PASSWORD: "<first secret>" HERMES_DASHBOARD_BASIC_AUTH_SECRET: "<second secret>" HERMES_GATEWAY_BOOTSTRAP_STATE: running CLAUDE_CODE_OAUTH_TOKEN: "<token, or delete this line>" MY_LLM_API_KEY: "<key, or delete this line>" volumes: - ./data:/opt/data - type: tmpfs target: /dev/shm tmpfs: size: 536870912 -
Run
docker compose up -d. - Open
http://<server-ip>:9119and sign in asadminwith the first secret.
It worked when curl -s http://<server-ip>:9119/api/status shows "gateway_state":"running".
Option B: Kubernetes¶
Create a folder with these files, and save your settings file in it as config.yaml.
kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: hermes
resources:
- namespace.yaml
- pvc.yaml
- deployment.yaml
- service.yaml
- route.yaml
configMapGenerator:
- name: hermes-config
files:
- config.yaml
namespace.yaml. baseline, not restricted: the container starts as root.
apiVersion: v1
kind: Namespace
metadata:
name: hermes
labels:
pod-security.kubernetes.io/enforce: baseline
pvc.yaml:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: hermes-data
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 20Gi
deployment.yaml. The seed init container copies config.yaml from Git over the volume on every start, so Git stays the source of truth:
apiVersion: apps/v1
kind: Deployment
metadata:
name: hermes
spec:
replicas: 1
# RWO volume: a rolling update would start a second pod that cannot attach it.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: hermes
template:
metadata:
labels:
app.kubernetes.io/name: hermes
spec:
automountServiceAccountToken: false
terminationGracePeriodSeconds: 60
nodeSelector:
kubernetes.io/arch: amd64
securityContext:
fsGroup: 10000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
initContainers:
- name: seed
image: ghcr.io/mitchross/hermes-dev:main@sha256:<digest>
command: ["/bin/sh", "-c", "install -o 10000 -g 10000 -m 0644 /etc/hermes-config/config.yaml /opt/data/config.yaml"]
securityContext:
runAsUser: 0
allowPrivilegeEscalation: false
volumeMounts:
- name: data
mountPath: /opt/data
- name: config
mountPath: /etc/hermes-config
readOnly: true
containers:
- name: hermes
image: ghcr.io/mitchross/hermes-dev:main@sha256:<digest>
args: ["gateway", "run"]
# s6-overlay must start as root; it drops the gateway and dashboard to uid 10000 itself.
securityContext:
runAsUser: 0
ports:
- name: http
containerPort: 9119
envFrom:
- secretRef:
name: hermes
env:
- name: HERMES_DASHBOARD
value: "1"
- name: HERMES_DASHBOARD_HOST
value: 0.0.0.0
# Must equal the route hostname, or dashboard sign-in redirects break.
- name: HERMES_DASHBOARD_PUBLIC_URL
value: https://hermes.example.com
- name: HERMES_DASHBOARD_BASIC_AUTH_USERNAME
value: admin
# A blank volume otherwise leaves the gateway registered but down.
- name: HERMES_GATEWAY_BOOTSTRAP_STATE
value: running
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
memory: 6Gi
# No liveness probe: a long agent run must not trigger a restart.
startupProbe:
httpGet:
path: /api/status
port: http
periodSeconds: 5
failureThreshold: 60
readinessProbe:
httpGet:
path: /api/status
port: http
periodSeconds: 15
volumeMounts:
- name: data
mountPath: /opt/data
- name: shm
mountPath: /dev/shm
volumes:
- name: data
persistentVolumeClaim:
claimName: hermes-data
- name: config
configMap:
name: hermes-config
- name: shm
emptyDir:
medium: Memory
sizeLimit: 512Mi
service.yaml:
apiVersion: v1
kind: Service
metadata:
name: hermes
spec:
selector:
app.kubernetes.io/name: hermes
ports:
- name: http
port: 9119
targetPort: http
route.yaml for Gateway API. Set parentRefs to your Gateway and its HTTPS listener:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: hermes
spec:
parentRefs:
- name: my-gateway
namespace: gateway
sectionName: https
hostnames:
- hermes.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: hermes
port: 9119
Or route.yaml for ingress-nginx, which closes idle WebSockets after 60 s unless you raise the timeouts:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hermes
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
ingressClassName: nginx
tls:
- hosts: [hermes.example.com]
secretName: hermes-tls
rules:
- host: hermes.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: hermes
port:
name: http
Deploy¶
- Replace
hermes.example.comand<digest>in the files. -
Create the namespace and the Secret. Keep its values out of Git. Leave out the model key you don't use:
kubectl apply -f namespace.yaml PASSWORD="$(openssl rand -base64 32)" kubectl -n hermes create secret generic hermes \ --from-literal=HERMES_DASHBOARD_BASIC_AUTH_PASSWORD="$PASSWORD" \ --from-literal=HERMES_DASHBOARD_BASIC_AUTH_SECRET="$(openssl rand -base64 32)" \ --from-literal=CLAUDE_CODE_OAUTH_TOKEN="<token>" \ --from-literal=MY_LLM_API_KEY="<key>" echo "$PASSWORD" -
Save the printed password in your password manager.
-
Deploy and wait. The first pull is large:
Check¶
kubectl -n hermes get pod,pvc
curl -s https://hermes.example.com/api/status | jq '{version, gateway_state}'
curl -s -o /dev/null -w '%{http_code}\n' https://hermes.example.com/
| Result | Meaning |
|---|---|
Pod 1/1 Running, PVC Bound |
Ready |
"gateway_state": "running" |
The gateway is up |
302 on / |
The dashboard sends you to its sign-in page, as it should |
Pod in CreateContainerConfigError |
The Secret hermes is missing |
| Pod rejected by Pod Security | The namespace is restricted; use baseline |
Connect and log in¶
- Open your hostname and sign in as
adminwith the password. - Open the dashboard terminal (bottom bar, terminal icon).
-
Log in to Codex and GitHub once. Logins stay on the volume:
-
Set your Git identity:
git config --global user.name "<name>"andgit config --global user.email "<email>".
Claude Code needs no login when CLAUDE_CODE_OAUTH_TOKEN is set. Codex device sign-in must be turned on first in chatgpt.com → Settings → Security and login.
This cluster's extras (Hindsight memory, the read-only kubectl access, the daily digest skill) live in my-apps/development/hermes. Copy them once the basic pod works.
Security¶
Hermes gives anyone with the dashboard password a shell with your logins. Treat it like SSH.
- Use a long random password.
- Prefer an identity gate in front, such as Cloudflare Access or a VPN. A bare password on a public hostname is the weakest option.
- Use a fine-grained GitHub token for the repos agents need:
gh auth login --with-token. - Protect your GitOps branch. A token that can push there can deploy anything.
- Do not give the pod a ServiceAccount with write access. The manifests above mount no token at all.
- Set a monthly spend limit at every paid model provider.
Update, back up, roll back¶
- Update: change the digest and apply again. The volume stays.
- Back up: snapshot
hermes-data(or./datafor Compose) with your storage tool or Velero. It holds sessions, memories, skills and logins. - Roll back: put the old digest back and apply again.
Warning: kubectl delete namespace hermes deletes the PVC with your sessions and logins.