Custom Resource Definitions (CRDs)#
CRDs extend the Kubernetes API with your own resource types. Once you create a CRD, you can kubectl get, kubectl apply, and kubectl delete instances of your custom type just like built-in resources. The custom resources are stored in etcd alongside native Kubernetes objects, benefit from the same RBAC, and participate in the same API machinery.
When to Use CRDs#
CRDs make sense when you need to represent application-specific concepts inside Kubernetes:
- Infrastructure abstractions:
Database,Certificate,DNSRecord - Application configuration:
FeatureFlag,RateLimitPolicy,CacheConfig - Operational workflows:
DatabaseBackup,MigrationJob,CanaryRelease - Multi-tenancy:
Tenant,Project,Environment
If your concept can be described as a desired state that a controller reconciles, it belongs as a CRD. If it is purely static configuration with no controller, a ConfigMap is usually simpler.
Creating a CRD#
A CRD definition tells Kubernetes about the shape of your new resource type:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: databasebackups.mycompany.io # must be <plural>.<group>
spec:
group: mycompany.io
names:
plural: databasebackups
singular: databasebackup
kind: DatabaseBackup
shortNames:
- dbb
categories:
- all # appears in 'kubectl get all'
- myco # custom category: 'kubectl get myco'
scope: Namespaced # or Cluster
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: ["database", "schedule"]
properties:
database:
type: string
description: "Name of the database to back up"
schedule:
type: string
description: "Cron schedule for backups"
pattern: '^(\*|[0-9,\-\/]+)\s+(\*|[0-9,\-\/]+)\s+(\*|[0-9,\-\/]+)\s+(\*|[0-9,\-\/]+)\s+(\*|[0-9,\-\/]+)$'
retentionDays:
type: integer
default: 30
minimum: 1
maximum: 365
storageLocation:
type: string
enum: ["s3", "gcs", "azure-blob"]
default: "s3"
compression:
type: boolean
default: true
status:
type: object
properties:
lastBackupTime:
type: string
format: date-time
lastBackupStatus:
type: string
enum: ["Success", "Failed", "Running"]
backupCount:
type: integer
conditions:
type: array
items:
type: object
properties:
type:
type: string
status:
type: string
lastTransitionTime:
type: string
format: date-time
reason:
type: string
message:
type: stringApply the CRD, then create an instance:
kubectl apply -f databasebackup-crd.yamlapiVersion: mycompany.io/v1
kind: DatabaseBackup
metadata:
name: production-db-backup
namespace: production
spec:
database: "orders-db"
schedule: "0 2 * * *"
retentionDays: 90
storageLocation: "s3"
compression: truekubectl apply -f prod-backup.yaml
kubectl get databasebackups -n production
kubectl get dbb -n production # using short nameSchema Validation with OpenAPI v3#
The openAPIV3Schema field defines the exact structure your custom resource must conform to. Kubernetes rejects any resource that does not match the schema at admission time.
Key validation features:
properties:
replicas:
type: integer
minimum: 1
maximum: 100
mode:
type: string
enum: ["active", "standby", "maintenance"]
tags:
type: object
additionalProperties:
type: string # free-form string map
endpoints:
type: array
minItems: 1
items:
type: object
required: ["host", "port"]
properties:
host:
type: string
port:
type: integer
minimum: 1
maximum: 65535For resources where you need to accept arbitrary nested structures (like forwarding configuration to another system), use x-kubernetes-preserve-unknown-fields: true on specific subtrees:
properties:
config:
type: object
x-kubernetes-preserve-unknown-fields: true # accepts any nested structureUse this sparingly. Unvalidated fields are a source of configuration errors that only surface at runtime.
Printer Columns#
Customize what kubectl get shows for your CRD with additionalPrinterColumns:
versions:
- name: v1
served: true
storage: true
additionalPrinterColumns:
- name: Database
type: string
jsonPath: .spec.database
- name: Schedule
type: string
jsonPath: .spec.schedule
- name: Last Backup
type: date
jsonPath: .status.lastBackupTime
- name: Status
type: string
jsonPath: .status.lastBackupStatus
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
schema:
openAPIV3Schema: ...Now kubectl get dbb produces readable output:
NAME DATABASE SCHEDULE LAST BACKUP STATUS AGE
production-db-backup orders-db 0 2 * * * 2026-02-21T02:00:12Z Success 30dSubresources#
Status Subresource#
Enabling /status separates the status from spec, so users update spec and controllers update status independently:
versions:
- name: v1
served: true
storage: true
subresources:
status: {}
schema: ...With this enabled, kubectl apply on the main resource does not overwrite .status, and kubectl status update does not overwrite .spec. This prevents accidental status clobbering by users and spec clobbering by controllers.
Controllers update status with:
backup.Status.LastBackupStatus = "Success"
backup.Status.LastBackupTime = metav1.Now()
err := r.Status().Update(ctx, backup)Scale Subresource#
Enable /scale to let HPA scale your custom resource:
subresources:
status: {}
scale:
specReplicasPath: .spec.replicas
statusReplicasPath: .status.replicas
labelSelectorPath: .status.labelSelectorCRD Versioning#
Real CRDs evolve over time. Kubernetes supports multiple served versions:
spec:
versions:
- name: v1alpha1
served: true # API server accepts this version
storage: false # not the version stored in etcd
- name: v1
served: true
storage: true # this is what etcd storesWhen you have multiple versions, you need a conversion webhook to translate between them:
spec:
conversion:
strategy: Webhook
webhook:
clientConfig:
service:
name: my-conversion-webhook
namespace: my-system
path: /convert
conversionReviewVersions: ["v1"]The webhook receives the object in one version and returns it in the requested version. This is how you handle field renames, restructuring, and deprecation across API versions.
Storage version migration: when changing the storage version, existing objects in etcd remain in the old format until they are re-written. Run a storage migration (read and re-save all objects) after changing the storage version.
CEL Validation Rules (v1.25+)#
Common Expression Language (CEL) validation lets you write complex validation rules directly in the CRD spec, without needing a validating webhook:
properties:
spec:
type: object
x-kubernetes-validations:
- rule: "self.minReplicas <= self.maxReplicas"
message: "minReplicas must not exceed maxReplicas"
- rule: "self.retentionDays >= 7 || self.storageLocation != 'gcs'"
message: "GCS backups require at least 7 days retention"
properties:
minReplicas:
type: integer
maxReplicas:
type: integer
retentionDays:
type: integer
storageLocation:
type: stringCEL supports cross-field validation, string operations, list operations, and transition rules (comparing new value to old value):
x-kubernetes-validations:
- rule: "self.replicas == oldSelf.replicas || self.allowScaling"
message: "Scaling is disabled. Set allowScaling: true first."CEL validation runs in the API server, so it is faster and simpler to deploy than a webhook. Use it for validation rules that reference multiple fields within the same object.
CRDs in Helm Charts#
Helm has two approaches for CRDs:
The crds/ directory: files in crds/ are installed once when the chart is first installed, and never upgraded or deleted afterward.
mychart/
crds/
databasebackup-crd.yaml
templates/
deployment.yaml
...This is safe (prevents accidental CRD deletion) but means CRD updates require manual kubectl apply.
CRDs as regular templates: place CRD manifests in templates/ with the rest of your chart. This lets Helm upgrade CRDs, but also means helm uninstall will delete the CRD and all its instances.
# templates/crd.yaml
{{- if .Values.installCRDs }}
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: databasebackups.mycompany.io
annotations:
"helm.sh/resource-policy": keep # prevent deletion on helm uninstall
spec: ...
{{- end }}The helm.sh/resource-policy: keep annotation prevents Helm from deleting the CRD on uninstall, which is the best practice for CRDs shipped as templates.
Common Gotchas#
CRD deletion cascades to all instances. Running kubectl delete crd databasebackups.mycompany.io immediately and irrecoverably deletes every DatabaseBackup resource in the cluster. Protect against this with RBAC (restrict CRD deletion to cluster admins) and with the Helm keep annotation.
Schema changes can break existing resources. If you add a new required field to the schema, all existing resources that lack that field become invalid. The resources still exist in etcd, but any update attempt will be rejected. Always add new fields as optional with defaults. If you must make a field required, introduce it in a new API version.
etcd storage impact. Each custom resource instance is stored as a separate key in etcd. CRDs with large specs (multi-kilobyte YAML) and thousands of instances can measurably increase etcd storage and write latency. Keep custom resource specs lean. If you need to store large blobs, reference an external storage location instead.
No garbage collection without owner references. Unlike Deployments that own ReplicaSets that own Pods, custom resources you create have no automatic cleanup chain. If your operator creates child resources (Deployments, Services, ConfigMaps), always set ownerReferences so that deleting the parent CRD instance cleans up the children.
ctrl.SetControllerReference(backup, childJob, r.Scheme)Without this, deleting a DatabaseBackup leaves orphaned Jobs, PVCs, and other resources behind.