Tools · Flux — Multi-Tenancy & Security

Flux — Multi-Tenancy & Security

A single Flux installation can serve twenty teams from twenty repositories — but only if you turn on the mechanisms that stop team A’s manifests from creating a ClusterRoleBinding, reading team B’s Secrets, or quietly pointing at a source nobody signed. This page is about those mechanisms: the tenant as a namespace with its own source and reconciler, ServiceAccount impersonation as the one control that actually bounds a tenant, the lockdown flags that close the cross-namespace side door, the supply-chain verification that decides whether a source is trustworthy at all, and the notification plumbing that makes sure a broken tenant is noticed by a human rather than discovered in a quarterly audit.

☺ Explain it like I’m 10

Imagine one big building with lots of classrooms, and one very fast caretaker who fetches whatever each class writes on its wish-list and actually goes and does it. Out of the box the caretaker carries a master key — so if a class writes “knock down the wall into the room next door,” the caretaker will do it. The fix is not to make the caretaker slower. The fix is to make him borrow that class’s little key before he starts, so he can only open doors that class is allowed to open. And before he does anything at all, he checks the wish-list has the right wax seal on it, so he knows the class really wrote it and nobody swapped the paper on the way. That borrowed key is impersonation, and the wax seal is signature verification. Everything else on this page is a detail of those two ideas.

🐢🦫Your hosts for this topic: Timmy the Turtle & Benny the Beaver — Timmy reads the RoleBinding before he reads the README, and asks the slow question everyone skips: “yes, but what could this tenant do if it wanted to?” Benny lays the rails that make the safe way also the easy way, so nobody has to remember to be careful.

What it is and the problem it solves

☺ Like you’re 10: One helper, many classrooms — so the helper has to borrow each classroom’s key instead of using the master key.

This page assumes you already know what Flux is. If the words GitRepository, Kustomization and source-controller are new, read the parent overview at Flux first; it covers the GitOps Toolkit, the reconciliation loop, and the CLI. What follows is the part that overview deliberately leaves alone: how one Flux installation safely serves many independent teams, and how it decides that the config it is about to apply came from somewhere legitimate.

The default install is one enormous blast radius

Here is the uncomfortable fact that every multi-tenancy conversation starts from. A default flux bootstrap creates a ClusterRoleBinding that binds the built-in cluster-admin ClusterRole to the kustomize-controller and helm-controller ServiceAccounts in the install namespace. That is not a mistake — those two controllers have to be able to apply arbitrary Kubernetes objects, including CRDs, namespaces and RBAC, because that is literally the job. But it means that, by default, anything that reaches a Kustomization is applied with cluster-admin.

Follow that through. If a team can commit to a repository that a Kustomization points at, they can commit a ClusterRoleBinding that makes their own ServiceAccount cluster-admin, and Flux will apply it without a murmur. No alert fires. Nothing turns red. The Kustomization reports Ready: True, because from the controller’s point of view everything worked perfectly. That is the single most important thing to understand on this page, and it is the reason the rest of it exists.

⚠ Check the binding on any cluster you inherit

Run kubectl get clusterrolebinding -o wide | grep flux on any Flux cluster you are handed. The default install creates a binding named after the install namespace — commonly cluster-reconciler-flux-system — whose roleRef is cluster-admin and whose subjects are the kustomize- and helm-controller ServiceAccounts. If that binding is present and no tenant Kustomization sets spec.serviceAccountName, then every repository wired into that cluster is, effectively, a cluster-admin credential. Confirm the exact name with kubectl rather than trusting any document, including this one — installs get customised.

What a “tenant” actually is in Flux

Flux has no Tenant CRD and no tenancy layer of its own. This is a deliberate design choice and it is the sharpest contrast with Argo CD, which invented the AppProject as a first-class tenancy object with its own RBAC grammar. Flux instead composes tenancy out of primitives Kubernetes already has, which means there is nothing new to learn and nothing new to trust — but also nothing that will stop you if you simply don’t configure it.

Concretely, a Flux tenant is four ordinary objects plus one field:

PieceObjectWhat it establishes
The boundaryNamespaceThe unit of isolation. Everything the tenant owns lives here, including their Flux objects.
The identityServiceAccountThe identity Flux will become when applying this tenant’s manifests.
The permissionsRoleBindingNamespace-scoped rights for that identity. A RoleBinding — never a ClusterRoleBinding.
The intentGitRepository + KustomizationWhere the tenant’s config comes from and what to do with it. Created in the tenant’s namespace.
The enforcementspec.serviceAccountNameThe one field that tells kustomize-controller to apply as the tenant rather than as itself.
◆ Key idea

The ownership split is what makes the model work: the platform team owns the Kustomization; the tenant owns the repository it points at. Tenants get complete freedom over content — any manifests they like, any Kustomize overlays, any Helm values — and zero control over the terms of reconciliation: which identity applies it, into which namespace, from which repo, with which verification. If a tenant can edit their own Kustomization, they can change spec.serviceAccountName and the boundary is decorative.

Soft tenancy, not hard tenancy — say so out loud

Flux’s own documentation is refreshingly honest about this and you should be too. What this page describes is soft multi-tenancy: many tenants on one cluster, separated by namespaces, RBAC and impersonation. It is a real boundary against accidents and ordinary misuse, and it is what almost every platform team actually runs — but it is not a boundary against an attacker who already has code execution inside a tenant pod, because that attacker now shares a kernel, a node, a CNI and a control plane with everyone else. Hard multi-tenancy means separate clusters, and Flux’s answer there is not a feature but a shape: a Flux per cluster, the fleet managed from a repository, which is where Cluster API and the multi-cluster deep dive take over. Stating that distinction cleanly is worth more in an exam scenario than memorising any flag on this page.

Where it fits in a platform

☺ Like you’re 10: Flux guards the door marked “what can this team ask Kubernetes to do?” — three other guards watch three other doors.

Multi-tenant Flux sits in the delivery layer of the platform architecture, but the boundary it enforces is an authorization boundary, not an isolation boundary. That distinction decides which tool you reach for when someone asks “can tenants hurt each other?”, and it is exactly the kind of question the governance conversation turns on.

Four doors, four guards

Read this table as a checklist for any shared cluster. Flux owns exactly one row. A platform that has done the Flux row and skipped the others has a tenancy story that looks good in a diagram and fails in practice — the classic shape catalogued in Anti-Patterns.

DoorQuestion it answersGuardWhat it does not stop
APIWhich Kubernetes objects may this tenant create?Flux impersonation + RBACA pod that is already running doing something nasty
AdmissionAre the objects they are allowed to create acceptable?Kyverno / Gatekeeper + Pod Security AdmissionAnything applied before the policy existed
NetworkWhich tenant can talk to which tenant?NetworkPolicy / Cilium / Istio mTLSShared node-level resources
RuntimeWhat is the workload actually doing right now?Falco, resource quotas, node isolationA supply-chain compromise that looks legitimate

The last column is the honest bit. Impersonation cannot stop a tenant whose allowed Deployment is running a compromised image — that is why the second half of this page is about verifying where config and images came from, and why Sigstore & cosign and Trivy are neighbours rather than optional extras.

Its neighbours on the paved road

