Every self-hosted PaaS that runs more than one tenant on a shared cluster ends up writing the same piece of infrastructure: a mutating admission webhook that stamps resource limits, tenant labels, and a sidecar or two onto every pod a tenant creates. It's also the piece of infrastructure most operators dread — an always-on Deployment with its own TLS certs, its own failure mode, and a documented history of taking the whole cluster's pod scheduling down with it when it hangs. Kubernetes v1.36, released April 22, 2026, lets you delete it. MutatingAdmissionPolicy — CEL-expression mutation logic the API server runs in-process, no webhook server required — graduated to stable and is enabled by default.
What actually shipped, concretely
MutatingAdmissionPolicy isn't new in 1.36 — it's the end of a three-release graduation under KEP-3962:
| Kubernetes version | Stage |
|---|---|
| v1.30 | Alpha |
| v1.34 | Beta |
| v1.36 ("Haru", April 22, 2026) | Stable, enabled by default |
A policy is three objects working together: a MutatingAdmissionPolicy (the mutation logic, written in CEL), an optional parameter resource (a ConfigMap or CRD the policy reads from — e.g., per-namespace defaults), and a MutatingAdmissionPolicyBinding (which scopes the policy to specific namespaces or resources). The policy runs inside kube-apiserver itself — no network hop, no separate process to keep alive, no cert to rotate.
Its sibling, ValidatingAdmissionPolicy, made the same jump two releases earlier (stable in v1.30) and has already displaced a meaningful share of Gatekeeper and Kyverno's validating-webhook use cases in clusters that don't need those projects' policy-bundle ecosystems. MutatingAdmissionPolicy closes the other half of the admission-control story — and it's the half every multi-tenant platform, not just security-policy shops, ends up writing custom code for.
The before/after: a tenant-defaulting webhook, replaced
Here's the shape of the webhook most self-hosted PaaS platforms already run. On every Pod CREATE, it needs to: inject default CPU/memory limits when a tenant didn't set any, stamp a tenant-ownership label derived from the namespace, and append a log-shipping sidecar container. A trimmed Go admission handler doing all three looks like this:
func mutate(ar admissionv1.AdmissionReview) *admissionv1.AdmissionResponse {
pod := decodePod(ar.Request.Object.Raw)
patches := []jsonPatchOp{}
for i, c := range pod.Spec.Containers {
if c.Resources.Limits == nil {
patches = append(patches, jsonPatchOp{
Op: "add", Path: fmt.Sprintf("/spec/containers/%d/resources/limits", i),
Value: map[string]string{"cpu": "500m", "memory": "512Mi"},
})
}
}
patches = append(patches, jsonPatchOp{
Op: "add", Path: "/metadata/labels/tenant.bex.co~1id",
Value: tenantIDFromNamespace(ar.Request.Namespace),
})
patches = append(patches, jsonPatchOp{
Op: "add", Path: "/spec/containers/-",
Value: logShipperSidecar(),
})
return admissionResponseWithPatch(patches)
}That handler is maybe 40 lines. What it requires to run in production is not: a Deployment and Service fronting it, a cert-manager Certificate (or hand-rolled cert rotation) for the TLS the API server requires to call it, a MutatingWebhookConfiguration wiring it up with failurePolicy/timeoutSeconds tuned correctly, liveness/readiness probes, and pod-disruption-budget-aware rollout so a deploy of the webhook itself never leaves zero replicas serving admission calls.
Kubernetes 1.36's stable MutatingAdmissionPolicy replaces the logic with two CEL-based policies and zero of the surrounding infrastructure. The resource-limit default and tenant label — both non-atomic, mergeable fields — use ApplyConfiguration:
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicy
metadata:
name: "tenant-defaults.bex.co"
spec:
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE"]
resources: ["pods"]
failurePolicy: Fail
reinvocationPolicy: IfNeeded
mutations:
- patchType: "ApplyConfiguration"
applyConfiguration:
expression: >
Object{
metadata: Object.metadata{
labels: {"tenant.bex.co/id": namespaceObject.metadata.labels["bex.co/tenant-id"]}
}
}
- patchType: "ApplyConfiguration"
applyConfiguration:
expression: >
Object{
spec: Object.spec{
containers: object.spec.containers.map(c,
Object.spec.containers{
name: c.name,
resources: Object.spec.containers.resources{
limits: {"cpu": "500m", "memory": "512Mi"}
}
}
)
}
}apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicyBinding
metadata:
name: "tenant-defaults-binding.bex.co"
spec:
policyName: "tenant-defaults.bex.co"
matchResources:
namespaceSelector:
matchLabels: {bex.co/tenant: "true"}The sidecar append is a list insertion, which the docs call out as unsafe for ApplyConfiguration (it can't touch atomic arrays without risking the merge dropping unrelated entries) — so that one mutation stays on JSONPatch, the same patch semantics the old webhook used, just evaluated in-process instead of over HTTP:
mutations:
- patchType: "JSONPatch"
jsonPatch:
expression: >
[JSONPatch{op: "add", path: "/spec/containers/-", value:
Object.spec.containers{name: "log-shipper", image: "bex.co/log-shipper:v3", restartPolicy: "Always"}
}]Same outcome on the pod — default limits, a tenant label, an injected sidecar — as three lines in a YAML object instead of a Go binary you build, ship, and keep alive.
What operationally disappears
The code sample above is the readable half of the win; the operational half is what a platform team stops having to run at all:
- No Deployment/Service for the webhook. The mutation logic lives inside
kube-apiserver's existing process. Nothing new to schedule, scale, or lose a replica of. - No webhook TLS cert lifecycle. No
cert-managerCertificate, no CA bundle to keep in sync withMutatingWebhookConfiguration.clientConfig.caBundle, no expired-cert outage. - No webhook-timeout-blocks-scheduling failure mode. This is the one that actually pages people.
kubectl's own good-practices doc and multiple public postmortems — including a 2023 incident where a Kubernetes 1.24 upgrade left Reddit's API server timing out on admission-controller calls for over five hours — trace straight back to a webhook that's unreachable, slow, or mid-rollout with zero ready replicas. Kubernetes enforces a hard 30-second total admission budget across every webhook in the chain; stack two or three webhooks at 10 seconds each withfailurePolicy: Ignoreand pod creation can still fail on timeout even though every webhook is configured to "fail open." An in-process CEL evaluation has no network call to time out on — that entire failure class is gone by construction, not by tuningtimeoutSecondsmore carefully. - No reinvocation coordination headache across services.
reinvocationPolicy: IfNeededstill exists forMutatingAdmissionPolicy, but there's no cross-service ordering to reason about when the "other mutator" is just another declarative policy object evaluated in the same process, not a second HTTP round trip that might reorder relative to the first.
None of this requires ripping out other cluster components. Istio's linkerd-proxy- and Envoy-style sidecar injectors, Datadog's agent injector, and Gatekeeper/Kyverno policy engines all still run as webhooks in 2026 — this is about the tenant-defaulting logic a platform team wrote themselves, which is exactly the kind of narrow, well-defined mutation MutatingAdmissionPolicy targets.
Where CEL still falls short of a hand-written webhook
The honest limitation, and the reason nobody should delete every webhook in their cluster this week: CEL expressions run entirely inside the API server with no side effects and no network access. Three concrete cases still need a real webhook:
External data lookups. Say a platform wants to inject a sidecar whose image tag comes from a per-tenant entitlement service — "this tenant is on the Pro plan, so ship the v3 log shipper with extended retention; everyone else gets v2." A MutatingAdmissionPolicy can read a params resource (a ConfigMap or CRD already inside the cluster) via paramKind, but it cannot make an outbound HTTP call to an external entitlements API at admission time. If the source of truth lives outside the Kubernetes API, the webhook stays.
Arbitrary imperative logic. CEL supports comprehensions (map, filter) and conditionals, but it isn't Go — there's no calling out to a shared internal library, no complex multi-step branching logic that's easier to express as code than as a CEL expression tree. A webhook that, say, parses a tenant's custom Dockerfile labels to decide sidecar placement is porting a program, not a patch, and stays a webhook.
Atomic-field mutation nuance. ApplyConfiguration deliberately refuses to touch atomic structs, maps, and arrays (the API server won't risk a merge silently dropping fields it didn't know about) — the sidecar-injection example above already had to fall back to JSONPatch for exactly this reason. JSONPatch mode covers most of that gap, but it means a platform team auditing "can I port this webhook" has to check every mutation's target field, not just assume the whole handler moves over cleanly.
What to actually port this quarter
A reasonable migration order for a platform team running its own tenant-defaulting webhook: audit the webhook's mutations one at a time, not the whole handler at once. Resource-limit defaults, label/annotation injection, and simple field defaulting (image pull policy, securityContext.runAsNonRoot) port cleanly to ApplyConfiguration. List insertions — sidecar containers, extra volumes — port to JSONPatch, same as before, just without the HTTP hop. Anything that reaches outside the cluster for data stays exactly where it is. For most tenant-defaulting webhooks, that's the majority of the logic moving off a service you had to keep alive, with a genuine, auditable remainder still running as a webhook on purpose rather than by default.
That split is worth writing down explicitly rather than leaving it as tribal knowledge: a short MIGRATED.md next to the old webhook's source noting which mutations moved to which MutatingAdmissionPolicy and why the rest didn't saves the next person from re-litigating the same audit. The remaining webhook footprint also gets easier to reason about once it's small — a webhook that only handles the "call out to the entitlements service" case is a much easier thing to keep highly available than one that also handles resource limits, labels, and sidecars, because its blast radius on a bad deploy is one specific mutation instead of every pod created in the cluster.
None of this is Kubernetes quietly removing webhooks from the picture — MutatingAdmissionPolicy still can't do everything a webhook can, and SIG API Machinery has been explicit that it's not trying to. What it changes is the default: the mutation logic every multi-tenant platform used to have to stand up a service for is now something you can express, review, and diff as a Kubernetes object, and reserve the operational cost of a webhook for the mutations that actually need to leave the cluster to do their job.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, orchestrated by Cluster API on hardware you actually control. Star the repo on GitHub or deploy your first app today.