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: string

Apply the CRD, then create an instance:

kubectl apply -f databasebackup-crd.yaml
apiVersion: mycompany.io/v1
kind: DatabaseBackup
metadata:
  name: production-db-backup
  namespace: production
spec:
  database: "orders-db"
  schedule: "0 2 * * *"
  retentionDays: 90
  storageLocation: "s3"
  compression: true
kubectl apply -f prod-backup.yaml
kubectl get databasebackups -n production
kubectl get dbb -n production  # using short name

Schema 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: 65535

For 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 structure

Use 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   30d

Subresources#

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.labelSelector

CRD 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 stores

When 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: string

CEL 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.