Build a Kubernetes Controller

Write a custom Kubernetes controller in Go that manages web apps via CRDs.

Introduction

30 Second Summary

Every time you tell Kubernetes to run three copies of your app, something watches the cluster and makes it happen. That something is a controller.

In this project, you will build your own Kubernetes controller using Kubebuilder and Go. Your controller manages a custom WebApp resource, automatically creating and scaling Deployments on your local cluster.

What You'll Build

Picture running kubectl apply with a simple YAML file and watching a full Deployment appear on your cluster within seconds.

By the end of this project, you'll have:

  • A custom WebApp resource you can create with kubectl apply and watch a full Deployment spin up on your cluster, complete with running pods.
  • A running controller that detects when you change the replica count or image on your WebApp and automatically reconciles the Deployment to match.
  • Automatic cleanup through owner references. Delete the WebApp, and the Deployment disappears with it.
  • Secret Mission: an optional challenge to push your controller skills even further.

Are there any prerequisites?

This project assumes a basic understanding of Kubernetes concepts like Pods, Deployments, and Services. You also need Go and Docker Desktop (with Kubernetes enabled) installed on macOS.

Before We Start

Before diving into code, take a moment to lock in what you are building and why it matters.

You are about to extend the Kubernetes API itself by defining a brand-new resource type and writing the controller that brings it to life.

Set Up Kubebuilder and Scaffold the Project

Every Kubernetes controller starts with tooling. You need the Kubebuilder CLI to scaffold the project, and you need your local Kubernetes cluster to be reachable so you can test against it.

In this step you will verify your existing Go and Kubernetes setup, install Kubebuilder, and scaffold a brand-new operator project with a WebApp API skeleton. By the end, you will have a full project directory with generated types, a controller skeleton, and all the build infrastructure ready.

In this step, get ready to:
  • Verify that Go and your Docker Desktop Kubernetes cluster are ready.
  • Install the Kubebuilder CLI.
  • Scaffold the WebApp operator project and API.
Verify Go and Kubernetes are ready

Kubebuilder requires Go version v1.24.6 or higher and a running Kubernetes cluster. Both should already be on your machine, so let's confirm they are working.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Check your Go version by running:
go version

You should see output like go version go1.27.0 darwin/arm64. The version number must be v1.24.6 or higher.

Seeing a version lower than v1.24.6?

Visit the official Go downloads page at https://go.dev/dl/ and install the latest stable release. After installing, close and reopen your terminal, then run go version again.

help me upgrade Go to v1.24.6 or higher on macOS

  • Now confirm your Kubernetes cluster is reachable by running:
kubectl cluster-info

You should see output showing your Kubernetes control plane address, something like Kubernetes control plane is running at https://127.0.0.1:6443.

Not seeing your cluster?

Make sure Docker Desktop is running and that Kubernetes is enabled. Open Docker Desktop, click the gear icon to go to Settings, then select Kubernetes and check Enable Kubernetes. Click Apply & Restart and wait for the green indicator in the bottom left.

help me enable Kubernetes in Docker Desktop

Install Kubebuilder

Kubebuilder is a CLI that scaffolds the entire project structure for a Kubernetes controller. It generates your CRD type definitions, controller skeleton, RBAC configuration, and a Makefile that ties everything together.

  • Download and install the Kubebuilder binary by running these commands:
curl -L -o kubebuilder "https://go.kubebuilder.io/dl/latest/$(go env GOOS)/$(go env GOARCH)"
chmod +x kubebuilder && sudo mv kubebuilder /usr/local/bin/

What do these commands do?

  • curl -L -o kubebuilder downloads the latest Kubebuilder binary for your OS and architecture.
  • chmod +x makes the file executable.
  • sudo mv kubebuilder /usr/local/bin/ moves it into your system path so you can run kubebuilder from anywhere.

Your terminal will ask for your macOS password because of the sudo command. Type it in and press Enter. You will not see the characters as you type.

  • Verify the installation by running:
kubebuilder version

✔️ I see a version number

You should see output starting with KubeBuilder: v4.15.0 (or a similar version). Kubebuilder is ready to go.

ⓧ Command not found

If your terminal prints something like command not found: kubebuilder, the binary did not land in your path. Try these fixes:

  • Check that the download completed. Run ls -la kubebuilder in the directory where you ran the curl command. If the file is missing, re-run the curl command above.
  • Make sure /usr/local/bin is in your PATH. Run echo $PATH and look for /usr/local/bin in the output.
  • Close and reopen your terminal, then run kubebuilder version again.

help me troubleshoot my Kubebuilder installation on macOS

Scaffold the project

With Kubebuilder installed, you can now scaffold the entire operator project. The kubebuilder init command creates the Go module, Makefile, and config directory structure. The kubebuilder create api command generates the CRD type file, controller skeleton, and sample CR.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir webapp-operator
cd webapp-operator
  • Initialize the Kubebuilder project by running:
