Claude Code for Kubernetes Operators: Custom Resources, Controllers, and Reconciliation — Claude Skills 360 Blog
Blog / DevOps / Claude Code for Kubernetes Operators: Custom Resources, Controllers, and Reconciliation
DevOps

Claude Code for Kubernetes Operators: Custom Resources, Controllers, and Reconciliation

Published: August 20, 2026
Read time: 9 min read
By: Claude Skills 360

Kubernetes operators encode operational knowledge as code: instead of runbooks, an operator automatically provisions databases, handles backups, manages upgrades, and responds to failures. Claude Code generates operator code correctly — understanding the reconciliation loop model, proper status conditions, finalizer patterns, and the watch-based controller logic.

This guide covers Kubernetes operators with Claude Code: CRD design, controller-runtime reconciliation, status updates, finalizers, and testing with envtest.

What an Operator Does

An operator extends Kubernetes with a custom resource type and a controller that watches those resources and reconciles desired state to actual state.

I want to build an operator that manages PostgreSQL databases.
When I create a PostgresDatabase resource, it should:
1. Create a PostgreSQL user and database
2. Create a Kubernetes secret with connection credentials
3. Update the resource status with the connection URL

Walk me through the structure.

Custom Resource Definition

# config/crd/bases/db.example.com_postgresdatabases.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: postgresdatabases.db.example.com
spec:
  group: db.example.com
  names:
    kind: PostgresDatabase
    listKind: PostgresDatabaseList
    plural: postgresdatabases
    singular: postgresdatabase
    shortNames:
      - pgdb
  scope: Namespaced
  versions:
    - name: v1alpha1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              required: [instance, databaseName]
              properties:
                instance:
                  type: string
                  description: Name of the PostgresInstance resource to create the DB on
                databaseName:
                  type: string
                  pattern: '^[a-z][a-z0-9_]*$'
                  maxLength: 63
                owner:
                  type: string
                  description: Database owner username (defaults to databaseName)
                extensions:
                  type: array
                  items:
                    type: string
                  description: PostgreSQL extensions to install (e.g., pgcrypto, uuid-ossp)
            status:
              type: object
              properties:
                phase:
                  type: string
                  enum: [Pending, Provisioning, Ready, Failed]
                connectionSecret:
                  type: string
                  description: Name of the Secret containing connection credentials
                conditions:
                  type: array
                  items:
                    type: object
                    required: [type, status, lastTransitionTime, reason, message]
                    properties:
                      type:
                        type: string
                      status:
                        type: string
                        enum: ["True", "False", "Unknown"]
                      lastTransitionTime:
                        type: string
                        format: date-time
                      reason:
                        type: string
                      message:
                        type: string
      subresources:
        status: {}  # Enable status subresource — updates don't conflict with spec updates
      additionalPrinterColumns:
        - name: Phase
          type: string
          jsonPath: .status.phase
        - name: Age
          type: date
          jsonPath: .metadata.creationTimestamp

Reconciler Implementation

// internal/controller/postgresdatabase_controller.go
package controller

import (
	"context"
	"fmt"

	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
	"sigs.k8s.io/controller-runtime/pkg/log"

	dbv1alpha1 "example.com/postgres-operator/api/v1alpha1"
	"example.com/postgres-operator/internal/postgres"
)

const finalizerName = "db.example.com/finalizer"

type PostgresDatabaseReconciler struct {
	client.Client
	Scheme       *runtime.Scheme
	PostgresAdmin postgres.AdminClient
}

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

	// Fetch the resource
	db := &dbv1alpha1.PostgresDatabase{}
	if err := r.Get(ctx, req.NamespacedName, db); err != nil {
		if errors.IsNotFound(err) {
			return ctrl.Result{}, nil // Resource deleted — nothing to do
		}
		return ctrl.Result{}, err
	}

	// Handle deletion: run cleanup via finalizer
	if !db.DeletionTimestamp.IsZero() {
		return r.handleDeletion(ctx, db)
	}

	// Ensure finalizer is present
	if !controllerutil.ContainsFinalizer(db, finalizerName) {
		controllerutil.AddFinalizer(db, finalizerName)
		if err := r.Update(ctx, db); err != nil {
			return ctrl.Result{}, err
		}
		return ctrl.Result{Requeue: true}, nil
	}

	// Main reconciliation
	return r.reconcileDatabase(ctx, db)
}

