Custom Resource Definition
A CustomResourceDefinition (CRD) is the Kubernetes mechanism for adding entirely new kinds of resources to the cluster’s API — extending the API surface without recompiling or restarting the API server. The Kubernetes documentation frames the distinction precisely: “A resource is an endpoint in the Kubernetes API that stores a collection of API objects of a certain kind … A custom resource is an extension of the Kubernetes API that is not necessarily available in a default Kubernetes installation. … The CustomResourceDefinition API resource allows you to define custom resources” (k8s.io — Custom Resources). Once you
kubectl applya CRD object, the API server immediately begins serving a full RESTful endpoint for the new kind —kubectl get,list,watch,create,update,patch,deleteall work, the resource participates in Kubernetes RBAC, it is stored in etcd alongside built-in resources, and it gets validation, defaulting, and a/statussubresource if you ask for one. The crucial conceptual point, and the reason this note is paired with Operator Pattern: a CRD is pure data — it defines a type and stores instances, but it does nothing. ACertificateCRD does not issue certificates; aPostgresClusterCRD does not provision databases. A CRD becomes behavior only when a controller — see Controller Pattern — watches instances of it and acts. The CRD is the API; the controller is the implementation. This note covers the API half: how you declare a new type, what the API server gives you for free, and where the CRD approach reaches its limits.
Mental Model
flowchart TB AUTHOR[Cluster admin or<br/>operator vendor] -->|"kubectl apply -f crd.yaml"| CRDOBJ["CustomResourceDefinition object<br/>(group, versions[], scope, names)"] CRDOBJ -->|registers a new type| APISERVER[kube-apiserver] APISERVER -->|"now serves a REST endpoint:<br/>/apis/GROUP/VERSION/.../KIND"| ENDPOINT[New API endpoint] USER[User] -->|"kubectl apply -f my-postgres.yaml"| ENDPOINT ENDPOINT -->|validate against<br/>OpenAPI v3 + CEL schema| VALIDATE{Schema valid?} VALIDATE -- no --> REJECT[422 rejected] VALIDATE -- yes --> STORE[(etcd<br/>stores the custom-resource instance)] STORE -.->|watch| CONTROLLER["Controller / Operator<br/>(separate process)"] CONTROLLER -->|"acts: provision StatefulSet,<br/>configure replication, ..."| WORLD[Real world] CONTROLLER -->|"writes .status"| STORE style CRDOBJ fill:#e8f0fe style CONTROLLER fill:#fde8e8
What this diagram shows. Two distinct lifecycles. The blue path is the CRD itself: applying a CustomResourceDefinition object teaches the API server a new type, after which the API server serves, validates, and stores instances of that type entirely on its own — no custom code runs. The red box is the controller, a separate program that watches the stored instances and makes the real world match them. The diagram’s load-bearing insight: everything in the blue path is free and built in; nothing in the red box is. A CRD with no controller is a perfectly functional, validated, queryable database table that no one reads. People who deploy a CRD and expect things to happen have confused the schema for the engine.
Mechanical Walk-through
What the API server gives you for free
When a CustomResourceDefinition is accepted, the API server immediately provides, with zero custom code:
- A full REST endpoint under
/apis/<group>/<version>/...supporting get / list / watch / create / update / patch / delete with the same semantics as built-in resources. - Watch streams — controllers and
kubectl get -wcan subscribe to changes (see Watch and Informers). - RBAC integration — the new kind is an ordinary resource for Kubernetes RBAC; you write
Roles withapiGroups,resources,verbsexactly as for Pods. - Schema validation — the API server rejects instances that violate the CRD’s OpenAPI v3 schema before they reach etcd.
- Defaulting — schema
default:values are applied server-side on write. - The standard object shape — every custom-resource instance is
{apiVersion, kind, metadata, spec, status}, integrating cleanly with Kubernetes Object Model, Owner References and Garbage Collection, Finalizers, labels, and annotations. kubectlintegration —kubectl get,describe,explainall work;additionalPrinterColumnscustomizes thegettable output.- Audit logging — operations on the custom resource appear in the audit log like any other.
- Field selectors over
selectableFields—spec.versions[*].selectableFieldsdeclares JSONPaths (e.g..spec.color) that the API server then accepts in--field-selectorfilters onLIST/WATCH/DELETECOLLECTION, indexed in etcd just like the built-inspec.nodeNameselector on Pods. This is GA since Kubernetes 1.32 (KEP-4358; Field Selectors), having moved through alpha in 1.30 and beta in 1.31. Before this, every CRD effectively supported onlymetadata.nameandmetadata.namespacein field selectors, which forced controllers into full-list-and-filter patterns over potentially huge collections.
Anatomy of a CRD object
A CustomResourceDefinition has four substantive parts inside its spec:
group— the API group the new kind belongs to, e.g.acme.example.com. Convention: a DNS domain you control, to avoid collisions.versions[]— a list of versions, because a CRD evolves over time. Each version entry carries:name— e.g.v1alpha1,v1beta1,v1.served(boolean) — whether the API server serves this version’s endpoint. You can keep an old versionserved: truefor backward compatibility while clients migrate.storage(boolean) — exactly one version must be the storage version; that is the form the object is persisted as in etcd. Other versions are converted to/from it on read/write.schema.openAPIV3Schema— the OpenAPI v3 schema validating instances of this version (see below).subresources— optionally enables the/statusand/scalesubresources.additionalPrinterColumns— extra columns forkubectl get.
scope—Namespaced(instances live in a namespace, like Pods) orCluster(instances are cluster-global, like Nodes). Chosen once, immutable.names—kind(PascalCase, e.g.CronTab),plural(lowercase, the URL path segment, e.g.crontabs),singular,listKind, and optionalshortNames(e.g.ct) andcategories(so the kind appears underkubectl get all-style groupings).
Structural schemas (mandatory in apiextensions.k8s.io/v1)
Early CRDs (the apiextensions.k8s.io/v1beta1 API) could be schemaless — the API server stored arbitrary JSON. That is gone. The GA API, apiextensions.k8s.io/v1, generally available since Kubernetes 1.16 (k8s.io — CRDs), requires a structural schema. The structural-schema rules are precise (see the Future of CRDs: Structural Schemas blog and the CRD docs): every leaf field has a declared type; logical combinators (allOf, anyOf, oneOf, not) are restricted to value validation and may not introduce or alter type/shape (they cannot live at the top of the schema or at object roots in a way that obscures the property set); and the property tree must be statically knowable. Structural schemas are what unlock the API-server-side machinery the rest of this note depends on: pruning of unknown fields, server-side defaulting from default:, conversion between versions, server-side kubectl apply (SSA), and field selectors over selectableFields.
The legacy apiextensions.k8s.io/v1beta1 API was deprecated in Kubernetes 1.16 and removed in Kubernetes 1.22 (Deprecated API Migration Guide). Migration to v1 is mechanical but not field-identical: spec.scope is no longer defaulted to Namespaced and must be set; spec.version, spec.validation, spec.subresources, and spec.additionalPrinterColumns move under each entry of spec.versions[]; spec.conversion.webhookClientConfig moves to spec.conversion.webhook.clientConfig; the JSONPath field on printer columns is renamed jsonPath; spec.preserveUnknownFields: true is disallowed at the spec level (use x-kubernetes-preserve-unknown-fields: true inside schema nodes); and spec.versions[*].schema.openAPIV3Schema is mandatory. Any pre-2020 CRD shipped under v1beta1 will fail to apply on a current cluster.
Validation: OpenAPI v3 plus CEL rules
Two layers of validation:
- OpenAPI v3 structural validation — types,
required,enum,minimum/maximum,minLength/maxLength,pattern(regex),format. The API server enforces these on every write. - CEL validation rules (
x-kubernetes-validations) — for cross-field and conditional constraints that OpenAPI cannot express (“spec.replicasmust be odd”, “spec.maxSizemust be ≥spec.minSize”, “spec.tiercannot change once set”). These use the Common Expression Language; see CEL in Kubernetes. CEL rules were beta and on-by-default since Kubernetes 1.25, and reached GA in Kubernetes 1.29 (k8s.io — CRD validation rules beta; Google Open Source Blog — CRD validation using CEL). They are a partial replacement for validating admission webhooks — running in-process in the API server, with no extra network hop, no webhook to operate, and no availability risk.
Subresources: /status and /scale
A CRD can opt into two subresources via spec.versions[].subresources:
status: {}— splits the resource into a/statusREST endpoint. This is what lets a controller write.status(observed state) without bumpingmetadata.generation(which then tracks only user-driven.specchanges — the basis of the “has the controller caught up?” check). Without the status subresource, a write to.statusis just an ordinary update and the generation/observedGeneration discipline breaks. Real operators almost always enable it.scale: {}— exposes a/scaleendpoint, mapping configured JSONPaths forspec.replicas,status.replicas, and a label selector. This is what lets the Horizontal Pod Autoscaler scale a custom resource —kubectl scaleand the HPA both target the/scalesubresource generically, so any CRD that wires it up is autoscalable.
Conversion between versions
When a CRD has multiple versions[], the API server must convert objects between them — a client may read v1 while the storage version is v1beta1. Two strategies, set in spec.conversion.strategy:
None— the API server changes only theapiVersionfield and otherwise passes the object through unchanged. Valid only if all versions are structurally identical (a pure rename). Most early CRDs use this.Webhook— the API server calls an external conversion webhook that translates the object between versions. Required whenever versions differ in shape (a renamed field, a restructured nested object). See Conversion Webhooks for the request/response contract and the operational cost (the webhook is now on the critical path of every read of that resource).
Configuration / API Surface
A complete CRD plus a custom-resource instance, annotated:
apiVersion: apiextensions.k8s.io/v1 # the GA CRD API — GA since K8s 1.16
kind: CustomResourceDefinition
metadata:
# name MUST be exactly <plural>.<group>
name: crontabs.stable.example.com
spec:
group: stable.example.com # the API group of the new kind
scope: Namespaced # instances live in namespaces
names:
kind: CronTab # PascalCase; what `kind:` says in instances
plural: crontabs # lowercase; the URL path segment
singular: crontab
shortNames: [ct] # `kubectl get ct`
categories: [all] # appears in `kubectl get all`
versions:
- name: v1 # this version's identifier
served: true # API server serves /apis/.../v1/crontabs
storage: true # persisted in etcd in THIS version's shape
subresources:
status: {} # enable the /status subresource
scale: # enable the /scale subresource (HPA-able)
specReplicasPath: .spec.replicas
statusReplicasPath: .status.replicas
labelSelectorPath: .status.selector
additionalPrinterColumns: # extra `kubectl get crontabs` columns
- name: Schedule
type: string
jsonPath: .spec.cronSpec
- name: Replicas
type: integer
jsonPath: .spec.replicas
schema:
openAPIV3Schema: # STRUCTURAL schema — mandatory in v1
type: object
properties:
spec:
type: object
properties:
cronSpec:
type: string
pattern: '^(\d+|\*)(/\d+)?(\s+(\d+|\*)(/\d+)?){4}$' # 5-field cron
image:
type: string
replicas:
type: integer
minimum: 1
maximum: 10
default: 1 # server-side defaulting
required: [cronSpec, image]
# CEL cross-field rule — GA since K8s 1.29
x-kubernetes-validations:
- rule: "self.replicas <= 5 || self.image.startsWith('ha-')"
message: "replicas > 5 requires an image whose name starts with 'ha-'"
status: # the controller writes here, not the user
type: object
properties:
replicas:
type: integer
selector:
type: string# An INSTANCE of the new kind — applied by an ordinary user.
apiVersion: stable.example.com/v1
kind: CronTab
metadata:
name: nightly-report
namespace: analytics
spec:
cronSpec: "0 2 * * *"
image: report-runner:v3
replicas: 2
# .status is omitted — only the controller writes it.Line-by-line, the points worth internalizing:
metadata.namemust be<plural>.<group>. The API server enforces this naming; it is not arbitrary.servedvsstorage. Exactly one version isstorage: true. Multiple may beserved: true— that is how you runv1beta1andv1simultaneously during a migration.- The schema is structural. Every field has a
type. Unknown fields a user adds are pruned (silently dropped) by the API server unless the schema explicitly allows them viax-kubernetes-preserve-unknown-fields: true. default:is server-side. A user who omitsreplicasgets1written into etcd by the API server — no controller involved.x-kubernetes-validationsruns CEL in the API server; the rejection happens at admission, before etcd, with no webhook.- The instance carries no
.status. Users declare.spec(desired state); the controller owns.status(observed state). This is the Desired State vs Observed State split made concrete.
Failure Modes
- CRD applied, nothing happens. The single most common confusion: a CRD without a controller is inert.
kubectl get crontabsworks, instances are stored — but no real-world action occurs because no operator is running. The CRD is the schema; you also need the engine. v1beta1CRD on a modern cluster.apiextensions.k8s.io/v1beta1was removed in Kubernetes 1.22; legacy CRDs fail to apply. Migrate the manifest toapiextensions.k8s.io/v1.- Non-structural schema rejected. The
v1API rejects CRDs whose schema is not structural (missingtype, disallowedoneOf/anyOfplacement). The API server’s error message names the offending path. - Fields silently disappear. Server-side pruning drops any field not in the structural schema. A user adds
spec.newField, applies successfully, thenkubectl getshows it gone — because the schema did not declare it. Fix: declare the field, or setx-kubernetes-preserve-unknown-fields: true. - Two CRDs claim the same
group+kind. Two operator vendors both definekind: Databasein groupdb.io— the secondapplyconflicts. CRD groups should use DNS domains the author controls. - Conversion webhook down. With
conversion.strategy: Webhook, if the webhook is unavailable, every read and write of that custom resource fails — the webhook is on the critical path. See Conversion Webhooks. - Storage-version change without re-encoding. Flipping which version is
storage: truedoes not rewrite existing objects; they stay in the old storage encoding until next written. The on-cluster Storage Version Migration API (storagemigration.k8s.io/v1beta1, available when the API server runs Kubernetes 1.30 or later with the feature enabled) or a manualkubectl get … -o yaml | kubectl replace -f -sweep is needed before retiring the old version —status.storedVersionson the CRD lists every encoding still present in etcd, and the migration is not complete until only the new version remains in that list. - Deleting a CRD deletes all its instances. Removing a
CustomResourceDefinitioncascades: every custom-resource instance of that kind is garbage-collected. A fat-fingeredkubectl delete crdcan wipe an operator’s entire managed estate.
Alternatives and When to Choose Them
The main architectural decision is CRD vs. Aggregated API Server:
| Dimension | CRD | Aggregated API Server |
|---|---|---|
| Implementation effort | Apply a YAML object | Build, deploy, and operate a separate API server |
| Storage | etcd, automatic | Your choice — etcd, a SQL DB, computed on the fly |
| Custom business logic on API calls | Limited (validation/conversion webhooks) | Arbitrary — full control of every verb |
| Custom subresources beyond status/scale | No | Yes |
| Protobuf serialization, custom field selectors | Limited | Full |
| Operational cost | Near zero | A whole extra API server to run and secure |
The Kubernetes documentation gives a long comparison table; the practical rule: use a CRD unless you specifically need something a CRD cannot do. Reasons to reach for an aggregated API server: you need to not store objects in etcd (e.g. metrics.k8s.io computes metrics on demand), you need arbitrary subresources, you need protobuf, or you need to run logic on read paths. For >95 % of extensions — including essentially every operator — a CRD is the right choice. Other alternatives:
ValidatingAdmissionPolicy(VAP) — if you only need to validate existing resource types with CEL, not define a new type, you do not need a CRD at all.- ConfigMap-as-data — if you just need to store structured config and a controller reads it, a ConfigMap works, but you lose typed schema validation, the
/statussubresource, RBAC granularity per kind, andkubectlergonomics. A CRD is almost always better once a controller is involved.
Production Notes
- Operators ship their CRDs. When you install cert-manager, the Prometheus Operator, ArgoCD, or CloudNativePG, step one is always applying a bundle of CRDs (
Certificate,ServiceMonitor,Application,Cluster, …). The CRDs and the controller are versioned and released together — a controller expects a specific CRD schema version. - CRD upgrades are delicate. Adding an optional field is safe. Removing a field, tightening validation, or changing the storage version requires a migration plan and often a conversion webhook. The Operator Lifecycle Manager exists partly to sequence CRD upgrades safely.
- Cluster-scoped vs namespaced is permanent.
scopecannot be changed after creation; choosing wrong means delete-and-recreate the CRD (destroying all instances). Decide deliberately. - CEL validation has displaced many webhooks. Since the 1.29 GA of
x-kubernetes-validations, new operators increasingly express their validation as CEL rules in the CRD schema rather than running a validating webhook — fewer moving parts, no availability risk, lower latency. Webhooks remain necessary for validation that needs to query other objects or external systems. additionalPrinterColumnsis underused. A well-chosen set of printer columns makeskubectl get <yourkind>genuinely useful for operators debugging the system; vendors that skip it produce CRDs that are painful to operate.
See Also
- Operator Pattern — the CRD’s other half: the controller that makes the CRD do something
- Controller Pattern — the reconcile loop acting on custom-resource instances
- Kubernetes Control Loop Pattern — the universal watch→diff→act model CRD controllers follow
- Aggregated API Server — the heavier alternative to a CRD
- CEL in Kubernetes — the Common Expression Language behind
x-kubernetes-validations - Conversion Webhooks — translating custom resources between CRD versions
- ValidatingAdmissionPolicy — CEL validation of existing types, no new CRD
- Kubernetes Object Model — the
{apiVersion, kind, metadata, spec, status}shape CRDs inherit - Kubernetes API Groups and Versions — how the CRD’s
group/versionslot into the API - Desired State vs Observed State — the
.spec/.statussplit a CRD embodies - Kubernetes RBAC — custom resources are ordinary RBAC resources
- Finalizers / Owner References and Garbage Collection — lifecycle machinery custom resources inherit
- Kubebuilder / Operator SDK — tools that scaffold a CRD plus its controller
- Kubernetes MOC §13 — Extensibility