kubebuilder init --domain example.com --repo example.com/webapp-operator

What does this command do?

  • --domain example.com sets the API group domain. Your CRDs will live under apps.example.com when combined with the group name.
  • --repo example.com/webapp-operator sets the Go module path. This is required because the project is outside your GOPATH.

You should see output confirming that the project was scaffolded, including lines about writing a go.mod file and creating the directory structure.

  • Confirm the project files were created by running:
ls

You should see Makefile, go.mod, go.sum, cmd/, and config/ in the output.

Now create the WebApp API. This is the command that generates the CRD types file, controller skeleton, and sample manifest.

  • Scaffold the WebApp API by running:
kubebuilder create api --group apps --version v1alpha1 --kind WebApp

What will this command ask you?

This command will prompt you with two questions. Answer y to both:

  • Create Resource [y/n] generates the CRD type definitions in api/v1alpha1/webapp_types.go and a sample CR in config/samples/.
  • Create Controller [y/n] generates the reconciler skeleton in internal/controller/webapp_controller.go.
  • When prompted with Create Resource [y/n], type y and press Enter.
  • When prompted with Create Controller [y/n], type y and press Enter.

What did this generate?

  • api/v1alpha1/webapp_types.go defines the WebApp spec and status structs. Right now it has a placeholder Foo field that you will replace in the next step.
  • internal/controller/webapp_controller.go contains the reconciler skeleton with an empty Reconcile function. This is where the controller logic will go.
  • config/samples/ has a sample WebApp CR YAML you will use to test with kubectl apply.
  • Before you move on, what do you think you will see if you run ls api/v1alpha1/? Predict what files Kubebuilder generated, then run:
ls api/v1alpha1/

You should see webapp_types.go along with other generated files like groupversion_info.go. The types file is where you will define what a WebApp resource looks like in the next step.

Don't see the generated files?

Make sure you answered y to both prompts. If you answered n to either one, the corresponding files were not created. Run the kubebuilder create api command again with the same flags and answer y to both prompts.

help me fix my Kubebuilder API scaffolding

Your operator project is scaffolded and ready. Next up, you will replace the placeholder spec with real fields that define what a WebApp looks like on your cluster.

Define the WebApp CRD and See It Live

Your project is scaffolded and your cluster is running. But the WebApp CRD is still using a placeholder Foo field that Kubebuilder generated as an example. That placeholder tells Kubernetes nothing about what a web application actually looks like.

In this step, you will define the real shape of a WebApp by adding fields for the container image, replica count, and port. Then you will install the Custom Resource Definition on your cluster and create your first WebApp resource.

In this step, get ready to:
  • Define the WebApp spec with Image, Replicas, and Port fields.
  • Generate and install the CRD on your Docker Desktop cluster.
  • Create a sample WebApp resource and see it listed in kubectl.
Define the WebApp spec and status types

Every CRD starts with a Go struct that describes its fields and validation rules. Kubebuilder reads special comment markers on your struct and uses them to generate the OpenAPI schema that Kubernetes enforces.

  • Open api/v1alpha1/webapp_types.go in your editor.
  • Find the WebAppSpec struct. It currently has a placeholder Foo field.

Here is what the placeholder looks like:

// WebAppSpec defines the desired state of WebApp
type WebAppSpec struct {
	// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
	// Important: Run "make" to regenerate code after modifying this file

	// foo is an example field of WebApp. Edit webapp_types.go to remove/update
	// +optional
	Foo *string `json:"foo,omitempty"`
}
  • Replace the entire WebAppSpec struct (including the comment above it) with the following code:
// WebAppSpec defines the desired state of WebApp
type WebAppSpec struct {
	// image is the container image to deploy
	// +kubebuilder:validation:MinLength=1
	// +required
	Image string `json:"image"`

	// replicas is the number of desired pods
	// +kubebuilder:validation:Minimum=1
	// +kubebuilder:validation:Maximum=10
	// +optional
	Replicas *int32 `json:"replicas,omitempty"`

	// port is the container port to expose
	// +kubebuilder:validation:Minimum=1
	// +kubebuilder:validation:Maximum=65535
	// +required
	Port int32 `json:"port"`
}

What does this code do?

Each field maps to a property in your custom resource's YAML spec. The comment markers above each field tell Kubebuilder how to validate them.

  • Image is the container image to deploy (e.g. nginx:1.27-alpine). +kubebuilder:validation:MinLength=1 prevents an empty string.
  • Replicas is a pointer to int32, which makes it optional. When omitted, the controller can default to 1. The markers cap it between 1 and 10.
  • Port is the container port to expose, validated to be in the range 1 to 65535.
  • Confirm the WebAppStatus struct is empty. It should already look like this in the scaffolded file:
