← Back to home

Kubernetes / Helm deployment

ddd generate system --k8s emits a Helm chart (helm/) and the raw manifests it renders to (k8s/) alongside the always-present docker-compose.yml. Compose stays the inner-loop story (docker compose up boots the whole stack against an in-container postgres); the chart is the cluster story.

ddd generate system app.ddd -o out          # compose only (default)
ddd generate system app.ddd -o out --k8s    # + helm/ + k8s/

The switch is additive: it never replaces the compose file. This is an emitter-only feature — nothing in the .ddd grammar or the IR changes. Every infra knob the DSL doesn't model (replicas, resources, ingress host, image registry, DB connection string) has a home in the chart's values.yaml, overridden per environment with --set / -f values.prod.yaml.

What gets emitted

out/
  docker-compose.yml          # unchanged — the inner loop
  helm/
    Chart.yaml
    values.yaml               # the tuning seam (see below)
    templates/
      _helpers.tpl            # name / label helpers
      <name>-deployment.yaml  # one per deployable
      <name>-service.yaml     # one per deployable (ClusterIP)
      <name>-config.yaml      # non-secret env (ConfigMap)
      <name>-ingress.yaml     # UI-serving deployables, gated on values
      secret.yaml             # DB connection + sensitive env (one Secret)
      NOTES.txt
  k8s/                        # the same chart rendered with its defaults
    <name>-deployment.yaml
    <name>-service.yaml
    <name>-config.yaml
    secret.yaml

Resource names are DNS-1123 (webApp → web-app); the Helm values.yaml keys and --set paths use the deployable's original identifier (webApp).

How a deployable maps onto a workload

Each deployable's k8s workload is derived from exactly the data the compose path already reads — PlatformSurface.composeService + DeployableIR — so the two stay structurally aligned:

Source k8s
internalPort container port + Service.targetPort
deployable port Service.port
/ready on DB backends, / on frontends readinessProbe
/health on DB backends, / on frontends livenessProbe
non-secret env ConfigMap (via envFrom)
DB-connection + sensitive env Secret ref (secretKeyRef)
frontend / UI-serving optional Ingress (off by default; same-origin /api + / split when it targets a backend — see below)

Probes are aligned across backends. Every DB-backed backend (hono / .NET / Python / Java / Phoenix) emits both a cheap /health (no DB) and a DB-aware /ready (pings the database), so the emitter uses a uniform rule — livenessProbe: /health, readinessProbe: /ready — rather than the deployable's healthPath (which reflects each backend's compose healthcheck choice and would otherwise leak a non-DB-aware readiness probe for Phoenix). /ready backing readiness means a transient DB outage gates traffic instead of restarting the pod. Frontends expose neither endpoint, so both probes hit the static root /.

Secrets stay out of the ConfigMap — and the backend owns the list. Which env is sensitive is declared by each backend on its platform surface (ComposeServiceShape.secretEnvKeys), not guessed from the variable name. Java declares SPRING_DATASOURCE_PASSWORD; Phoenix declares SECRET_KEY_BASE; Hono / .NET / Python / the frontends declare nothing (they have no non-DB secrets). Declared entries plus the DB connection go into the Secret; everything else (PHX_HOST, PORT, SPRING_DATASOURCE_USERNAME, …) lands in the plaintext ConfigMap. The DB connection itself is identified by its value embedding the compose db host — a structural match generated by the same backend code, so it's deterministic, not a name heuristic.

values.yaml — the tuning seam

global:
  image:
    registry: ""          # set to your registry; empty ⇒ local <name>:<tag>
    tag: latest
    pullPolicy: IfNotPresent
ingress:
  className: ""
  host: ""

api:                      # one block per deployable
  enabled: true           # render this workload; --set api.enabled=false drops it
  replicas: 1
  resources: { requests: {...}, limits: {...} }
  env: {}                 # extra env overlaid onto the ConfigMap
  database:
    url: "postgres://…@db:5432/api"   # placeholder — point at your managed DB
  secrets: {}             # sensitive env (only present when the backend has any),
                          # e.g. SECRET_KEY_BASE / SPRING_DATASOURCE_PASSWORD
webApp:
  enabled: true
  replicas: 1
  resources: { ... }
  env: {}
  ingress: { enabled: false, host: "" }

Backends default to a heavier resource class than static frontends. The DB database.url defaults to the dev-compose connection string as a recognisable placeholder — production overrides it with the real managed-DB URL.

Every deployable carries a default-true enabled toggle; each Deployment/Service/ConfigMap/Ingress template is wrapped in {{- if .Values.<key>.enabled }}, so --set <key>.enabled=false installs a subset of the system — one backend at a time, say. The default render is byte-unchanged (all on); the k8s-e2e matrix uses it to install a single backend per cell.

Ingress — same-origin routing

A UI-serving deployable gets one optional Ingress, off by default — enable it per deployable:

--set webApp.ingress.enabled=true webApp.ingress.host=app.example.com

When that deployable targets: a distinct backend, the Ingress is same-origin: one host, two path rules.

rules:
  - host: app.example.com
    http:
      paths:
        - path: /api                 # → the targeted backend's Service
          pathType: Prefix
          backend: { service: { name: <release>-api,     port: { number: 8080 } } }
        - path: /                     # → the SPA's own Service
          pathType: Prefix
          backend: { service: { name: <release>-web-app, port: { number: 3000 } } }

/api is listed first so the longer prefix wins, and no path rewrite is needed — every backend already mounts its routes under /api, the toolchain-wide base path (src/util/api-base.ts). That is why the split needs zero per-deployable config: the built SPA fetches /api relative (its src/api/config.ts defaults to /api), so on one host the browser stays same-origin and the Ingress fans /api to the backend and everything else to the bundle — no CORS, no second hostname.

A fullstack host (Phoenix LiveView, or a backend that mounts its own ui:) keeps a single / catch-all — it already serves its own /api from the same process, so there is nothing to split.

The relative base is load-bearing. If you bake an absolute VITE_API_BASE_URL into the bundle at build time, the browser calls that URL directly and bypasses the Ingress. Leave it unset for same-origin.

Decisions (v1)

Broker channels (M-T4.4 slice 5b)

A wired channelSource adds its broker to the chart, auth-provisioned exactly like the compose sidecars (design §7):

Seams this does NOT cover (v1)

Testing

Two tiers:

Implementation

Two sibling emitters under src/system/, wired into emitSystem next to renderDockerCompose and gated on the emitKubernetes option:

Sensitive env is declared by each backend via ComposeServiceShape.secretEnvKeys (src/platform/surface.ts) — the k8s emitter reads that rather than guessing from variable names.