Custom Resource Definitions (CRDs)
Every resource you interact with in Kubernetes — Pods, Deployments, Services, ConfigMaps — is defined by a schema registered in the API server. CustomResourceDefinitions let you register your own resource types using the same mechanism. Once a CRD is installed, you create, list, update, and delete instances with kubectl, RBAC controls access to them, and controllers can watch them via the standard informer pattern.
What Is a CRD
A CRD is a Kubernetes API object (kind: CustomResourceDefinition) that registers a new resource type with the API server. After installation, the API server accepts and stores instances of that type — called Custom Resources (CRs). The CRD defines the schema; a controller (operator) watches the CRs and acts on them.
Defining a CRD
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: databases.myapp.io # must be plural.group
spec:
group: myapp.io
scope: Namespaced # or Cluster
names:
plural: databases
singular: database
kind: Database
shortNames: ["db"] # kubectl get db works
versions:
- name: v1
served: true # this version is active
storage: true # this version is persisted in etcd
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: ["engine", "storage"]
properties:
engine:
type: string
enum: ["postgres", "mysql", "redis"]
storage:
type: string
pattern: '^[0-9]+Gi$'
replicas:
type: integer
minimum: 1
maximum: 5
default: 1
status:
type: object
properties:
phase:
type: string
connectionString:
type: string
apiVersion: myapp.io/v1
kind: Database
metadata:
name: production-db
namespace: production
spec:
engine: postgres
storage: 20Gi
replicas: 2
Structural Schema Validation
The openAPIV3Schema is validated at admission time — the API server rejects CRs that don't match. A structural schema requires every field to have a type, and the schema must cover the entire object (no additionalProperties: true at the top level). Structural schemas enable pruning (remove unknown fields) and defaulting.
properties:
spec:
type: object
properties:
replicas:
type: integer
minimum: 1
maximum: 10
default: 1 # injected if omitted — requires x-kubernetes-preserve-unknown-fields: false
engine:
type: string
enum: ["postgres", "mysql"] # validation: only these values accepted
version:
type: string
pattern: '^\d+\.\d+$' # regex validation: "14.5" passes, "latest" fails
backupSchedule:
type: string
format: "cronexpr" # informational — not enforced by default
nullable: true # allows null / omission
Status Subresource
Enabling the status subresource separates spec (desired state) from status (observed state). This is important: with the subresource enabled, only a controller can update status — kubectl apply cannot overwrite it. It also enables kubectl get database production-db -o jsonpath='{.status.phase}'.
versions:
- name: v1
served: true
storage: true
subresources:
status: {} # enable the /status subresource
# Controller updates status via:
# kubectl patch database production-db --subresource=status \
# --type=merge -p '{"status":{"phase":"Running"}}'
Printer Columns
Control what kubectl get database prints by defining additional printer columns in the CRD. Without this, kubectl get only shows NAME, AGE.
versions:
- name: v1
additionalPrinterColumns:
- name: Engine
type: string
jsonPath: .spec.engine
- name: Storage
type: string
jsonPath: .spec.storage
- name: Replicas
type: integer
jsonPath: .spec.replicas
- name: Phase
type: string
jsonPath: .status.phase
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
# kubectl get database:
# NAME ENGINE STORAGE REPLICAS PHASE AGE
# production-db postgres 20Gi 2 Running 3d
API Versioning
CRDs support multiple API versions simultaneously. Serve both v1alpha1 and v1; mark only one as storage: true. When upgrading from alpha to stable, use a conversion webhook to translate between versions on the fly — existing CRs stored as v1alpha1 are served as v1 without a migration job.
versions:
- name: v1alpha1
served: true
storage: false # v1alpha1 no longer stored, but still served for old clients
- name: v1
served: true
storage: true # new storage format
conversion:
strategy: Webhook
webhook:
clientConfig:
service:
name: database-webhook
namespace: myapp-system
path: /convert
conversionReviewVersions: ["v1", "v1beta1"]
When to Use CRDs
CRDs are not free — they add API surface, require schema maintenance, and need controllers to be useful. Use them when:
- You are building an operator that manages complex stateful infrastructure (databases, ML pipelines, certificate lifecycles).
- You want to expose a higher-level abstraction to platform users (a
DatabaseCR that provisions RDS + creates a K8s Secret automatically). - You need kubectl-native UX for a concept that doesn't map to existing resources.
Don't use CRDs for simple config data — a ConfigMap or a Helm values file is simpler. Don't write a CRD if an existing one (cert-manager Certificate, ESO ExternalSecret) already covers your use case.
kubectl Commands
# List all installed CRDs
kubectl get crd
# Describe a CRD (shows schema, versions, printer columns)
kubectl describe crd databases.myapp.io
# List custom resources of a type
kubectl get databases -A
kubectl get databases -n production
# Check CRD schema validation
kubectl explain database.spec
kubectl explain database.spec.engine
# Delete a CRD (also deletes all instances — be careful)
kubectl delete crd databases.myapp.io