Upstream, CI builds and signs artifacts and Flux verifies those signatures before using them. Beside Flux, External Secrets is the alternative to in-repo SOPS when you want an external store as the source of truth — Secrets Management makes that call, and this page covers only the Flux-side mechanics. Downstream, Flagger rolls a tenant’s workload out progressively; because it is a cluster-wide controller with its own permissions, a tenant’s Canary is one more object your admission policy should have an opinion about. For onboarding, Backstage turns “create a tenant” into a form that raises a pull request against the platform repository — self-service in its most literal form.

The siblings carry the rest of the Flux surface: Flux — Helm & OCI Delivery and Flux — Image Automation. Both touch tenancy — a HelmRelease takes the same spec.serviceAccountName, and an ImageUpdateAutomation holds write credentials to a tenant’s repository — so this page states the tenancy rule and those pages state the mechanism.

CNPE domain relevance

This material straddles two domains of the exam blueprint. The reconciliation half belongs to GitOps & Continuous Delivery; the impersonation, RBAC and verification half belongs to Security & Compliance, and overlaps the Security & Policy lesson. That double weighting is why tenancy questions are good value to study: the same fact — “a Kustomization without spec.serviceAccountName applies as the controller” — can be asked as a delivery question or as a security question, and it is the answer either way.

How it works — impersonation, scoping and verification

☺ Like you’re 10: Before Flux applies your file, it puts on your name badge, checks the wax seal, and refuses to look in other people’s cupboards.

Three independent mechanisms combine to make a tenant boundary. They fail independently too, which is why the troubleshooting section later is organised by symptom: each mechanism has its own characteristic error string.

Impersonation — the one mechanism that matters

When a Kustomization sets spec.serviceAccountName: flux, kustomize-controller does not apply the built manifests with its own credentials. It builds a Kubernetes client configured to impersonate system:serviceaccount:<kustomization-namespace>:flux, and every create, patch, update and delete in that reconciliation is authorised as if the tenant’s ServiceAccount had issued it. The API server does the authorization; Flux is not making a policy decision at all, it is simply declining to use its own power.

Two consequences follow, and both are exam-shaped. First, the named ServiceAccount must exist in the same namespace as the Kustomization — you cannot impersonate an identity from another namespace, which is why tenant Flux objects live in the tenant’s namespace rather than in flux-system. Second, for impersonation to be permitted at all, the controller’s own identity needs the impersonate verb on the serviceaccounts resource. With the default cluster-admin binding that is satisfied automatically; if you strip that binding as a hardening step, you must grant impersonate explicitly or every tenant reconciliation fails at once.

HelmRelease takes the same field and behaves the same way: helm-controller impersonates the named ServiceAccount for the install, upgrade, rollback and uninstall actions, so the chart’s rendered objects are subject to the tenant’s RBAC exactly as a Kustomization’s would be.

Platform repo clusters/prod · tenants/ Tenant repo owned by the team source gate spec.verify · PGP/cosign spec.decryption · SOPS Kustomization ns: flux-system no serviceAccountName Kustomization ns: apps serviceAccountName: flux targetNamespace: apps creates the tenant Namespace ServiceAccount RoleBinding · tenant CRs impersonated apply system:serviceaccount: apps:flux API server authorises 🔒 ns: apps ClusterRoleBinding ✗ forbidden platform creates it runs as the controller — cluster-admin runs as the tenant — namespace-bounded The tenant owns the repo. The platform owns the Kustomization. Remove spec.serviceAccountName and the bottom path becomes the top path. Nothing turns red when that happens — which is exactly the danger.

Which identity actually applies — the precedence chain

Three settings decide the identity, and they are evaluated in a fixed order. Knowing the order is what lets you answer “why did this apply succeed when I expected it to be forbidden?” in one step instead of five.

#SettingScopeEffect
1spec.serviceAccountName on the objectPer Kustomization / HelmReleaseImpersonate that SA in the object’s own namespace. Wins over everything below.
2--default-service-account=<name> controller flagWhole controllerWhen the field is unset, impersonate an SA of this name in the object’s namespace. Fails if it doesn’t exist — which is the point.
3Neither of the aboveApply as the controller’s own ServiceAccount. On a default install that is cluster-admin.

Row 2 is the single highest-value hardening change on this page, and it is one line in a patch. Setting --default-service-account=flux on kustomize-controller and helm-controller converts the failure mode from silent over-privilege into a loud, obvious error: any Kustomization in a namespace without a flux ServiceAccount stops reconciling and says so. You want your safety mechanisms to fail noisily, and this one does.

⚠ Your own root Kustomization is the exception you must think about

The platform’s own top-level Kustomization — the one flux bootstrap creates, which installs Flux itself, CRDs, cluster add-ons, namespaces and tenant RBAC — genuinely needs cluster-scoped power. If you set --default-service-account, that root Kustomization needs a ServiceAccount of that name in flux-system with a real ClusterRoleBinding, or it will grind to a halt and take the whole cluster’s reconciliation with it. Plan that ServiceAccount before you set the flag, and keep it as the only identity in the cluster with that much power.

Cross-namespace references and why a hardened install forbids them

By default, several Flux fields accept a namespace alongside a name. A Kustomization in namespace apps can set spec.sourceRef.namespace: flux-system and reconcile the platform team’s source. An Alert in one namespace can subscribe to events from another. An ImagePolicy can reference an ImageRepository next door.

That flexibility is convenient on a single-team cluster and corrosive on a shared one, for a reason worth stating precisely: Kubernetes RBAC has no way to express “only namespace X may reference this object.” RBAC controls who may read or write a GitRepository; it says nothing about who may point at one. So a tenant who can create a Kustomization in their own namespace can consume any source anywhere in the cluster — including private repositories whose credentials they were never given, because the credential is used by source-controller, not by them.

The fix is a controller flag, --no-cross-namespace-refs=true, which makes every reference resolve strictly within the referring object’s own namespace. Set it on the controllers that resolve references: kustomize-controller, helm-controller, notification-controller and image-reflector-controller. Once it is on, the fields still exist in the schema but populating them makes the object fail with a clear message rather than silently working.

ObjectField that goes cross-namespaceWhat a tenant could otherwise reach
Kustomizationspec.sourceRef.namespaceAny source in the cluster, including private repos they have no credential for
HelmReleasespec.chart.spec.sourceRef.namespace, spec.chartRef.namespaceAny chart repository or OCI source, including internal-only charts
Alertspec.eventSources[].namespaceAnother tenant’s reconciliation events — an information leak, not just noise
Receiverspec.resources[].namespaceThe ability to trigger reconciliation of someone else’s objects on demand
ImagePolicyspec.imageRepositoryRef.namespaceAnother tenant’s registry scan results

Its companion flag is --no-remote-bases=true on kustomize-controller, which forbids a kustomization.yaml from listing a remote URL in its resources. A remote base is config fetched outside the source-controller artifact chain — so it is never signature-verified, never checksummed into the artifact, and never visible in flux diff. On a tenant cluster that is a hole straight through everything else on this page, and closing it costs nothing except telling tenants to vendor what they need into their own repository.

Verification — deciding a source is trustworthy at all

Impersonation answers “what may this config do?” Verification answers the prior question: “is this config what its author actually wrote?” Flux offers three distinct gates, all of them enforced by source-controller or kustomize-controller before anything is applied, and it is worth being precise about which artefact each one protects.

