Custom Resource Definitions (CRD & CR)
Video: Day 49 — Extending the Kubernetes API with CRDs • Theme: teach the API server new object kinds without recompiling it.
Key terms
| Term | Meaning |
|---|---|
| Resource | An endpoint that stores API objects of a kind (e.g. pods) |
| CRD | CustomResourceDefinition — registers a new kind/endpoint |
| CR | Custom Resource — an instance of a kind defined by a CRD |
| Group / Version / Kind | The API coordinates, e.g. cert-manager.io/v1, Certificate |
| Scope | Namespaced or Cluster reach of the resource |
| OpenAPI v3 schema | Validation rules for the CR's spec |
| Controller | Code that watches a CR and drives the real world to match it |
Problem & solution
Kubernetes ships with built-in kinds (Pod, Service, Deployment) but real
platforms need their own objects. cert-manager is a perfect example: it adds
a Certificate kind so teams can request TLS certificates declaratively. You do
not want to fork or recompile the API server to add such a kind.
Solution: A CustomResourceDefinition teaches the API server a brand-new
kind at runtime. Once cert-manager applies its CRD, kubectl get certificate,
RBAC, watches, validation and etcd storage all work for Certificate objects
exactly like native objects.
The analogy
Imagine the port only accepts a fixed set of cargo forms, and a tenant needs a brand-new kind the front desk has never seen. Rather than rebuild the whole front desk, the port authority registers the new form type in its rulebook, so the desk now knows how to accept it, validate it, and file it in the ledger like any standard form. In Kubernetes, registering that new form type is a CustomResourceDefinition (cert-manager's certificates.cert-manager.io), the front desk is the api-server, each filled-out copy is a Custom Resource (a Certificate), and the ledger it lands in is etcd.
Graph legend — each Kubernetes node maps a port concept to cert-manager's Certificate kind:
| Graph node | Maps to | What it does |
|---|---|---|
| CRD certificates.cert-manager.io | the CustomResourceDefinition | Registers the Certificate kind, endpoint, and schema |
| api-server | kube-apiserver | Serves and validates Certificate objects like native kinds |
| Certificate (Custom Resource) | a kind: Certificate instance | A team's declarative TLS request |
| etcd | cluster datastore | Persists each Certificate object |
Where this fits in the cluster
The same cluster entities appear in every day's notes; the diagram below shows where this day's topic fits.
Graph legend — each node is a cluster component touched when cert-manager's CRD is installed:
| Graph node | Maps to | What it does |
|---|---|---|
| api-server adds certificates.cert-manager.io endpoint | kube-apiserver | Serves the new /certificates REST endpoint |
| etcd stores each Certificate | cluster datastore | Persists Certificate objects alongside native ones |
| cert-manager controller pod | the cert-manager Deployment | Watches Certificate CRs and issues real TLS Secrets |
| watch Certificate | a watch API call | Streams Certificate changes to the controller |
A CRD only adds the API; a controller adds behaviour
This is the most common misconception. A CRD by itself just gives you a typed,
validated place to store data in etcd. Nothing happens to the world until a
controller watches those objects and acts. cert-manager's CRD gives you the
Certificate kind; cert-manager's controller is what actually issues certs.
CRD -> registers the kind + endpoint + schema (storage only)
CR -> a stored Certificate (data in etcd, served by the api-server)
Controller -> cert-manager watches Certificates and issues real TLS Secrets
Defining a CRD
The CRD declares the API group, one or more versions with an OpenAPI v3 schema,
the scope, and the names used in URLs and kubectl. Below is an abbreviated
view of cert-manager's real Certificate CRD (the shipped one has many more
fields):
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: certificates.cert-manager.io # must be <plural>.<group>
spec:
group: cert-manager.io
scope: Namespaced # or Cluster
names:
plural: certificates
singular: certificate
kind: Certificate
shortNames: ["cert"]
versions:
- name: v1
served: true # this version is reachable
storage: true # exactly ONE version stores to etcd
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
secretName:
type: string
dnsNames:
type: array
items:
type: string
duration:
type: string
renewBefore:
type: string
issuerRef:
type: object
properties:
name: { type: string }
kind: { type: string }
group: { type: string }
required: ["name"]
required: ["secretName", "issuerRef"]
additionalPrinterColumns:
- name: Ready
type: string
jsonPath: .status.conditions[?(@.type=="Ready")].status
- name: Secret
type: string
jsonPath: .spec.secretName
# cert-manager ships and applies this CRD as part of its install
kubectl apply -f \
https://github.com/cert-manager/cert-manager/releases/download/v1.15.3/cert-manager.yaml
kubectl get crd certificates.cert-manager.io
kubectl api-resources | grep certificates # the new resource now appears
Creating a Custom Resource
Once the CRD is established, your new kind is just another object. The api-server validates each CR against the OpenAPI schema before persisting it.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: shop-tls
namespace: store
spec:
secretName: shop-tls # cert-manager writes the TLS keypair here
dnsNames:
- shop.example.com
duration: 2160h # 90 days
renewBefore: 360h # renew 15 days before expiry
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
group: cert-manager.io
kubectl apply -f shop-tls.yaml
kubectl get certificates # uses additionalPrinterColumns
kubectl get cert shop-tls -o yaml # shortName works too
kubectl describe certificate shop-tls
If a field violates the schema (e.g. secretName missing), the apply is
rejected by the api-server with a clear validation error — no controller needed.
Scope, validation, and structural schemas
- Scope
Namespacedobjects live in a namespace (kubectl get cert -A);Clusterobjects are global (no namespace). apiextensions.k8s.io/v1requires a structural schema (every level typed, no barex-kubernetes-preserve-unknown-fieldsat the root) — this enables pruning of unknown fields and reliable defaulting.- Use
default:in the schema to set field defaults, andx-kubernetes-validations(CEL) for cross-field rules:
duration:
type: string
default: "2160h"
x-kubernetes-validations:
- rule: "size(self.dnsNames) > 0"
message: "at least one dnsName is required"
Versions and conversion
A CRD can serve multiple versions (v1alpha2, v1beta1, v1). Exactly one has
storage: true. cert-manager historically migrated its Certificate through
several versions; to migrate fields between versions you set a conversion
strategy (None for identical schemas, or Webhook to call a converter — which
cert-manager uses).
kubectl get certificates.v1.cert-manager.io # ask for a specific version
Graph legend — each node is a real part of CRD version conversion:
| Graph node | Maps to | What it does |
|---|---|---|
| client asks Certificate v1 / v1beta1 | a kubectl get for a version | Requests the object in a specific served version |
| cert-manager conversion webhook | spec.conversion.strategy: Webhook | Rewrites the object between versions on the fly |
| stored as v1 in etcd | the storage: true version | The single canonical form persisted in etcd |
End-to-end: from CRD to reconciled state
This is the full flow from registering the kind to cert-manager acting on it.
Graph legend — each node maps to a step from CRD registration to issued cert:
| Graph node | Maps to | What it does |
|---|---|---|
| kubectl apply CRD | the CustomResourceDefinition | Registers the Certificate kind and schema |
| api-server registers /certificates endpoint | kube-apiserver | Serves the new resource and its OpenAPI schema |
| kubectl apply Certificate shop-tls | a Certificate CR | The declarative TLS request |
| validates against OpenAPI v3 schema | api-server validation | Rejects CRs that violate the schema |
| Certificate stored in etcd | persistence | Saves the object like any native kind |
| cert-manager controller reconciles | cert-manager controller | Issues the cert and writes the shop-tls Secret |
| status.conditions Ready=True | .status write-back | Reports issuance success on the Certificate |
End-to-end example: install cert-manager's Certificate kind and use it
A complete, runnable walkthrough: install cert-manager (which registers the
Certificate CRD), prove validation works, see the printer columns, and confirm
RBAC and watches behave like native objects.
Step 1 — install cert-manager, which applies the Certificate CRD.
kubectl apply -f \
https://github.com/cert-manager/cert-manager/releases/download/v1.15.3/cert-manager.yaml
# ... customresourcedefinition.apiextensions.k8s.io/certificates.cert-manager.io created
kubectl get crd certificates.cert-manager.io
# NAME CREATED AT
# certificates.cert-manager.io 2025-06-22T10:00:00Z
# wait until the api-server reports the kind Established
kubectl wait --for=condition=Established crd/certificates.cert-manager.io --timeout=60s
# customresourcedefinition.apiextensions.k8s.io/certificates.cert-manager.io condition met
Step 2 — confirm the new endpoint is live.
kubectl api-resources --api-group=cert-manager.io | grep certificates
# NAME SHORTNAMES APIVERSION NAMESPACED KIND
# certificates cert cert-manager.io/v1 true Certificate
Step 3 — create a valid Certificate CR.
# shop-tls.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: shop-tls
namespace: store
spec:
secretName: shop-tls
dnsNames:
- shop.example.com
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
group: cert-manager.io
kubectl apply -f shop-tls.yaml
# certificate.cert-manager.io/shop-tls created
kubectl get certificates -n store
# NAME READY SECRET AGE
# shop-tls True shop-tls 30s
kubectl get cert shop-tls -n store -o jsonpath='{.spec.secretName}'
# shop-tls
Step 4 — prove schema validation rejects bad input (no controller needed).
kubectl apply -f - <<'EOF'
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: broken-cert
namespace: store
spec:
dnsNames:
- shop.example.com
# secretName + issuerRef omitted
EOF
# The Certificate "broken-cert" is invalid:
# spec.secretName: Required value
# spec.issuerRef: Required value
Step 5 — verify native behaviour: watches and RBAC work automatically.
# watches stream events like any built-in kind
kubectl get certificates -n store -w &
kubectl patch cert shop-tls -n store --type merge -p '{"spec":{"renewBefore":"720h"}}'
# certificate.cert-manager.io/shop-tls patched
# RBAC scopes the custom kind exactly like pods/services
kubectl create role cert-reader \
--verb=get,list,watch --resource=certificates.cert-manager.io -n store
kubectl auth can-i list certificates --as=system:serviceaccount:store:default -n store
# no (until the role is bound)
Graph legend — each node is a real step in admitting a Certificate CR:
| Graph node | Maps to | What it does |
|---|---|---|
| kubectl apply Certificate CR | client request | Submits a Certificate object |
| api-server routes to certificates endpoint | kube-apiserver routing | Sends it to the CRD-backed resource |
| Schema check against OpenAPI v3 | CRD validation | Enforces required secretName/issuerRef |
| 422 reject with field error | validation failure | Refuses invalid CRs before storage |
| Pruning and defaulting applied | structural schema | Strips unknown fields, fills defaults |
| Admission and RBAC checks | admission + authz | Applies policy and permissions to the kind |
| written to etcd | persistence | Stores the Certificate object |
| Served to the cert-manager controller | watch delivery | Lets cert-manager reconcile it into a Secret |
Key takeaways
- A CRD extends the Kubernetes API with a new kind at runtime — no recompile.
- A CR is an instance; the api-server stores and validates it like a native object.
- CRD = data + schema only; behaviour requires a controller (cert-manager) that reconciles CRs.
- Pick a
scope(Namespaced/Cluster), provide a structural OpenAPI v3 schema, and mark exactly one versionstorage: true. kubectl get/describe, RBAC, and watches all work for custom kinds automatically.
Checklist
- [ ] Installed cert-manager and saw
certificatesinkubectl api-resources - [ ] Created a Certificate CR and confirmed schema validation rejects bad input
- [ ] Used
additionalPrinterColumnsand ashortName - [ ] Explained why a CRD without a controller does nothing
- [ ] Described served vs storage versions and conversion