// WebAppStatus defines the observed state of WebApp
type WebAppStatus struct {
}

Why leave the status empty?

You do not need status fields yet because the controller has not been written. Status conditions will come later once the reconciler has something to report.

  • Verify the +kubebuilder:subresource:status marker is present above the WebApp type. The scaffold should already have it:
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status

// WebApp is the Schema for the webapps API
type WebApp struct {
  • Save api/v1alpha1/webapp_types.go.
  • Regenerate the DeepCopy implementations by running this command:
make generate

If your types are valid Go, the command completes with no errors. It regenerates the zz_generated.deepcopy.go file under api/v1alpha1/.

Seeing a compilation error?

  • Check that you replaced the entire WebAppSpec struct, including the old Foo field. Leftover fields cause type conflicts.
  • Make sure your backtick tags match exactly: json:"image", json:"replicas,omitempty", and json:"port". A missing backtick or mismatched quote breaks the struct tag.

Help me debug a make generate error in my Kubebuilder project.

✔️ Awesome, I've got everything!

Great. Double check you have saved api/v1alpha1/webapp_types.go before moving on.

ⓧ I'd like to double check the full code

Your file is longer than the individual chunks above because it includes the scaffolded boilerplate. Scroll through api/v1alpha1/webapp_types.go and confirm the following sections appear in order:

  • The package v1alpha1 declaration and import block including metav1 and runtime.
  • The updated WebAppSpec struct with Image, Replicas, and Port fields (the chunk you pasted above).
  • The empty WebAppStatus struct.
  • The +kubebuilder:object:root=true and +kubebuilder:subresource:status markers above the WebApp struct.
  • The WebAppList struct and the init() function registering both types.
Generate the CRD manifests and install them

Your Go types describe the WebApp shape, but Kubernetes does not know about them yet. You need to generate a CRD YAML manifest from your types and install it on the cluster.

  • Generate the CRD manifest by running this command:
make manifests

This runs controller-gen to produce a CRD YAML file under config/crd/bases/.

  • Install the CRD onto your Docker Desktop cluster by running:
make install

What do these commands do?

  • make manifests reads the validation markers on your Go structs and generates the OpenAPI v3 schema that Kubernetes uses to validate your custom resources.
  • make install applies the generated CRD to your cluster using kubectl. After this, Kubernetes recognizes WebApp as a valid resource type.

Seeing an error from make install?

  • Make sure your Docker Desktop Kubernetes cluster is running. Check the Docker Desktop icon in your menu bar for a green status indicator.
  • Run kubectl cluster-info to confirm your cluster is reachable.

Help me fix a make install error in my Kubebuilder project.

Now you need a sample WebApp resource that uses the new spec fields.

  • Open config/samples/apps_v1alpha1_webapp.yaml in your editor.
  • Replace the entire file contents with the following:
apiVersion: apps.example.com/v1alpha1
kind: WebApp
metadata:
  labels:
    app.kubernetes.io/name: webapp-operator
    app.kubernetes.io/managed-by: kustomize
  name: webapp-sample
spec:
  image: nginx:1.27-alpine
  replicas: 2
  port: 80

What does this sample describe?

This YAML is an instance of your custom WebApp resource. It tells Kubernetes you want a WebApp named webapp-sample running the nginx:1.27-alpine image with 2 replicas on port 80.

Right now, creating this resource stores data in etcd but nothing else happens. The controller you build in the next step is what will bring it to life by creating a matching Deployment.

  • Save config/samples/apps_v1alpha1_webapp.yaml.

Before you apply the sample, what do you think kubectl get webapps will show after you create this resource?

  • Apply the sample WebApp to your cluster by running:
kubectl apply -f config/samples/
  • Verify your WebApp resource exists on the cluster by running:
kubectl get webapps

You should see webapp-sample listed in the output. Your custom resource is live on the cluster.

Not seeing webapp-sample?

  • Make sure you ran make install before applying the sample. Without the CRD installed, Kubernetes does not recognize the WebApp kind.
  • Check for validation errors in the kubectl apply output. If a field value violates a marker (e.g. replicas set to 0), Kubernetes rejects the resource.

Help me figure out why kubectl get webapps is not showing my resource.

Your custom resource is live on the cluster, but it is just data sitting in etcd. Next up, you will write the reconciler that watches for WebApp resources and automatically creates matching Deployments.

Write the Reconciler and Create Your First Deployment

In the last step, you installed your WebApp CRD on the cluster and created your first WebApp resource. Running kubectl get webapps shows it listed and ready.

There is a problem though. Creating a WebApp does nothing. No Deployment appears, no pods spin up. The CRD is just data sitting in etcd. The controller is the piece that brings it to life by watching for WebApp resources and creating the corresponding Deployments.

In this step, get ready to:
  • Implement the Reconcile function that fetches WebApp resources and creates Deployments.
  • Build a helper function that constructs a Deployment with owner references.
  • Run the controller and watch it create a real Deployment on the cluster.
Implement the Reconcile function

Every Kubernetes controller has a Reconcile function at its core. This function runs each time a watched resource changes. Your reconciler will fetch the WebApp CR, check if a matching Deployment exists, and create one if it does not.

The scaffolded webapp_controller.go has a skeleton Reconcile function that does nothing. You will replace the entire file with your controller logic, building it section by section.

  • Open internal/controller/webapp_controller.go in your editor.
  • Select all the existing code and replace it with this package declaration and import block:
package controller

import (
	"context"
	"fmt"

	appsv1 "k8s.io/api/apps/v1"
	corev1 "k8s.io/api/core/v1"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/types"
	"k8s.io/utils/ptr"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	logf "sigs.k8s.io/controller-runtime/pkg/log"

	appsv1alpha1 "example.com/webapp-operator/api/v1alpha1"
)

What do these imports do?

  • appsv1 and corev1 give you Go types for Kubernetes Deployments, Pods, and Containers.
  • apierrors lets you check whether an API error means "not found" versus a real failure.
  • ctrl is the controller-runtime entry point. It provides the manager, the reconciler interface, and helpers like SetControllerReference.
  • appsv1alpha1 is your own WebApp type from the api/v1alpha1 package you defined in the previous step.
  • Below the import block, paste the reconciler struct, RBAC markers, and the first half of the Reconcile function:
// WebAppReconciler reconciles a WebApp object
type WebAppReconciler struct {
	client.Client
	Scheme *runtime.Scheme
}

// +kubebuilder:rbac:groups=apps.example.com,resources=webapps,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=apps.example.com,resources=webapps/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=apps.example.com,resources=webapps/finalizers,verbs=update
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete

func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	log := logf.FromContext(ctx)

	// Fetch the WebApp instance
	webapp := &appsv1alpha1.WebApp{}
	err := r.Get(ctx, req.NamespacedName, webapp)
	if err != nil {
		if apierrors.IsNotFound(err) {
			log.Info("WebApp resource not found. Ignoring since object must be deleted")
			return ctrl.Result{}, nil
		}
		log.Error(err, "Failed to get WebApp")
		return ctrl.Result{}, err
	}

What does this code do?

  • The WebAppReconciler struct embeds a Kubernetes client and a scheme. Every reconciler needs these to read and write resources on the cluster.
  • The RBAC markers (// +kubebuilder:rbac:...) tell Kubebuilder what permissions this controller needs. One marker grants access to WebApp resources. The other grants access to Deployments so the controller can create them.
  • The Reconcile function starts by fetching the WebApp CR using r.Get(). If the resource was deleted before the reconciler ran, it returns early.
  • Still inside the Reconcile function, paste this code directly below the closing } of the if-err block you just added:
	// Check if the Deployment already exists
	found := &appsv1.Deployment{}
	err = r.Get(ctx, types.NamespacedName{Name: webapp.Name, Namespace: webapp.Namespace}, found)
	if err != nil && apierrors.IsNotFound(err) {
		// Define a new Deployment
		dep, err := r.deploymentForWebApp(webapp)
		if err != nil {
			log.Error(err, "Failed to define new Deployment resource for WebApp")
			return ctrl.Result{}, err
		}

		log.Info("Creating a new Deployment",
			"Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name)
		if err = r.Create(ctx, dep); err != nil {
			log.Error(err, "Failed to create new Deployment",
				"Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name)
			return ctrl.Result{}, err
		}

		return ctrl.Result{}, nil
	} else if err != nil {
		log.Error(err, "Failed to get Deployment")
		return ctrl.Result{}, err
	}

	return ctrl.Result{}, nil
}

What does this code do?

This is the core reconciliation loop logic. It checks whether a Deployment with the same name as the WebApp already exists on the cluster.

  • If the Deployment is not found, it calls r.deploymentForWebApp(webapp) to construct one, then creates it with r.Create().
  • If the Deployment already exists, the function returns without doing anything. You will add update logic in the next step.
  • The closing return ctrl.Result{}, nil tells controller-runtime that reconciliation succeeded and there is no need to requeue.

Why two separate code blocks for one function?

The Reconcile function is long enough that breaking it into two blocks keeps each one readable. Both blocks belong inside the same function. The first block is the function signature and fetch logic. The second block is the deployment check-and-create logic through the closing brace.

Add the deployment construction helper

The Reconcile function calls deploymentForWebApp, but that function does not exist yet. You also need SetupWithManager to register the controller with the manager so it starts watching WebApp resources.

  • Below the closing } of the Reconcile function, paste the SetupWithManager function and the start of the deployment helper:
// SetupWithManager sets up the controller with the Manager.
func (r *WebAppReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&appsv1alpha1.WebApp{}).
		Owns(&appsv1.Deployment{}).
		Named("webapp").
		Complete(r)
}