GateFieldProtectsFailure behaviour
Git commit signaturesGitRepository.spec.verifyThat the commit was signed by a key you listedThe source never becomes Ready; no artifact is published; every dependent Kustomization stalls on the last good revision
OCI artifact signaturesOCIRepository.spec.verifyThat the config artifact was signed by an identity you namedSame — the artifact is refused, so nothing downstream moves
Encrypted valuesKustomization.spec.decryptionThat plaintext secrets never existed in GitThe build fails with a decryption error and nothing is applied
◆ Key idea

All three of these verify configuration, not container images. Flux happily reconciles a perfectly signed manifest that references a completely unsigned image. Verifying the image is admission-time work for Kyverno’s verifyImages rules or the Sigstore Policy Controller, as covered in Sigstore & cosign. Exam scenarios love this seam: “Flux verifies the source” and “the cluster only runs signed images” are two different controls in two different places, and you need both.

Flux’s own RBAC posture

Each controller runs with its own ServiceAccount in the install namespace, and their permissions are wildly asymmetric. source-controller, notification-controller and the two image controllers are narrow: they read and write their own CRDs and status, read Secrets and ConfigMaps for credentials, emit events and hold leader-election leases. Nothing about them needs cluster-admin. kustomize-controller and helm-controller are the broad ones, for the structural reason that a tool which applies arbitrary manifests needs arbitrary permission.

So the hardening ladder has three rungs, and most teams should climb at least two:

  1. Impersonate per tenant. Set spec.serviceAccountName on every tenant Kustomization and HelmRelease. Cheap, immediate, no controller changes.
  2. Make the default safe. Add --default-service-account, --no-cross-namespace-refs and --no-remote-bases so a forgotten field fails loudly instead of quietly escalating.
  3. Remove the master key. Replace the cluster-admin ClusterRoleBinding with a purpose-built ClusterRole for kustomize- and helm-controller that grants the impersonate verb on serviceaccounts plus whatever the platform’s own root Kustomization genuinely needs. This is real work and it will break things the first time; do it on a non-production cluster and expect an afternoon.
🦆 Dot’s-eye view

“The first time impersonation was switched on, my Kustomization went red with cannot create resource "clusterroles" and I filed a bug against the platform. It wasn’t a bug. I’d copied a chart that installs its own CRDs and a ClusterRole, and my namespace SA can’t do that — correctly. What actually helped was the platform team giving me a one-liner to check before committing: kubectl auth can-i --list --as=system:serviceaccount:apps:flux -n apps. Now I know what I’m allowed to ask for, so I stop being surprised.”

The resources you will actually write

☺ Like you’re 10: A tenant is a folder of five small files, and none of them are magic.

Everything below is ordinary YAML you commit to the platform repository. There is no Tenant kind and no installer to run — the whole tenancy model is composition, which is exactly why it survives Flux upgrades so well.

⚠ Confirm the served API version before you write any of this

Flux promotes its API groups to GA one at a time, so version suffixes move between releases while kinds and field names stay stable. Run kubectl api-resources --api-group=kustomize.toolkit.fluxcd.io (and the same for source., helm., notification. and image.toolkit.fluxcd.io), then kubectl explain kustomization.spec --recursive for the real schema on the cluster in front of you. The parent Flux overview makes the same point at length; the habit matters more here than anywhere else, because a security field you spelled from memory and got wrong is worse than one you never set.

The tenant itself — namespace, identity, permissions

Three objects, committed by the platform team, one file per tenant. Note what is not here: no ClusterRoleBinding, and no ClusterRole of your own invention. The RoleBinding below references a built-in ClusterRole but binds it inside a single namespace, so the grant is namespace-scoped no matter how powerful that ClusterRole is elsewhere.

apiVersion: v1
kind: Namespace
metadata:
  name: apps
  labels:
    toolkit.fluxcd.io/tenant: checkout-team
    # Pod Security Admission — the second door. Impersonation does not do this.
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: flux                        # the identity Kustomizations in this namespace impersonate
  namespace: apps
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding                    # RoleBinding, NOT ClusterRoleBinding — this is the whole boundary
metadata:
  name: flux-reconciler
  namespace: apps
subjects:
  - kind: ServiceAccount
    name: flux
    namespace: apps
roleRef:
  kind: ClusterRole
  name: admin                        # built-in: full control of namespaced objects in THIS namespace
  apiGroup: rbac.authorization.k8s.io

Two deliberate choices in that file are worth defending. Binding the built-in admin rather than cluster-admin matters because a namespace-scoped binding of cluster-admin also carries the escalate and bind verbs, letting the tenant construct new RoleBindings inside their namespace referencing any ClusterRole — still namespace-bounded, but a wider surface than most teams intend. The built-in admin role deliberately withholds role escalation and quota writes, which is usually what you actually want. And the Pod Security Admission labels sit on the Namespace rather than being enforced by Flux, because Flux is not an admission controller: a tenant with namespace-admin can otherwise schedule a privileged, host-mounting pod that walks straight out of the boundary you just drew.

⚠ The tenant must not be able to edit their own Kustomization

spec.serviceAccountName names a ServiceAccount in the Kustomization’s own namespace. If your tenant’s RoleBinding lets them patch kustomizations.kustomize.toolkit.fluxcd.io in that namespace — and the built-in admin role does cover custom resources — then they can repoint their Kustomization at a more privileged ServiceAccount, or delete the field entirely and inherit the controller’s. Two ways out, and you need one of them: put the tenant’s Flux objects in a namespace the tenant has no write access to, or make sure the tenant namespace contains no ServiceAccount more privileged than their own. Many teams do both and add a Kyverno policy that rejects any Kustomization whose serviceAccountName is unset or off the allow-list.

The flux CLI can scaffold the trio, which is a good way to learn the shape even if you end up hand-writing the file:

# Generate the Namespace + ServiceAccount + RoleBinding and print it; do not apply it.
# Read what it produces rather than trusting anyone's description of it — the generated
# names and the default ClusterRole have varied between releases.
flux create tenant checkout-team \
  --with-namespace=apps \
  --export > ./tenants/apps/rbac.yaml

# Then commit it. The tenant is created by the platform's own Kustomization on the
# next reconcile, like everything else. Onboarding is a pull request, not a ticket.
git add ./tenants/apps/rbac.yaml
git commit -m 'feat(tenants): onboard checkout-team'

The tenant’s source and reconciler — owned by the platform

These two objects live in the tenant’s namespace but are committed to the platform’s repository. That is the ownership split made concrete: the tenant changes what is inside ./deploy in their own repo; only a platform pull request can change the terms below.

apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
  name: checkout-team
  namespace: apps                    # in the TENANT namespace, not flux-system
spec:
  interval: 1m
  url: https://github.com/acme/checkout-team.git
  ref:
    branch: main
  secretRef:
    name: checkout-team-auth         # must also live in the apps namespace
  verify:                            # see "Verifying Git commit signatures" below
    mode: HEAD
    secretRef:
      name: checkout-team-signing-keys
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: checkout-team
  namespace: apps
