Extending the Kubernetes API: A comprehensive guide to Custom Resource Definitions (CRDs)

Learn how to extend the Kubernetes API with Custom Resource Definitions (CRDs). This comprehensive tutorial covers CRD controllers, security, versioning, and best practices.

8 minutes reading time

Written by

Jubril Oyetunji
Jubril Oyetunji

Technical Writer at Civo

Kubernetes ships with a fixed vocabulary of objects: Pods, Services, Deployments, and a few dozen more. That vocabulary is excellent until the day you need to model something it does not know about, a managed database, a certificate, a feature flag, or some domain object specific to your platform. You could bolt that concept on with config maps and naming conventions, but then you lose the things that make Kubernetes objects pleasant to work with: a typed schema, server-side validation, kubectl get, RBAC, and a stored history.

The better answer is to teach the API server a new noun. Custom Resource Definitions (CRDs) let you define new types of resources that your cluster manages just like the built-in ones. Once you have defined a CRD, you can create, get, list, watch, update, patch, and delete instances of your custom resource exactly as you would a native one.

In this guide we'll define a real, validated custom resource and drive it entirely with kubectl, which is all most people ever need. Then, for the readers who want their resources to actually do something, we'll cover controllers using the modern approach and link out to the tooling that scaffolds them for you.

The Kubernetes resource life cycle

Before any YAML, it is worth understanding what goes into creating a custom resource and how it ties into the wider resource life cycle.

The Kubernetes resource life cycle
  1. API extension is the idea: teach the Kubernetes API server about a new noun.
  2. CustomResourceDefinition (CRD) is the schema: it registers that noun and says what fields are valid. This is the part most people need.
  3. Custom Resource (CR) is an instance: one actual object of your new kind, created with kubectl apply.
  4. Controller is the behaviour: a program that watches your custom resources and makes the cluster match what they ask for.
  5. Operator is the package: a controller plus its CRDs, shipped together so others can install it in one step.

Prerequisites

You will need:

  • A Civo account and a running Kubernetes cluster. A single-node K3s cluster is plenty, since everything here is control-plane work with no real workloads.
  • kubectl installed and pointed at your cluster.
  • Cluster admin rights (registering a CRD is a cluster-scoped operation).
  • A basic understanding of Kubernetes objects like Pods and Deployments.

To spin up a cluster on Civo:

civo k3s create --create-firewall --nodes 1 -m --save --switch --wait crd-demo
kubectl get nodes

Define the Fruit CRD

We'll use a single running example throughout: a Fruit resource with a color and a sweetness score. Apply the CRD with a heredoc so you can copy and paste it directly:

kubectl apply -f - <<'EOF'
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: fruits.examples.com
spec:
group: examples.com
scope: Namespaced
names:
plural: fruits
singular: fruit
kind: Fruit
shortNames:
- fr
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: ["color"]
properties:
color:
type: string
sweetness:
type: integer
minimum: 0
maximum: 10
default: 5
additionalPrinterColumns:
- name: Color
type: string
jsonPath: .spec.color
- name: Sweetness
type: integer
jsonPath: .spec.sweetness
EOF

A few things are doing important work here:

  • type: object at the root is what makes the schema structural: Under apiextensions.k8s.io/v1, a structural schema.openAPIV3Schema is mandatory for every served version. A CRD without one is rejected at apply time.
  • required: ["color"] sits on the spec object: OpenAPI required is a list of property names scoped to the object that declares it, not a dotted path. Writing required: ["spec.color"] at the top would silently enforce nothing.
  • default: 5 on sweetness means a resource that omits the field gets 5 written back into the stored object.
  • additionalPrinterColumns make kubectl get fruit show Color and Sweetness instead of just NAME and AGE.

Confirm the CRD registered and reached the Established condition:

kubectl get crd fruits.examples.com
kubectl wait --for=condition=Established crd/fruits.examples.com --timeout=30s

The wait should return condition met.

Create and read custom resources

Now create an instance. This is the happy path, our running apple:

kubectl apply -f - <<'EOF'
apiVersion: examples.com/v1
kind: Fruit
metadata:
name: apple
spec:
color: red
sweetness: 10
EOF

Create a second one that omits sweetness so we can watch the default apply:

kubectl apply -f - <<'EOF'
apiVersion: examples.com/v1
kind: Fruit
metadata:
name: pear
spec:
color: green
EOF

Now read them back:

kubectl get fruit

The printer columns render, and pear comes back with sweetness: 5 even though we never set it:

NAME COLOR SWEETNESS
apple red 10
pear green 5
Create and read custom resources

kubectl get fruit: apple at 10, pear defaulted to 5, with the Color and Sweetness printer columns.

Inspect a single object in full, and describe it:

kubectl get fruit apple -o yaml
kubectl describe fruit apple

Everything you can do with a native object works here: kubectl get, -o yaml, describe, edit, patch, delete, label selectors, and the short name fr (kubectl get fr).

