FluxCD hub-and-spoke: one cluster manages two others
Published: 2026-02-09
Our GitOps topology is three k3s clusters — infra, dev, and test — with FluxCD running only on infra. The infra cluster acts as the hub: it reads the Git repo and applies HelmReleases not just to itself but to dev and test via remote kubeconfig references. This is the hub-and-spoke pattern.
This post covers the full setup, the operational nuances we learned after six months of running this topology, and the troubleshooting commands that have saved us repeatedly.
Why hub-and-spoke
The alternative is running Flux on every cluster. That's fine when clusters are independent, but ours share chart versions, Vault paths, and monitoring configs. With hub-and-spoke there's one source of truth and one place to watch for drift.
Operationally: if dev gets inconsistent, you check the Kustomization status on the infra cluster — not on dev.
The more subtle advantage is lifecycle management. When you upgrade Flux itself, you do it once on infra. All spokes inherit the upgrade through the kubeconfig mechanism without needing direct access. Spoke clusters can be entirely air-gapped from the internet — only the hub needs Git access.
Tradeoffs
| Aspect | Hub-and-spoke | Flux per cluster |
|---|---|---|
| Flux upgrades | one place | each cluster separately |
| Failure isolation | hub outage = no reconciliation on all spokes | independent |
| Shared config | easy, single base | requires repo sync conventions |
| Spoke internet access | not required | needs Git access |
| Audit trail | single cluster for all events | distributed |
If clusters are in separate security domains (different teams, different compliance requirements), running Flux per cluster may be better. Hub-and-spoke shines when you're one team operating multiple clusters with strong config overlap.
Directory layout
fluxcd/
kustomization.yaml # root — lists infra + spoke kustomizations
infra-kustomization.yaml # HelmReleases targeting infra cluster (self)
dev-kustomization.yaml # HelmReleases targeting dev cluster
test-kustomization.yaml # HelmReleases targeting test cluster (commented until ready)
base/
monitoring/ # shared chart configs
external-secrets/
...
custom/
monitoring/ # env-specific overrides
...
The base/ directory contains HelmRelease manifests without a spec.kubeConfig field. The per-cluster kustomization files add it via JSON patches. This keeps base portable — if you ever want to run Flux directly on dev, you just remove the kubeConfig injection.
The custom/ directory holds environment-specific value overrides. For example, dev might have replicas: 1 while test has replicas: 2. These are Kustomize strategic merge patches on top of base.
How spoke targeting works
Every HelmRelease that should land on dev or test gets a spec.kubeConfig injected by the Kustomization patch:
yaml# dev-kustomization.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: dev
namespace: flux-system
spec:
interval: 10m
path: ./fluxcd/custom
sourceRef:
kind: GitRepository
name: flux-system
kubeConfig:
secretRef:
name: dev-kubeconfig # SealedSecret, decrypted by infra's Sealed Secrets
patches:
- patch: |
- op: add
path: /spec/kubeConfig
value:
secretRef:
name: dev-kubeconfig
target:
kind: HelmRelease
Flux applies the HelmRelease manifest on infra, but the Helm controller calls the dev API server using the kubeconfig from dev-kubeconfig secret.
The kubeConfig field on the Kustomization itself handles non-HelmRelease resources (ConfigMaps, Secrets, etc.). The patch on HelmRelease targets ensures the Helm controller also uses the remote kubeconfig when performing Helm operations.
Why the patch is needed in addition to kubeConfig
Setting kubeConfig on the Kustomization tells the Kustomize controller to apply resources to the remote cluster. But HelmReleases are special — the Helm controller reconciles them separately. Without the patch adding spec.kubeConfig to each HelmRelease, the Helm controller would execute against the local (infra) cluster instead.
This is a common trap when first setting up hub-and-spoke: Kustomization shows Ready=True, but the charts land on the wrong cluster.
Kubeconfig secret
The kubeconfig is extracted from the dev node, the server address replaced with the node's external IP, and then sealed using the infra cluster's Sealed Secrets controller:
bash# 1. Extract and fix address
kubectl --kubeconfig /etc/rancher/k3s/k3s.yaml config view --raw \
| sed 's/127.0.0.1/192.168.1.20/g' > /tmp/dev.yaml
# 2. Create the Secret manifest
kubectl create secret generic dev-kubeconfig \
--namespace=flux-system \
--from-file=value=/tmp/dev.yaml \
--dry-run=client -o yaml > /tmp/dev-secret.yaml
# 3. Seal it with infra's public cert
kubeseal --cert .kubeconfigs/sealed-secrets-infra.pem \
--format yaml \
< /tmp/dev-secret.yaml \
> fluxcd/dev-kubeconfig-sealed.yaml
# 4. Commit and push
git add fluxcd/dev-kubeconfig-sealed.yaml && git commit -m "add dev kubeconfig" && git push
Flux detects the new file, infra's Sealed Secrets controller decrypts it, and the hub immediately has access to the dev cluster.
Namespace matters
The kubeconfig Secret must be in the same namespace as the Kustomization that references it — flux-system in most setups. If the secret is in a different namespace, Flux will fail to find it and log secret "dev-kubeconfig" not found.
Certificate rotation
k3s rotates its cluster CA only when explicitly requested (e.g., k3s certificate rotate). However, if you rebuild the dev cluster, the CA changes and the stored kubeconfig becomes invalid. After a cluster rebuild:
bash# Re-extract, re-seal, commit
kubectl --kubeconfig /etc/rancher/k3s/k3s.yaml config view --raw \
| sed 's/127.0.0.1/192.168.1.20/g' > /tmp/dev.yaml
kubectl create secret generic dev-kubeconfig \
--namespace=flux-system \
--from-file=value=/tmp/dev.yaml \
--dry-run=client -o yaml \
| kubeseal --cert .kubeconfigs/sealed-secrets-infra.pem --format yaml \
> fluxcd/dev-kubeconfig-sealed.yaml
git add fluxcd/dev-kubeconfig-sealed.yaml && git commit -m "rotate dev kubeconfig" && git push
dependsOn ordering
Some HelmReleases depend on others being deployed first (e.g., monitoring CRDs before PrometheusRules). Use spec.dependsOn:
yamlspec:
dependsOn:
- name: kube-prom-stack # name of the Kustomization, not a directory
This is a common confusion: dependsOn refers to the Kustomization object name, not a path or file name.
Cross-spoke dependencies
You cannot have a dependsOn that crosses spokes. If dev's PrometheusRules depend on infra's kube-prom-stack CRDs, you need to install the CRDs separately on dev (e.g., via a dedicated CRD HelmRelease) and make the PrometheusRules Kustomization depend on that.
Attempting to reference a Kustomization from a different kubeConfig context in dependsOn silently fails — Flux will wait forever because it looks for the named Kustomization in the local flux-system namespace.
Checking spoke health
From the infra cluster:
bash# Check all Kustomizations
flux get kustomizations -A
# Check HelmReleases on the dev spoke
flux get helmreleases -n dev --kubeconfig .kubeconfigs/infra.yaml
# Force reconcile
flux reconcile kustomization dev --kubeconfig .kubeconfigs/infra.yaml
# Watch reconciliation in real time
flux get kustomization dev -w
Drift on dev shows up as a failed HelmRelease in flux get hr output — no need to SSH into the dev node.
Status fields to watch
bashkubectl get kustomization dev -n flux-system -o yaml | grep -A5 "conditions:"
Key condition types:
Ready: True— all resources applied successfullyReconciling: True— in progressStalled: True— blocked, usually a dependency waiting
For HelmReleases, add --verbose to flux get to see the Helm status message:
bashflux get helmrelease monitoring -n flux-system --verbose
Suspend and resume a spoke
During cluster maintenance (node OS updates, etc.), suspend the spoke to prevent Flux from fighting your changes:
bashflux suspend kustomization dev
# ... do maintenance ...
flux resume kustomization dev
Flux will immediately reconcile after resume, bringing the cluster back to the desired state. Any manual changes made during maintenance will be reverted — this is intentional GitOps behavior.
To prevent specific resources from being reconciled without suspending the entire kustomization, annotate them:
bashkubectl annotate helmrelease myapp -n app kustomize.toolkit.fluxcd.io/reconcile=disabled
When to add a new spoke
If a new cluster is added (e.g., staging):
- Run
install-k3s.yaml+ Cilium for the new node - Run
get-kubeconfig.yamlto extract and seal the kubeconfig - Add
staging-kustomization.yamlto the repo (copy from dev, change names) - Uncomment the entry in root
kustomization.yaml - Push — Flux picks it up in under a minute
The time from "cluster is ready" to "all apps deployed by Flux" is typically 3–5 minutes. The bottleneck is usually chart downloads on the first pull, not Flux reconciliation.
Troubleshooting common issues
HelmRelease lands on infra instead of spoke
- Cause:
spec.kubeConfignot in HelmRelease (patch not applied) - Fix: check that the patch
target.kind: HelmReleaseis correct and the path./fluxcd/customcontains the right HelmReleases
Kustomization is Ready but charts are missing from spoke
- Cause: Kustomization applied resources to spoke, but HelmRelease needs a separate
spec.kubeConfig - Fix: add the kubeConfig patch to the Kustomization
secret "dev-kubeconfig" not found
- Cause: Secret in wrong namespace, or Sealed Secrets controller failed to decrypt
- Fix: check
kubectl get sealedsecret -n flux-system, check Sealed Secrets controller logs
Reconciliation stuck on Progressing
- Cause: dependent resource not ready (CRDs not installed yet, etc.)
- Fix:
flux get hr -Ato find the blocked release, check events withkubectl describe helmrelease