Getting Started with K8s Operators: Building a Todo Operator

Ruhika BRuhika B
9 min read

It’s been a while since I last wrote a blog. The first Kubernetes controller I wrote was years ago—it now feels like a lifetime ago. So I decided to return to two things I missed: writing and building with Kubernetes.

In this blog, we’ll create a simple yet powerful Todo Operator from scratch that interacts with an external Todo API. Whether you're new to Operators or looking to reinforce your understanding of Kubernetes controllers, this blog provides a hands-on walkthrough of CRDs, reconciliation loops, and finalizers. We’ll also delve into status updates and conditions—an area that often confuses folks with subtle pitfalls.

If you’ve ever wondered how to automate external systems using Kubernetes-native patterns, this is the guide for you. Read on to bring your custom resources to life—one Todo at a time.

Enter the Todo Operator.


🎓 What Are Kubernetes Operators?

Operators enable you to extend Kubernetes to manage applications and infrastructure beyond the built-in resources, such as Pods and Services. Think of them as powerful automation tools that bring the Kubernetes control loop to external systems.

But what exactly makes up an Operator?

At its core, an Operator consists of:

  • A Custom Resource (CR): your domain-specific object (e.g., a Todo).

  • A Controller 🎮 : a loop that watches these CRs and takes actions when they change.

In other words, an Operator listens for changes to your custom objects and reacts accordingly, creating, updating, or deleting external resources as needed. It follows the principle of eventual consistency, where it continuously works to align the observed state of the system with the desired state defined in the custom resource.


📄 What Resources Do Operators Work With?

Operators work with Custom Resource Definitions (CRDs)—these tell Kubernetes what your new object types look like.

Resource TypeExample
Built-inPod, Service
CustomTodo, Database, Backup,Pizza

In our case, we’ll define a Todo Custom Resource (CR) using a Custom Resource Definition (CRD).

Think of a CRD as a blueprint or schema, and a CR as an instance created from that blueprint.

By creating a CRD, we teach Kubernetes about a new type of object of Kind Todo—that it didn’t support out of the box.

🤔 Why Use Operators?

Operators shine when you want:

  • Declarative control over external systems

  • To eliminate manual, repetitive scripts

  • Kubernetes to be the source of truth

They bring Kubernetes-native automation to workloads that live outside the cluster.

✨ Find the Source Code

You could find the Todo-Operator Project source code in this repository for reference.


🚀 Let’s Build: The Todo Operator

We’ll keep it simple and focus on:

  1. Defining a CRD for Todo

  2. Applying a Todo Custom Resource

  3. Writing a Controller that:

    • Creates/Deletes Todo items via an external API

    • Uses finalizers for cleanup

    • Updates the CR status with an ID and Condition

🧠 Visual: How the Operator Works


⚙️ Prerequisites

  • Go

  • kubectl

  • kubebuilder

  • controller-runtime

Set Up the Todo Operator Project

To start building our Kubernetes Operator, we’ll scaffold the basic project structure using Kubebuilder—a powerful SDK for building Kubernetes APIs using Go.

📦 Initialize the Project

kubebuilder init --domain example.com --repo github.com/example/todo-operator

This command sets up a new Go module with:

  • A clean directory structure

  • Project files (PROJECT, go.mod, etc.)

  • Dependencies including controller-runtime

  • A Makefile for building, testing, and deploying your operator

➕ Create the API and Controller

kubebuilder create api --group task --version v1alpha1 --kind Todo

This command does a lot behind the scenes. It scaffolds:

ComponentPurpose
api/v1alpha1/todo_types.goDefine your TodoSpec and TodoStatus (i.e., the schema of your CR)
internal/controller/todo_controller.goReconcile logic placeholder for the Todo resource
config/crd/bases/CRD YAML manifests used to install your resource definitions
config/samples/Sample YAMLs for testing your custom resource

With just these two commands, you now have:

  • A custom Kubernetes API called Todo

  • A controller ready to implement reconciliation logic

  • Everything is wired together to start watching and reacting to changes in your CR

Next, we’ll define the Spec and Status for our Todo resource, then explore how the controller syncs it with an external API.


✍️ Define the Todo Spec and Status

The heart of any Custom Resource Definition (CRD) lies in its schema—what it should track and how it communicates its state to the controller.

Update the todo_types.go file to define the structure of your Todo object. This includes:

🧱 Spec vs Status

  • Spec: Represents the desired state—what the user wants.

  • Status: Represents the current state—what the controller has observed and tracked

// Spec = Desired State
Title string `json:"title,omitempty"`

// Status = Current State
ID int `json:"id,omitempty"`
Condition []metav1.Condition `json:"conditions,omitempty"`

Using metav1.Condition allows us to follow Kubernetes API conventions for status updates.

This includes timestamps, reasons, and messages, making debugging and observability much easier.

⚙️ Generate and Apply CRDs

After defining the types, run the following to generate and apply the CRDs to your cluster:

make manifests     # Generates CRD YAMLs under config/crd/
make install       # Applies those CRDs to your cluster

💡 make manifests and make install serve different purposes:

  • make manifests: Generates the YAML files based on your Go type definitions.

  • make install: Applies those manifests to your cluster so Kubernetes understands your new resource type.

