Kubernetes Custom Resources: Extending the Kubernetes API (2026)
Custom Resource Definitions (CRDs) are the foundation for everything you add on top of vanilla Kubernetes — from Operators and service meshes to GitOps controllers and policy engines. This guide covers CRD anatomy, OpenAPI v3 schema validation, versioning with conversion webhooks, finalizers, and owner references, with a complete real-world example.
CRD Basics: Group, Version, Kind, Scope
A Custom Resource Definition registers a new resource type with the Kubernetes API server. Once applied, you can use kubectl, client libraries, and the watch API with your new resource exactly as you would with a built-in resource like Pod or Service.
Every Kubernetes resource is identified by four coordinates:
- Group — a DNS-style namespace, e.g.,
apps.techoral.com. Use your company domain to avoid collisions. - Version — e.g.,
v1alpha1,v1beta1,v1. Multiple versions can be served simultaneously. - Kind — the PascalCase name of the resource, e.g.,
ApplicationConfig. - Scope —
Namespaced(lives within a namespace) orCluster(cluster-scoped, like Node or PersistentVolume).
The CRD name is always plural.group, e.g., applicationconfigs.apps.techoral.com.
example.com or squatting on well-known groups causes conflicts when multiple controllers are deployed. Even for internal projects, use your company domain.
OpenAPI v3 Schema Validation
Since Kubernetes 1.25, CRDs require an OpenAPI v3 schema (structural schema). The API server uses it to validate all creates and updates, generate documentation, and support server-side apply merge strategies. Without a valid schema, your CRD will be rejected.
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [appName, replicas, image]
properties:
appName:
type: string
minLength: 1
maxLength: 63
pattern: '^[a-z][a-z0-9-]*$'
replicas:
type: integer
minimum: 0
maximum: 100
default: 1
image:
type: string
environment:
type: string
enum: [development, staging, production]
default: development
resources:
type: object
properties:
cpu:
type: string
pattern: '^[0-9]+m?$'
memory:
type: string
pattern: '^[0-9]+[KMGTkmgt]i?$'
tags:
type: object
additionalProperties:
type: string
immutableField:
type: string
x-kubernetes-validations:
- rule: "self == oldSelf"
message: "immutableField is immutable after creation"
status:
type: object
properties:
phase:
type: string
observedGeneration:
type: integer
format: int64
conditions:
type: array
items:
type: object
required: [type, status]
properties:
type:
type: string
status:
type: string
enum: [True, False, Unknown]
reason:
type: string
message:
type: string
lastTransitionTime:
type: string
format: date-time
Structural Schema and Default Values
A structural schema has two requirements beyond basic OpenAPI v3: every field must have an explicit type, and the schema must be complete (no bare additionalProperties: true at the top level). These constraints allow the API server to prune unknown fields and apply default values server-side.
# Default values are applied by the API server on admission
# Users do not need to specify these fields — the API server fills them in
spec:
properties:
replicas:
type: integer
default: 1 # injected if not provided
environment:
type: string
default: development
config:
type: object
default: {} # ensures config is never null
properties:
logLevel:
type: string
default: info
metricsPort:
type: integer
default: 9090
type: object with x-kubernetes-preserve-unknown-fields: true on that specific property.
Printer Columns and Subresources
Additional printer columns control what kubectl get shows for your custom resources. Without them, kubectl only shows NAME and AGE. With them, you get a meaningful table view that shows the fields your users care about.
additionalPrinterColumns:
- name: App
type: string
jsonPath: .spec.appName
- name: Environment
type: string
jsonPath: .spec.environment
- name: Replicas
type: integer
jsonPath: .spec.replicas
- name: Phase
type: string
jsonPath: .status.phase
- name: Ready
type: string
jsonPath: .status.conditions[?(@.type=="Ready")].status
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
subresources:
status: {} # required: separates spec writes from status writes
scale: # optional: enables kubectl scale and HPA
specReplicasPath: .spec.replicas
statusReplicasPath: .status.readyReplicas
CRD Versioning and Conversion Webhooks
As your CRD evolves, you will need to add or rename fields without breaking existing users. CRDs support multiple versions served simultaneously, with one designated as the storage version. A conversion webhook translates between versions on read and write.
spec:
group: apps.techoral.com
versions:
- name: v1alpha1
served: true # still accepts v1alpha1 requests
storage: false # not the storage version
- name: v1beta1
served: true
storage: true # all objects stored in this version
conversion:
strategy: Webhook
webhook:
conversionReviewVersions: ["v1"]
clientConfig:
service:
name: appconfig-conversion-webhook
namespace: operators
path: /convert
port: 443
caBundle: |-
LS0tLS1CRUdJTi... # base64-encoded CA cert
The conversion webhook receives a ConversionReview request with the source object and desired target version. It transforms the fields accordingly and returns the converted object. Common patterns include field renames (v1alpha1 replicas → v1beta1 desiredReplicas) and nested restructuring.
CRDs vs Aggregated API Servers
CRDs are the right choice for the vast majority of use cases. Aggregated API servers (AA servers) are a more powerful but significantly more complex extension mechanism used by projects like metrics-server and kube-aggregator itself.
| Capability | CRD | Aggregated API Server |
|---|---|---|
| Complexity to build | Low — just YAML + controller | High — full API server implementation |
| Storage backend | etcd (via kube-apiserver) | Custom (etcd, SQL, in-memory) |
| Custom validation logic | Via admission webhooks | Built into the API handler |
| Custom subresources | status and scale only | Any arbitrary subresource |
| Non-standard verbs | Not supported | Supported (e.g., exec, log, portforward) |
| Non-etcd storage | Not supported | Supported (time-series, graph DB, etc.) |
| kubectl compatibility | Full (discovery-based) | Full (same discovery mechanism) |
| Recommended for | 99% of Operator use cases | metrics-server, service catalog, specialized systems |
Finalizers and Owner References
Finalizers prevent a resource from being deleted until cleanup logic completes. When you add a finalizer string to metadata.finalizers, kubectl delete sets DeletionTimestamp but does not remove the object until all finalizers are cleared by your controller.
# Resource with finalizer added by controller
apiVersion: apps.techoral.com/v1beta1
kind: ApplicationConfig
metadata:
name: myapp
namespace: production
finalizers:
- apps.techoral.com/cleanup # controller must remove this before deletion completes
ownerReferences:
- apiVersion: v1
kind: Namespace
name: production
uid: "abc-123"
blockOwnerDeletion: true # prevents namespace deletion while this exists
Owner references create parent-child relationships between resources. When the parent is deleted, Kubernetes garbage-collects all children automatically. Use this to ensure Deployments, Services, and ConfigMaps created by your controller are cleaned up when the parent custom resource is deleted — without needing explicit finalizer logic.
Real CRD Example: ApplicationConfig
Here is a complete, production-ready custom resource that ties together all the concepts above: a namespaced ApplicationConfig that describes a microservice deployment with environment-specific settings.
apiVersion: apps.techoral.com/v1beta1
kind: ApplicationConfig
metadata:
name: payment-service
namespace: production
labels:
team: payments
tier: backend
spec:
appName: payment-service
image: ghcr.io/techoral/payment-service:v2.4.1
replicas: 3
environment: production
immutableField: "us-east-1" # cannot be changed after creation
resources:
cpu: "500m"
memory: "512Mi"
config:
logLevel: warn
metricsPort: 9090
tags:
cost-center: "payments-team"
pci-scope: "true"
# Apply the CRD
kubectl apply -f applicationconfig-crd.yaml
# Apply the custom resource
kubectl apply -f payment-service.yaml
# View with custom printer columns
$ kubectl get appconfigs -n production
NAME APP ENVIRONMENT REPLICAS PHASE READY AGE
payment-service payment-service production 3 Running True 12m
# Describe for full detail
kubectl describe appconfig payment-service -n production
# Update a field and see the controller reconcile
kubectl patch appconfig payment-service -n production \
--type=merge -p '{"spec":{"replicas":5}}'
FAQ
- Can I delete a CRD and keep the custom resources?
- No. Deleting a CRD immediately deletes all custom resources of that type from etcd. The deletion cascades instantly. Always back up custom resources before removing a CRD in production.
- What is the difference between served and storage versions?
- A version marked
served: truecan be used by clients (GET, POST, PATCH against that API version). Thestorage: trueversion is the one used internally in etcd. There can only be one storage version. Old objects in etcd are converted on read if the storage version changes. - How do I add a new required field to an existing CRD without breaking existing resources?
- You generally cannot add a required field to an existing version — that would break existing resources that do not have it. Instead, create a new API version (v1beta2) with the required field, and provide a default value or conversion webhook to populate it for objects coming from the old version.
- Can a custom resource trigger autoscaling via HPA?
- Yes, if you enable the scale subresource on your CRD with
specReplicasPathandstatusReplicasPath. The HPA will then treat your custom resource like a Deployment and scale it based on CPU or custom metrics. - What are x-kubernetes-validations and when should I use them?
- These are Common Expression Language (CEL) validation rules embedded directly in the CRD schema, introduced in Kubernetes 1.25. Use them for cross-field validation (e.g., max must be greater than min), immutability constraints, and format checks that go beyond what OpenAPI v3 patterns support. They run in the API server during admission, before any webhook, which makes them fast and reliable.