30. Kubernetes deploy, generated from the app model
Date: 2026-08-30
Status
Accepted.
Context
The bundled stack has one definition, deploy/docker-compose.yml, and it is not hand-written: it is a projection of the services topology in the app model (SERVICES → toComposeObject), regenerated by gen:all and diff-checked in CI. That is what stopped the compose file drifting from the code — a hand-edit that the generator would drop is caught the next time anyone runs it.
People run this on Kubernetes too. A single-node k3s cluster is a common homelab shape, and the app image already assumes nothing about where its backends are (ADR 0002, containers.md). Until now the only route was to hand-write manifests against the compose file, which is exactly the drift the generator exists to prevent — with a second copy of every port, image tag, env name and healthcheck to keep in step.
Two ways to add Kubernetes support:
- Hand-written manifests / a Helm chart under
deploy/. Familiar, flexible, and a second source of truth for the same topology. - A second projection of the model —
toKubernetesObjectsbesidetoComposeObject, emitting a kustomize base — generated, committed, diff-checked like the compose file.
Decision
Option 2. packages/shared/src/blueprint/k8s.ts projects the same SERVICES into plain manifests; scripts/gen-k8s.ts writes them to deploy/k8s/base/ as a kustomize base, one file per service, plus a kustomization.yaml and a .env.template. It runs as part of gen:all, so CI fails on a stale base the same way it does for compose.
The translation rules, so the two shapes stay recognisably the same stack:
${VAR}→ one Secret. Compose interpolates from.env; a manifest cannot. Every${VAR}/${VAR:-default}in a service'scontainerEnvbecomes asecretKeyRefinto a single Secret,claude-transcripts-env, which kustomize'ssecretGeneratorbuilds from a.envbeside the kustomization. Compose'senv_file:becomesenvFrom:on that Secret. The variable names are the ones the repo-root.envalready uses, so that file is a valid starting point. The generated.env.templatelists the keys, carrying compose's defaults.- Bind mounts → PVCs, one per writable mount,
ReadWriteOnce, default StorageClass; a Deployment with state usesRecreate, since RWO cannot be shared with a rolling replacement. Read-only file mounts → ConfigMaps, inlined from the same file compose mounts (deploy/garage.toml) at generation time — kustomize will not read above its directory, and a copy is a second thing to keep in step. - Service names are the compose service names, so the in-cluster endpoints the app is pinned to (
http://couchdb:5984, …) are literally the compose ones. - Nothing is published. Services are ClusterIP; the stack has no auth (ADR 0020), so how it is reached is an overlay's decision. A hand-written example overlay adds an Ingress.
- Images are the pinned upstream refs — the compose
--upstreamposture, sokubectl apply -kworks from a fresh clone with no mirror — and the app comes from the project's release registry at the lockstep release tag. Kustomize'simages:transformer retargets either (ADR 0024). - Probes from healthchecks: where compose could only
curlinside the image, the kubelet probes HTTP itself. The app image ships no curl, so the model gained anhttpHealthfield (/health) that only this projection reads.
Kustomize rather than Helm because the generated artifact should be plain manifests anyone can read and kubectl apply; kustomize is built into kubectl, and overlays are the right home for the per-cluster choices (exposure, storage, mirror) that a generator must not bake in.
Consequences
- One topology, two deploy shapes, no hand-maintained copy of either. Adding a service to
SERVICESadds it to both. deploy/k8s/base/is generated: edits there are lost. Overrides live in overlays.- The projection is deliberately narrow — single replica, no resource limits, no StatefulSets — because the model describes a single-user, single-node stack (architecture.md). Multi-node or HA shapes are a Tier 3 concern and would extend the model, not the generator.
- The
hookstill writes to CouchDB and S3 directly (ADR 0016); on Kubernetes that means the machine running Claude Code needs a route to those Services (port-forward, Ingress, a tailnet) — the same requirement compose's localhost ports satisfy implicitly.