func (r *PostgresDatabaseReconciler) reconcileDatabase(ctx context.Context, db *dbv1alpha1.PostgresDatabase) (ctrl.Result, error) {
	log := log.FromContext(ctx)

	// Update phase to Provisioning
	if err := r.updateStatus(ctx, db, dbv1alpha1.PhaseProvisioning, "Provisioning", "Starting database provisioning"); err != nil {
		return ctrl.Result{}, err
	}

	// Fetch the PostgresInstance to get connection details
	instance := &dbv1alpha1.PostgresInstance{}
	if err := r.Get(ctx, client.ObjectKey{
		Namespace: db.Namespace,
		Name:      db.Spec.Instance,
	}, instance); err != nil {
		if errors.IsNotFound(err) {
			return ctrl.Result{}, r.setFailedCondition(ctx, db, "InstanceNotFound",
				fmt.Sprintf("PostgresInstance %s not found", db.Spec.Instance))
		}
		return ctrl.Result{}, err
	}

	// Idempotent: create database if it doesn't exist
	owner := db.Spec.Owner
	if owner == "" {
		owner = db.Spec.DatabaseName
	}

	if err := r.PostgresAdmin.CreateDatabaseIfNotExists(ctx, postgres.CreateDatabaseParams{
		Host:         instance.Status.Host,
		AdminSecret:  instance.Status.AdminSecret,
		DatabaseName: db.Spec.DatabaseName,
		Owner:        owner,
		Extensions:   db.Spec.Extensions,
	}); err != nil {
		log.Error(err, "Failed to create database")
		return ctrl.Result{}, r.setFailedCondition(ctx, db, "ProvisioningFailed", err.Error())
	}

	// Create or update the connection Secret
	secretName := fmt.Sprintf("%s-credentials", db.Name)
	secret := r.buildConnectionSecret(db, instance, secretName, owner)
	
	if err := controllerutil.SetControllerReference(db, secret, r.Scheme); err != nil {
		return ctrl.Result{}, err
	}

	if err := r.createOrUpdate(ctx, secret); err != nil {
		return ctrl.Result{}, err
	}

	// Update status to Ready
	db.Status.ConnectionSecret = secretName
	return ctrl.Result{}, r.updateStatus(ctx, db, dbv1alpha1.PhaseReady, "Ready", "Database provisioned successfully")
}

func (r *PostgresDatabaseReconciler) handleDeletion(ctx context.Context, db *dbv1alpha1.PostgresDatabase) (ctrl.Result, error) {
	if controllerutil.ContainsFinalizer(db, finalizerName) {
		// Drop the database before removing the finalizer
		if err := r.PostgresAdmin.DropDatabase(ctx, db.Spec.DatabaseName); err != nil {
			return ctrl.Result{}, err
		}

		controllerutil.RemoveFinalizer(db, finalizerName)
		if err := r.Update(ctx, db); err != nil {
			return ctrl.Result{}, err
		}
	}
	return ctrl.Result{}, nil
}

func (r *PostgresDatabaseReconciler) updateStatus(ctx context.Context, db *dbv1alpha1.PostgresDatabase, phase, reason, message string) error {
	db.Status.Phase = dbv1alpha1.Phase(phase)
	
	// Add condition
	condition := metav1.Condition{
		Type:               "Ready",
		Status:             metav1.ConditionTrue,
		LastTransitionTime: metav1.Now(),
		Reason:             reason,
		Message:            message,
	}
	if phase == dbv1alpha1.PhaseReady {
		condition.Status = metav1.ConditionTrue
	} else {
		condition.Status = metav1.ConditionFalse
	}
	
	// Use SetStatusCondition to handle transition time correctly
	// (only updates transition time when status actually changes)
	apimeta.SetStatusCondition(&db.Status.Conditions, condition)

	return r.Status().Update(ctx, db)
}

// SetupWithManager registers the controller and sets up watches
func (r *PostgresDatabaseReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&dbv1alpha1.PostgresDatabase{}).
		Owns(&corev1.Secret{}). // Also reconcile when owned Secrets change
		Complete(r)
}

Testing with envtest

// internal/controller/suite_test.go
package controller_test

import (
	"context"
	"path/filepath"
	"testing"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"k8s.io/client-go/rest"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/envtest"
)

var (
	cfg       *rest.Config
	k8sClient client.Client
	testEnv   *envtest.Environment
	ctx       context.Context
	cancel    context.CancelFunc
)

func TestControllers(t *testing.T) {
	RegisterFailHandler(Fail)
	RunSpecs(t, "Controller Suite")
}

var _ = BeforeSuite(func() {
	ctx, cancel = context.WithCancel(context.Background())

	testEnv = &envtest.Environment{
		CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")},
	}

	var err error
	cfg, err = testEnv.Start()
	Expect(err).NotTo(HaveOccurred())

	mgr, err := ctrl.NewManager(cfg, ctrl.Options{})
	Expect(err).NotTo(HaveOccurred())

	// Register the controller with a mock PostgresAdmin
	err = (&PostgresDatabaseReconciler{
		Client:       mgr.GetClient(),
		Scheme:       mgr.GetScheme(),
		PostgresAdmin: &mockPostgresAdmin{},
	}).SetupWithManager(mgr)
	Expect(err).NotTo(HaveOccurred())

	k8sClient = mgr.GetClient()

	go func() {
		defer GinkgoRecover()
		Expect(mgr.Start(ctx)).To(Succeed())
	}()
})

var _ = AfterSuite(func() {
	cancel()
	Expect(testEnv.Stop()).To(Succeed())
})

For deploying operators on real clusters with Helm, see the Helm charts guide. For GitOps management of operator deployments via ArgoCD, see the GitOps guide. The Claude Skills 360 bundle includes Kubernetes skill sets covering operator development, custom controllers, and RBAC design. Start with the free tier to try Kubernetes operator code generation.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free