// deploymentForWebApp returns a WebApp Deployment object
func (r *WebAppReconciler) deploymentForWebApp(
	webapp *appsv1alpha1.WebApp) (*appsv1.Deployment, error) {

	var replicas int32 = 1
	if webapp.Spec.Replicas != nil {
		replicas = *webapp.Spec.Replicas
	}

	labels := map[string]string{
		"app.kubernetes.io/name":       "webapp",
		"app.kubernetes.io/instance":   webapp.Name,
		"app.kubernetes.io/managed-by": "webapp-operator",
	}

What does this code do?

  • SetupWithManager registers the controller with the manager. For(&appsv1alpha1.WebApp{}) tells it to watch WebApp resources. Owns(&appsv1.Deployment{}) tells it to also watch Deployments that this controller owns, so changes to those Deployments trigger reconciliation too.
  • deploymentForWebApp starts by reading the desired replica count from the WebApp spec, defaulting to 1 if not set. It then defines the standard Kubernetes labels that will be applied to the Deployment and its pods.
  • Still inside the deploymentForWebApp function, paste the Deployment object construction:
	dep := &appsv1.Deployment{
		ObjectMeta: metav1.ObjectMeta{
			Name:      webapp.Name,
			Namespace: webapp.Namespace,
		},
		Spec: appsv1.DeploymentSpec{
			Replicas: ptr.To(replicas),
			Selector: &metav1.LabelSelector{
				MatchLabels: labels,
			},
			Template: corev1.PodTemplateSpec{
				ObjectMeta: metav1.ObjectMeta{
					Labels: labels,
				},
				Spec: corev1.PodSpec{
					Containers: []corev1.Container{{
						Image:           webapp.Spec.Image,
						Name:            "webapp",
						ImagePullPolicy: corev1.PullIfNotPresent,
						Ports: []corev1.ContainerPort{{
							ContainerPort: webapp.Spec.Port,
							Name:          "http",
						}},
					}},
				},
			},
		},
	}

What does this Deployment object contain?

This Go struct literal builds a complete Kubernetes Deployment from the WebApp spec. The Deployment's name matches the WebApp name. The container image, replica count, and container port all come from the fields you defined in webapp_types.go.

The ptr.To(replicas) helper converts the int32 value into a pointer, which is what the Deployment spec expects.

  • Finally, paste the owner reference, function closing, and the fmt usage line at the bottom of the file:
	// Set the ownerRef for the Deployment
	if err := ctrl.SetControllerReference(webapp, dep, r.Scheme); err != nil {
		return nil, err
	}
	return dep, nil
}