spec:
  interval: 10m
  retryInterval: 2m
  sourceRef:
    kind: GitRepository
    name: checkout-team              # NO namespace field — same namespace only
  path: ./deploy
  prune: true
  wait: true
  timeout: 5m
  serviceAccountName: flux           # ← the impersonation. Without this line: cluster-admin.
  targetNamespace: apps              # force every namespaced object into this namespace
  decryption:
    provider: sops
    secretRef:
      name: sops-age-apps            # a key that decrypts ONLY this tenant's secrets

targetNamespace and serviceAccountName are belt and braces, and it is worth knowing why you want both. targetNamespace runs the Kustomize namespace transformer over the build, so every namespaced object lands in apps regardless of what the tenant wrote in metadata.namespace. It does nothing at all to cluster-scoped kinds — a ClusterRole has no namespace to rewrite. That is exactly the gap serviceAccountName closes, because the API server refuses the create outright. Set only targetNamespace and you have tidied the harmless objects while leaving the dangerous ones untouched.

Note also that secretRef, decryption.secretRef and postBuild.substituteFrom all resolve within the object’s own namespace. That is quietly important: a tenant cannot use postBuild substitution to lift values out of another tenant’s Secrets, and each tenant can hold a different SOPS key without any of them being able to reach the others.

A HelmRelease under the same impersonation

Everything above applies unchanged to Helm. helm-controller impersonates the same way, so a chart that installs a ClusterRole, a CRD or a webhook configuration — and a great many public charts do — will fail against a namespace-scoped tenant identity. That is correct behaviour, and it is the moment most teams discover which of their charts are cluster-scoped in disguise.

apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: checkout-cache
  namespace: apps
spec:
  interval: 10m
  releaseName: checkout-cache
  serviceAccountName: flux           # same field, same meaning, same protection
  chart:
    spec:
      chart: redis
      version: "20.x"
      sourceRef:
        kind: HelmRepository
        name: bitnami                # must exist in the apps namespace under lockdown
        # namespace: flux-system     # ← rejected when --no-cross-namespace-refs is set
  install:
    createNamespace: false           # a Namespace is cluster-scoped: the tenant SA cannot make one
    remediation:
      retries: 3
  upgrade:
    remediation:
      retries: 3
      remediateLastFailure: true
  valuesFrom:
    - kind: Secret
      name: checkout-cache-values    # decrypted from SOPS by the Kustomization that applied it
      valuesKey: values.yaml

install.createNamespace: true is the field that catches people. Creating a Namespace is a cluster-scoped operation, so under impersonation it is forbidden and the release never installs — with an error that reads like a Helm problem and is actually an RBAC one. The tenant’s namespace was created by the platform’s root Kustomization long before; leave the field off. Chart sourcing, OCI charts and spec.chartRef are the sibling page’s territory — see Flux — Helm & OCI Delivery for the full treatment, including how chart verification hangs off an OCIRepository.

The bridge from SOPS to Helm values deserves spelling out, because it trips everyone once. SOPS decryption is a kustomize-controller capability: it is Kustomization.spec.decryption that turns an encrypted file into a real Secret in the cluster. The idiomatic pattern is therefore two-step — a Kustomization decrypts the file into a Secret, and the HelmRelease reads that Secret through valuesFrom. Order it with dependsOn, or the HelmRelease spends its first few intervals retrying against a Secret that does not exist yet.

Locking down the controllers

The flags live on the controller Deployments, so the GitOps-native way to set them is a Kustomize patch in the very directory flux bootstrap writes its own manifests into. Commit this and the next reconcile applies it — Flux hardens Flux, and the hardening is in the audit trail like everything else.

# clusters/prod/flux-system/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - gotk-components.yaml
  - gotk-sync.yaml
patches:
  - target:
      kind: Deployment
      name: "(kustomize-controller|helm-controller)"
    patch: |
      - op: add
        path: /spec/template/spec/containers/0/args/-
        value: --no-cross-namespace-refs=true
      - op: add
        path: /spec/template/spec/containers/0/args/-
        value: --default-service-account=flux
  - target:
      kind: Deployment
      name: kustomize-controller
    patch: |
      - op: add
        path: /spec/template/spec/containers/0/args/-
        value: --no-remote-bases=true
  - target:
      kind: Deployment
      name: "(notification-controller|image-reflector-controller)"
    patch: |
      - op: add
        path: /spec/template/spec/containers/0/args/-
        value: --no-cross-namespace-refs=true

Recent flux bootstrap releases also expose these as CLI flags, so the bootstrap command can write the patched manifests for you. Either route ends in the same committed YAML; the patch is shown here because it is the version a reviewer can read. Always confirm what actually landed with kubectl -n flux-system get deploy kustomize-controller -o jsonpath='{.spec.template.spec.containers[0].args}' — a patch whose target.name regex matched nothing fails silently, which is the worst possible outcome for a security control.

Verifying Git commit signatures

The verify block on the tenant source above makes source-controller check the OpenPGP signature on the commit before it will publish an artifact. The public keys live in an ordinary Secret in the same namespace, one armoured key per data entry; the entry names are arbitrary, the values are what matter.

# Collect the public keys of everyone allowed to author commits on this tenant's repo.
gpg --export --armor alice@acme.example > alice.asc
gpg --export --armor bob@acme.example   > bob.asc

kubectl -n apps create secret generic checkout-team-signing-keys \
  --from-file=alice.asc --from-file=bob.asc
# GitRepository.spec.verify, in full:
  verify:
    # What is verified: the commit at the tip of the checked-out ref, the tag, or both.
    # The accepted spellings changed when the source API went GA — older beta versions
    # used a lowercase "head". ALWAYS confirm on the cluster with:
    #   kubectl explain gitrepository.spec.verify.mode
    mode: HEAD
    secretRef:
      name: checkout-team-signing-keys   # Secret in the SAME namespace

Two honest caveats. A signature proves who signed, not that what they signed is correct — it is an authorship control, not a review control, and it is only as strong as the branch protection in front of it. And Flux’s spec.verify is built around OpenPGP; if your organisation has standardised on SSH-signed or certificate-based commit signing, check what your release supports before promising anyone this works.

Verifying OCI artifacts with cosign

The more modern shape is to stop pulling config from Git at reconcile time and instead publish an immutable, signed OCI artifact from CI — the same registry, the same signing story and the same digest pinning you already use for container images. Flux ships the CLI verbs for it, and Sigstore & cosign covers the signing side properly.

# In CI, after the tests pass: package the manifests as an OCI artifact, then sign it
# keylessly using the pipeline's own OIDC identity — no private key anywhere.
flux push artifact oci://ghcr.io/acme/checkout-team-config:$(git rev-parse --short HEAD) \
  --path=./deploy \
  --source="$(git config --get remote.origin.url)" \
  --revision="main@sha1:$(git rev-parse HEAD)"

cosign sign --yes ghcr.io/acme/checkout-team-config:$(git rev-parse --short HEAD)
apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
metadata:
  name: checkout-team
  namespace: apps
spec:
  interval: 5m
  url: oci://ghcr.io/acme/checkout-team-config
  ref:
    tag: latest                      # or semver: / digest: — a digest is the strongest pin
  secretRef:
    name: ghcr-pull
  verify:
    provider: cosign
    # KEYLESS: accept only artifacts signed by a specific workflow, attested by a specific
    # OIDC issuer. Both fields are regular expressions and both are anchored on purpose.
    matchOIDCIdentity:
      - issuer: '^https://token\.actions\.githubusercontent\.com$'
        subject: '^https://github\.com/acme/checkout-team/\.github/workflows/release\.yaml@refs/heads/main$'

