Skip to main content

Delete Your Validating Webhook: What Kubernetes 1.36 Declarative Validation GA Actually Means for PaaS Operators

10 min readDora NodaDora Noda
Share
On this page

The most reliable admission webhook is the one you delete.

If you run a self-hosted platform with your own App, Tenant, or Deploy custom resources, you have probably operated a validating webhook just to reject malformed objects: a deployment to keep available, certificates to rotate, and a failurePolicy: Fail setting that turns every webhook outage into a cluster-wide write outage. Kubernetes 1.36 ("Haru," released April 22, 2026, with 70 enhancements: 18 stable, 25 beta, 25 alpha) graduated declarative validation to general availability — and the honest version of this story starts with a correction, because the thing most operator authors want from that headline actually shipped earlier.

Here is the correction, stated plainly so the rest of this post is trustworthy: CEL validation rules for CRDs (x-kubernetes-validations, the self/oldSelf expressions this post is mostly about) were promoted to GA in Kubernetes 1.29. What went GA in 1.36 is declarative validation for Kubernetes native types: the validation-gen code generator and +k8s: marker tags that replace thousands of lines of handwritten ValidateX() Go functions inside Kubernetes itself (Yongrui Lin's release blog, May 5, 2026). Same philosophy, different artifact. And operator authors should still care about 1.36, because it locks in the direction the whole ecosystem — including Kubebuilder — is moving: validation that travels with the schema instead of living in a separate service.

The payoff, either way, is the same before/after. Before: a webhook server. After: five lines of CEL in the CRD:

yaml
x-kubernetes-validations:
  - rule: "self.replicas >= 1 && self.replicas <= self.plan.maxReplicas"
    message: "replicas must be within the plan's maxReplicas"
  - rule: "self == oldSelf || self.image != oldSelf.image"
    message: "image is immutable after creation — create a new App instead"
    optionalOldSelf: true

That rejects a bad kubectl apply inside the API server, with no network hop, no certificate, and no second deployment to monitor. The rest of this post earns that snippet: what 1.36 actually shipped, the full before/after with a second Tenant example and real rejection output, the decision table for what stays in a webhook, and a migration checklist sized for a small fleet.

What 1.36 actually shipped: validation-gen, not CRD CEL

Three things graduated or matured in 1.36 that matter here, kept strictly separate so nobody misattributes them.

First, validation-gen. Kubernetes historically validated native types (Pods, Deployments, Services) with handwritten Go validation functions — thousands of lines of ValidatePod(), ValidateService(), each with its own conventions, its own bugs, and its own review burden. The problems SIG API Machinery named were discoverability (tooling could not see rules hidden in Go code), inconsistency (every hand-written validator phrases constraints differently), and review cost (every new API field needs a human to check its validation). The fix is a code generator, in the same family as the deep-copy and defaulting generators: you annotate types.go with marker tags and the generator emits the validation functions, registered seamlessly with the API scheme.

Second, the +k8s: tag suite. The common tags cover presence (+k8s:optional, +k8s:required), basic constraints (+k8s:minimum=0, +k8s:maximum=100, +k8s:maxLength=16, +k8s:format=k8s-short-name), and lifecycle semantics including the one operator authors should steal conceptually: ambient ratcheting. Previously, tightening validation required shipping handwritten ratcheting code, waiting a release, then tightening — otherwise existing objects that predated the rule would fail on their next unrelated update. With declarative validation, ratcheting is built in: on update, the framework compares the incoming object with the old object, and if a field's value is semantically unchanged, the new rule is bypassed for that field. You can tighten validation immediately with the least disruptive behavior as the default.

Third, the review pipeline: kube-api-linter. Because rules now live as structured markers instead of opaque Go, a linter can statically analyze API types and enforce conventions automatically — which is how the project scaled API review while migrating. The DeclarativeValidation feature gate is enabled by default in 1.36, and declarative validation is now the required mechanism for new APIs and new fields.

What 1.36 did not do is change anything about CRD CEL syntax — that surface has been stable since 1.29. What it did is signal, credibly, that the declarative model won: the release blog explicitly names OpenAPI publication of validation rules (so kubectl, client libraries, and IDEs can validate client-side before a request leaves the workstation) and Kubebuilder consumption of the same framework as the forward path. If you write CRDs with Kubebuilder today, you are already writing the ecosystem side of this bet with +kubebuilder:validation: markers. 1.36 is the core project confirming it will meet you there.

The before/after you came for: deleting a webhook with CEL

Consider a platform App CRD with the three rule shapes the TODO for this topic promised: a required field, a numeric range, and a cross-field constraint. Here is the schema-fragment version, with the Kubebuilder markers you would actually commit:

go
// +kubebuilder:validation:MinLength=1
Name string `json:"name"`
 
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=64
Replicas int32 `json:"replicas"`
 
// +kubebuilder:validation:XValidation:rule="self.replicas >= 1 && self.replicas <= self.plan.maxReplicas",message="replicas must be within the plan's maxReplicas"
// +kubebuilder:validation:XValidation:rule="self == oldSelf || self.plan == oldSelf.plan",message="plan is immutable after creation",optionalOldSelf=true
Plan PlanSpec `json:"plan"`

The first two rules are the OpenAPI-schema basics — required-ness, minimum, maximum — that never needed a webhook in any Kubernetes version. The last two are the ones that used to need one: a cross-field comparison (replicas against plan.maxReplicas) and an immutability transition rule (plan cannot change on update, expressed with oldSelf). The optionalOldSelf=true on the transition rule matters: on create there is no old object, and without it the rule would need to handle a missing oldSelf itself.

A Tenant quota example shows the range rule plus a second cross-field shape in one place:

yaml
x-kubernetes-validations:
  - rule: "self.quota.cpuMillis >= 100 && self.quota.cpuMillis <= 64000"
    message: "quota.cpuMillis must be between 100m and 64000m"
  - rule: "self.quota.memoryMi >= self.quota.cpuMillis / 4"
    message: "quota.memoryMi must scale with cpuMillis (at least 1Mi per 4m CPU)"

And the rejection is what the developer actually feels. Before (webhook down, failurePolicy: Fail): every write fails, including valid ones, with a webhook-call error. After (CEL in the schema):

text
$ kubectl apply -f app.yaml
The App "shop" is invalid: spec: Invalid value: object:
  replicas must be within the plan's maxReplicas

That error comes from the API server itself, deterministically, with the message you wrote. No second service participated.

Two CEL mechanics deserve explicit mention because they bite real platforms. Scope: every rule is scoped to the current object — self is bound to the value at the rule's location in the schema tree, and cross-object or stateful checks (does this StorageClass exist, is this hostname already claimed by another Tenant) are not expressible.

Cost: CEL evaluation has deterministic cost accounting — simple comparisons cost 1 unit each — and the API server estimates worst-case cost when the expression is written, rejecting CEL that would be prohibitively expensive at write time rather than letting it slow down admission at runtime. Keep rules tight; a rule that walks a huge list will be refused at CRD-apply time, which is the system protecting your apiserver latency from your own cleverness.

Webhook vs CEL: the decision table

The operators that have already made this call converge on the same split. The Neo4j Kubernetes operator's architecture record puts CEL as the primary admission guard (structural rules, enums, cross-field guards — cheap, in CRD OpenAPI) and reserves the validating webhook for what CEL cannot see (edition and license checks, storage-class existence, scale-in policy).

The GitLab-runner operator went further and replaced its admission webhook with CEL outright, moving the one flag CEL could not see into the reconciler. The pattern is consistent enough to tabulate:

ConcernCEL (x-kubernetes-validations)Validating webhook
Where it runsIn-process in the API serverSeparate deployment, called over the network per matching request
AvailabilityNo extra failure domain; no failurePolicy to misconfigurefailurePolicy: Fail blocks writes when the webhook is down; Ignore silently skips validation — pick your poison per rule
OperationsZero: ships inside the CRD YAML, versioned with the schemaDeployment + Service + certificates (rotation!), scope selectors excluding kube-system and the operator namespace
LatencyIn-process evaluation, worst-case cost budgeted at write timeNetwork round trip per admission, on the write path of every matching object
Cross-field / transition rulesYes: self plus oldSelf on updateYes, with full Go expressiveness
Cross-object / external stateNo — current object onlyYes: list other resources, call external systems
Mutation and defaultingSchema default: only; logic beyond that needs a mutating webhook or operator-side renderingFull defaulting and normalization
AuditabilityRules visible in the CRD; kubectl explain and future OpenAPI tooling can display themRules hidden in operator code; discoverable only by reading the binary's source

The honest bottom line for a PaaS: move every rule CEL can express into the schema, keep a narrow webhook only for the rest, and treat "we kept the webhook for everything because it was already there" as tech debt with a monthly certificate-rotation reminder attached. The failure mode to design around explicitly is the one the table's second row names: a broad webhook with failurePolicy: Fail converts a small operator outage into a control-plane-wide write freeze, and Ignore converts it into silent acceptance of invalid objects. CEL has neither failure mode because there is no second service to fail.

What stays in the webhook (or moves to the reconciler with a status.conditions warning instead of a rejection) is a short, principled list: references to other objects (StorageClass existence, hostname uniqueness across Tenants), licensed or edition-gated features, anything requiring a network call, and defaulting logic the schema cannot express. Everything else — required fields, ranges, enums, formats, cross-field comparisons, immutability — belongs in CEL.

Migration checklist for a small fleet

This is ordered for a team with no spare on-call capacity, where each step must leave the fleet strictly safer than it found it.

  1. Inventory the webhook's rules. List every rejection your webhook can produce. Tag each one: schema-expressible (required, range, enum, pattern, cross-field, immutable-field) or webhook-only (cross-object, external call, complex defaulting). Most operators find the first bucket holds the large majority.
  2. Write the CEL first, webhook second. Add XValidation rules (or +kubebuilder:validation:XValidation markers and regenerate) alongside the running webhook. CEL and webhooks compose — both must pass — so this is a safe additive step. Include message on every rule; the default generated message is accurate but unfriendly.
  3. Test the ratcheting behavior on update. Create an object, then tighten a rule and update an unrelated field: the update must succeed (ambient equivalent — unchanged values pass). Then change the guarded field to a violating value and confirm rejection. Cover optionalOldSelf paths by testing create separately from update.
  4. Shrink the webhook to the webhook-only list. Delete every rule CEL now enforces. If nothing remains, delete the webhook deployment, service, certificates, and ValidatingWebhookConfiguration — and the cert-manager plumbing that existed only to feed it. If something remains, narrow the webhook's match rules and namespace/object selectors to exactly that remainder.
  5. Version the CRD change like a schema migration. CEL rules ship in the CRD YAML, so a bad rule blocks writes the same way a bad schema does. Roll CRD updates through the same staging-first path as any storage-version change, and keep the previous CRD YAML one kubectl apply away until the new rules have seen real traffic.

One versioning note: CEL validation rules require a reasonably current cluster (GA since 1.29, so any supported 1.3x fleet has them), while the 1.36 validation-gen surface concerns the API server's own native types, not your CRDs. Your migration depends on the former; the latter is the reason to believe the former is the permanent direction rather than a fashion.

Validation should travel with the schema

The through-line from 1.29's CEL GA through 1.36's validation-gen GA is one idea: a validation rule nobody can see without reading a service's source code is a rule that will surprise someone at 2 a.m. Rules embedded in the schema are visible to kubectl, to reviewers, to linters, and soon to every OpenAPI consumer — the same object that declares a field declares what values it accepts, and no certificate expiry can suspend that.

If you maintain a PaaS operator this quarter, the concrete deliverable is small: open your webhook's validation function, move every self-contained rule into x-kubernetes-validations with a human message, and measure what is left. Most teams will find the webhook that remains is either tiny enough to delete or specific enough to finally deserve its pager.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex