Flux CD Self-Hosted
The Flux counterpart of Argo CD on GKE, aimed at where Flux users actually are: a self-hosted cluster — k3s on hardware you own — with Traefik and cert-manager beside it. Chant authors typed manifests, Flux reconciles them from git in dependency order, and chant reads convergence back. One GitRepository and three Kustomizations, declared in TypeScript with FluxGitSource and FluxAppFor.
This mirrors the flux-apps example, and the estate shape comes from a real one: jhgaylor/home-cloud runs k3s across several Mac minis with Flux reconciling thirty-one hand-written GitRepository + Kustomization call sites, Traefik IngressRoutes, cert-manager, CNPG, and Infisical — with chant-built manifests already onboarded as one of the sources.
None. Everything runs on a cluster you already have (k3s, k3d, kind, anything with Traefik). The controller installs take a few minutes; there is no cloud account anywhere in the loop.
What you’ll build
Section titled “What you’ll build”self-hosted cluster (k3s) └── Flux (namespace flux-system) └── GitRepository "flux-apps" ──fetches──▶ git repo (dist/) ├── Kustomization "platform" ──▶ Namespace + ClusterIssuer ├── Kustomization "api" ──▶ Deployment + Service (dependsOn: platform) └── Kustomization "web" ──▶ Deployment + Service + IngressRoute + Certificate (dependsOn: platform, api)The key idea carries over from the Argo tutorial unchanged: the k8s lexicon stays runtime-agnostic. The workloads (src/platform, src/apps) are plain Chant k8s — they know nothing about Flux. Flux is opt-in, added by four calls in src/flux.
Layout
Section titled “Layout”src/ config.ts # repo URL, namespaces, hosts, the demo images platform/platform.ts # Namespace + self-signed ClusterIssuer apps/api/api.ts # WebApp (Deployment + Service) apps/web/web.ts # WebApp + Traefik IngressRoute + Certificate flux/apps.ts # FluxGitSource + 3× FluxAppFor deploy/flux.component.ts # flux-reconcile deploy leafFour build outputs: three workload manifests you commit to the repo Flux watches, and dist/flux.yaml — the Flux CRs you apply once to bootstrap the loop.
1. Declare the workloads
Section titled “1. Declare the workloads”src/apps/web/web.ts is ordinary Chant — a WebApp composite plus the two CRs the self-hosted stack fronts it with. No Flux anywhere:
import { WebApp, IngressRoute, Certificate } from "@intentius/chant-lexicon-k8s";import { config } from "../../config";
const { deployment, service } = WebApp({ name: config.webName, image: config.webImage, port: config.webPort, namespace: config.appNamespace, replicas: 2,});
export const route = new IngressRoute({ metadata: { name: config.webName, namespace: config.appNamespace }, spec: { entryPoints: ["websecure"], routes: [{ match: `Host(\`${config.host}\`)`, kind: "Rule", services: [{ name: config.webName, port: config.webPort }] }], tls: { secretName: config.tlsSecretName }, },});IngressRoute and Certificate are the typed K8s::Traefik::* and K8s::CertManager::* CRD classes — no escape hatch, no synthetic entity types.
2. Declare the GitOps loop
Section titled “2. Declare the GitOps loop”src/flux/apps.ts adds Flux in four calls — one source, many apps:
import { FluxGitSource, FluxAppFor } from "@intentius/chant-lexicon-k8s";import { config } from "../config";
export const source = FluxGitSource("flux-apps", { url: config.repo, branch: config.branch,});
export const platform = FluxAppFor("platform", { source, path: "./dist/platform",});
export const api = FluxAppFor("api", { source, path: "./dist/apps/api", dependsOn: ["platform"],});
export const web = FluxAppFor("web", { source, path: "./dist/apps/web", dependsOn: ["platform", "api"],});The repo is declared once; every FluxAppFor reconciles a path out of it. A GitRepository per app is the mistake this split makes hard. Each call renders to a Kustomization with estate defaults — 10m interval, prune: true, wait: true:
apiVersion: kustomize.toolkit.fluxcd.io/v1kind: Kustomizationmetadata: name: web namespace: flux-systemspec: interval: '10m' path: ./dist/apps/web prune: true wait: true sourceRef: kind: GitRepository name: flux-apps namespace: flux-system dependsOn: - name: platform - name: apiThree lint rules keep the loop honest before anything reaches a cluster:
- FLUX001 — a
GitRepositorywith nospec.reffalls back to themasterbranch, which on most repos no longer exists; the source stalls and everything downstream stalls with it.FluxGitSourcealways pins a branch or tag. - FLUX002 — every
Kustomization.spec.sourceRefmust name a source the build declares (the bootstrap-createdflux-systemrepo is exempt). An undeclared source means the kustomize-controller waits forever on an artifact that never arrives. - FLUX003 — every
dependsOnentry is joined against the Kustomizations the build declares. In raw YAML those names have no referential integrity — a typo stalls the app silently; here it’s a build-time diagnostic. Cross-repo edges are warnings, not errors, because estates legitimately split infra and apps across repos.
3. Build and push
Section titled “3. Build and push”npm installnpm run build # → dist/platform/ + dist/apps/{api,web}/ + dist/flux.yamlCommit dist/ to the repo referenced by FLUX_REPO so Flux has something to reconcile.
4. Install the controllers
Section titled “4. Install the controllers”npm run install-flux # Flux v2.9.1 into flux-system (the CRD codegen pin)npm run install-cert-manager # cert-manager v1.16.2(k3s already ships Traefik. On another cluster, install it or swap the IngressRoute for a vanilla Ingress.)
5. Bootstrap the loop
Section titled “5. Bootstrap the loop”npm run bootstrap # kubectl apply dist/flux.yamlnpm run wait # block until kustomization/web is Readynpm run statusOr as a component deploy with the convergence wait built in — src/deploy/flux.component.ts uses the k8s lexicon’s flux-reconcile capability:
import { phase, type Component } from "@intentius/chant/components/component";import { fluxReconcile } from "@intentius/chant-lexicon-k8s/components";
export const fluxBootstrap: Component = { name: "flux-bootstrap", archetype: "service", dependsOn: [], deploy: [ phase("Reconcile", [ fluxReconcile({ manifest: "dist/flux.yaml", stack: "flux-apps", noRollback: "..." }), ]), ],};npm run deploy # chant run --components flux-bootstrap --env homeflux-reconcile applies the CRs through the same server-side apply kubectl-apply uses, then waits for every applied Flux CR to report Ready — sources first, because a Kustomization cannot become Ready before its GitRepository has an artifact, so a wedged clone surfaces as the source’s error rather than a reconciler timeout downstream of it. Wedge reasons like BuildFailed fail fast instead of polling out the timeout.
What success looks like
Section titled “What success looks like”$ npm run statusNAME READY STATUSflux-apps True stored artifact for revision 'main@sha1:...'
NAME READY STATUSplatform True Applied revision: main@sha1:...api True Applied revision: main@sha1:...web True Applied revision: main@sha1:...The workloads exist in the demo namespace, created by Flux in dependency order. Change src/apps, rebuild, push to git, and Flux reconciles the diff on its own.
Reading convergence back
Section titled “Reading convergence back”Flux applied the manifests — but attribution doesn’t depend on who applied them. The example’s chant.config.ts sets ownership: { stack: "flux-apps" }, so the build stamps chant.intentius.io/stack: flux-apps (alongside app.kubernetes.io/managed-by: chant) on every resource, and the labels travel with the manifests through git and through Flux:
npm run status:components # chant components status home --liveThe k8s lexicon’s live read selects on exactly those labels and rolls readiness up from the matching workloads’ controllers — the labels channel. Flux prunes by its own labels, chant observes by its own; neither needs the other’s. The same channel is what lets chant kube separate chant’s resources from the operator-generated ones beside them in a cluster where Flux and several operators all create Deployments.
Next steps
Section titled “Next steps”- Point
FLUX_REPOat a private repo and passsecretReftoFluxGitSource. - Swap the self-signed
ClusterIssuerfor an ACME issuer once the cluster has a real domain. - Split infra and apps across repos the way home-cloud does; FLUX003 tolerates cross-repo
dependsOnedges by design. - Converting an existing Flux estate?
chant import --kustomizebrings a manifest you already have into typed source — hand-rewriting is how a conversion stalls at app four. - The full composite reference lives at Flux Composites; the Argo counterpart at Argo CD Composites.