The keyed variant swaps matchOIDCIdentity for a secretRef naming a Secret that holds a cosign.pub public key. Keyless is generally the better default, for the reason the cosign page argues at length: there is no private key to leak, rotate or lose, and the thing you pin is a workflow identity rather than a file somebody could copy onto a laptop. Later Flux releases added verification providers beyond cosign — kubectl explain ocirepository.spec.verify.provider will tell you which ones your install actually offers.

⚠ An unpinned verify is a decoration, not a control

Keyless verification with no matchOIDCIdentity means “this was signed by somebody holding some OIDC identity, and the signature is in the transparency log.” Anyone with a GitHub account satisfies that. The identity match is not an optional extra — it is the control. Anchor both regexes with ^ and $, escape the dots (an unescaped . matches any character, so github.com also matches githubXcom), and pin the branch as well as the workflow path, or a pull request from a fork can mint a signature you will happily accept.

Decrypting secrets with SOPS

SOPS encrypts the values in a YAML file and leaves the structure readable, so a pull-request diff still shows which fields changed without showing what they changed to. Secrets Management compares it against Sealed Secrets and External Secrets; what matters here is the Flux-side mechanics, which are fussier than they look because the Secret key names carry meaning.

# age: generate a keypair. The PUBLIC key (age1...) goes in .sops.yaml and is committed.
# The PRIVATE key goes into the cluster and nowhere else.
age-keygen -o age.agekey

# The data key MUST end in .agekey — that suffix is how kustomize-controller recognises
# an age identity. A data key named "age.key" is silently ignored and decryption fails.
kubectl -n apps create secret generic sops-age-apps \
  --from-file=age.agekey=age.agekey

# For OpenPGP the suffix is .asc instead. For cloud KMS the well-known data keys are
# sops.aws-kms, sops.azure-kv, sops.gcp-kms and sops.vault-token — or omit the Secret
# entirely and let the controller authenticate with workload identity.
# .sops.yaml in the repo root — creation rules decide which key encrypts which path.
# This file is where per-environment and per-tenant key separation actually happens.
creation_rules:
  # Encrypt ONLY the values, never the whole document, or kustomize cannot read
  # kind/metadata and the build fails long before decryption is even attempted.
  - path_regex: tenants/apps/.*\.enc\.yaml$
    encrypted_regex: '^(data|stringData)$'
    age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

  - path_regex: clusters/prod/.*\.enc\.yaml$
    encrypted_regex: '^(data|stringData)$'
    kms: 'arn:aws:kms:eu-west-1:111122223333:key/2f3a0000-prod-flux'
# Encrypt in place before committing. The file is now safe to push.
sops --encrypt --in-place ./tenants/apps/db-credentials.enc.yaml

# Sanity-check what a reviewer will see: keys visible, values ciphertext, sops: block appended.
git diff --cached ./tenants/apps/db-credentials.enc.yaml

The Kustomization that applies the tenant’s path already carries the decryption block shown earlier. Because that block resolves a Secret in the Kustomization’s own namespace, giving each tenant its own key is simply a matter of creating a different Secret per namespace — and it means an encrypted file lifted from one tenant’s repository cannot be opened by another tenant’s cluster identity. Newer releases also let the controller authenticate to a cloud KMS with workload identity instead of a stored credential; kubectl explain kustomization.spec.decryption is the authority on what your install supports.

age or KMS — the key-management decision

This is the choice teams agonise over, and it reduces to two questions: who do you need to be able to revoke, and does someone need to be able to prove when a secret was decrypted?

DimensionageCloud KMS or Vault
Where the private key livesA Secret in the cluster. It is the crown jewel.Inside the KMS. It never leaves, and the cluster never holds it.
How the controller authenticatesIt reads the Secret.IAM — ideally workload identity (IRSA / GKE / Azure), so there is no stored credential at all.
Audit trailNone. You cannot tell whether the key has ever been used.Every decrypt is a logged API call.
RevocationRotate the key, re-encrypt every file, commit. Manual and total.Change one IAM policy. Effective immediately, no re-encryption.
Per-environment separationDifferent recipients per path in .sops.yaml; enforcement is convention.Different keys per environment; enforcement is IAM. Prod ciphertext is undecryptable from dev.
Offline / air-gappedYes. No network dependency at reconcile time.No — a KMS outage stalls decryption and therefore reconciliation.
Good fitHomelabs, bootstrap, dev clusters, break-glass recoveryAnything with a compliance story, several environments, or more than one team

SOPS can encrypt to several recipients at once, and the mature answer usually uses that: encrypt production files to the prod KMS key and to an offline age key kept somewhere physical, so a cloud-account catastrophe does not also mean losing every secret you own. If you take one rule from this section, take that one — the KMS-only setup is strictly more secure right up until the day it locks you out of your own cluster.

Alerting so a tenant failure is never silent

Here is the operational reality that makes notification part of tenancy rather than an afterthought. On a single-team cluster a broken reconciliation gets noticed, because the person who pushed the commit is watching for it. On a twenty-tenant cluster, a tenant whose Kustomization has been Ready: False for eleven days is invisible: the platform team’s dashboard is green because their objects are fine, and the tenant assumed it deployed. Silence is the failure mode, and it is the one that actually bites.

notification-controller answers it with three small objects, and under cross-namespace lockdown each tenant needs their own set in their own namespace. That is a feature rather than a chore — it routes each team’s failures to that team’s channel, without the platform team becoming a switchboard.

apiVersion: notification.toolkit.fluxcd.io/v1beta3
kind: Provider
metadata:
  name: checkout-slack
  namespace: apps                     # same namespace as the Alert that references it
spec:
  type: slack                         # also msteams, discord, googlechat, telegram,
                                      # generic, generic-hmac, alertmanager, grafana,
                                      # opsgenie, pagerduty, sentry — plus the Git commit
                                      # status providers github / gitlab / bitbucketserver
  channel: checkout-alerts
  secretRef:
    name: checkout-slack-webhook      # data key: address
---
apiVersion: notification.toolkit.fluxcd.io/v1beta3
kind: Alert
metadata:
  name: checkout-failures
  namespace: apps
spec:
  providerRef:
    name: checkout-slack
  eventSeverity: error                # "info" also forwards every successful reconcile
  eventSources:
    - kind: Kustomization
      name: '*'                       # no namespace field — this namespace only
    - kind: HelmRelease
      name: '*'
    - kind: GitRepository
      name: '*'                       # catches signature-verification failures too
  exclusionList:
    - 'waiting for rollout to finish'   # regex; silence known-transient chatter
  eventMetadata:                        # newer API versions replaced the older single
    cluster: prod-eu-west-1             # "summary" string with this metadata map —
    tenant: checkout-team               # confirm with kubectl explain alert.spec

Pick eventSeverity per tenant rather than as a blanket policy: error is an on-call channel, while info also forwards every successful reconcile — a useful deploy log in a quiet tenant, unbearable in a busy one. The exclusionList regexes are the middle ground.

The third object closes the loop in the other direction. A Receiver is an inbound webhook that lets the tenant’s Git host tell Flux “new commit” instead of waiting out the interval — for the tenant that means a deploy landing in seconds instead of minutes, and for the platform it means you can raise intervals and stop twenty tenants polling the Git provider every sixty seconds.