// Ensure fmt is used
var _ = fmt.Sprintf

What is the owner reference?

ctrl.SetControllerReference links the Deployment back to the WebApp that owns it. This tells Kubernetes that the Deployment belongs to the WebApp. When you delete the WebApp, Kubernetes automatically cascade-deletes the Deployment too.

The var _ = fmt.Sprintf line is a temporary measure. Go does not allow unused imports, and the fmt package is not used in this version of the controller yet. You will use it in the next step when you add update logic.

  • Save webapp_controller.go.
  • Verify the code compiles by running:
go build ./...

A successful build produces no output. If you see error messages, check the troubleshooting callout below.

Seeing compile errors?

  • If Go reports an undefined function or type, make sure you pasted all six code blocks in order, with no missing closing braces.
  • If you see an unused import error for fmt, confirm that var _ = fmt.Sprintf is at the very bottom of the file.
  • If you see a type mismatch, verify that the import alias appsv1alpha1 points to example.com/webapp-operator/api/v1alpha1. This must match the module path in your go.mod.

Still stuck? Help me debug a Go compile error in my Kubebuilder controller

✔️ Awesome, I've got everything!

Great. Double check you have saved webapp_controller.go and that go build ./... produces no output.

ⓧ I'd like to double check the full code

Your webapp_controller.go file should contain the following sections in order. Scroll up and confirm each one is present:

  • Package declaration and import block (package controller through closing )).
  • WebAppReconciler struct with client.Client and Scheme.
  • Four RBAC marker comments starting with // +kubebuilder:rbac:.
  • Reconcile function: fetches WebApp, checks for existing Deployment, creates if not found, returns ctrl.Result{}.
  • SetupWithManager function: For(WebApp), Owns(Deployment), Named("webapp").
  • deploymentForWebApp function: replicas, labels, Deployment object, owner reference via ctrl.SetControllerReference.
  • var _ = fmt.Sprintf at the very end.
