A Helm chart deploys a fixed configuration: it installs resources according to values provided at deployment time, then stops acting until the next helm upgrade a human runs. An Operator does something different: a controller that runs continuously inside the cluster, watching a state and acting on its own, with no human needing to trigger anything.

The pattern, in three pieces

An Operator combines a CRD (Custom Resource Definition), declaring a new Kubernetes object type specific to the managed domain (a database cluster, a certificate, a scheduled backup), and a controller applying the same reconciliation loop that keeps a Deployment in shape: watch the declared state, compare it to the real state, act to close the gap, continuously.

# The CRD declares a domain-specific business object,
# "PostgresCluster" doesn't natively exist in Kubernetes
apiVersion: postgres-operator.example.com/v1
kind: PostgresCluster
metadata:
  name: production-db
spec:
  replicas: 3
  version: "16"

The controller watching this object encodes what a DBA would do manually: provisioning replicas, handling failover if the primary goes down, orchestrating a major version upgrade in the right order. That operational knowledge, normally living in someone’s head or in a runbook, becomes code that runs continuously.

What a Helm chart structurally can’t do

A Helm chart runs once, at deployment time, then vanishes: it watches nothing afterward. If a database’s primary goes down an hour after deployment, no Helm chart reacts, since it no longer exists as an active process. An Operator, by contrast, keeps running: its reconciliation loop detects the primary’s failure and triggers a failover, with no human needing to run anything.

// Simplified: the controller is woken by the watches set on the
// objects it cares about, not by a timer
func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    // watches real state, compares to spec, acts on any gap
    // empty Result: reconciled, nothing to redo until something changes
    return ctrl.Result{}, nil
}

The empty Result is not an example shortcut, it is the normal case. The Kubebuilder book comments it in exactly those terms: “we return an empty result and no error, which indicates to controller-runtime that we’ve successfully reconciled this object and don’t need to try again until there’s some changes”. The RequeueAfter field does exist — it “tells the Controller to requeue the reconcile key after the Duration” — but it is a safety net, useful when the state you care about produces no observable event. What drives the loop is the watches.

The deciding criterion: ongoing operational logic

Building an Operator makes sense when managing a resource requires ongoing logic, not just an initial deployment: automatic failover, a scheduled backup with integrity verification, an upgrade that must respect a strict order between several dependent components. A Helm chart is more than enough when the need is limited to installing a correct configuration once, with no ongoing monitoring needed afterward: most stateless applications fall into this second case.

Three steps, not two

Framing the choice as “Helm or Operator” skips a step, and it happens to be the one covering most of the needs people believe require a controller.

Step 1 — Helm alone. helm install lays down the resources, then nothing watches. If somebody edits a Deployment by hand, the gap stands until the next helm upgrade.

Step 2 — Helm reconciled by GitOps. Argo CD documents the principle bluntly: “Helm is only used to inflate charts with helm template. The lifecycle of the application is handled by Argo CD instead of Helm.” On the Flux side, the helm-controller’s HelmRelease exposes a driftDetection field: once enabled, the controller compares the manifest held in Helm storage against the cluster’s current state using a server-side dry-run apply, and reports — or corrects — as soon as drift appears. Drift gets caught continuously, without writing a line of Go.

Step 3 — Operator. It earns its place when the logic isn’t expressible as a desired state of manifests. A failover means knowing which replica is primary, promoting the right one, in an order that depends on the state of replication: no amount of reconciled YAML does that.

The need The answer
Install a correct configuration, once Helm alone
Keep the installed configuration matching the repository Helm reconciled by GitOps
React to state the cluster doesn’t describe (replication, verified backup) Operator
Offer a domain object type to other teams Operator: CRD plus controller

The trap joining both halves: when an Operator ships as a Helm chart

An Operator still has to be installed somewhere. The moment it ships as a Helm chart, Helm comes back in through the side door, carrying a limitation that has nothing to do with the controller. Helm does know how to install CRDs — the crds/ directory exists for that — but it does not know how to upgrade them. The Helm documentation is categorical: “There is no support at this time for upgrading or deleting CRDs using Helm.” It adds, in passing, that if the CRD already exists it is skipped with a warning.