apiVersion: notification.toolkit.fluxcd.io/v1
kind: Receiver
metadata:
  name: checkout-webhook
  namespace: apps
spec:
  type: github                        # also gitlab, bitbucket, harbor, dockerhub,
                                      # quay, gcr, acr, nexus, generic, generic-hmac
  events: [ "ping", "push" ]
  secretRef:
    name: checkout-webhook-token      # data key: token — shared with the Git host
  resources:
    - kind: GitRepository
      name: checkout-team             # no namespace field — this namespace only

The URL you hand the Git host is not something you construct by hand: read it from kubectl -n apps get receiver checkout-webhook -o jsonpath='{.status.webhookPath}' and append it to the public address of the webhook-receiver Service. That path is derived from the token, so rotating the token changes the URL — update the Git host in the same change, or pushes quietly stop triggering and the tenant drops back to interval speed without anyone noticing. A small and extremely common own-goal.

◆ Key idea

Alerts tell you about events; they say nothing about absence. No Alert ever fires for a tenant whose GitRepository stopped being fetched three weeks ago because a token expired quietly, or for one somebody left suspended after an incident. Pair the event-driven Alerts with a state-driven check scraped from the controllers’ Prometheus metrics. That dead-man’s switch is the part that actually catches the eleven-day failure — and it belongs to the platform team, not the tenant.

Day-to-day commands

☺ Like you’re 10: Two habits: ask “what is red?” and ask “what could this tenant actually do?”

The flux CLI is a friendly wrapper over the CRDs and it will carry most of the day. But the commands that matter most for tenancy are kubectl ones, because the questions you are asking are RBAC questions and Flux does not answer those — the API server does. The parent Flux page has the general reconcile/suspend/trace vocabulary; what follows is the tenancy-specific layer on top.

Onboard and inspect a tenant

# Scaffold the tenant RBAC and read it before committing.
flux create tenant checkout-team --with-namespace=apps --export

# What Flux objects exist in this tenant's namespace, and are they healthy?
flux -n apps get all

# Fleet-wide sweep — the first command of any morning on a shared cluster.
flux get all -A --status-selector ready=false

# What did this Kustomization actually put in the cluster?
flux -n apps tree kustomization checkout-team

# Reverse lookup: which tenant, repo, path and commit produced this live object?
flux trace deployment/checkout -n apps

Prove the boundary holds — the commands that matter most

These are the highest-value commands on the page, and they are pure kubectl. --as makes the API server evaluate authorization as the impersonated identity and tell you the answer without you having to break anything. Run them the day you create a tenant, and again whenever anybody changes a RoleBinding.

# The single most useful check: enumerate everything the tenant identity can do.
kubectl auth can-i --list \
  --as=system:serviceaccount:apps:flux -n apps

# The three that must come back "no" on a correctly bounded tenant:
kubectl auth can-i create clusterrolebindings --as=system:serviceaccount:apps:flux
kubectl auth can-i create namespaces          --as=system:serviceaccount:apps:flux
kubectl auth can-i get secrets -n other-tenant --as=system:serviceaccount:apps:flux

# And the one that must come back "yes", or nothing will ever deploy:
kubectl auth can-i create deployments -n apps --as=system:serviceaccount:apps:flux

# Does the controller still hold the master key? (Expect a match on a default install.)
kubectl get clusterrolebinding -o wide | grep -E 'kustomize-controller|helm-controller'

# Are the lockdown flags actually on the Deployments?
kubectl -n flux-system get deploy kustomize-controller \
  -o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n'

# Which Kustomizations are missing the impersonation field entirely?
kubectl get kustomizations -A \
  -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,SA:.spec.serviceAccountName'

That last one is worth putting in a scheduled job. A blank SA column on any row outside flux-system is either a deliberate exception you documented, or a tenant boundary that quietly does not exist. On a cluster with --default-service-account set the blank is harmless; without it, the blank is the finding.

Debug an impersonation or verification failure

# Why is this tenant not Ready? Conditions first, logs second.
kubectl -n apps get kustomization checkout-team -o yaml | yq '.status.conditions'
flux -n apps events --for Kustomization/checkout-team
flux logs --namespace=apps --kind=Kustomization --name=checkout-team --level=error

# Signature and decryption problems surface on the SOURCE, not on the Kustomization.
kubectl -n apps get gitrepository checkout-team -o yaml | yq '.status.conditions'

# Dry-run the tenant's change against the live cluster before it merges.
flux -n apps diff kustomization checkout-team --path ./deploy

# Verify an OCI config artifact by hand, using the same identity pin the cluster uses.
cosign verify ghcr.io/acme/checkout-team-config:abc1234 \
  --certificate-oidc-issuer='https://token.actions.githubusercontent.com' \
  --certificate-identity-regexp='^https://github\.com/acme/checkout-team/.*$'

Watch for absence, not just for errors

The controllers export Prometheus metrics, and two families of series turn the dead-man’s-switch idea from the previous section into an actual alert. Confirm the exact names on your install — curl a controller’s /metrics endpoint rather than trusting a page — then wire the rules into Prometheus and put the fleet view on a Grafana dashboard beside your delivery panels.

# Any Flux object that has been un-Ready for 15 minutes, labelled by tenant namespace.
sum by (namespace, kind, name) (
  gotk_reconcile_condition{type="Ready", status="False"}
) > 0

# The one people forget: suspended and nobody resumed it.
sum by (namespace, kind, name) (gotk_suspend_status) > 0
🦆 Dot’s-eye view

“The platform team gave every tenant a Slack channel wired to their own Alert, and honestly that changed more than any of the RBAC did. Before, a failed deploy was something I found out about when someone asked why the fix wasn’t live. Now the bot posts my Kustomization, my namespace and the actual error, in my channel, about forty seconds after I push. I still don’t have a Flux login. I don’t need one.”

Gotchas and failure modes

☺ Like you’re 10: The scary bugs here are the ones where nothing turns red.

Tenancy failures split cleanly into two families, and they need opposite reactions. Loud failures — a forbidden error, a verification failure — are the system working: read the message, it is usually literally telling you the answer. Silent failures — a missing field, a patch that matched nothing, an alert nobody configured — are the dangerous ones, because the cluster looks healthy the entire time. Every item below is labelled with which kind it is.

⚠ The cluster-admin footgun — the one that ends careers

Symptom: none. A tenant’s Kustomization has no spec.serviceAccountName, so kustomize-controller applies with its own identity. The tenant commits a ClusterRoleBinding granting their namespace ServiceAccount cluster-admin — perhaps innocently, copied from a chart’s README — and Flux applies it. The Kustomization goes Ready: True. No alert fires, because nothing failed. You discover it in an audit, or you don’t. The fix is two changes: set --default-service-account so a missing field becomes a hard error rather than an escalation, and add an admission policy that rejects cluster-scoped RBAC objects from tenant namespaces. Do not rely on code review to catch it — the whole point of GitOps is that the machine applies what is written.

Symptoms and their causes

This table is the one to drill. Under exam pressure — and at 3am — the useful reflex is error string to cause, not concept to definition.

