CRDs & Operators Basics
This page is a hands-on intro to Custom Resource Definitions and the controllers that act on them. By the end you can define a new API type, create objects of that type, and understand what a controller adds.
Prerequisites
- kubectl matching your cluster (Kubernetes 1.36.2 here; keep within one minor of the API server).
- A cluster you can apply CRDs to (kind, minikube, or a cloud cluster). CRDs are cluster-scoped, so you need appropriate RBAC.
- Optional for building a controller: Go 1.23+,
kubebuilder, and Docker Engine 29.x to build the controller image.
# Quick sanity check
kubectl version
kubectl api-resources | headBasic Examples
1. Define a minimal CRD
A CRD registers a new kind. This one adds a namespaced Widget resource in group example.com.
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: widgets.example.com
spec:
group: example.com
scope: Namespaced
names:
plural: widgets
singular: widget
kind: Widget
shortNames: ["wdg"]
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
size:
type: stringapiextensions.k8s.io/v1is the stable, required API for CRDs.scope: Namespacedmeans eachWidgetlives in a namespace, like a Pod.- Exactly one version must set
storage: true; that is the version persisted toetcd. served: truemeans the API server will accept and return that version.- The
openAPIV3Schemais mandatory inv1and controls validation.
2. Apply the CRD and see the new type
Once applied, the new kind behaves like a built-in one.
kubectl apply -f widget-crd.yaml
kubectl api-resources | grep widgets
kubectl explain widget.speckubectl applyregisters the type cluster-wide.kubectl api-resourcesnow listswidgets, proving the API server knows the kind.kubectl explainreads the schema you supplied, so documentation is generated from the CRD.
3. Create a custom resource
A CR is an instance of the type, written like any manifest.
apiVersion: example.com/v1
kind: Widget
metadata:
name: blue-widget
spec:
size: large- The
apiVersioncombines the group and version from the CRD. kubectl apply -f blue-widget.yamlstores the object;kubectl get widgetslists it.- At this point nothing acts on the object; it is just stored, validated data.
4. Add validation to the schema
Structural schemas can enforce allowed values and required fields.
spec:
type: object
required: ["size"]
properties:
size:
type: string
enum: ["small", "medium", "large"]
replicas:
type: integer
minimum: 1
maximum: 10requiredrejects objects missing the field at admission time.enumlimitssizeto known values, catching typos before they persist.minimumandmaximumbound numeric fields without any controller code.
5. Add printer columns
Printer columns customize what kubectl get shows.
additionalPrinterColumns:
- name: Size
type: string
jsonPath: .spec.size
- name: Age
type: date
jsonPath: .metadata.creationTimestamp- Columns are defined per version under the CRD
versionsentry. jsonPathselects the field to display.kubectl get widgetsnow shows aSizecolumn, improving day-to-day usability.
6. Scope RBAC to your resource
Custom resources use standard RBAC by group and resource name.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: widget-editor
namespace: default
rules:
- apiGroups: ["example.com"]
resources: ["widgets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]apiGroupsmatches the CRDspec.group.resourcesmatches the plural name.- Bind this Role to a ServiceAccount so a controller or user can act on
Widgetobjects.
7. Inspect and delete
Custom resources support the same lifecycle verbs as built-ins.
kubectl get widgets -o yaml
kubectl describe widget blue-widget
kubectl delete widget blue-widget-o yamlshows the stored object including any status the controller writes.describesurfaces events, useful once a controller emits them.- Deleting the CRD deletes all its custom resources, so treat CRD deletion with care.
Intermediate Examples
8. Add a status subresource
The status subresource splits spec (user intent) from status (controller-reported state).
versions:
- name: v1
served: true
storage: true
subresources:
status: {}
schema:
openAPIV3Schema:
type: object
properties:
spec: { type: object }
status:
type: object
properties:
phase: { type: string }- With
subresources.status: {}, updates to.statusgo through a separate endpoint. - Controllers update status without racing user edits to spec.
- This is the idiomatic split for any resource a controller manages.
9. What a controller adds
A CRD stores data; a controller makes it mean something.
// Reconcile is called whenever a Widget changes.
func (r *WidgetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var w examplev1.Widget
if err := r.Get(ctx, req.NamespacedName, &w); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// Drive real state toward w.Spec, then update w.Status.
return ctrl.Result{}, nil
}- The
Reconcilefunction is the control loop for your type. - It reads the object, does the work to match the spec, and returns.
- A CRD plus a reconciling controller is exactly the operator pattern; see The Operator Pattern Explained.
10. Scaffold a controller with kubebuilder
Kubebuilder generates the project, CRD manifests, and reconciler skeleton.
kubebuilder init --domain example.com --repo example.com/widget-operator
kubebuilder create api --group example --version v1 --kind Widget
make manifestsinitcreates the controller-runtime project layout.create apiscaffolds both the CRD types and aReconcilestub.make manifestsregenerates CRD YAML from your Go types, keeping them in sync.
Stack versions: This page was written for Kubernetes 1.36.2, Docker Engine 29.6.1 (BuildKit default), containerd (CRI runtime on nodes), Helm 3, Compose v2, Argo CD (latest - verify at build), and Gateway API (GA - verify controller support at build).