Watch validation

The API server enforces it on the way in. This sweetness: 99 violates maximum: 10:

kubectl apply -f - <<'EOF'
apiVersion: examples.com/v1
kind: Fruit
metadata:
name: toosweet
spec:
color: green
sweetness: 99
EOF

The apply is rejected before anything is stored:

The Fruit "toosweet" is invalid: spec.sweetness: Invalid value: 99: spec.sweetness in body should be less than or equal to 10

Omitting the required color fails the same way:

kubectl apply -f - <<'EOF'
apiVersion: examples.com/v1
kind: Fruit
metadata:
name: nocolor
spec:
sweetness: 3
EOF
The Fruit "nocolor" is invalid: spec.color: Required value
Watch validation

Schema validation rejecting sweetness 99 (over the maximum) and a resource missing the required color.

Multiple versions and conversion

Over time, CRDs evolve. CRDs support serving more than one version of the same kind at once. The CRD below adds a v2 alongside v1, with v1 remaining the storage version:

kubectl apply -f - <<'EOF'
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: fruits.examples.com
spec:
group: examples.com
scope: Namespaced
names:
plural: fruits
singular: fruit
kind: Fruit
shortNames:
- fr
conversion:
strategy: None
versions:
- name: v1
served: true
storage: true
schema: &fruitSchema
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: ["color"]
properties:
color:
type: string
sweetness:
type: integer
minimum: 0
maximum: 10
default: 5
- name: v2
served: true
storage: false
schema: *fruitSchema
EOF

Three rules to remember here:

  • Exactly one version is the storage version (storage: true): That is the form the object is actually persisted as. Here it is v1.
  • Each version needs its own schema.openAPIV3Schema: The YAML anchors (&fruitSchema and *fruitSchema) just let us reuse one schema block without retyping it. If you prefer, inline the second schema.
  • strategy: None is the default and only changes the apiVersion stamped on the object: It is safe only while every served version shares an identical schema. The moment v2 adds or renames a field, None would hand back malformed data, so you must switch to strategy: Webhook and run a conversion webhook.

You can prove conversion is working by reading the same stored apple through both versions:

kubectl get fruit apple -o yaml | grep apiVersion
kubectl get fruits.v2.examples.com apple -o yaml | grep apiVersion

The object is stored as v1 but served as v2 on request, body unchanged:

examples.com/v1
examples.com/v2

Locking it down with RBAC

Custom resources participate in RBAC like any other API object. To grant full control over fruits, target the examples.com group and the fruits resource:

kubectl apply -f - <<'EOF'
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: fruit-admin
rules:
- apiGroups: ["examples.com"]
resources: ["fruits"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
EOF

Bind it to a user or service account with a ClusterRoleBinding (or a namespaced RoleBinding) the same way you would for any built-in resource.

Clean up

kubectl delete fruit --all
kubectl delete crd fruits.examples.com

If you just wanted a typed, validated, kubectl-managed object, you are done: you now have a custom kind with schema validation, defaulting, printer columns, multiple versions, and RBAC, all without writing a line of code. Read on only if you want your resources to trigger real behaviour.

Controllers and operators

A CRD by itself doesn't do much. It stores data and validates it, but nothing happens when you create a Fruit. To make creating a resource cause something, for example provisioning a Deployment, calling an external API, or updating status, you add a controller.

A controller watches your custom resources and continuously drives the cluster toward the state they describe. The key idea is that controllers are level-triggered, not edge-triggered. The controller does not react to "a Fruit was created" or "a Fruit was edited" as distinct events. Instead, whenever anything changes, it looks at the current desired state of the object and makes the world match it. That makes the loop naturally robust: a missed event does not matter, because the next reconcile sees the true current state anyway.

The modern way: controller-runtime

You almost never write the informer and work-queue plumbing by hand anymore. You scaffold the project with Kubebuilder or the Operator SDK, and both generate the manager, the CRD, the RBAC, and all the wiring. Your job is to fill in a single method:

// Reconcile is called whenever a Fruit changes. controller-runtime handles the
// informers, caching, work queue, and retries for you. You write the desired-state
// logic and return.
func (r *FruitReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var fruit examplesv1.Fruit
if err := r.Get(ctx, req.NamespacedName, &fruit); err != nil {
// The object was deleted. Nothing to do, stop requeuing.
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// Desired state lives in fruit.Spec. Make the world match it here, for example
// create or update a Deployment, then report status back on fruit.Status.
// Returning an empty Result tells controller-runtime we are reconciled.
// Return ctrl.Result{RequeueAfter: time.Minute} to be called again later.
return ctrl.Result{}, nil
}

That is the entire shape of a modern controller. The framework calls Reconcile whenever a Fruit changes, hands you the request, and you make the cluster match the spec.

Error handling and retries, the Kubernetes-native way

Because the loop is level-triggered, retries are built in. You do not reach for a general-purpose retry library. You have two levers:

  • Return the error: controller-runtime requeues the request with exponential backoff automatically. Use this for transient failures (an API call timed out, a dependency is not ready yet).
  • Return ctrl.Result{RequeueAfter: d}: This schedules another reconcile after a fixed delay, useful when you are polling something that takes time to settle.

Both keep all retry logic inside the controller loop where the framework can manage it.

Packaging as an operator

When you bundle a controller together with the CRDs it manages and ship them as one installable unit, you have an operator. Both Kubebuilder and the Operator SDK produce the manifests and container image to do this, so installing your custom behaviour on a new cluster becomes a single kubectl apply or Helm install.

A controller from scratch with client-go

You will not normally write this much code, controller-runtime does it for you, but it is worth seeing once to understand what the framework is doing on your behalf. The listing below builds the same machinery by hand with client-go: a shared informer to watch Fruit objects, a rate-limited work queue, event handlers that enqueue keys, and a worker loop that drains the queue and reconciles each item.

package main
import (
"fmt"
"time"
"k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/client-go/tools/cache"
"k8s.io/client-go/util/workqueue"
"k8s.io/klog/v2"
)
// FruitController watches Fruit objects and reconciles them. controller-runtime
// generates all of this for you; this is the underlying machinery.
type FruitController struct {
informer cache.SharedIndexInformer
queue workqueue.RateLimitingInterface
}
func NewFruitController(informer cache.SharedIndexInformer) *FruitController {
queue := workqueue.NewRateLimitingQueue(workqueue.DefaultControllerRateLimiter())
informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
if key, err := cache.MetaNamespaceKeyFunc(obj); err == nil {
queue.Add(key)
}
},
UpdateFunc: func(old, new interface{}) {
if key, err := cache.MetaNamespaceKeyFunc(new); err == nil {
queue.Add(key)
}
},
DeleteFunc: func(obj interface{}) {
if key, err := cache.DeletionHandlingMetaNamespaceKeyFunc(obj); err == nil {
queue.Add(key)
}
},
})
return &FruitController{informer: informer, queue: queue}
}
func (c *FruitController) Run(workers int, stopCh <-chan struct{}) {
defer runtime.HandleCrash()
defer c.queue.ShutDown()
go c.informer.Run(stopCh)
if !cache.WaitForCacheSync(stopCh, c.informer.HasSynced) {
runtime.HandleError(fmt.Errorf("timed out waiting for caches to sync"))
return
}
for i := 0; i < workers; i++ {
go wait.Until(c.runWorker, time.Second, stopCh)
}
<-stopCh
}
func (c *FruitController) runWorker() {
for c.processNextItem() {
}
}
func (c *FruitController) processNextItem() bool {
key, quit := c.queue.Get()
if quit {
return false
}
defer c.queue.Done(key)
if err := c.reconcile(key.(string)); err != nil {
// Requeue with backoff on error, the same behaviour controller-runtime
// gives you for free when you return an error from Reconcile.
c.queue.AddRateLimited(key)
} else {
c.queue.Forget(key)
}
return true
}
func (c *FruitController) reconcile(key string) error {
obj, exists, err := c.informer.GetIndexer().GetByKey(key)
if err != nil {
return err
}
if !exists {
klog.Infof("Fruit %s deleted", key)
return nil
}
// Desired state lives on the object's spec. Make the world match it here.
klog.Infof("reconciling Fruit %s: %v", key, obj)
return nil
}

Compare this to the eight-line Reconcile method above and the value of controller-runtime is obvious: every concern except your actual business logic, the informer, the cache sync, the queue, the backoff, the worker pool, is boilerplate the framework owns. Write it once by hand to understand it, then let Kubebuilder generate it forever after.

Summary

Kubernetes extensibility provides endless opportunities for building on top of it. We defined a Fruit CRD with a real structural schema, validation, defaults, printer columns, multiple served versions with conversion, and RBAC, all driven by kubectl. That covers what most teams need.

For the cases that need behaviour, we saw the modern controller-runtime Reconcile loop and the scaffolding tools that generate everything around it, with the from-scratch client-go machinery in the appendix for the curious.

Additional resources

If you want to know more about Custom Resource Definitions and extending the Kubernetes API, take a look at these resources:


Jubril Oyetunji
Jubril Oyetunji

Technical Writer at Civo

Jubril Oyetunji is a DevOps engineer and technical writer with a strong focus on cloud-native technologies and open-source tools. His work centers on creating practical tutorials that help developers better understand platforms such as Kubernetes, NGINX, Rust, and Go.

As a contract technical writer, Jubril authored an extensive library of technical guides covering cloud-native infrastructure and modern development workflows. Many of his tutorials achieved strong search rankings, helping developers around the world learn and adopt emerging technologies.

View author profile