chant-based Kubernetes apps for home-cloud
This repository has been archived on 2026-09-29. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
  • TypeScript 93.1%
  • JavaScript 6.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jake Gaylor 73f1a15915
Guard the control-plane invariants so prune can be turned on
The control-plane Kustomization has been running with prune off since the
handoff, because with it on a build that silently lost an app would have Flux
delete that app's Kustomization — which cascades into pruning its PVCs and
Postgres. This moves that protection to the PR boundary so the flag can go back
to doing its job.

clusters/home now asserts, against what is actually on disk:

  - every app under apps/ has a Kustomization building ./apps/<name>/k8s —
    the one that catches a silent deletion
  - no Kustomization points at an app that does not exist
  - names are unique, so one cannot quietly shadow another
  - every app Kustomization prunes (prune off belongs to the control-plane one
    in home-cloud, not to the apps)

Derived from the filesystem rather than a hardcoded list of six, so adding an
app does not require remembering to update the check.

Verified against the failure modes it exists for, not just the happy path.
Un-exporting mealie's declaration produces:

  app "mealie" exists on disk but no Kustomization builds ./apps/mealie/k8s —
  with prune on, deploying this would DELETE mealie and its storage

and setting prune: false on one app is caught too. CI runs it after the build
step for clusters/home only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 01:37:22 -04:00
.github/workflows Guard the control-plane invariants so prune can be turned on 2026-08-08 01:37:22 -04:00
apps Bind the cluster context and turn on ownership markers (#10) 2026-08-08 01:05:43 -04:00
clusters/home Guard the control-plane invariants so prune can be turned on 2026-08-08 01:37:22 -04:00
packages Move home-chant's own Flux control plane into this repo (#11) 2026-08-08 01:28:14 -04:00
README.md Declare domain and issuer as build parameters (#9) 2026-08-08 00:59:29 -04:00

home-chant

chant-based Kubernetes apps deployed to the home-cloud k3s cluster. Each app is TypeScript-defined infrastructure compiled to plain Kubernetes YAML — the TypeScript in apps/<name>/src/ is the source of truth; the generated apps/<name>/k8s/manifests.yaml is a build artifact and should never be hand-edited.

Layout

packages/
  traefik-app/      # shared composite — the boilerplate every public app repeats
apps/
  <app-name>/
    src/
      infra.ts        # chant TypeScript source (Deployment, Service, ...)
    chant.config.ts
    package.json
    k8s/
      manifests.yaml     # `chant build` output — committed, not hand-edited
      kustomization.yaml # references manifests.yaml

The TraefikApp composite

packages/traefik-app holds the resources every public app in this cluster repeats verbatim: the Namespace, the Infisical ServiceAccount + InfisicalSecret pair, the Service, and the Certificate + IngressRoute + redirect-IngressRoute trio. Apps depend on it through a file: reference and call it like this:

const app = TraefikApp({
  name, host: hostname, port,
  infisical: { identityId, projectSlug, secretName },
});
export const { namespace, serviceAccount, secret, service,
               certificate, ingressRoute, ingressRouteHttp } = app;

TraefikEndpoint is the same thing minus the Namespace and Infisical pair — for a second web surface in an existing namespace (mem0's dashboard is the only one).

It deliberately does not cover the Deployment. The five converted apps share almost nothing there: calcom needs a startupProbe with a 45-failure runway, mem0 replaces the image's CMD with alembic && uvicorn, radicale runs as uid 2999 with an fsGroup, ntfy and mealie mount different volumes, and only calcom keeps the default RollingUpdate. A prop per variation is how you arrive at the k8s lexicon's own WebApp — thirty-odd optional props that still can't express command. Workloads stay hand-written.

hello-chant doesn't use the composite either: it lives in default with no Namespace, no secrets, and Tailscale annotations on its Service. Forcing it through would mean props that exist for one caller.

Because the dependency is a file: link and Node resolves links to their real path, packages/traefik-app needs its own npm ci before an app can build — CI does this as a separate step.

home-cloud's cluster uses Traefik IngressRoute CRDs rather than standard Kubernetes Ingress. The k8s lexicon types those natively (IngressRoute, IngressRouteTCP, IngressRouteUDP, Middleware, TraefikService, ...), alongside the other operator CRDs this cluster runs — cert-manager's Certificate, Infisical's InfisicalSecret, CNPG's Cluster, and Flux's GitRepository/Kustomization/HelmRelease. They're all just new X({...}) imported from @intentius/chant-lexicon-k8s; no CRD in home-cloud currently needs an escape hatch.

Adding a new app

mkdir -p apps/<name>/src && cd apps/<name>
npx chant init --lexicon k8s
# edit src/infra.ts — see apps/hello-chant/src/infra.ts for the shape.
# For an existing app, import it instead of hand-writing:
#   npx chant import --kustomize ../../../home-cloud/k8s/<name> --output src
npx chant lint src
npx chant build src --lexicon k8s --format yaml --output k8s/manifests.yaml

Then onboard it to Flux by adding a Kustomization (pointing at apps/<name>/k8s) to clusters/home/apps/home-chant.yaml in the home-cloud repo — see home-cloud's deploying-apps.md. Pick an app name that doesn't collide with anything already running in the default namespace — Deployment selectors are immutable, so a name collision blocks Flux from ever applying it.

Build parameters

Each app declares domain and issuer under buildParams in its chant.config.ts, and src/infra.ts reads them as params.domain / params.issuer. The defaults reproduce what's deployed, so a plain chant build is unchanged; overriding gives a staging-cert path without a second source tree:

npx chant build src --lexicon k8s --format yaml --param issuer=letsencrypt-staging
npx chant build src --lexicon k8s --format yaml --param domain=lab.example.com

domain is the base domain the public hostname hangs off — dav.${domain} for radicale, mem0-ui.${domain} for mem0's dashboard. Its default is per app: everything is on inevitable.fyi except calcom, which is on jakegaylor.com. issuer declares an enum, so a typo is a build error naming the parameter rather than a bad Certificate reaching the cluster.

These are parameters rather than process.env reads on purpose: a parameter is resolved before any source file is read, so it folds to a literal. An ambient env read can't fold, and chant rejects a bare process reference in project source.

Note the EMAIL_FROM / SMTP_FROM_EMAIL addresses on mealie and calcom are not parameterized — those are on the mail domain (updates.inevitable.fyi), which is independent of where the app is hosted. calcom is the case that proves the point: its public host is on jakegaylor.com while its mail stays on inevitable.fyi.

CI

.github/workflows/validate.yml discovers every app under apps/*/ and, for each, runs chant lint and rebuilds k8s/manifests.yaml, failing if the committed output has drifted from the TypeScript source.

Namespace

Resources intentionally don't hardcode a namespace (chant's linter flags this as WK8001 — hardcoded namespaces should come from deploy-time config, not source). The namespace is set once, at the Flux Kustomization level (spec.targetNamespace), in home-cloud.