Platform Engineering: Policy as Code — Kyverno, OPA Gatekeeper, and Admission Controllers
Deep guide to Kubernetes policy enforcement — Kyverno validate/mutate/generate policies with full YAML, OPA Gatekeeper with Rego constraint templates, CEL-native ValidatingAdmissionPolicy, and a platform policy library strategy.
Policy as Code: What It Solves
Without policy enforcement, Kubernetes is permissive by default — teams can deploy containers as root, skip resource limits, use unapproved images, and skip security contexts. Drift is invisible until a security audit or incident exposes it.
Policy as Code embeds governance directly into the admission control pipeline. Every kubectl apply or Helm install passes through policy evaluation before anything lands in etcd. Non-compliant resources are rejected with a human-readable error.
Three approaches:
- Kyverno — Kubernetes-native policies in YAML. No new language to learn. Best for teams that want "policies that look like Kubernetes."
- OPA Gatekeeper — Policies in Rego (Datalog-inspired). More expressive, cross-system (can also policy Terraform, CI). Best for complex logic.
- ValidatingAdmissionPolicy (CEL) — Built into Kubernetes 1.26+. No CRDs needed. Limited but zero-dependency.
Most platform teams use Kyverno for cluster governance (image policies, security contexts, labels) and OPA for complex business rules or multi-system policies.
Admission Controller Flow
kubectl apply → API Server → Authentication → Authorization (RBAC)
↓
MutatingAdmissionWebhook
(Kyverno mutate / Istio inject)
↓
Schema validation
↓
ValidatingAdmissionWebhook
(Kyverno validate / OPA Gatekeeper)
↓
Persisted to etcd ✓Mutations run first (sidecars injected, defaults added), then validation. A rejected resource never reaches etcd.
Kyverno: Kubernetes-Native Policies
helm install kyverno kyverno/kyverno \
--namespace kyverno \
--create-namespace \
--set replicaCount=3 # HA for productionValidate: reject non-compliant resources
Require resource requests and limits on all containers:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-resource-limits
spec:
validationFailureAction: Enforce # Enforce = reject; Audit = log only
background: true # also audit existing resources
rules:
- name: check-resource-limits
match:
any:
- resources:
kinds: [Pod]
exclude:
any:
- resources:
namespaces: [kube-system, kyverno, monitoring]
validate:
message: "CPU and memory limits are required for all containers."
pattern:
spec:
containers:
- name: "*"
resources:
limits:
cpu: "?*"
memory: "?*"
requests:
cpu: "?*"
memory: "?*"Disallow privileged containers:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: disallow-privileged-containers
spec:
validationFailureAction: Enforce
rules:
- name: no-privileged
match:
any:
- resources:
kinds: [Pod]
validate:
message: "Privileged containers are not allowed."
deny:
conditions:
any:
- key: "{{ request.object.spec.containers[].securityContext.privileged | contains(@, `true`) }}"
operator: Equals
value: trueRequire non-root user:
rules:
- name: check-run-as-non-root
validate:
message: "Containers must not run as root (runAsNonRoot: true required)."
pattern:
spec:
securityContext:
runAsNonRoot: true
containers:
- name: "*"
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: falseMutate: auto-add defaults
Auto-add team label and cost center from namespace annotations:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: add-team-labels
spec:
rules:
- name: add-labels-from-namespace
match:
any:
- resources:
kinds: [Pod]
mutate:
patchStrategicMerge:
metadata:
labels:
+(team): "{{ namespace_labels.'team' }}"
+(cost-center): "{{ namespace_labels.'cost-center' }}"Set default security context if missing:
rules:
- name: set-security-context
match:
any:
- resources:
kinds: [Pod]
mutate:
patchStrategicMerge:
spec:
securityContext:
+(runAsNonRoot): true
+(seccompProfile):
type: RuntimeDefault
containers:
- (name): "*"
securityContext:
+(allowPrivilegeEscalation): false
+(readOnlyRootFilesystem): trueThe +() prefix means: only set if the field is missing. This avoids overwriting intentional values.
Generate: auto-create resources for new namespaces
Auto-create a NetworkPolicy for every new namespace:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: generate-default-networkpolicy
spec:
rules:
- name: generate-deny-all
match:
any:
- resources:
kinds: [Namespace]
generate:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
name: default-deny-all
namespace: "{{request.object.metadata.name}}"
synchronize: true # update the generated resource if the policy changes
data:
spec:
podSelector: {}
policyTypes: [Ingress, Egress]synchronize: true means if someone deletes the generated NetworkPolicy, Kyverno re-creates it. The platform enforces it persistently.
Auto-create LimitRange for every namespace:
generate:
apiVersion: v1
kind: LimitRange
name: default-limits
namespace: "{{request.object.metadata.name}}"
data:
spec:
limits:
- type: Container
default:
cpu: "500m"
memory: "256Mi"
defaultRequest:
cpu: "100m"
memory: "128Mi"
max:
cpu: "4"
memory: "4Gi"Image verification with Kyverno (supply chain security)
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-image-signatures
spec:
validationFailureAction: Enforce
rules:
- name: check-image-signature
match:
any:
- resources:
kinds: [Pod]
verifyImages:
- imageReferences:
- "registry.example.com/*"
attestors:
- count: 1
entries:
- keyless:
subject: "https://github.com/org/*"
issuer: "https://token.actions.githubusercontent.com"
rekor:
url: https://rekor.sigstore.dev
attestations:
- predicateType: https://slsa.dev/provenance/v0.2
conditions:
- all:
- key: "{{ buildType }}"
operator: Equals
value: "https://github.com/slsa-framework/slsa-github-generator"Only images signed by GitHub Actions via Sigstore keyless are allowed. An unsigned image or one from an unapproved registry is rejected at admission.
OPA Gatekeeper: Rego Policies
helm install gatekeeper gatekeeper/gatekeeper \
--namespace gatekeeper-system \
--create-namespaceGatekeeper uses a two-step model:
- ConstraintTemplate — defines the policy logic in Rego
- Constraint — instantiates the template with parameters for a specific enforcement scope
ConstraintTemplate + Constraint
Require specific labels on all Deployments:
# Step 1: Define the policy logic in Rego
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: requirelabels
spec:
crd:
spec:
names:
kind: RequireLabels
validation:
openAPIV3Schema:
properties:
labels:
type: array
items:
type: string
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package requirelabels
violation[{"msg": msg}] {
provided := {label | input.review.object.metadata.labels[label]}
required := {label | label := input.parameters.labels[_]}
missing := required - provided
count(missing) > 0
msg := sprintf("Missing required labels: %v", [missing])
}# Step 2: Instantiate with parameters
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: RequireLabels
metadata:
name: require-team-labels
spec:
enforcementAction: deny
match:
kinds:
- apiGroups: ["apps"]
kinds: ["Deployment"]
excludedNamespaces: [kube-system]
parameters:
labels:
- team
- cost-center
- environmentRego: allowed image registries
rego: |
package allowedregistries
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
not starts_with_allowed(container.image)
msg := sprintf("Container image '%v' from unapproved registry", [container.image])
}
starts_with_allowed(image) {
allowed := input.parameters.registries[_]
startswith(image, allowed)
}parameters:
registries:
- "registry.example.com/"
- "gcr.io/distroless/"
- "ghcr.io/org/"Rego: advanced — cross-object validation with data sync
OPA Gatekeeper can sync cluster state into OPA's data store for cross-object policy. For example, require that a ServiceAccount referenced by a Pod actually exists:
apiVersion: config.gatekeeper.sh/v1alpha1
kind: Config
metadata:
name: config
namespace: gatekeeper-system
spec:
sync:
syncOnly:
- group: ""
version: v1
kind: ServiceAccount
- group: ""
version: v1
kind: Namespaceviolation[{"msg": msg}] {
sa_name := input.review.object.spec.serviceAccountName
ns := input.review.object.metadata.namespace
# Look up synced state
not data.inventory.namespace[ns]["v1"]["ServiceAccount"][sa_name]
msg := sprintf("ServiceAccount '%v' does not exist in namespace '%v'", [sa_name, ns])
}ValidatingAdmissionPolicy (CEL) — Kubernetes Built-in
Available in Kubernetes 1.26+ (stable in 1.30). No webhook, no CRDs from external projects:
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: require-run-as-non-root
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
validations:
- expression: >
object.spec.securityContext.runAsNonRoot == true ||
object.spec.containers.all(c,
c.?securityContext.?runAsNonRoot.orValue(false) == true
)
message: "Pods must run as non-root."
- expression: >
!object.spec.containers.exists(c,
c.?securityContext.?privileged.orValue(false) == true
)
message: "Privileged containers are not allowed."
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: require-run-as-non-root-binding
spec:
policyName: require-run-as-non-root
validationActions: [Deny]
matchResources:
namespaceSelector:
matchExpressions:
- key: kubernetes.io/metadata.name
operator: NotIn
values: [kube-system, kyverno]CEL policies are processed in-process by the API server — no webhook latency, no external dependency. The tradeoff: less expressive than Rego, no mutation, no generation.
Platform Policy Library
Structure your policies as a Git-managed library deployed via ArgoCD:
gitops-repo/
└── platform/
└── policies/
├── kyverno/
│ ├── baseline/ # Pod Security Standards baseline
│ │ ├── disallow-privileged.yaml
│ │ ├── require-non-root.yaml
│ │ └── require-seccomp.yaml
│ ├── restricted/ # Stricter — for payment/PII namespaces
│ │ ├── require-readonly-root.yaml
│ │ └── require-drop-all-caps.yaml
│ ├── supply-chain/ # Image signing and SBOM
│ │ ├── verify-image-signatures.yaml
│ │ └── require-sbom-attestation.yaml
│ └── generators/ # Auto-create resources for new namespaces
│ ├── networkpolicy.yaml
│ ├── limitrange.yaml
│ └── resourcequota.yaml
└── gatekeeper/
├── templates/
└── constraints/ArgoCD syncs this directory to every cluster. New policies go through the same GitOps review process as application changes — a PR with a policy change gets reviewed before enforcement.
Policy tiers
| Tier | Scope | Mode |
|---|---|---|
| baseline | All namespaces except kube-system | Enforce |
| restricted | Namespaces with label security-tier=restricted | Enforce |
| audit | New policies under review | Audit (log, don't reject) |
Teams onboard in audit mode first — they see violations in Kyverno's PolicyReport without being blocked:
# Check policy violations for a namespace (audit mode)
kubectl get policyreport -n my-team
# NAME PASS FAIL WARN ERROR SKIP
# polr-ns-my-team 47 3 0 0 0
# See which pods are failing
kubectl describe policyreport polr-ns-my-team -n my-teamTeams fix violations, then the namespace label is promoted to Enforce.
Choosing: Kyverno vs Gatekeeper vs CEL
| Criterion | Kyverno | Gatekeeper (OPA) | CEL | |---|---|---|---| | Language | YAML | Rego | CEL (Go expression) | | Mutation | Yes | No | No | | Generation | Yes | No | No | | Cross-object policy | Limited | Yes (via sync) | No | | Image verification | Yes (Cosign) | No (external) | No | | Learning curve | Low | High | Medium | | Performance | External webhook | External webhook | In-process (fast) | | Best for | Cluster governance | Complex logic | Simple validation (no deps) |
Recommended default: Kyverno for cluster governance, image verification, and namespace bootstrapping. Add Gatekeeper only when Rego's expressiveness is genuinely needed (cross-resource policy, multi-system policy, complex arithmetic). Use CEL for simple rules on clusters where you want zero external dependencies.
Enjoyed this article?
Explore the Cloud & DevOps learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.