What you seeLoud / silentCauseFix
... is forbidden: User "system:serviceaccount:apps:flux" cannot create resource "clusterroles"LoudImpersonation working exactly as designed. The tenant’s manifests include a cluster-scoped object.Move the cluster-scoped object into the platform repo, or decide deliberately to widen the tenant’s RBAC.
Everything applies, including cluster-scoped objectsSilentspec.serviceAccountName is unset and --default-service-account is not configured.Set the field; set the flag; audit every namespace with the custom-columns command above.
Reconciliation fails mentioning a cross-namespace referenceLoudA sourceRef, eventSources or imageRepositoryRef carries a namespace while the lockdown flag is on.Duplicate the source into the tenant namespace, or accept that this tenant is not isolated.
Kustomization stuck: serviceaccounts "flux" not foundLoud--default-service-account=flux is set but this namespace has no such ServiceAccount.Create it with its RoleBinding — including in flux-system for the platform’s own root Kustomization.
Every tenant fails at once after hardeningLoudYou removed the cluster-admin binding without granting impersonate on serviceaccounts.Add the verb to the replacement ClusterRole. Test on a throwaway cluster first.
GitRepository not Ready with a PGP verification errorLoudThe commit was signed by a key not in the Secret — very often because the tip is the Git host’s own merge commit.See the traps below.
OCIRepository not Ready: no matching signaturesLoudThe artifact was copied between registries without its signature, or the identity regex does not match.Use cosign copy, not a plain image copy. Test the regex with cosign verify locally.
Decryption error mentioning no matching key or credsLoudThe Secret’s data key does not end in .agekey/.asc, or the controller identity cannot use the KMS key named in the file’s metadata.Recreate the Secret with the correct data key name; check the IAM policy on the KMS key.
kustomize build fails on an encrypted fileLoudThe whole document was encrypted, so kind and metadata are ciphertext.Re-encrypt with encrypted_regex: '^(data|stringData)$'.
HelmRelease fails to install, mentioning namespacesLoudinstall.createNamespace: true under impersonation — a Namespace is cluster-scoped.Remove the field. The namespace already exists.
Lockdown flags appear to do nothingSilentThe Kustomize patch’s target.name regex matched no Deployment, so nothing was patched.Read the live args back with the jsonpath command above. Never assume a patch landed.
A tenant has been broken for days and nobody knewSilentNo Alert in that namespace, or the Provider’s Secret is wrong so delivery fails quietly.Alerts per tenant, plus the gotk_reconcile_condition rule as a backstop.
Deploys are slow again after a token rotationSilentThe Receiver token changed, so status.webhookPath changed, and the Git host posts to a dead URL.Re-read the path and update the webhook whenever the token rotates.

Two signature traps worth memorising

The merge-commit trap: when you merge a pull request through a Git host’s web UI, the merge commit is created and signed by the host with its own key, not by anyone on your team. A GitRepository verifying the tip of main against a Secret of developer keys will therefore reject every web merge. Three ways out — add the host’s public signing key to the Secret, switch the repository to rebase or fast-forward merges so the tip is human-signed, or verify a signed tag instead of the branch tip. Choose deliberately: the first means trusting the Git host as a signer.

The mirrored-registry trap: a cosign signature lives in the registry as a separate object alongside the artifact, not inside it. Promote an artifact between registries with a plain copy — dev to prod, public to an internal mirror, an air-gap transfer — and the signature stays behind. Verification then fails on a perfectly legitimate artifact, and the natural-but-wrong reaction is to switch verification off. Use cosign copy, which brings the signature and any attestations along.

Ways a tenant escapes the namespace anyway

Impersonation bounds what a tenant may ask the API server for. It does nothing about these four, and a tenancy design that has not considered them is incomplete:

⚠ Over-broad tenant RBAC is the quiet default

The path of least resistance when a tenant hits a forbidden error is to widen their Role until the error stops. Do that four times and you have reinvented cluster-admin one verb at a time, with no single change that looked alarming in review. Two habits prevent it: make every RBAC widening its own reviewed pull request that names which manifest needed it, and re-run kubectl auth can-i --list --as=... per tenant each quarter, diffing against last quarter. Permissions accrete; nothing removes them unless somebody looks.

🐢 Timmy’s workshop · 30 min

On a kind cluster with Flux bootstrapped: 1) Create namespace apps, a flux ServiceAccount and a namespace-scoped RoleBinding, then a Kustomization in apps pointing at a folder containing an innocent Deployment and a ClusterRoleBinding. Leave serviceAccountName unset and watch both apply successfully — sit with that for a moment. 2) Add serviceAccountName: flux, reconcile, and read the exact forbidden message. 3) Run kubectl auth can-i --list --as=system:serviceaccount:apps:flux -n apps and compare it against what you expected. 4) Patch kustomize-controller with --default-service-account=flux, then create a Kustomization in a namespace with no such ServiceAccount and confirm it fails loudly. 5) Encrypt a Secret with age, wire up spec.decryption, then deliberately rename the Secret’s data key from age.agekey to age.key to see what a decryption failure looks like. Steps 1 and 5 are worth more than the rest combined.

Alternatives and when to choose it

☺ Like you’re 10: Flux borrows Kubernetes’ own locks; Argo CD brought its own. Neither can turn one house into two.

The real choice is not “Flux or Argo CD” — it is “how much isolation does this actually need?”, and the honest answer sometimes rules out both.

Four tenancy models, compared

DimensionFlux soft tenancyArgo CD AppProjectNamespace-as-a-service (Capsule, HNC)Cluster per tenant
Boundary enforced byKubernetes RBAC, via impersonationArgo CD’s own policy layer, plus optional impersonationAn operator that manages namespaces and RBAC for youThe API server itself — there are two of them
New concepts to learnNone. A field and some RBAC.AppProject, Argo’s own RBAC grammar, SSO groupsThe operator’s CRDs and hierarchy modelFleet management, Cluster API
Blast radius of an engine bugWhatever the impersonated SA can doWhatever the Argo CD ServiceAccount can do, unless impersonation is onWhatever the operator can doOne cluster
Tenant self-service UINone built in — CLI, portal, or BackstageBuilt-in, scoped per project — a genuine strengthVaries by operatorWhatever you build
Stops a privileged podNoNoUsually — most bundle Pod Security policyN/A — nobody else is there
Choose whenPlatform team owns delivery config; tenants are colleagues, not strangersMany app teams need visible, self-serve sync statusNamespace sprawl needs governing independently of the delivery engineRegulatory, hostile, or hard-isolation requirements

Flux against Argo CD, on tenancy specifically

The general comparison lives on the Flux overview and on Argo CD. On tenancy alone the difference is philosophical and it cuts both ways. Argo CD invented a tenancy object: AppProject restricts which repositories, destinations and cluster-scoped kinds an Application may use, and Argo’s own RBAC layer maps SSO groups to roles over projects. That is far more discoverable, and it lets a tenant see their own deployments in a browser without holding a cluster credential. The cost is that it is Argo CD’s policy layer rather than Kubernetes’ — one more authorization system to configure, audit and get right — and by default the apply itself still runs as Argo CD’s own privileged ServiceAccount. Flux takes the opposite bet: no new policy language, no new audit surface, and the apply authorised by the same API server as everything else — but nothing discoverable, nothing enforced unless you configure it, and the field you forgot is invisible. Argo CD’s risk is a misconfigured project; Flux’s risk is an unconfigured field. Know which mistake your team is likelier to make.

