- Published on
Building Your First Operator with Kubebuilder — From Project Creation to Deployment
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction
- Prerequisites
- Project Creation: init and create api
- A Look at the Project Structure
- Defining API Types: Spec and Status
- The Full Reconcile Code
- RBAC Markers
- CRD Generation: make manifests
- Local Run vs. Cluster Deploy
- Testing: The envtest Concept
- Common Mistakes
- The 2026 Context
- Conclusion
- References
Introduction
In the previous article we looked at the principles of the Operator pattern. Now it is time to get our hands dirty. Using Kubebuilder, we will build a simple but genuinely working Operator from scratch. The target is a CRD called Guestbook: when a user declares the desired replica count and image, the Operator automatically creates and reconciles a matching Deployment and Service.
The goal here is to understand the whole flow "without magic." We will follow all the way through what scaffolding produces, what to fill into the reconcile function, how RBAC and the CRD are generated, and how to run it both locally and in a cluster. This is based on controller-runtime v0.24.x.
Prerequisites
Before starting, you need the following tools.
- Go 1.26 or later: The modules Kubebuilder generates assume a recent Go toolchain.
- Kubebuilder CLI: Handles project scaffolding and code generation.
- kubectl: The standard CLI for talking to the cluster.
- A local or remote cluster: A local cluster made with kind or minikube is enough.
- Docker or an equivalent container builder: Needed to build and deploy the controller image.
Once installed, check the versions.
go version
kubebuilder version
kubectl version --client
Project Creation: init and create api
kubebuilder init
First, initialize the project in an empty directory. --domain becomes the suffix of the API group, and --repo is the Go module path.
mkdir guestbook-operator && cd guestbook-operator
kubebuilder init --domain example.com --repo example.com/guestbook-operator
This command generates main.go, a Makefile, a Dockerfile, the config/ directory (manifests), and a go.mod with dependencies filled in. At this point the project is just an empty manager with no APIs.
kubebuilder create api
Now add an API and a controller.
kubebuilder create api --group webapp --version v1 --kind Guestbook
The prompt asks whether to generate both Resource (the type definition) and Controller (the reconcile logic). Choose yes for both. That produces:
api/v1/guestbook_types.go— API type definitions like Spec/Statusinternal/controller/guestbook_controller.go— the controller where reconcile logic goesconfig/crd/,config/rbac/,config/samples/— related manifests
A Look at the Project Structure
Understanding the directory structure scaffolding created at a glance makes subsequent work much easier. Summarizing only the key directories:
guestbook-operator/
api/v1/ # API type definitions (Spec/Status, markers)
internal/controller/ # reconcile logic
config/
crd/ # generated CRD manifests
rbac/ # generated RBAC (role.yaml, etc.)
manager/ # the controller manager Deployment
samples/ # example CRs
cmd/main.go # manager entry point (scheme registration, manager startup)
Makefile # standard targets like build/run/deploy
Dockerfile # builds the controller image
- api/: This is where we work directly. We define types and markers here.
- internal/controller/: Also where we fill in reconcile.
- config/: Mostly auto-generated, so there is little to edit by hand. Manage it by changing markers and running make manifests.
- cmd/main.go: Bootstrap code that creates the manager, registers types with the scheme, and wires controllers into the manager. When you add a new controller, registration code usually goes here automatically.
The core philosophy of this structure is to clearly separate "the code I write (api, controller)" from "generated artifacts (config)." Do not edit generated artifacts directly; keep the flow of always editing the source (types and markers) then regenerating, and consistency is maintained.
Defining API Types: Spec and Status
Open api/v1/guestbook_types.go and define the fields we want. Spec is the "desired state" the user declares, and Status is the "observed state" the controller fills in.
package v1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// GuestbookSpec defines the desired state the user declares.
type GuestbookSpec struct {
// Desired number of replicas.
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=10
Replicas int32 `json:"replicas"`
// Container image to use.
// +kubebuilder:validation:MinLength=1
Image string `json:"image"`
// Port the service exposes.
// +kubebuilder:default=80
Port int32 `json:"port,omitempty"`
}
// GuestbookStatus holds the observed state.
type GuestbookStatus struct {
// Number of ready replicas.
ReadyReplicas int32 `json:"readyReplicas"`
// List of status conditions.
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Replicas",type=integer,JSONPath=`.spec.replicas`
// +kubebuilder:printcolumn:name="Ready",type=integer,JSONPath=`.status.readyReplicas`
// Guestbook is our custom resource.
type Guestbook struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec GuestbookSpec `json:"spec,omitempty"`
Status GuestbookStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// GuestbookList is a list of Guestbook.
type GuestbookList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []Guestbook `json:"items"`
}
func init() {
SchemeBuilder.Register(&Guestbook{}, &GuestbookList{})
}
The lines starting with // +kubebuilder: are called markers. Markers are directives read by code generation tools. For example, validation:Minimum adds a validation rule to the CRD's OpenAPI schema, subresource:status enables the status subresource, and printcolumn shows extra columns in kubectl get.
The Full Reconcile Code
Now we fill in the core reconcile function. Write internal/controller/guestbook_controller.go as follows. This controller reads a Guestbook, idempotently creates the desired Deployment and Service, and finally updates status.
package controller
import (
"context"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/intstr"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
webappv1 "example.com/guestbook-operator/api/v1"
)
type GuestbookReconciler struct {
client.Client
Scheme *runtime.Scheme
}
func (r *GuestbookReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
// 1. Read the target Guestbook.
var gb webappv1.Guestbook
if err := r.Get(ctx, req.NamespacedName, &gb); err != nil {
if errors.IsNotFound(err) {
// Already deleted. Thanks to owner references, GC cleans up children.
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}
// 2. Build the desired Deployment and reconcile it idempotently.
desiredDeploy := r.buildDeployment(&gb)
if err := ctrl.SetControllerReference(&gb, desiredDeploy, r.Scheme); err != nil {
return ctrl.Result{}, err
}
var existingDeploy appsv1.Deployment
err := r.Get(ctx, client.ObjectKeyFromObject(desiredDeploy), &existingDeploy)
if errors.IsNotFound(err) {
logger.Info("Creating Deployment", "name", desiredDeploy.Name)
if err := r.Create(ctx, desiredDeploy); err != nil {
return ctrl.Result{}, err
}
} else if err != nil {
return ctrl.Result{}, err
} else {
// If it already exists, bring it to the desired shape.
existingDeploy.Spec = desiredDeploy.Spec
if err := r.Update(ctx, &existingDeploy); err != nil {
return ctrl.Result{}, err
}
}
// 3. Reconcile the desired Service.
desiredSvc := r.buildService(&gb)
if err := ctrl.SetControllerReference(&gb, desiredSvc, r.Scheme); err != nil {
return ctrl.Result{}, err
}
var existingSvc corev1.Service
err = r.Get(ctx, client.ObjectKeyFromObject(desiredSvc), &existingSvc)
if errors.IsNotFound(err) {
if err := r.Create(ctx, desiredSvc); err != nil {
return ctrl.Result{}, err
}
} else if err != nil {
return ctrl.Result{}, err
}
// 4. Update status.
gb.Status.ReadyReplicas = existingDeploy.Status.ReadyReplicas
if err := r.Status().Update(ctx, &gb); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
func (r *GuestbookReconciler) buildDeployment(gb *webappv1.Guestbook) *appsv1.Deployment {
labels := map[string]string{"app": gb.Name}
replicas := gb.Spec.Replicas
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: gb.Name,
Namespace: gb.Namespace,
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{MatchLabels: labels},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "app",
Image: gb.Spec.Image,
Ports: []corev1.ContainerPort{{ContainerPort: gb.Spec.Port}},
}},
},
},
},
}
}
func (r *GuestbookReconciler) buildService(gb *webappv1.Guestbook) *corev1.Service {
labels := map[string]string{"app": gb.Name}
return &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: gb.Name,
Namespace: gb.Namespace,
},
Spec: corev1.ServiceSpec{
Selector: labels,
Ports: []corev1.ServicePort{{
Port: gb.Spec.Port,
TargetPort: intstr.FromInt32(gb.Spec.Port),
}},
},
}
}
func (r *GuestbookReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&webappv1.Guestbook{}).
Owns(&appsv1.Deployment{}).
Owns(&corev1.Service{}).
Complete(r)
}
The Owns calls in SetupWithManager matter. Registering them this way triggers reconcile even when a Deployment or Service the controller created is changed externally. In other words, even if someone changes the replicas by hand, the controller immediately reverts it to the desired value. This is the principle of self-healing.
RBAC Markers
For the controller to create Deployments and Services, it needs the corresponding permissions. In Kubebuilder, RBAC is declared with markers. Add the following comments at the top of the controller file.
// +kubebuilder:rbac:groups=webapp.example.com,resources=guestbooks,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=webapp.example.com,resources=guestbooks/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete
These markers are converted into a ClusterRole in config/rbac/role.yaml during code generation. Instead of writing RBAC YAML by hand, you declare the permission intent right next to the code, so permissions and code stay together. It is good practice to minimize verbs so you do not grant more permission than necessary.
CRD Generation: make manifests
Once you have written the API types and markers, generate the manifests.
make manifests
make generate
make manifestsreads the markers to generate the CRD inconfig/crd/and the RBAC inconfig/rbac/.make generategenerates boilerplate Go code such asDeepCopy.
Install the generated CRD into the cluster.
make install
kubectl get crd guestbooks.webapp.example.com
Local Run vs. Cluster Deploy
Local run (suited to the dev loop)
During development, running the controller outside the cluster — that is, directly on your laptop — is the fastest approach. It connects to the cluster via kubeconfig.
make run
Edit the code and run make run again, and it is reflected immediately, so iteration is fast. Now apply a sample CR.
kubectl apply -f config/samples/webapp_v1_guestbook.yaml
kubectl get guestbook
kubectl get deployment,service
Change the sample CR's replicas to 5 and re-apply, and you will see the Deployment's replicas follow to 5. Change the Deployment's replicas to 3 by hand, and the controller immediately reverts it to the original value.
Cluster deploy (suited to production)
In real production, you run the controller as a pod inside the cluster too. Build and push the image, then deploy.
make docker-build docker-push IMG=registry.example.com/guestbook-operator:v0.1.0
make deploy IMG=registry.example.com/guestbook-operator:v0.1.0
make deploy applies the RBAC, CRD, and controller Deployment all at once. After deployment, check behavior in the controller manager pod's logs.
kubectl -n guestbook-operator-system get pods
kubectl -n guestbook-operator-system logs deploy/guestbook-operator-controller-manager
Testing: The envtest Concept
To build an Operator seriously, you need tests. Kubebuilder provides a tool called envtest. envtest spins up only the etcd and kube-apiserver binaries — no real cluster — so you can test as if the controller interacts with a real API Server.
make test
The advantage of envtest is that it is more realistic than a fake client. You can actually verify API Server-level behavior such as CRD validation, the status subresource, and owner references. In tests, the common pattern is to create a CR, then poll to confirm that reconcile created the desired Deployment.
Common Mistakes
Here are pitfalls you frequently hit when building your first Operator.
| Mistake | Symptom | Fix |
|---|---|---|
| Missing RBAC markers | Resource creation fails with "forbidden" | Add the needed group/resource/verb markers, then make manifests |
| Owner reference not set | Children remain even after deleting the CR | Call SetControllerReference |
| Non-idempotent reconcile | "already exists" errors, infinite loop | get then create if missing, update if present |
| Updating status with Update | Status conflicts or is ignored | Use Status().Update (subresource) |
| Missing Owns | Does not react to child changes | Add Owns in SetupWithManager |
| Forgetting make generate | DeepCopy-related compile errors | Run make generate after changing types |
In particular, idempotency and owner references are the two fundamentals emphasized in the previous article. Just keeping these two right avoids most beginner bugs.
The 2026 Context
As of 2026, Kubebuilder supports Kubernetes 1.36 and Go 1.26, and internally uses controller-runtime v0.24.x and controller-tools v0.21.x. The kube-rbac-proxy sidecar formerly included in scaffolding has been removed; instead, controller-runtime's authentication/authorization middleware (WithAuthenticationAndAuthorization) protects the metrics endpoint. For a newly created project this is the default, so you can expose metrics safely without an extra container.
Conclusion
The flow of building an Operator with Kubebuilder ultimately comes down to five steps: create the project with init, add the type and controller with create api, define the API with Spec/Status and markers, fill reconcile with idempotent reconciliation logic, and run it with make manifests/install/run.
At first the many files scaffolding produces look daunting, but the core is really just one reconcile function. Moving the single sentence "build the desired state, compare it with the actual state, and converge idempotently" into code is the essence of Operator development. In the next article, we dig deeper into this reconcile loop from the angles of performance and error handling.