Installing Kyverno and Capsule with Flux
A local Flux setup becomes much more interesting once it manages more than demo applications.
In the previous article, Learning GitOps with Flux, k3d, and the Flux CLI, I used a small k3d repository to explain the basic reconciliation loop. This article builds on that idea and adds two tools that make a cluster feel more like a platform:
- Kyverno for policy enforcement
- Capsule for Kubernetes multi-tenancy
The useful mental model is simple.
Flux keeps the desired state synchronized.
Kyverno decides whether resources are allowed, mutated, generated, or reported.
Capsule gives teams a tenant boundary inside a shared cluster.
Together, they let you turn a plain Kubernetes cluster into something closer to a self-service platform:
Git
-> Flux reconciles platform components
-> Kyverno enforces cluster policy
-> Capsule delegates namespaces and quotas to teams
-> application teams deploy inside controlled boundaries
That is a strong pattern because it keeps the platform declarative. The cluster administrator does not click around in dashboards or run one-off Helm commands. The installation, policies, tenants, quotas, and examples all live in Git.
What Kyverno Does
Kyverno is a Kubernetes-native policy engine.
The important part is “Kubernetes-native”. You write policies as Kubernetes resources. You apply them with the same tools you already use. Flux can reconcile them like any other manifest.
Kyverno can do several things:
- validate resources
- mutate resources
- generate resources
- verify images
- produce policy reports
- enforce Pod Security Standards
The simplest use case is validation.
For example:
- require CPU and memory requests
- block privileged pods
- require labels on namespaces
- restrict image registries
- enforce non-root containers
Kyverno runs as admission webhooks. When a matching resource is created or updated, the Kubernetes API server sends the admission request to Kyverno. Kyverno evaluates the policy and either allows the request, denies it, mutates it, or records a report depending on the policy mode.
This is exactly the kind of thing that should be managed through GitOps. A policy is not just a runtime trick. It is part of the platform contract.
What Capsule Does
Capsule solves a different problem.
Kubernetes namespaces are useful, but they are flat. A namespace can isolate some resources, but it does not give you a higher-level tenant object. Once you have multiple teams, you often need something more structured:
- who owns a group of namespaces
- how many namespaces a team may create
- which quotas apply across the tenant
- which labels or annotations must exist
- which resources are allowed
- which network policies or service restrictions apply
Capsule adds a cluster-scoped Tenant custom resource for that.
Instead of giving every team a separate cluster, Capsule lets you create controlled tenant spaces inside one cluster. Each tenant can own multiple namespaces, and Capsule can apply tenant-level constraints across those namespaces.
That makes Capsule a good fit for homelab platforms, internal development clusters, teaching environments, and smaller shared clusters where full cluster-per-team isolation would be too expensive or too much operational work.
Why Use Both?
Kyverno and Capsule overlap only a little.
Kyverno is best at policy decisions:
Is this pod allowed?
Does this workload define resources?
Is this namespace labeled correctly?
Is this image registry permitted?
Capsule is best at tenancy:
Who owns this tenant?
How many namespaces can the team create?
What quota applies across all tenant namespaces?
Which tenant-level metadata should be inherited?
In practice, I would use them together:
- Capsule defines the team boundary.
- Kyverno defines cluster-wide safety rules.
- Flux defines how both tools and their configuration reach the cluster.
That separation keeps the platform understandable.
Repository Shape
Using the same style as the Flux demo repository, I would add a security or platform layer under infra.
One possible layout:
clusters/
k3d-demo/
infra/
security.yaml
tenants.yaml
infra/
security/
base/
cert-manager.yaml
kyverno.yaml
capsule.yaml
kustomization.yaml
policies/
kyverno/
require-pod-resources.yaml
kustomization.yaml
tenants/
solar/
tenant.yaml
kustomization.yaml
The exact names are not important. The important part is the separation:
- platform components go under
infra/security - policies go under
infra/policies - tenant definitions go under
infra/tenants - the cluster entrypoint references them through Flux
Kustomizationobjects
This means Kyverno, Capsule, policies, and tenants all reconcile independently.
One detail matters when you model this in Flux: Kustomization.dependsOn points to other Flux Kustomization objects, not directly to HelmRelease objects. A common pattern is to put each Helm release in its own directory and then create a Flux Kustomization for that directory.
For example:
Flux Kustomization "cert-manager" -> ./infra/security/base/cert-manager
Flux Kustomization "kyverno" -> ./infra/security/base/kyverno
Flux Kustomization "capsule" -> ./infra/security/base/capsule
Then policies can depend on the kyverno Kustomization, and tenants can depend on the capsule Kustomization.
Install cert-manager First
Capsule uses admission webhooks. By default, it expects cert-manager for webhook certificates. You can configure Capsule differently, but for a normal platform setup I would install cert-manager explicitly.
With Flux, create an OCI HelmRepository and a HelmRelease.
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
name: cert-manager
namespace: flux-system
spec:
type: oci
interval: 12h
url: oci://quay.io/jetstack/charts
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: cert-manager
namespace: flux-system
spec:
interval: 10m
targetNamespace: cert-manager
releaseName: cert-manager
chart:
spec:
chart: cert-manager
version: v1.20.2
sourceRef:
kind: HelmRepository
name: cert-manager
namespace: flux-system
interval: 12h
install:
createNamespace: true
remediation:
retries: 3
upgrade:
remediation:
retries: 3
values:
crds:
enabled: true
The exact version should be updated intentionally. The versions in this article are examples from the current chart ecosystem at the time of writing. Pin versions in Git, review upgrades, and let Flux apply them.
Install Kyverno with Flux
Kyverno is available through the official Kyverno Helm repository.
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
name: kyverno
namespace: flux-system
spec:
interval: 12h
url: https://kyverno.github.io/kyverno/
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: kyverno
namespace: flux-system
spec:
interval: 10m
targetNamespace: kyverno
releaseName: kyverno
chart:
spec:
chart: kyverno
version: 3.8.1
sourceRef:
kind: HelmRepository
name: kyverno
namespace: flux-system
interval: 12h
install:
createNamespace: true
remediation:
retries: 3
upgrade:
remediation:
retries: 3
values:
admissionController:
replicas: 1
backgroundController:
replicas: 1
cleanupController:
replicas: 1
reportsController:
replicas: 1
For a local k3d cluster, one replica per controller is enough. For production, size this properly and read the Kyverno high availability guidance.
After Flux reconciles it:
flux get helmreleases -A
kubectl -n kyverno get pods
If you want to force the reconciliation while learning:
flux reconcile source helm kyverno -n flux-system
flux reconcile helmrelease kyverno -n flux-system
A Simple Kyverno Policy
Start with an audit policy. Audit mode gives feedback without breaking developers immediately.
This policy checks that every container has CPU and memory requests and memory limits. I am using Kyverno’s classic ClusterPolicy resource here because it is still common, easy to read, and widely represented in the policy examples. For a new production policy library, also look at Kyverno’s newer CEL-based policy types.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-pod-resources
spec:
validationFailureAction: Audit
background: true
rules:
- name: require-requests-and-limits
match:
any:
- resources:
kinds:
- Pod
exclude:
any:
- resources:
namespaces:
- kube-system
- kyverno
- cert-manager
- capsule-system
validate:
message: "CPU and memory requests, plus memory limits, are required."
pattern:
spec:
containers:
- resources:
requests:
cpu: "?*"
memory: "?*"
limits:
memory: "?*"
Put this in:
infra/policies/kyverno/require-pod-resources.yaml
Then reconcile the policy path with Flux.
For example, create a Flux Kustomization for policies:
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: kyverno-policies
namespace: flux-system
spec:
interval: 5m
prune: true
wait: true
sourceRef:
kind: GitRepository
name: flux-system
path: ./infra/policies/kyverno
dependsOn:
- name: kyverno
That dependsOn is the important part. It assumes you have a Flux Kustomization named kyverno which installs the Kyverno Helm release. Policies should not reconcile before Kyverno and its CRDs exist.
Now inspect the policy:
kubectl get clusterpolicy
kubectl describe clusterpolicy require-pod-resources
Create a bad pod:
kubectl create namespace kyverno-demo
kubectl -n kyverno-demo run bad-nginx --image=nginx
Because the example uses Audit, the pod is allowed, but Kyverno records policy results. That is a good first step when introducing policies into an existing cluster.
When you are ready to enforce it, change:
validationFailureAction: Audit
to:
validationFailureAction: Enforce
Then reconcile:
flux reconcile kustomization kyverno-policies -n flux-system
Now the same pod should be denied. A valid pod needs resources:
apiVersion: v1
kind: Pod
metadata:
name: good-nginx
namespace: kyverno-demo
spec:
containers:
- name: nginx
image: nginx:1.27
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi
Apply it:
kubectl apply -f good-nginx.yaml
This is a clean learning loop:
write policy in Git
-> Flux applies policy
-> Kyverno evaluates admission requests
-> bad resources are audited or denied
Install Capsule with Flux
Capsule can also be installed through Flux as a Helm release. The project publishes an OCI chart under GitHub Container Registry.
First define the source:
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
name: capsule
namespace: flux-system
spec:
type: oci
interval: 12h
url: oci://ghcr.io/projectcapsule/charts
Then define the release:
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: capsule
namespace: flux-system
spec:
interval: 10m
targetNamespace: capsule-system
releaseName: capsule
chart:
spec:
chart: capsule
version: 0.13.5
sourceRef:
kind: HelmRepository
name: capsule
namespace: flux-system
interval: 12h
install:
createNamespace: true
remediation:
retries: 3
upgrade:
remediation:
retries: 3
driftDetection:
mode: enabled
values:
crds:
install: true
manager:
options:
capsuleConfiguration: default
users:
- kind: Group
name: capsule.clastix.io
This is enough for a local learning cluster. For production, I would spend more time on strict RBAC, webhook behavior, monitoring, and certificate management.
Capsule should depend on cert-manager. Model that in Flux:
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: capsule
namespace: flux-system
spec:
interval: 5m
prune: true
wait: true
sourceRef:
kind: GitRepository
name: flux-system
path: ./infra/security/base/capsule
dependsOn:
- name: cert-manager
This assumes you have a Flux Kustomization named cert-manager which reconciles the cert-manager Helm release directory.
Then inspect it:
flux get helmreleases -A
kubectl -n capsule-system get pods
kubectl api-resources | grep -i capsule
You should see the Capsule controller running and the Tenant API available.
A Simple Capsule Tenant
Now create a tenant.
This example creates a solar tenant owned by the user alice. It allows at most two namespaces, adds labels to tenant namespaces, and defines a tenant-wide quota.
apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
name: solar
spec:
owners:
- kind: User
name: alice
namespaceOptions:
quota: 2
additionalMetadataList:
- labels:
tenant: solar
cost-center: solar
managed-by: capsule
resourceQuotas:
scope: Tenant
items:
- hard:
requests.cpu: "4"
requests.memory: 8Gi
limits.cpu: "8"
limits.memory: 16Gi
pods: "20"
Put it in:
infra/tenants/solar/tenant.yaml
Then reconcile it through Flux:
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: tenants
namespace: flux-system
spec:
interval: 5m
prune: true
wait: true
sourceRef:
kind: GitRepository
name: flux-system
path: ./infra/tenants
dependsOn:
- name: capsule
Check the tenant:
kubectl get tenants
kubectl describe tenant solar
For local testing, impersonate alice:
kubectl --as alice --as-group capsule.clastix.io create namespace solar-dev
kubectl --as alice --as-group capsule.clastix.io create namespace solar-test
The third namespace should fail because the tenant quota is two:
kubectl --as alice --as-group capsule.clastix.io create namespace solar-extra
Inspect what Capsule did:
kubectl get namespaces --show-labels | grep solar
kubectl get resourcequota -A
The important thing is that alice is not a cluster admin. Capsule gives her controlled self-service inside the tenant boundary.
Combining Capsule and Kyverno
Now the combination starts to make sense.
Capsule lets alice create namespaces inside the solar tenant.
Kyverno still evaluates workloads created in those namespaces.
Try this after creating solar-dev:
kubectl --as alice --as-group capsule.clastix.io \
-n solar-dev run bad-nginx --image=nginx
If the Kyverno policy is in Audit, the pod is created and reported.
If the policy is in Enforce, the pod is rejected because it has no resource requests.
Now apply a valid workload:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
namespace: solar-dev
spec:
replicas: 1
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.27
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi
Apply it as alice:
kubectl --as alice --as-group capsule.clastix.io apply -f nginx.yaml
That is the platform contract in action:
- Capsule says where the team may work.
- Capsule limits how much the team may consume.
- Kyverno says which workload shape is acceptable.
- Flux keeps all of that declared in Git.
A Practical Reconciliation Order
The ordering matters.
I would structure the Flux Kustomizations like this:
cert-manager
-> kyverno
-> capsule
-> kyverno-policies
-> tenants
You could install Kyverno and Capsule in parallel after cert-manager, but keeping a simple chain is easier while learning.
The dependency logic is:
- cert-manager first because Capsule needs webhook certificates
- Kyverno before Kyverno policies because policies need Kyverno CRDs
- Capsule before tenants because tenants need Capsule CRDs
- policies before tenant workloads because workloads should be checked
In Flux terms, that means dependsOn.
Example:
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: tenants
namespace: flux-system
spec:
dependsOn:
- name: capsule
- name: kyverno-policies
That small amount of ordering removes a lot of race conditions.
Useful Debugging Commands
For Flux:
flux get sources helm -A
flux get helmreleases -A
flux get kustomizations -A
flux logs --all-namespaces --follow
For Kyverno:
kubectl -n kyverno get pods
kubectl get clusterpolicies
kubectl get policyreport -A
kubectl get clusterpolicyreport
For Capsule:
kubectl -n capsule-system get pods
kubectl get tenants
kubectl describe tenant solar
kubectl get resourcequota -A
For a forced reconciliation:
flux reconcile helmrelease kyverno -n flux-system
flux reconcile helmrelease capsule -n flux-system
flux reconcile kustomization kyverno-policies -n flux-system
flux reconcile kustomization tenants -n flux-system
The first question should always be: which controller is unhappy?
If the HelmRelease is not ready, look at Flux and Helm events.
If Kyverno is installed but policy does not work, inspect the ClusterPolicy.
If Capsule is installed but namespace creation fails unexpectedly, inspect the Tenant status.
What I Would Not Do First
I would not start by writing twenty policies.
I would not start with production-grade multi-tenancy.
I would not immediately enforce everything.
Start small:
- Install cert-manager, Kyverno, and Capsule through Flux.
- Add one Kyverno policy in
Audit. - Add one Capsule tenant with one owner and a small namespace quota.
- Create a namespace as that owner.
- Deploy one bad pod and one good deployment.
- Flip the Kyverno policy from
AudittoEnforce. - Watch how the behavior changes.
That sequence teaches the operating model without burying you in platform design.
Final Thoughts
Flux is the delivery mechanism. Kyverno and Capsule are platform controls.
That distinction matters.
Flux should not become a bag of random YAML. It should describe a coherent cluster model: sources, controllers, policies, tenants, apps, and dependencies.
Kyverno gives you admission control and policy reports.
Capsule gives you tenant boundaries and delegated namespace ownership.
Together they are a practical next step after a basic Flux demo. You move from “Flux deploys my app” to “Flux manages the rules of the cluster.”
That is where GitOps starts to feel like platform engineering instead of just another deployment method.