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.
  • ScopeNamespaced (lives within a namespace) or Cluster (cluster-scoped, like Node or PersistentVolume).

The CRD name is always plural.group, e.g., applicationconfigs.apps.techoral.com.

Naming Convention: Always use a domain you control as the group. Using generic groups like 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
Field Pruning: With a structural schema, the API server automatically removes any fields not listed in the schema. This prevents clients from storing arbitrary data in your custom resources. If you legitimately need to store arbitrary key-value data, use 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.

CapabilityCRDAggregated API Server
Complexity to buildLow — just YAML + controllerHigh — full API server implementation
Storage backendetcd (via kube-apiserver)Custom (etcd, SQL, in-memory)
Custom validation logicVia admission webhooksBuilt into the API handler
Custom subresourcesstatus and scale onlyAny arbitrary subresource
Non-standard verbsNot supportedSupported (e.g., exec, log, portforward)
Non-etcd storageNot supportedSupported (time-series, graph DB, etc.)
kubectl compatibilityFull (discovery-based)Full (same discovery mechanism)
Recommended for99% of Operator use casesmetrics-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: true can be used by clients (GET, POST, PATCH against that API version). The storage: true version 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 specReplicasPath and statusReplicasPath. 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.