---
title: "Custom Resource Definitions (CRDs): Extending the Kubernetes API"
description: "How to create, validate, version, and manage Custom Resource Definitions to extend Kubernetes with your own resource types."
url: https://agent-zone.ai/knowledge/kubernetes/custom-resource-definitions/
section: knowledge
date: 2026-02-21
categories: ["kubernetes"]
tags: ["crds","custom-resources","api-extensions","openapi-validation","cel-validation","operator-pattern","helm"]
skills: ["api-extension","schema-design","crd-development","kubernetes-api"]
tools: ["kubectl","helm"]
levels: ["intermediate"]
word_count: 1268
formats:
  json: https://agent-zone.ai/knowledge/kubernetes/custom-resource-definitions/index.json
  html: https://agent-zone.ai/knowledge/kubernetes/custom-resource-definitions/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=Custom+Resource+Definitions+%28CRDs%29%3A+Extending+the+Kubernetes+API
---


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

```yaml
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:

```bash
kubectl apply -f databasebackup-crd.yaml
```

```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
```

```bash
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:

```yaml
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:

```yaml
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`:

```yaml
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:

```yaml
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:

```go
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:

```yaml
subresources:
  status: {}
  scale:
    specReplicasPath: .spec.replicas
    statusReplicasPath: .status.replicas
    labelSelectorPath: .status.labelSelector
```

## CRD Versioning

Real CRDs evolve over time. Kubernetes supports multiple served versions:

```yaml
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:

```yaml
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:

```yaml
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):

```yaml
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.

```yaml
# 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.

```go
ctrl.SetControllerReference(backup, childJob, r.Scheme)
```

Without this, deleting a DatabaseBackup leaves orphaned Jobs, PVCs, and other resources behind.

