Skip to content
Published on

Building Your First Operator with Kubebuilder — From Project Creation to Deployment

Authors

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/Status
  • internal/controller/guestbook_controller.go — the controller where reconcile logic goes
  • config/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 manifests reads the markers to generate the CRD in config/crd/ and the RBAC in config/rbac/.
  • make generate generates boilerplate Go code such as DeepCopy.

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.

MistakeSymptomFix
Missing RBAC markersResource creation fails with "forbidden"Add the needed group/resource/verb markers, then make manifests
Owner reference not setChildren remain even after deleting the CRCall SetControllerReference
Non-idempotent reconcile"already exists" errors, infinite loopget then create if missing, update if present
Updating status with UpdateStatus conflicts or is ignoredUse Status().Update (subresource)
Missing OwnsDoes not react to child changesAdd Owns in SetupWithManager
Forgetting make generateDeepCopy-related compile errorsRun 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.

References