Run the controller and test creation

Your controller compiles and is ready to run. When you start it, the controller-runtime manager connects to your cluster and begins watching for WebApp resources. The webapp-sample CR you applied in the previous step is already on the cluster, so the controller should immediately reconcile it.

  • Regenerate the RBAC manifests to pick up the new Deployment permission markers by running:
make manifests

Why run make manifests again?

The RBAC markers you added (// +kubebuilder:rbac:groups=apps,resources=deployments,...) tell Kubebuilder what permissions the controller needs. Running make manifests regenerates the RBAC YAML so the permissions are applied when you run the controller.

  • Start the controller locally by running:
make run

What does make run do?

make run compiles and runs the controller binary on your machine. It connects to your Docker Desktop Kubernetes cluster using your local kubectl credentials. The controller runs in the foreground and prints structured log messages. It will keep running until you stop it with Ctrl+C.

You should see startup log messages followed by a line containing Creating a new Deployment. The controller detected the existing webapp-sample CR and created a Deployment for it.

  • Open a second terminal window.
  • Navigate to your project directory by running:
cd ~/Desktop/webapp-operator

Before you check the cluster, predict what you expect to see. If the controller reconciled webapp-sample successfully, what should kubectl get deployments show?

  • Check that the Deployment was created by running:
kubectl get deployments

You should see a webapp-sample Deployment listed with 2/2 in the READY column. The 2 replicas match the replicas: 2 value you set in your sample CR.

  • Verify the pods are running by running:
kubectl get pods

You should see 2 pods with names starting with webapp-sample, each in Running status.

Don't see the Deployment?

  • Check the terminal running make run for error messages. A common cause is a missing RBAC marker. Verify you have four // +kubebuilder:rbac comments in your controller file and that you ran make manifests before make run.
  • If pods are stuck in Pending or ImagePullBackOff, make sure the sample CR uses nginx:1.27-alpine as the image and that your Docker Desktop has internet access.
  • If the webapp-sample CR is missing, re-apply it from the config/samples/ directory with kubectl apply -f config/samples/ while the controller is running.

Still stuck? Help me troubleshoot why my Kubebuilder controller is not creating a Deployment for my WebApp CR

Your controller is alive. It detected a WebApp CR on the cluster, constructed a Deployment to match its spec, and created it. Next up, you will test what happens when you change the WebApp and discover the gap that every real controller must close.

Handle Updates and Complete the Reconciliation Loop

Your controller can watch for WebApp custom resources and spin up Deployments to match. The creation path is working.

But creation is only half the job. What happens when someone edits the WebApp to change the replica count or swap the container image? Right now, nothing. The Deployment stays exactly as it was first created. This step confronts that gap head-on, then closes it with update logic that makes your reconciler truly reactive.

In this step, get ready to:
  • Observe the reconciliation gap by changing a WebApp spec and seeing the Deployment ignore it.
  • Add update logic that detects drift in replicas and container image.
  • Test the full reconciliation loop: create, update, and delete.
Observe the update gap

Your controller is still running from Step 3. The WebApp CR on your cluster specifies 2 replicas, and the Deployment matches. Let's see what happens when you change the spec.

  • Open config/samples/apps_v1alpha1_webapp.yaml in your editor.
  • Find this line:
  replicas: 2
  • Change 2 to 3, so the line looks like this:
  replicas: 3
  • Save the file.
  • Apply the updated WebApp by running this command in your second terminal:
kubectl apply -f config/samples/apps_v1alpha1_webapp.yaml

Before you check the Deployment, pause and predict. You told Kubernetes the WebApp should have 3 replicas. Do you think the Deployment will scale from 2 to 3?

  • Check the Deployment by running:
kubectl get deployments

The Deployment still shows 2/2 replicas. Your change was saved to the WebApp CR, but the controller did nothing about it.

Why didn't the controller react?

Look at your current Reconcile function. Once it finds an existing Deployment, it hits a bare return ctrl.Result{}, nil and exits. The reconciler only handles the "Deployment not found, create it" path.

This is the core challenge of controller development. A reconciler that only creates is a one-shot script, not a controller. Real controllers must continuously compare actual state against desired state and converge the two.

Add update logic to the reconciler

The reconciler needs new logic that runs when the Deployment already exists. Instead of returning immediately, it should compare the Deployment's current replicas and image against the WebApp spec, then update the Deployment if they differ.

  • In the terminal where the controller is running, press Ctrl+C to stop it.
  • Open internal/controller/webapp_controller.go.
  • Find the closing brace after the else if err != nil block, followed by the bare return ctrl.Result{}, nil at the end of the Reconcile function:
	} else if err != nil {
		log.Error(err, "Failed to get Deployment")
		return ctrl.Result{}, err
	}

	return ctrl.Result{}, nil
}
  • Replace the bare return ctrl.Result{}, nil with the following replica comparison logic. The return ctrl.Result{}, nil will come back later, after all the update checks:
	// Ensure the Deployment replicas match the WebApp spec
	var desiredReplicas int32 = 1
	if webapp.Spec.Replicas != nil {
		desiredReplicas = *webapp.Spec.Replicas
	}

	if found.Spec.Replicas == nil || *found.Spec.Replicas != desiredReplicas {
		found.Spec.Replicas = ptr.To(desiredReplicas)
		if err = r.Update(ctx, found); err != nil {
			log.Error(err, "Failed to update Deployment",
				"Deployment.Namespace", found.Namespace, "Deployment.Name", found.Name)
			return ctrl.Result{}, err
		}
		log.Info("Updated Deployment replicas",
			"Deployment.Name", found.Name, "Replicas", desiredReplicas)
	}

