| date | 2026-03-14 | ||||
|---|---|---|---|---|---|
| author | Onur Solmaz <onur@textcortex.com> | ||||
| title | Local kind Development Guide | ||||
| tags |
|
This guide documents the working local Kubernetes setup for Spritz on a laptop
using kind.
It covers:
- creating a local cluster
- installing ingress
- installing the Gateway API CRDs required by the operator
- building and loading local images
- configuring a simple HTTP-only local install
- creating a Claude Code preset backed by a Kubernetes secret
- debugging the failure modes hit during the first working setup
The goal is a copyable workflow that a teammate can follow without rediscovering cluster-specific details from terminal history.
A local Spritz install has four main parts:
spritz-ui: the web UIspritz-api: the backendspritz-operator: the Kubernetes reconciler that creates instance resources- instance images: the actual per-instance containers such as Claude Code or OpenClaw
In this guide:
- the control plane runs in namespace
spritz-system - instances run in namespace
spritz - the public host is
console.example.com - traffic is served over plain
httpfor simplicity
Install and verify:
- Docker Desktop or another working local Docker runtime
kubectlkindhelm
Optional but useful:
jq
Create a cluster config that exposes host port 80 to the kind control-plane node:
# kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
extraPortMappings:
- containerPort: 80
hostPort: 80
listenAddress: "127.0.0.1"
protocol: TCPCreate the cluster:
kind create cluster --name spritz --config kind-config.yaml
kubectl config use-context kind-spritzFor local kind development, the default ingress-nginx chart install is not
enough by itself. The controller should run as a DaemonSet with hostPort
enabled so the kind node actually listens on host port 80.
Install it like this:
helm upgrade --install ingress-nginx ingress-nginx \
--repo https://kubernetes.github.io/ingress-nginx \
--namespace ingress-nginx \
--create-namespace \
--set controller.kind=DaemonSet \
--set controller.hostPort.enabled=trueWait for it to become ready:
kubectl rollout status -n ingress-nginx daemonset/ingress-nginx-controller --timeout=180sThe operator watches HTTPRoute resources at startup. Without the Gateway API
CRDs, the operator can fail during cache sync before it reconciles any
instances.
Install the matching CRDs:
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.1.0/standard-install.yamlThe instance namespace is not created automatically by the current local path.
kubectl create namespace spritz-system --dry-run=client -o yaml | kubectl apply -f -
kubectl create namespace spritz --dry-run=client -o yaml | kubectl apply -f -From the repository root:
docker build -f operator/Dockerfile -t spritz-operator:latest operator
docker build -f api/Dockerfile -t spritz-api:latest .
docker build -f ui/Dockerfile -t spritz-ui:latest uiLoad them into kind:
kind load docker-image spritz-operator:latest --name spritz
kind load docker-image spritz-api:latest --name spritz
kind load docker-image spritz-ui:latest --name spritzIf you want a chat-capable instance locally, build the Claude Code example image.
Build from images/:
cd images
docker build -f examples/claude-code/Dockerfile -t spritz-claude-code:local .
cd ..Load it into kind:
kind load docker-image spritz-claude-code:local --name spritzUse a non-latest tag such as :local. For instance images, Kubernetes will
normally try to pull :latest from a registry, which leads to ImagePullBackOff
even if the image was loaded into kind.
Export the key in your shell:
export ANTHROPIC_API_KEY='replace-me'Create the secret in the instance namespace:
kubectl create secret generic anthropic-api-key \
-n spritz \
--from-literal=ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
--dry-run=client -o yaml | kubectl apply -f -Shared mounts let instances persist and sync files (such as ~/.config) across
disposable instance restarts for the same owner. The syncer sidecar uses rclone
for storage. For local kind, we use the local filesystem type inside the API pod.
The operator and API authenticate syncer traffic with a shared token:
SHARED_TOKEN=$(openssl rand -hex 32)The token must exist in spritz-system (for the API) and spritz (for the
syncer sidecar running inside instance pods):
kubectl create secret generic spritz-shared-mounts-token \
-n spritz-system \
--from-literal=token="$SHARED_TOKEN" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl create secret generic spritz-shared-mounts-token \
-n spritz \
--from-literal=token="$SHARED_TOKEN" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl create secret generic spritz-api-shared-mounts-token \
-n spritz-system \
--from-literal=token="$SHARED_TOKEN" \
--dry-run=client -o yaml | kubectl apply -f -For local kind, a local rclone remote stores data inside the API pod at
/tmp/spritz-shared:
cat <<'EOF' | kubectl create secret generic spritz-rclone-config \
-n spritz-system \
--from-file=rclone.conf=/dev/stdin \
--dry-run=client -o yaml | kubectl apply -f -
[local]
type = local
EOFCreate local-kind.values.yaml:
global:
host: console.example.com
ingress:
className: nginx
tls:
enabled: false
secretName: ""
x-claude-code-preset: &claude_code_preset
id: claude-code
name: Claude Code
image: spritz-claude-code:local
description: Claude Code via ACP
env:
- name: ANTHROPIC_API_KEY
valueFrom:
secretKeyRef:
name: anthropic-api-key
key: ANTHROPIC_API_KEY
ui:
ownerId: local-user
presets:
- *claude_code_preset
operator:
sharedMounts:
enabled: true
mounts:
- name: config
mountPath: /home/dev/.config
scope: owner
mode: snapshot
syncMode: poll
apiUrl: http://spritz-api.spritz-system:8080
tokenSecret:
name: spritz-shared-mounts-token
key: token
syncerImage: spritz-api:latest
syncerImagePullPolicy: IfNotPresent
api:
presets:
- *claude_code_preset
sharedMounts:
enabled: true
mounts:
- name: config
mountPath: /home/dev/.config
scope: owner
mode: snapshot
syncMode: poll
prefix: spritz-shared
rclone:
remote: local
bucket: /tmp/spritz-shared
configSecret:
name: spritz-rclone-config
key: rclone.conf
internalTokenSecret:
name: spritz-api-shared-mounts-token
key: token
provisioners:
allowCustomImage: true
defaultIngress:
mode: ingress
hostTemplate: console.example.com
path: /i/{name}
className: nginxImportant local choices:
tls.enabled: falsekeeps the setup on plainhttpui.ownerId: local-usergives instances an owner in auth-disabled local modeapi.presetsis the real preset catalog and injects the Anthropic key via a Kubernetes secretui.presetsmirrors the same preset entry for the current UI until the UI readsGET /api/presetsdirectlydefaultIngressgives each instance a browser routeoperator.sharedMountsandapi.sharedMountsenable owner-scoped config persistence using the local filesystem via rclonesyncerImage: spritz-api:latestreuses the API image which bundles thespritz-shared-syncerbinary
helm upgrade --install spritz ./helm/spritz \
--namespace spritz-system \
--create-namespace \
-f local-kind.values.yamlWait for the control plane:
kubectl get pods -n spritz-system -wYou want:
spritz-apirunningspritz-uirunningspritz-operatorrunning
Map the host used in global.host to 127.0.0.1:
echo "127.0.0.1 console.example.com" | sudo tee -a /etc/hostsVerify the UI route:
curl -I http://console.example.comIt should return HTTP/1.1 200 OK.
Open:
http://console.example.com
This local guide intentionally uses http, not https.
In the UI:
- choose the
Claude Codepreset - create the instance
Then inspect it:
kubectl get spritzes -n spritz
kubectl get pods,svc,ingress -n spritzThese are the fastest commands to understand where an instance is stuck:
kubectl get spritzes -n spritz
kubectl describe spritz <name> -n spritz
kubectl get pods,svc,ingress -n spritz
kubectl describe pod <pod-name> -n spritz
kubectl logs <pod-name> -n spritz --all-containers --tail=200
kubectl logs -n spritz-system deploy/spritz-operator --tail=100
kubectl logs -n spritz-system deploy/spritz-api --tail=100When using --repo, pass the chart name only:
helm upgrade --install ingress-nginx ingress-nginx \
--repo https://kubernetes.github.io/ingress-nginx \
--namespace ingress-nginx \
--create-namespaceIf curl says it cannot resolve the host, add it to /etc/hosts:
echo "127.0.0.1 console.example.com" | sudo tee -a /etc/hostsMake sure ingress-nginx was installed with:
controller.kind=DaemonSetcontroller.hostPort.enabled=true
Without that, the controller may exist in-cluster but the kind node will not
actually listen on host port 80.
Install the Gateway API CRDs before or immediately after installing the control plane:
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.1.0/standard-install.yaml
kubectl rollout restart -n spritz-system deployment/spritz-operatorIn auth-disabled local mode, the UI needs a fallback owner id:
ui:
ownerId: local-userThe instance spec includes shared mounts but the operator does not have the
shared mount backend configured. Follow step 8 to create the required secrets
and add the operator.sharedMounts and api.sharedMounts sections to your
Helm values file, then upgrade the release.
The syncer init container runs inside the instance pod in the spritz
namespace. The token secret must exist in both spritz-system (for the API)
and spritz (for instance pods):
kubectl create secret generic spritz-shared-mounts-token \
-n spritz \
--from-literal=token="$SHARED_TOKEN" \
--dry-run=client -o yaml | kubectl apply -f -Inspect the pod:
kubectl describe pod <pod-name> -n spritzOne common cause is using an image that does not satisfy the Spritz runtime contract.
Spritz expects the instance to answer ACP health on port 2529.
For example, a plain image such as nginx:alpine will run as a container, but it
will never pass the ACP readiness checks required for chat.
Do not use :latest for an instance image loaded into kind.
Use a non-latest tag such as:
spritz-claude-code:local
Then load that exact tag into kind:
kind load docker-image spritz-claude-code:local --name spritzThe simplest local path in this guide is HTTP-only.
If a generated instance URL uses https://console.example.com/..., switch it to
http://console.example.com/... in the browser.
Delete an instance:
kubectl delete spritz <name> -n spritzUninstall Spritz:
helm uninstall spritz -n spritz-systemDelete the kind cluster:
kind delete cluster --name spritz