Kubernetes CRDs & Operators: Extending the API, Episode 4
What Is a CRD? · CRDs You Already Use · CRD Anatomy · Write Your First CRD · CEL Validation · Controller Loop · Build an Operator · CRD Versioning · Admission Webhooks · CRDs in Production
TL;DR
- Writing a Kubernetes CRD requires five YAML files: the CRD itself, a ClusterRole/ClusterRoleBinding, a namespaced Role/RoleBinding for consumers, and a sample custom resource
- The
BackupPolicyCRD built in this episode is the running example throughout the rest of the series — operators, versioning, and production patterns all use it - Apply the CRD, verify it with
kubectl get crds, create a custom resource, and watch the API server validate your spec - RBAC for CRDs follows the same Role/ClusterRole model as built-in resources — the generated resource name is
{plural}.{group} - Schema validation fires at apply time: bad field types, missing required fields, and out-of-range values all return clear errors before anything reaches etcd
- Without a controller, a
BackupPolicyis stored in etcd but nothing acts on it — that is the topic of EP05 and EP07
The Big Picture
WHAT WE'RE BUILDING IN THIS EPISODE
1. backuppolicies-crd.yaml ← registers the BackupPolicy type
2. backuppolicies-rbac.yaml ← controls who can create/view/delete
3. nightly-backup.yaml ← our first custom resource instance
After applying:
kubectl get crds | grep backup ← BackupPolicy type exists
kubectl get backuppolicies -n demo ← nightly instance exists
kubectl describe bp nightly -n demo ← spec visible, status empty
kubectl apply -f bad-backup.yaml ← schema validation rejects bad data
Writing your first Kubernetes CRD is the step that bridges understanding CRDs conceptually to operating them in a real cluster. This episode is hands-on — every block of YAML is something you apply and verify.
Prerequisites
You need a running Kubernetes cluster and kubectl configured. Any of these work:
# Local options
kind create cluster --name crd-demo
# or
minikube start
# Verify cluster access
kubectl cluster-info
kubectl get nodes
Step 1: Write the CRD
Save this as backuppolicies-crd.yaml:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: backuppolicies.storage.example.com
spec:
group: storage.example.com
scope: Namespaced
names:
plural: backuppolicies
singular: backuppolicy
kind: BackupPolicy
shortNames:
- bp
categories:
- storage
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
required: ["spec"]
properties:
spec:
type: object
required: ["schedule", "retentionDays"]
properties:
schedule:
type: string
description: "Cron expression (e.g. '0 2 * * *' for 02:00 daily)"
retentionDays:
type: integer
minimum: 1
maximum: 365
description: "How many days to retain backup snapshots"
storageClass:
type: string
default: "standard"
description: "StorageClass to use for backup volumes"
targets:
type: array
description: "Namespaces and resources to include in the backup"
maxItems: 20
items:
type: object
required: ["namespace"]
properties:
namespace:
type: string
includeSecrets:
type: boolean
default: false
suspended:
type: boolean
default: false
description: "Set to true to pause backup execution"
status:
type: object
x-kubernetes-preserve-unknown-fields: true
subresources:
status: {}
additionalPrinterColumns:
- name: Schedule
type: string
jsonPath: .spec.schedule
- name: Retention
type: integer
jsonPath: .spec.retentionDays
- name: Suspended
type: boolean
jsonPath: .spec.suspended
- name: Ready
type: string
jsonPath: .status.conditions[?(@.type=='Ready')].status
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
Apply it:
kubectl apply -f backuppolicies-crd.yaml
Verify it registered correctly:
kubectl get crds backuppolicies.storage.example.com
NAME CREATED AT
backuppolicies.storage.example.com 2026-04-25T08:00:00Z
Check the API server now knows about it:
kubectl api-resources | grep backuppolic
backuppolicies bp storage.example.com/v1alpha1 true BackupPolicy
Check it is Established:
kubectl get crd backuppolicies.storage.example.com \
-o jsonpath='{.status.conditions[?(@.type=="Established")].status}'
True
If you see False or empty output, wait a few seconds and retry — the API server takes a moment to register new CRDs.
Step 2: Write RBAC
CRDs follow the same RBAC model as built-in resources. The resource name is {plural}.{group}.
Save this as backuppolicies-rbac.yaml:
# ClusterRole for operators/controllers that manage BackupPolicy objects
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: backuppolicy-controller
rules:
- apiGroups: ["storage.example.com"]
resources: ["backuppolicies"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["storage.example.com"]
resources: ["backuppolicies/status"]
verbs: ["get", "update", "patch"]
- apiGroups: ["storage.example.com"]
resources: ["backuppolicies/finalizers"]
verbs: ["update"]
---
# Role for application teams to manage BackupPolicies in their namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: backuppolicy-editor
rules:
- apiGroups: ["storage.example.com"]
resources: ["backuppolicies"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
# Read-only role for auditors
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: backuppolicy-viewer
rules:
- apiGroups: ["storage.example.com"]
resources: ["backuppolicies"]
verbs: ["get", "list", "watch"]
kubectl apply -f backuppolicies-rbac.yaml
Verify the roles exist:
kubectl get clusterrole | grep backuppolicy
backuppolicy-controller 2026-04-25T08:01:00Z
backuppolicy-editor 2026-04-25T08:01:00Z
backuppolicy-viewer 2026-04-25T08:01:00Z
Note on
backuppolicies/status: The separate status RBAC rule is only meaningful if you enabled the status subresource (we did). Without it, status and spec share the same update path.
Step 3: Create a Namespace and Your First Custom Resource
kubectl create namespace demo
Save this as nightly-backup.yaml:
apiVersion: storage.example.com/v1alpha1
kind: BackupPolicy
metadata:
name: nightly
namespace: demo
labels:
app.kubernetes.io/managed-by: manual
spec:
schedule: "0 2 * * *"
retentionDays: 30
storageClass: standard
targets:
- namespace: production
includeSecrets: false
- namespace: staging
includeSecrets: false
suspended: false
Apply it:
kubectl apply -f nightly-backup.yaml
Get it back:
kubectl get backuppolicies -n demo
NAME SCHEDULE RETENTION SUSPENDED READY AGE
nightly 0 2 * * * 30 false <none> 5s
The Ready column is <none> because there is no controller writing status yet. The custom resource exists and is stored in etcd, but nothing is acting on it.
Describe it:
kubectl describe bp nightly -n demo
Name: nightly
Namespace: demo
Labels: app.kubernetes.io/managed-by=manual
Annotations: <none>
API Version: storage.example.com/v1alpha1
Kind: BackupPolicy
Metadata:
Creation Timestamp: 2026-04-25T08:05:00Z
...
Spec:
Retention Days: 30
Schedule: 0 2 * * *
Storage Class: standard
Suspended: false
Targets:
Include Secrets: false
Namespace: production
Include Secrets: false
Namespace: staging
Status:
Events: <none>
Step 4: Test Schema Validation
The API server now validates every BackupPolicy against the schema. Try creating an invalid one:
kubectl apply -f - <<'EOF'
apiVersion: storage.example.com/v1alpha1
kind: BackupPolicy
metadata:
name: bad-policy
namespace: demo
spec:
schedule: "not-a-cron"
retentionDays: 500
EOF
The BackupPolicy "bad-policy" is invalid:
spec.retentionDays: Invalid value: 500:
spec.retentionDays in body should be less than or equal to 365
Missing required field:
kubectl apply -f - <<'EOF'
apiVersion: storage.example.com/v1alpha1
kind: BackupPolicy
metadata:
name: missing-schedule
namespace: demo
spec:
retentionDays: 7
EOF
The BackupPolicy "missing-schedule" is invalid:
spec.schedule: Required value
Wrong type:
kubectl apply -f - <<'EOF'
apiVersion: storage.example.com/v1alpha1
kind: BackupPolicy
metadata:
name: wrong-type
namespace: demo
spec:
schedule: "0 2 * * *"
retentionDays: "thirty"
EOF
The BackupPolicy "wrong-type" is invalid:
spec.retentionDays: Invalid value: "string":
spec.retentionDays in body must be of type integer: "string"
All validation fires at the API boundary — before etcd, before any controller sees the object.
Step 5: Verify Default Values Apply
The schema defines storageClass: default: "standard" and suspended: default: false. Verify they are applied even when not specified:
kubectl apply -f - <<'EOF'
apiVersion: storage.example.com/v1alpha1
kind: BackupPolicy
metadata:
name: minimal
namespace: demo
spec:
schedule: "0 0 * * 0"
retentionDays: 7
EOF
kubectl get bp minimal -n demo -o jsonpath='{.spec.storageClass}'
standard
kubectl get bp minimal -n demo -o jsonpath='{.spec.suspended}'
false
Defaults are injected by the API server at admission time. They appear in etcd and in every kubectl get -o yaml output — the stored object includes the defaults even if the user did not specify them.
Step 6: Explore the API Endpoints
Your custom resource is now available at standard REST endpoints:
kubectl proxy --port=8001 &
# List all BackupPolicies in the demo namespace
curl -s http://localhost:8001/apis/storage.example.com/v1alpha1/namespaces/demo/backuppolicies \
| jq '.items[].metadata.name'
"nightly"
"minimal"
# Get a specific BackupPolicy
curl -s http://localhost:8001/apis/storage.example.com/v1alpha1/namespaces/demo/backuppolicies/nightly \
| jq '.spec'
This is how controllers discover and watch custom resources — via the same API server endpoints, using informers that wrap these REST calls with efficient list-and-watch semantics.
Step 7: Clean Up
kubectl delete namespace demo
kubectl delete -f backuppolicies-rbac.yaml
kubectl delete -f backuppolicies-crd.yaml # WARNING: deletes all BackupPolicy instances first
⚠ Common Mistakes
metadata.name does not match {plural}.{group}. The most common error. If you name the CRD backuppolicy.storage.example.com (singular) but the spec says plural: backuppolicies, the API server rejects it. The name must always be {plural}.{group}.
No required fields on spec. Without required constraints, kubectl apply accepts an empty spec: {}. The controller then receives objects with no configuration and has to handle the nil case. Define required fields in the schema.
Forgetting subresources: status: {}. Without this, controllers writing .status also overwrite .spec on full PUT updates. This causes status updates to reset user edits. Enable the status subresource from day one.
Not testing validation errors. Schema validation is the first line of defense. Always explicitly test that your required fields are required, types are enforced, and range constraints work — before deploying the controller.
Quick Reference
# All kubectl operations work on custom resources
kubectl get backuppolicies -n demo
kubectl get bp -n demo # shortName
kubectl describe bp nightly -n demo
kubectl edit bp nightly -n demo
kubectl delete bp nightly -n demo
# Output formats
kubectl get bp -n demo -o yaml
kubectl get bp -n demo -o json
kubectl get bp -n demo -o jsonpath='{.items[*].metadata.name}'
# Watch for changes
kubectl get bp -n demo -w
# List across all namespaces
kubectl get bp -A
# Patch spec
kubectl patch bp nightly -n demo \
--type=merge -p '{"spec":{"suspended":true}}'
Key Takeaways
- A working CRD deployment needs: the CRD YAML, RBAC ClusterRoles, and at least one sample custom resource
- The API server validates all custom resources against the schema at apply time — errors are surfaced immediately, not inside the controller
- Default values in the schema are injected at admission time and appear in every stored object
- RBAC for custom resources uses
{plural}.{group}as the resource name —statusandfinalizersare separate sub-resources - Without a controller, custom resources are stored in etcd and serve as validated configuration — nothing acts on them until a controller is deployed
What’s Next
EP05: Kubernetes CRD CEL Validation extends schema validation beyond simple type and range checks — cross-field rules (“if storageClass is premium, retentionDays must be at most 90″), regex validation beyond pattern, and immutable field enforcement. All without an admission webhook.
Get EP05 in your inbox when it publishes → subscribe at linuxcent.com