What does this code do?

  • desiredReplicas defaults to 1 if the WebApp spec doesn't set a replica count. Otherwise it reads the spec value.
  • The if block compares what the Deployment currently has against what the WebApp wants. If they differ, it overwrites the Deployment's replicas and calls r.Update() to push the change to the cluster.
  • ptr.To(desiredReplicas) converts the int32 value into a pointer, which is what the Deployment spec expects.
  • Right after the replica check (still before the closing } of the Reconcile function), add the image comparison logic:
	// Ensure the Deployment image matches the WebApp spec
	containers := found.Spec.Template.Spec.Containers
	if len(containers) > 0 && containers[0].Image != webapp.Spec.Image {
		found.Spec.Template.Spec.Containers[0].Image = webapp.Spec.Image
		if err = r.Update(ctx, found); err != nil {
			log.Error(err, "Failed to update Deployment image",
				"Deployment.Namespace", found.Namespace, "Deployment.Name", found.Name)
			return ctrl.Result{}, err
		}
		log.Info("Updated Deployment image",
			"Deployment.Name", found.Name, "Image", webapp.Spec.Image)
	}

	return ctrl.Result{}, nil

What does this code do?

  • containers grabs the container list from the existing Deployment. The guard len(containers) > 0 prevents a panic if the list is somehow empty.
  • If the first container's image doesn't match the WebApp spec, the code overwrites it and pushes the update to the cluster.
  • The final return ctrl.Result{}, nil closes the function. Every path through Reconcile now either creates, updates, or confirms that the Deployment already matches.
  • Scroll to the very bottom of the file and find these two lines:
// Ensure fmt is used
var _ = fmt.Sprintf
  • Delete both lines. This placeholder is no longer needed.
  • Save webapp_controller.go.

✔️ Awesome, I've got everything!

Great. Make sure you have saved webapp_controller.go before moving on.

ⓧ I'd like to double check the full code

The full file is long, so confirm these sections are present in order inside your Reconcile function:

  • Fetch the WebApp instance with r.Get(ctx, req.NamespacedName, webapp).
  • Check if the Deployment exists with r.Get(ctx, types.NamespacedName{...}, found).
  • If not found, create a new Deployment with r.Create(ctx, dep) (from Step 3).
  • Replica comparison: compute desiredReplicas, compare against found.Spec.Replicas, update if different (added just now).
  • Image comparison: compare containers[0].Image against webapp.Spec.Image, update if different (added just now).
  • Final return ctrl.Result{}, nil at the end of the function.

Also confirm the var _ = fmt.Sprintf line at the bottom of the file is deleted.

Seeing a compile error?

  • Make sure the replica check and image check are placed AFTER the } else if err != nil { ... } block's closing brace, not inside it.
  • Check that the final return ctrl.Result{}, nil comes AFTER the image check block, still inside the Reconcile function.
  • Verify that every opening brace { has a matching closing brace }. A missing brace will cascade errors throughout the file.

Why won't my reconciler compile after adding the update logic?

Test the full reconciliation loop

Time to prove the reconciler works. Remember, your WebApp CR still says 3 replicas from the earlier edit, but the Deployment is stuck at 2. Restarting the controller should trigger a fresh reconciliation.

  • Start the controller by running this command in the terminal where you stopped it:
make run

Before you check the Deployment, predict what will happen. The WebApp CR says 3 replicas. The Deployment has 2. Will the new reconciler close the gap this time?

  • In your second terminal, check the Deployment by running:
kubectl get deployments

The Deployment now shows 3/3 replicas. The reconciler detected the drift and updated the Deployment to match the WebApp spec.

What is the reconciliation loop?

This is the heart of every Kubernetes controller. The reconciliation loop continuously compares actual state (what exists on the cluster) against desired state (what the CR spec says). When they differ, the controller takes action to converge them.

Your reconciler now covers three cases: create when the Deployment is missing, update replicas when they drift, and update the image when it changes. Every real operator follows this same pattern.

