From Java Dev to Kubernetes Operator: Building Smart Automation with Quarkus
Master custom controllers the Java way. Leverage Quarkus to build powerful Kubernetes Operators with ease, speed, and native tooling.
You’re a Java developer. You’ve tamed Maven. You wield your IDE like a lightsaber. Your code flows, your containers build, and deploying apps is muscle memory.
Then someone drops the phrase: “Kubernetes Operator.”
Suddenly, it’s like stepping into an alien dimension: Custom Resources? Reconciliation loops? CRDs? Controllers? What happened to good old Java?
Don’t worry. This article is your bridge across the Kubernetes chasm. We'll walk through exactly what Operators are, why they matter, and how to build one using tools you already know: Java, Maven, and Quarkus, with the help of the Quarkus Operator SDK.
Why Operators? Why Now?
Sure, you can deploy your Java app with a plain old Deployment and a Service. But real-world production workloads are rarely that simple. Think:
Stateful apps that need persistent volumes, initialization logic, or automated backups.
Domain-specific configurations that need to be validated or transformed.
Systems that should scale, self-heal, or upgrade themselves automatically.
That’s where Operators come in. Think of them as programmable Kubernetes admins: custom controllers that extend the Kubernetes API and manage application-specific behavior using custom resources. They encode the know-how of a seasoned SRE directly into the platform.
Core Concepts of the Operator Pattern
Let's break down the core concepts behind the Operator pattern:
Custom Resource Definitions (CRDs): CRDs allow you to define your own custom resources within Kubernetes. For example, if you're building an Operator for a "Database" application, you might define a Database CRD with fields like size, version, backupSchedule, etc. These custom resources become first-class citizens in your cluster, just like Pods and Deployments.
Custom Resources (CRs): CRs are instances of your CRDs. A user might create a Database CR named "my-database" with size: 10GB, version: 12, and backupSchedule: daily.
Controller: The heart of the Operator. The controller watches for changes to CRs (and potentially other resources). When a change occurs (creation, update, deletion), the controller's reconciliation loop kicks in.
Reconciliation Loop: This is the core logic of your Operator. It compares the desired state (defined by the CR) with the actual state of the system. If there's a difference, the controller takes actions to bring the actual state in line with the desired state. This might involve creating Pods, Services, PersistentVolumeClaims, or interacting with external APIs.
Spec: The desired state that your Operator is trying to achieve is usually encoded as part of the spec subresource of your CR. The spec is under a user’s control and should normally be left untouched by your Operator.
Status: If the spec of your CR, its status subresource, which it also often provides, represents a useful view of the actual state of the system. Your Operator uses this to report back to Kubernetes users about the state of its managed CRs. A Database resource might have its status to Provisioning, Running, Updating or Error. The status can also be used by different controllers to coordinate their work.
At its heart, an Operator is just a loop: detect → compare → act → repeat.
Meet Quarkus Operator SDK (QOSDK)
Writing Operators in raw Java would be painful. You’d have to manage client libraries, boilerplate logic, error handling, and Kubernetes quirks. Thankfully, Quarkus and the Java Operator SDK (JOSDK) take that burden off your shoulders.
The Quarkus Operator SDK (QOSDK) wraps JOSDK into a Quarkus-native extension with:
Simplified Development: QOSDK provides annotations and abstractions that handle much of the low-level Kubernetes API interaction for you. You focus on your application logic, not the plumbing.
Fast Startup and Low Memory Footprint: Quarkus's core strengths shine here. Quarkus will wire your Operator as much as feasible at build time so that your Operator will start up quickly and consume minimal resources, thus preserving precious cluster resources.
Developer Productivity: Quarkus' "live coding" feature works with Operators too! You can make changes to your code and see them reflected in your running Operator almost instantly, without restarting the entire application. QOSDK will also take care of automatically regenerating your CRD when needed and apply it to your cluster automatically, keeping you in the flow! Similarly, QOSDK will help by generating appropriate RBACs, Helm chart or OLM bundle.
Seamless Integration: QOSDK integrates smoothly with other Quarkus extensions. You can easily use extensions for things like configuration, metrics, health checks, and more.
Built on a mature base: JOSDK, which QOSDK is extending, is a mature and battle-tested framework that already powers numerous production-grade operators.
Native Image Compilation: You can compile your Operator to a native image using GraalVM, resulting in even faster startup times and reduced memory usage. While this might not be as important for Operators than regular applications, as Operators are typically long-running, this is still interesting in severely constrained Kubernetes clusters.
Let’s build a simple Operator to show how smooth the experience is.
Hands-On: Your First Java Operator
We’ll build an Operator that manages a custom Greeting
resource. When a Greeting
is created, it will automatically generate a corresponding ConfigMap
containing a message.
Prerequisites
Java 17+
Maven
Brew installed on MacOS
Let’s start with installing Minikube and Kubectl on your machine!
Running Kubernetes Locally on macOS with Minikube
If you're developing Operators or cloud-native Java applications on macOS, you need a local Kubernetes cluster that's easy to spin up, reset, and tear down. Minikube is one of the simplest and most developer-friendly tools for this job and it works great with Quarkus.
Install Minikube and kubectl
The easiest way to install both is via Homebrew:
brew install minikube
brew install kubectl
This installs the Minikube CLI and the Kubernetes command-line tool (kubectl
), which lets you interact with your cluster.
Start the Minikube Cluster
You can start Minikube using the default VM driver (on macOS this is typically hyperkit
or qemu
, depending on what's available).
minikube start
Minikube will:
Start a lightweight Kubernetes cluster inside a virtual machine
Download any needed dependencies (like
kubelet
andcontainerd
)Set up your local environment
The first run may take a few minutes. Subsequent starts are much faster.
If you have specific virtualization needs, you can explicitly choose a driver:
minikube start --driver=hyperkit
# or minikube start --driver=virtualbox
Configure kubectl (It’s Automatic!)
Minikube automatically configures your local kubectl
context. You can verify it:
kubectl config current-context
Expected output:
minikube
And now test your cluster:
kubectl get nodes
You should see a single node in Ready
state.
Bonus: Open the Kubernetes Dashboard
Minikube ships with a web-based dashboard. Launch it with:
minikube dashboard
It will open in your browser and connect to your local cluster.
To Stop and Clean Up
When you’re done:
minikube stop # Stop the VM
minikube delete # Delete the entire cluster
Why Minikube and Quarkus
Minikube supports all the features you need for Quarkus-based operator development:
LoadBalancer support via
minikube tunnel
Persistent volumes for stateful workloads
Compatible with Quarkus Dev Services (just point
KUBECONFIG
correctly)Clean isolation from other environments
Minikube is battle-tested, CLI-friendly, and perfect for short-lived testing and operator development without needing full-blown cloud infrastructure. Puh. Enough Kubernetes for now. Let’s get back to Quarkus!
Step 1: Scaffold the Project
mvn io.quarkus:quarkus-maven-plugin:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=greeting-operator \
-Dextensions=qosdk
cd greeting-operator
This sets up a new Quarkus project with the Operator SDK pre-wired.
Step 2: Launch Dev Mode
mvn quarkus:dev
This starts Quarkus in live coding mode. At this point, you'll see a message that no Reconciler (i.e., controller) has been defined yet.
Step 3: Generate Your Kubernetes API
In the terminal window where you have launched Quarkus’ Dev mode, press ‘:’, this will activate the Quarkus terminal. You can then type ‘help’ to see the available commands and among these, by virtue of using the QOSDK extension, you should see a ‘qosdk’ command. Type ‘qosdk’ in your terminal, you should see something like:
quarkus$ qosdk
Usage: qosdk
Quarkus Operator SDK Commands
qosdk commands:
versions Outputs QOSDK, Quarkus, Fabric8 and JOSDK versions, with which this QOSDK version was built
api Creates a Kubernetes API represented by a CustomResource and associated reconciler
The command that interests us is the ‘api’ command. Type the following command in your terminal:
qosdk api --group=demo.example.com --version=v1 --kind=Greeting
This generates:
Greeting.java
: Your CR definition.GreetingSpec.java
: The desired state (spec
).GreetingStatus.java
: The actual state (status
).GreetingReconciler.java
: The logic that syncs actual to desired.
Let’s explain a little bit what happened. Creating a Kubernetes API is what Operators are about: your controller and its associated Custom Resource will expose a new REST endpoint to users of the Kubernetes cluster. For this to work properly, your API needs to be namespaced (using a ‘group’ identifier, in our case ‘demo.example.com’), versioned (using the ‘version’ identifier, ‘v1’ in our case) and finally a ‘kind’, which can be thought of as the name of the entities your API will deal with. The controller (or Reconciler in QOSDK parlance) ties this all together to let Kubernetes know what should happen when users create, modify or delete instances of your Custom Resource.
The generated CRD is automatically applied to your cluster. You’re live coding a Kubernetes extension. No YAML in sight.
Just go to the newly created folder and start your favorite IDE to edit the generated files.
Let’s look at the Greeting.java file, which represents your Custom Resource:
package com.example.demo;
import io.fabric8.kubernetes.api.model.Namespaced;
import io.fabric8.kubernetes.client.CustomResource;
import io.fabric8.kubernetes.model.annotation.Group;
import io.fabric8.kubernetes.model.annotation.Version;
@Version("v1")
@Group("demo.example.com")
public class Greeting extends CustomResource<GreetingSpec, GreetingStatus> implements Namespaced { }
QOSDK enforces the good practice of structuring your Custom Resource with ‘spec’ and ‘status’ fields, which are represented by the ‘CustomResource’ class type parameters. Before looking at the other files, let’s switch back to the regular Quarkus console to check what has happened after the files got created: press ‘q’ in the Quarkus terminal to do so.
For the sake of brevity, we won’t repeat the console output here but you should now have a bunch of information telling you that some classes got registered for reflection (useful for native compilation), that your CRD got generated automatically, that RBACs also got generated to match what your Operator requires access to, that the generated CRD got applied to the cluster and that your controller got registered to watch all namespaces (by default) and started! Quite a bit without having written one line of code yet!
Step 4: Define the Greeting Spec
Speaking of code, we want our Greeting resource to have a ‘message’ field so let’s open the GreetingSpec.java file, since that field is part of our desired state. By default, QOSDK created an empty class but we can make things simpler and change that to a Java record:
Update GreetingSpec.java
to define your custom field:
package com.example.demo;
public record GreetingSpec(String message) {}
The CRD regenerates and reapplies on the fly.
If you now look at the console, you’ll see that the CRD got automatically re-generated and re-applied without you having to do anything. How cool is that? You can focus on your business logic in Java and QOSDK will take care of the machinery to make it work for you! Let’s do the same for the status class:
Update GreetingStatus.java
to define your custom field:
package com.example.demo;
public record GreetingStatus(String message) {}
Step 5: Create a Dependent Resource (ConfigMap)
QOSDK generated a controller class for you: GreetingReconciler. Before taking a look at it, let’s step back and think a little bit about what we want to do. When a user creates a Greeting resource, we want the message String defined in the spec of the CR to be used to populate a ConfigMap and then, assuming things went well, update the status of the CR with that message. In Kubernetes parlance, ConfigMap in this case is called a secondary resource for our operator, its primary resources being Greeting CRs. In our case, we want to tell our Operator that it needs to manage ConfigMap secondary resources whenever something happens to a Greeting CR: when a Greeting CR is created, we want an associated ConfigMap to be created, if it is updated, the associated ConfigMap should be updated accordingly and if the Greeting is deleted, so should the ConfigMap. Such behavior is so typical of secondary resources that JOSDK / QOSDK provides a feature to make dealing with typical secondary resource support very easy: Dependent Resources.
We won’t go into the details of the feature because the framework provides a wealth of options but in our case, we simply need to create an extension of the JOSDK CRUDKubernetesDependentResource
class. Similarly to what CRs do, our dependent resource implementation will also tell the framework what is its desired state so that JOSDK can manage it automatically for us. Create a new ConfigMapDR
class as follows:
Create src/main/java/com/example/demo/ConfigMapDR.java
package com.example.demo;
import io.fabric8.kubernetes.api.model.ConfigMap;
import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource;
import java.util.Map;
public class ConfigMapDR extends CRUDKubernetesDependentResource<ConfigMap, Greeting> {
public ConfigMapDR() {
super(ConfigMap.class);
}
@Override
protected ConfigMap desired(Greeting greeting, Context<Greeting> context) {
return new ConfigMapBuilder()
.withNewMetadata()
.withName(greeting.getMetadata().getName() + "-config")
.withNamespace(greeting.getMetadata().getNamespace())
.endMetadata()
.withData(Map.of("message", greeting.getSpec().message()))
.build();
}
}
What we’ve done is tell the framework that anytime a Greeting resource is modified or created, we want to ensure that there is an associated ConfigMap named after the Greeting CR with ‘-config’ appended and which contains the Greeting message under a ‘message’ key. We chose the CRUDKubernetesDependentResource implementation to tell QOSDK that we want it to manage all CRUD (Create Replace Update Delete) operations for that secondary resource for us. The type parameters indicate which secondary resource we’re dealing with (ConfigMap) and which primary resource it is associated with (Greeting).
Step 6: Hook It into the Reconciler
Now that we have the code for our dependent resource, we need to tell QOSDK to use it. This is done in a declarative way by adding an annotation to our Reconciler implementation. Update GreetingReconciler.java
:
package com.example.demo;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
import io.javaoperatorsdk.operator.api.reconciler.UpdateControl;
import io.javaoperatorsdk.operator.api.reconciler.Workflow;
import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent;
@Workflow(dependents = @Dependent(type = ConfigMapDR.class))
public class GreetingReconciler implements Reconciler<Greeting> {
@Override
public UpdateControl<Greeting> reconcile(Greeting resource, Context<Greeting> context) {
resource.setStatus(new GreetingStatus(resource.getSpec().message()));
return UpdateControl.patchStatus(resource);
}
}
The Workflow annotation refers to another very powerful JOSDK feature that we won’t be talking about here. Note, however, that annotation references a Dependent annotation pointing to our ConfigMapDR class. That’s all there is to do to tell the framework to use our dependent resource.
We still need to update our CR status once the ConfigMap has been dealt with, though. By default, the framework reconciles all dependent resources before dealing with the “main” reconciler. So, in our case, if the framework ever gets to our reconcile method, this means that our ConfigMap was successfully handled and we can update our CR’s status, which is what the body of the reconcile method above is doing.
Step 7: Test It!
Create a file called greeting.yaml
:
apiVersion: demo.example.com/v1
kind: Greeting
metadata:
name: my-greeting
namespace: default
spec:
message: "Hello from Quarkus Operator!"
Apply it:
kubectl apply -f greeting.yaml
Check that the ConfigMap
was created:
kubectl get configmap -n default
See the custom resource and its status:
kubectl get configmap my-greeting-config -o yaml -n default
You should see the message you specified in the Greeting resource. You can also get the custom resource:
kubectl get greeting my-greeting -o yaml -n default
You will see that the status has been updated by the operator.
Step 8: Clean Up
kubectl delete -f greeting.yaml
kubectl delete -f target/kubernetes/greetings.demo.example.com-v1.yml
Since we told JOSDK to also manage the deletion of our ConfigMap dependent resources, deleting the primary Greeting resource will also delete all its associated dependents (via automation of the owner reference mechanism).
Where to Go From Here?
This was just your “Hello World.” You’ve now seen how an Operator can:
Watch and reconcile state automatically.
Manage secondary resources.
Update Kubernetes status for observability.
Live-reload during development.
Generate CRDs and RBAC automatically.
To level up your Operator:
Add support for finalizers to handle deletion logic.
Integrate with Micrometer to export Prometheus metrics.
Test with Quarkus' unit/integration support.
Package your Operator with Helm or OLM for enterprise readiness.
Build native images for extreme performance.
Final Thoughts
Kubernetes Operators don’t have to be scary. With Quarkus and the Quarkus Operator SDK, building one feels natural for Java developers. You stay productive. You write clean, declarative Java. And you automate infrastructure the right way—inside the platform.
So next time someone throws “reconciliation loop” your way, just smile, fire up Dev Mode, and go full Operator Hero.