When one cluster is simply not the answer

Say this plainly, because it is the most valuable judgement on the page: if your tenants are mutually distrusting — different customers, different regulatory regimes, or code you did not write and cannot review — then no arrangement of namespaces and RBAC is sufficient, because they still share a kernel, a scheduler, a CNI and a control plane. That is a Cluster API and fleet problem, laid out in Multi-Cluster, and Flux is very good at it: a Flux per cluster, all reconciling from one fleet repository, is a standard and durable shape. Reserve soft tenancy for what it is actually good at — many teams inside one organisation who broadly trust each other, where the boundary exists to prevent accidents rather than attacks.

🎬 At the Platform Guild
🦊

Foxy: Flux is fine, we’ve got namespaces. Each team has their own. That is multi-tenancy, isn’t it?

🐢

Timmy: Show me one Kustomization. [reads] No serviceAccountName. So every one of those namespaces is a cluster-admin credential with extra steps.

🦊

Foxy: But nobody would

🐢

Timmy: Nobody would on purpose. Somebody will copy a chart README that installs a ClusterRole, Flux will do it, and nothing will turn red. That’s the part that worries me.

🦫

Benny: One patch fixes the whole class: --default-service-account=flux. After that, forgetting the field is a loud error instead of a quiet promotion.

👺

Gizmo: I hit a forbidden error last week so I bound cluster-admin to my tenant SA. Unblocked in thirty seconds! Efficiency. 🤑

🐢

Timmy: …and now the impersonation is decorative, and the audit report says “impersonation enabled.” That is worse than never turning it on, because now everyone believes it.

🦆

Dot: The bit that changed my life was the per-tenant Slack alert. I find out my deploy broke in forty seconds instead of on Thursday.

Exam relevance and going further

☺ Like you’re 10: One field, one flag, one signature check — and knowing which one fixes which problem.

Tenancy questions are unusually good value because they sit across two domains of the CNPE blueprint at once: GitOps & Continuous Delivery for the reconciliation half, Security & Compliance for the RBAC and supply-chain half. And they are practical by nature — expect to be asked to make a boundary hold, or to explain why one does not, rather than to define multi-tenancy.

⚠ You cannot look any of this up during the exam

fluxcd.io is not on the CNPE documentation allowlist — only kubernetes.io/docs, kubernetes.io/blog, docs explicitly linked in a task’s Quick Reference box, and local man//usr/share docs on the exam desktop. The parent Flux page covers the allowlist in full. What it means here is that spec.serviceAccountName, spec.decryption and spec.verify have to come out of your memory or out of kubectl explain. The good news: kubectl explain kustomization.spec --recursive prints the entire schema straight from the CRD on the cluster, and it is not blocked by anything. Practise reaching for it reflexively.

⚖ CNPA vs CNPE — CNPA is fully closed-book: no allowlist, because there is nothing to allow. That makes the conceptual half of this page the part that transfers — why a delivery engine that applies as itself is a security problem, and what a namespace boundary does and does not contain. CNPE is where the specific fields and flags earn their keep, because CNPE asks you to configure things rather than describe them.

What to be able to do cold

Without notes, you should be able to: create a tenant namespace, ServiceAccount and namespace-scoped RoleBinding from a blank file; write a GitRepository and Kustomization in that namespace with serviceAccountName, targetNamespace and prune; explain in one sentence what happens if serviceAccountName is omitted; name the two hardening flags and what each one closes; say where SOPS decryption is configured and which controller performs it; name the three notification objects and which direction each one points; and read a forbidden error and say immediately whether it means the boundary is working or broken. Drill the reconciliation half in Practice: GitOps and the RBAC half in Practice: Security, keep the Command Reference beside you, and use Know Cold for the shapes you must be able to type from memory. When something is stuck, Triage: Delivery has the decision tree.

Where to go next on this site

Start from the parent Flux overview if the GitOps Toolkit vocabulary felt shaky, then take the siblings — Flux — Helm & OCI Delivery and Flux — Image Automation, the latter of which has its own tenancy question since an ImageUpdateAutomation holds write access to a tenant’s repository. For the theory underneath, the GitOps lesson covers the OpenGitOps principles and why pull-based delivery changes the credential story. Then Security & Policy and Kyverno for the admission half of the boundary, Sigstore & cosign for signing in depth, Secrets Management for the SOPS-versus-ESO decision, and Flagger for what happens to a tenant’s workload after Flux delivers it. Weighing engines rather than configuring one? Argo CD and the Tool Landscape.

Official sources — for study time, not exam time

Read these before the exam, never during it: the multi-tenancy guidance and reference implementation at fluxcd.io/flux/installation/configuration/multitenancy and github.com/fluxcd/flux2-multi-tenancy, the security documentation at fluxcd.io/flux/security, the component API references at fluxcd.io/flux/components for the exact verify and decryption schemas, SOPS itself at github.com/getsops/sops, and Kubernetes’ own RBAC and impersonation reference at kubernetes.io/docs/reference/access-authn-authz/rbac — which, unlike all the others, is on the exam allowlist and is worth knowing your way around.

🐢 Timmy’s checkpoint

1. A tenant’s Kustomization has no spec.serviceAccountName on a default Flux install. What identity applies its manifests, and what can that identity do? 2. Name the three-step precedence chain that decides which identity performs the apply. 3. Why does targetNamespace not make serviceAccountName unnecessary? 4. What does --no-cross-namespace-refs=true prevent, and why can’t plain RBAC prevent it instead? 5. Which controller performs SOPS decryption, and how does an encrypted value reach a HelmRelease? 6. Give two ways a tenant can escape its namespace even with impersonation configured perfectly.

Check your answers
  1. kustomize-controller’s own ServiceAccount, which the default install binds to cluster-admin via a ClusterRoleBinding. It can create anything, anywhere — including a ClusterRoleBinding that escalates the tenant permanently. Nothing turns red when it happens.
  2. spec.serviceAccountName on the object wins; failing that, the controller’s --default-service-account=<name> flag impersonates that name in the object’s own namespace; failing both, the controller applies as itself.
  3. targetNamespace rewrites the namespace of namespaced objects only. Cluster-scoped kinds — ClusterRole, ClusterRoleBinding, CRDs, Namespace, webhook configurations — have no namespace to rewrite, so they pass straight through. Impersonation is what makes the API server refuse them.
  4. It stops a sourceRef, chartRef, eventSources, imageRepositoryRef or Receiver resource entry from naming another namespace. RBAC cannot express this, because RBAC governs who may read or write an object, not who may reference one — and the credential is used by source-controller, not by the referring tenant.
  5. kustomize-controller, configured by Kustomization.spec.decryption with provider: sops and a secretRef in the same namespace. For Helm it is a two-step bridge: the Kustomization decrypts the file into a real Secret, and the HelmRelease reads it via valuesFrom.
  6. Any two of: running a privileged or hostPath pod when Pod Security Admission is not enforcing; using spec.kubeConfig.secretRef to target a different cluster entirely; pulling a Kustomize remote base from an arbitrary URL when --no-remote-bases is unset; or creating a namespaced custom resource that a cluster-wide operator then acts on with its own elevated permissions.