Now test the image update path. Change the container image in your sample CR and confirm the Deployment picks it up.

  • In config/samples/apps_v1alpha1_webapp.yaml, find this line:
  image: nginx:1.27-alpine
  • Change it to a different image, so the line looks like this:
  image: httpd:2.4-alpine
  • Save the file.
  • Apply the updated WebApp by running:
kubectl apply -f config/samples/apps_v1alpha1_webapp.yaml
  • Confirm the Deployment updated its image by running:
kubectl describe deployment webapp-sample | grep Image

You should see httpd:2.4-alpine in the output. The controller detected the image change and updated the Deployment.

One last test. Delete the WebApp CR and confirm the Deployment is automatically cleaned up via the owner reference you set in Step 3.

  • Delete the WebApp CR by running:
kubectl delete -f config/samples/apps_v1alpha1_webapp.yaml
  • Check that the Deployment is gone by running:
kubectl get deployments

The Deployment is gone. Kubernetes saw that the WebApp (the owner) was deleted and automatically cascade-deleted the Deployment it owned. You did not need to write any deletion logic.

What are owner references?

The ctrl.SetControllerReference() call in your deploymentForWebApp function stamps the Deployment with metadata that says "I belong to this WebApp." Kubernetes uses that metadata to garbage-collect owned resources when the owner is deleted.

Deployment still showing after delete?

  • Wait a few seconds and run kubectl get deployments again. Cascade deletion can take a moment to propagate.
  • If the Deployment persists, check that the controller from Step 3 set the owner reference correctly. Run kubectl get deployment webapp-sample -o yaml and look for an ownerReferences section in the metadata.

Why isn't the Deployment cascade-deleted when I remove the WebApp CR?

  • Stop the controller by pressing Ctrl+C in the terminal running make run.

Your controller now handles the full lifecycle of a WebApp resource: creating Deployments, detecting drift, converging state, and cascading deletions. That is exactly how production Kubernetes operators work. Next up, an optional Secret Mission to make your controller report its status so you can observe it with kubectl.

Secret mission

Add Status Conditions to Your WebApp

Your controller works, but it is a black box. You cannot tell from kubectl whether the WebApp is healthy, progressing, or degraded. Can you give it a voice? Add status conditions so you can check your WebApp's health with a single kubectl command.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local Docker Desktop Kubernetes cluster, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • WebApp CRD and sample WebApp CR on your Docker Desktop Kubernetes cluster.
  • The webapp-operator project directory on your Desktop.
  • Kubebuilder CLI binary installed at /usr/local/bin/kubebuilder.

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment further with your controller.

  • The controller only runs when you execute make run. It does not consume resources when stopped.
  • Your WebApp CRD and sample resources remain on the cluster but are lightweight and consume no meaningful memory or CPU.
  • The Kubebuilder binary stays at /usr/local/bin/kubebuilder and takes up minimal disk space.

Pause - I'll come back to this later

Stop the controller process to free up your terminal. Your CRDs and project files stay intact for when you return.

  • If the controller is still running in a terminal, press Ctrl+C to stop it.
  • Your WebApp CRD and sample resources remain on the cluster. They are inert without the controller running.
  • To resume later, open a terminal in your webapp-operator directory and run make run again.

Delete - I don't want to use this again

Remove all project resources from your cluster and your machine.

  • If the controller is still running, press Ctrl+C to stop it.
  • Remove the WebApp CRD and all WebApp resources from your cluster by running this from inside the webapp-operator directory:
make uninstall
  • This removes the CRD, which cascade-deletes all WebApp CRs. The owner references you set up also cascade-delete the Deployments and Pods your controller created.
  • Delete the project directory by running:
cd ~/Desktop
rm -rf webapp-operator
  • Remove the Kubebuilder CLI binary by running:
sudo rm /usr/local/bin/kubebuilder

Your Docker Desktop Kubernetes cluster is unaffected by these steps.

Nice Work!

Nice Work!

Nice work! You just built a fully functional Kubernetes controller from scratch using Kubebuilder and Go. Your controller watches for custom WebApp resources, spins up matching Deployments, detects drift, and converges back to the desired state automatically.

You've learned how to:

  • Defined a Custom Resource Definition with validated fields for image, replicas, and port, then installed it on your cluster so you could create and manage WebApp resources with kubectl.
  • Implemented the reconciliation loop pattern to create Deployments, detect configuration drift, and converge actual state back to desired state. This is the core mechanism behind every Kubernetes operator.
  • Used owner references to link managed Deployments back to their parent WebApp resources, enabling automatic cascade deletion and proper RBAC permissions through Kubebuilder markers.
  • Completed an optional Secret Mission to push your controller skills even further.

Ready to quiz yourself?