GitOps with Fleet¶
Design: Git as the Source of Truth¶
Every resource in the cluster — Helm releases, raw manifests, RBAC, ingress rules, ExternalSecrets — is declared in the mdapi/fleet Git repository at https://gitlab.mdapi.ch/mdapi/fleet (public mirror). No kubectl apply is ever run manually. This makes the cluster state fully auditable, reproducible, and diff-able.
Rancher Fleet watches the repo and continuously reconciles the cluster state to match. A single GitRepo named mdapi-prod points at the repo's main branch; the Fleet controller itself runs on the Rancher local (management) cluster and pushes BundleDeployments down to mdapi-prod.
Pipeline¶
flowchart TD
push["git push\n(main)"]
subgraph gitlab["GitLab — mdapi/fleet"]
main_br["main branch"]
end
subgraph rancher_mgmt["mdapi-mgmt (management cluster)"]
fleet_ctrl["Fleet Controller\n(GitRepo: mdapi-prod)"]
bd_prod["BundleDeployments"]
end
prod["mdapi-prod\nHelm releases + raw manifests"]
push --> gitlab
main_br --> fleet_ctrl
fleet_ctrl --> bd_prod --> prod
Repository Structure¶
fleet/
├── gitlab/ # GitLab (vendored Helm chart)
├── bootstrap/ # GitLab's adjacent resources (CNPG, Valkey, ExternalSecrets)
├── windmill/ # Windmill (Helm)
├── keycloak/ # Keycloak
├── joplin/ # Joplin + MCP server
├── nameserver/ # BIND9 + Webmin
├── tv/ # Media stack
├── longhorn/ # Longhorn config
├── ... # 100+ more bundles
└── README.md
Helm History and ErrApplied¶
Fleet does not need a deep Helm release history. The agent reads only the deployed revision (the one in the BundleDeployment's status.release) and the one before it, and a missing older revision is not an error. The real failure mode is a prune race: the agent's Helm storage reads release secrets from an informer cache but deletes them through the API. When a release at its history cap prunes, a revision that a concurrent upgrade already deleted can still be in the cache, the delete returns secrets "sh.helm.release.v1.<release>.vN" not found, and the BundleDeployment enters ErrApplied. The race can fire on any prune, whatever the cap.
History depth: every Helm-type fleet.yaml sets helm.maxHistory explicitly, because an unset key means Fleet's default of 2. The key is maxHistory, not historyMax — the wrong key is silently ignored. The value is currently 25, which is a convenience for helm history and helm get manifest --revision, not a requirement; a lower value is on trial.
Recovery: clear status.release on the BundleDeployment to force a fresh helm install:
kubectl --context mdapi-mgmt -n <cluster-ns> patch bundledeployment <name> \
--type=merge --subresource=status \
-p '{"status":{"release":""}}'
Renovate — Automated Image Updates¶
Renovate owns every container image in the fleet repo — manifest image: fields, Helm values.yml pins, and the fleet.yml chart versions — and raises a merge request when upstream ships a new build.
Floating tags (latest, stable, 3-alpine) are pinned to the @sha256 digest they currently resolve to: the tag keeps tracking upstream while git records the exact build, so a moving tag becomes a reviewable commit instead of a live patch. Pinned version tags get ordinary version updates plus the same digest pinning, so an in-place rebuild upstream still arrives as a commit. Minor and patch updates auto-merge; a major is always a merge request a human approves. A short MR-only list overrides this. The GitLab chart is on it, because GitLab must follow its required upgrade stops.
Reloader — Config-Change Rollouts¶
Stakater Reloader is the companion to Renovate: where Renovate rolls a workload when its image changes, Reloader rolls it when a ConfigMap or Secret it mounts changes. It watches every workload by default — the reloader.stakater.com/auto: "true" annotation is legacy and no longer required — and covers the hand-managed config maps Fleet delivers that would otherwise need a manual kubectl rollout restart to take effect.
Reloader rollouts show as Modified in Fleet
When Reloader triggers a rollout it patches the live workload, so Fleet briefly reports that bundle as Modified. Fleet correctDrift is off, so it only reports the drift — it does not fight Reloader. Renovate image bumps and VPA request mutations are the other two usual sources of benign Modified drift — when a bundle shows drift, one of those three is almost always the author.
cert-manager and DNS-01¶
cert-manager uses RFC 2136 dynamic updates to add _acme-challenge TXT records to BIND9. The target nameserver is 31.3.128.59:53 (external IP of ns.mdapi.ch, configured on the single cluster-wide letsencrypt-prod ClusterIssuer that every public-facing Certificate references).
The mdapi.ch zone requires allow-update { key "mdapi"; } in BIND9's named.conf. This is configured at https://gitlab.mdapi.ch/mdapi/fleet/-/tree/main/nameserver (public mirror) and applied via Fleet.
mdapi.ch uses offline DNSSEC signing managed by Webmin. Dynamic updates land in a zone journal without DNSSEC signatures. This is acceptable — Let's Encrypt verifies with a non-validating resolver.
external-dns and internal service discovery¶
The same RFC 2136 protocol is reused to keep the internal home.tillo.ch zone in sync with cluster state — but against a different nameserver, with a different TSIG key, and a much narrower update policy.
external-dns watches Service and Ingress objects and dynamically publishes A / AAAA / CNAME records into Technitium (the authoritative server for home.tillo.ch, see Hardware → DNS & DHCP Architecture). A dedicated TSIG key is registered in Technitium with an update policy scoped to the home.tillo.ch zone apex plus *.home.tillo.ch — the key cannot touch any other zone the server hosts.
The controller is opt-in by annotation: only resources explicitly tagged are published, so existing manual / DHCP records keep working while the system is burned in.
metadata:
annotations:
external-dns.alpha.kubernetes.io/manage: mdapi
external-dns.alpha.kubernetes.io/target: 192.168.1.191 # optional VIP pin
external-dns.alpha.kubernetes.io/ttl: "300"
The controller runs with policy=sync (full reconcile, including delete) and a txt-prefix=extdns- registry so ownership is recorded on a sibling TXT record — the earlier upsert-only bring-up period ended once the manual records were retired.
RFC 2136 mode needs AXFR enabled
external-dns must read the current zone to know which records already exist. With --rfc2136-tsig-axfr it pulls the zone over a TSIG-signed AXFR; without it, it cannot see existing records and re-issues an ADD for every managed record on each reconcile, never converging.
Non-Fleet Resources¶
A small number of resources apply directly with kubectl apply (against mdapi-mgmt) and are not managed by Fleet:
- The
mdapi/rancher-localrepo — themdapi-mgmtcluster's own cluster-issuer. Committed for traceability but not auto-reconciled.
GitLab itself (namespace bootstrap on mdapi-prod) is the gitlab/ bundle: the upstream chart is vendored into the repo so the Bundle stays small, and Fleet reconciles it like everything else. The docs.mdapi.ch ingress and its Certificate (referencing the cluster-wide letsencrypt-prod ClusterIssuer) are in the fleet/docs/ bundle at https://gitlab.mdapi.ch/mdapi/fleet/-/tree/main/docs (public mirror), reconciled by the mdapi-prod GitRepo.
Fleet's own settings (Rancher-side, not in git)¶
Rancher installs and upgrades Fleet itself, so two Fleet settings cannot live in the fleet repo. Both were set on 2026-11-06 after a burst of eight Renovate merges made the mdapi-prod fleet-agent render about 25 Helm bundles at once: it grew from 0.8 to 5.9 GiB with no memory limit and tipped node quo into a memory wedge.
| Setting | Where it lives | Value |
|---|---|---|
| Agent reconcile workers | rancher-config ConfigMap in cattle-system on mdapi-mgmt, key fleet (Rancher passes it to the Fleet chart as values) |
agent.reconciler.workers.bundledeployment: "10", drift: "10" (chart default 50/50) |
| fleet-agent memory | management cluster c-m-zk4x2xrw (mdapi), spec.fleetAgentDeploymentCustomization.overrideResourceRequirements (mirrored on the provisioning cluster fleet-default/mdapi) |
request 1Gi, limit 4Gi |
Fewer workers is the cause fix: 100 concurrent workers rendered many charts at once and also exhausted the agent's client-side request budget (GET /version waited ~20 s per call; ~4 s after the change). The limit comes second on purpose: at 50/50 workers an OOM restart would re-sync every bundle at once and could crash-loop.
Two traps when changing them
- A new
agentWorkersvalue reaches the agent only after fleet-controller reloads its config (delete its pod; a Helm revision that only changes the ConfigMap does not restart it) and the agent bundle is regenerated (bumpspec.redeployAgentGenerationon the FleetCluster, the UI's Force Update). Before the reload the controller rewritesfleet-agent-mdapiwith the old value and the agent reverts within minutes. - For this Harvester-provided cluster, Rancher reads
fleetAgentDeploymentCustomizationfrom the management cluster; setting it only on the provisioning cluster changes nothing.
Rollback: remove the fleet key from rancher-config (Rancher redeploys Fleet with the chart defaults), delete the fleet-controller pod and bump redeployAgentGeneration; remove fleetAgentDeploymentCustomization from both cluster objects. The Rancher chart renders only priorityClassName into that ConfigMap, so a Rancher upgrade should keep the fleet key, but that is not yet proven: check kubectl -n cattle-fleet-system get cm fleet-controller -o jsonpath='{.data.config}' | grep -A3 agentWorkers after each one.