The direct and rarely anticipated consequence: helm upgrade installs the new controller with the old CRDs. The N+1 controller reconciles objects whose schema stayed at N, and a field added in the new version is refused by the API server, which doesn’t know it.

Three workarounds are documented, and it pays to know who suggests which. Helm offers only one: factor the CRDs out into a separate chart, installed on its own. The second — putting them in templates/ rather than crds/ — comes from the Flux documentation, which credits Helm with the separate chart alone. It works precisely by stepping outside the crds/ mechanism, of which Helm notes that the files “cannot be templated. They must be plain YAML documents”; templating was removed from them so that helm keeps a valid view of the APIs available in the cluster. You buy back the upgrade by handing the chart the uncertainty crds/ was built to remove.

The third also comes from Flux, but as a feature rather than as advice: a .spec.install.crds and .spec.upgrade.crds policy on the HelmRelease, with three values: Skip, Create and CreateReplace.

The defaults are worth reading twice: Create on install, Skip on upgrade. Flux’s default behaviour therefore reproduces Helm’s limitation exactly. You have to set CreateReplace explicitly for the CRDs to follow the chart.

spec:
  install:
    crds: CreateReplace
  upgrade:
    # Without this line the default is Skip:
    # the controller gets upgraded, its CRDs don't.
    crds: CreateReplace

The real cost of building an Operator

An Operator is a piece of software in its own right: it has its own bugs, its own tests, its own release cycle, and a poorly written reconciliation loop can create side effects hard to diagnose (an action repeated in a loop if the exit condition is badly defined). Kubebuilder and Operator SDK reduce the code needed for a minimal Operator, but don’t eliminate the responsibility of operating this extra piece of software over the long run, a cost often underestimated against the apparent simplicity of a Helm chart.

Four more costs pile on, rarely present at decision time.

The RBAC scope. Kubebuilder’s default scaffolding generates a ClusterRole, produced by controller-gen from the +kubebuilder:rbac markers on the reconciler. A cluster-scoped controller able to create Secrets or Deployments in any namespace is an escalation path: whoever gets code execution in that pod gets its permissions.

Leader election. From two replicas onward it becomes necessary, otherwise two loops act on the same objects. controller-runtime provides it — the manager exposes a LeaderElection option, and its runnables either run permanently or under the election’s control. That is one more dependency on the API server, with its LeaseDuration and RenewDeadline to understand on the day the controller quietly stops acting.

Finalizers. The normal cleanup mechanism, and its failure point. On deletion the API server sets a deletionTimestamp, returns a 202, and “prevents the object from being removed until all items are removed from its metadata.finalizers field”. The controller is what removes them, once the conditions are satisfied. Dead controller, finalizer never removed, object never deleted — and the delete request stays accepted, therefore silent.

Conversion webhooks. The most underestimated cost of all, because it only shows up at the second version. A CRD can carry several versions with different schemas, and the Kubernetes documentation states that conversion webhooks are what convert custom resources between versions. You have to write that server, deploy it, give it a certificate and keep it available: evolving a CRD isn’t editing a file, it’s operating one more service in the API’s critical path.

The Operator SDK also offers to package an existing chart as a controller (operator-sdk init --plugins helm, with --helm-chart to start from a local or remote chart). The option exists and is documented; what that mode can and cannot express is judged chart by chart, and deserves checking before it becomes a plan.

Takeaway

A Helm chart deploys a fixed configuration then disappears; an Operator combines a CRD and a continuously running controller, applying a reconciliation loop to encode operational knowledge that keeps acting after the initial deployment. The deciding criterion between the two is whether ongoing operational logic needs automating (failover, scheduled backup, orchestrated upgrade), not a technical preference. Building an Operator carries a real software maintenance cost, to weigh against a Helm chart’s simplicity, a trade-off that comes up the moment a Kubernetes migration needs to automate an operation nobody wants to redo by hand.