Kubebuilder also auto-generates deepcopy methods and sample CR YAMLs, which are handy for testing and validation.

Now that we’ve taught Kubernetes about our custom resource—both its structure (CRD) and instances (CR)—let’s move on to the core part of an Operator: Reconciliation.

💡 What Is Reconciliation?

Reconciliation is the core loop of every Kubernetes controller. It's the continuous process where the controller observes the actual state of the world and compares it against the desired state defined by the user (in the Custom Resource). If there's a mismatch, the controller takes action to bring them in sync.

In our case:

  • If a Todo Custom Resource is created, and we call the external API to add the Todo.

  • If it's being deleted, we remove it from the API before final deletion.

📝 Our External Todo API

To simulate integration with an external system, we built a simple Todo API in Go. In our project, this corresponds to the file api/v1/todo_request.go

type Todo struct {
  ID    int    `json:"id"`
  Title string `json:"title"`
}

func AddTodoHandler(w http.ResponseWriter, r *http.Request) { ... }
func ListTodosHandler(w http.ResponseWriter, r *http.Request) { ... }
func DeleteTodoHandler(w http.ResponseWriter, r *http.Request) { ... }

http.ListenAndServe(":8080", nil)

⚙️ How It Fits In

This API isn’t deployed inside the Kubernetes cluster—it's just a lightweight web server running locally or on a VM, used for demonstration purposes.

Your Todo Controller interacts with this API:

  • Creates a Todo API Object when a new Custom Resource is applied.

  • Deletes the Todo API object on resource deletion


🔁 Controller Reconcile Logic

The core of any Kubernetes controller lies in its Reconcile loop—this is where the magic happens.

In our project, the reconciliation logic is located in: internal/controller/todo_controller.go

func (r *TodoReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    ...
    ...
    return ctrl.Result{}, nil
}

This function gets triggered whenever there's a change to a Todo Custom Resource (create, update, or delete). Its job is to observe the current state, compare it to the desired state, and take necessary actions to make them match.

🔧 What Does It Do?

Here’s a high-level overview of what our Reconcile Function handles in the Todo-Operator Project:

  1. Fetch the Todo CR
    Get the latest state of the custom resource from the Kubernetes API.

  2. Handle Deletion
    If .metadata.deletionTimestamp is set, trigger finalizer logic to remove the corresponding Todo from the external API.

  3. Sync External API State
    If the external Todo doesn't exist yet and the CR is valid, create a new Todo in the external API.

  4. Update Status
    Once the external Todo is created, update the CR's status with the generated ID and set appropriate conditions.

🧠 Event-Driven Loop

This loop follows the eventual consistency model:
Kubernetes continuously requeues and retries reconciliation until the actual state matches the desired state, making your system robust and self-healing

If you noticed in ourtodo_controller.go We aren’t directly creating a Todo, but also using Finalizers, but what are they?

🪜 What Are Finalizers?

Finalizers tell Kubernetes:

“Wait! Don’t delete this resource just yet—I have some cleanup to do.”

In our case, before a Todo CR is fully deleted, we want to:

  • Delete the corresponding Todo from the external API

  • Then remove the finalizer so Kubernetes can proceed

const finalizer = "todo.task.example.com"

if !todo.ObjectMeta.DeletionTimestamp.IsZero() {
  if ctrlutil.ContainsFinalizer(todo, finalizer) {
    // Call DeleteTodo in API
    // Remove finalizer
  }
}

This ensures external resources aren't left dangling when a Kubernetes resource is deleted.

✏️ Updating CR Status and Conditions

Your controller can update the status field in the CR to reflect the current state.
This includes updating Conditions, which are standardized fields to track lifecycle and health.

🧠 Why Use Conditions?

  • They give users visibility into what’s happening.

  • Each condition includes:

    • LastTransitionTime

    • Reason

    • Message

For example, when the Todo CR is successfully reconciled

condition := metav1.Condition{
  Type:    "TodoReconciled",
  Status:  metav1.ConditionTrue,
  Reason:  "Created",
  Message: "Todo item created in external API",
}
todo.Status.Conditions = append(todo.Status.Conditions, condition)

If the external API is unreachable, you might set the condition status to Unknown. Shutting down the server after the first reconciliation and restarting the Operator will mark the Status as Unknown. You can verify that with kubectl get Todo <todo-object> -o yaml

📘 For best practices, refer to Kubernetes API conventions.

Running the Operator

Finally, to test the reconciliation logic, start the operator using the command below.

go run main.go

🌐 Starting the External Todo API Server

If you're using the external API (like the Todo server in this example), start it separately:

go run main.go server

Make sure the Todo Custom Resource is applied, and this server is running and accessible before the controller attempts to call external API endpoints.

🧩 Hope the Todo Operator is up and running smoothly—ready to sync Todos like a pro!

📖 References


🌟 Conclusion

Writing a Kubernetes Operator may sound intimidating, but it doesn’t have to be.

This Todo Operator walks through the fundamentals—CRDs, controllers, finalizers, and status updates—in a way that’s practical and approachable.

It’s a solid starting point if you’re building custom automation into Kubernetes

3
Subscribe to my newsletter

Read articles from Ruhika B directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ruhika B
Ruhika B