A Helm hybrid operator is a Go-based Kubernetes operator whose Reconcile function calls the Helm SDK directly - it is the pre-built helm-operator binary, written by you, with full control over every line. The Helm hybrid operator pattern gives you everything the pre-built Helm operator gives you (Helm install/upgrade/uninstall driven by CR events) plus everything the pre-built operator cannot do: custom finalizers against external systems, custom status fields, custom decision logic, cross-CR coordination.
This is a two-part tutorial. Part 1 of 2 (this article) lays the foundation: scaffold the Go operator, define the CRD via Go API types, embed the chart, wire the Helm SDK, and implement install/upgrade. Part 2 of 2 adds the features that justified going from scratch in the first place - custom status, a real finalizer, drift via Owns(), and a worked cross-CR coordination demo.
A note on terminology. Searches for "helm hybrid operator" today often land on the operator-framework
helm-operator-pluginslibrary (scaffolded byoperator-sdk init --plugins=hybrid.helm.sdk.operatorframework.io), which runs a Helm reconciler and a Go reconciler side-by-side in the same manager and still bundles the Helm v3 Go SDK as of mid-2026. This article uses "Helm hybrid operator" in a different (and arguably more useful) sense: a single Go reconciler that calls the Helm v4 SDK directly, with nohelm-operator-pluginsdependency. Either pattern lifts the operator above Level I of the capability model; the path here is the one you want when you would rather own every line than configure a generic reconciler.
Here is the Helm hybrid operator pattern in one table, framed against the pre-built path so you can decide which to read:
| Property | Pre-built helm-operator (no Go) | Helm hybrid operator (this article) |
|---|---|---|
| Lines of Go you write | 0 | ~200 (Part 1) + ~200 (Part 2) |
| Helm SDK version | v3 (bundled in plugin image) | v4 (you import helm.sh/helm/v4) |
| Reconcile control | Generic, ships in operator image | Your code - any logic Go can express |
| Custom status / finalizer / cross-CR | Impossible | Standard Go (covered in Part 2) |
Prerequisites: Go 1.26+ (Helm v4 SDK requires go 1.26.0 in go.mod; go get helm.sh/helm/v4 will auto-bump your module directive), operator-sdk v1.40+, helm v4.x, kubectl, kind, Docker. Basic Go reading ability (you are not writing a Go application from scratch - mostly you are filling in three or four functions in the scaffolded controller). If you have built the pre-built Helm operator (Part 1), this article picks up the same demo-app chart so the comparison is apples-to-apples - same Deployment, Service, ConfigMap, Secret, just rendered by a Reconcile loop you own.
What "hybrid" means here (and how it differs from the pre-built helm plugin)
The pre-built helm-operator binary (the helm.sdk.operatorframework.io/v1 plugin) ships from Operator SDK as a pre-compiled, generic reconciler. You provide a chart and a watches.yaml; the binary does the rest. Nothing you write is Go. This is one valid operator design pattern, and it sits at Level I of the capability model.
A Helm hybrid operator is the same idea, written by you in Go. The chart still renders manifests; the Helm SDK still calls helm install / upgrade / uninstall. The difference is that your Reconcile function decides when and how those calls happen, and you can put anything else around them - validation, external API calls, custom status writes, finalizers with retries. By owning the reconcile body the hybrid pattern unlocks Level II and above of the operator capability / maturity model, which the pre-built plugin's three fixed conditions cannot reach.
A second "hybrid" to disambiguate. The Operator SDK also ships a separate
--plugins=hybrid.helm.sdk.operatorframework.ioscaffold that wires the operator-frameworkhelm-operator-pluginslibrary intomain.goand mounts a Helm reconciler (built withreconciler.New(reconciler.WithChart(*chart), reconciler.WithGroupVersionKind(gvk))) alongside a Go reconciler in the same manager. That approach lets you customise the Helm reconciler's event recorder / logger / pre-/post-hooks (covered in Part 2) but inherits the library's still-Helm-v3 implementation. This article deliberately rejects the builder API: a single hand-written Reconcile that callshelm.sh/helm/v4directly is simpler, depends on one less library, and lets you adopt Helm v4 today.
| Property | Pre-built helm-operator | Hybrid (this article) |
|---|---|---|
| Reconcile function | Generic, ships in operator image | You write it (~150 lines in Part 1) |
| CRD source of truth | CLI flags to operator-sdk init |
Go API types + Kubebuilder markers |
| Schema strictness (default) | Permissive (x-kubernetes-preserve-unknown-fields) |
Strict (from your Go field types) |
| Chart distribution | Copied into image at scaffold time | //go:embed baked into Go binary |
| Status conditions | Fixed three: Initialized/Deployed/Failed |
Whatever your status struct says |
| Finalizer | None (Helm pre-delete hook is closest) | Real Go finalizer, can call external systems |
| Drift detection | watchDependentResources in watches.yaml |
Owns() in SetupWithManager (more flexible) |
| Reading external state | Not possible | Standard Go HTTP/SDK calls |
| Cross-CR coordination | Not possible | Standard controller-runtime client List |
| Custom decision logic | Not possible | Anything Go can express |
The cost is volume: about 350-450 lines of Go across both parts, vs zero in the pre-built. The payoff is the entire right-hand column. Conceptually this is the same Kubernetes operator vs controller vs CRD split you have always had - the difference is what you do inside the controller body.
What you will build across the two-part series
The same demo-app chart as the pre-built tutorial - four templates (Deployment, Service, ConfigMap, Secret) driven by a DemoApp CR. The user-visible behaviour at the end of Part 1 is identical to the pre-built operator. The difference comes in Part 2.
| Phase | What lands |
|---|---|
| Part 1 — Foundation (this article) | Scaffold, CRD via Go types, embed chart, install/upgrade |
| Part 2 of 2 — Beyond pre-built | Custom status, finalizer with audit, drift via Owns(), cross-CR demo |
Prerequisites
go version # go1.26.0 or newer (required by Helm v4 SDK)
operator-sdk version # v1.40.0+ (this article tested with v1.42.2)
helm version --short # v4.x (this article tested with v4.2.0)
kubectl version --client # any modern version
kind version # any modern version
docker version --format '{{.Server.Version}}'Why Go 1.26? Helm 4.2.0's
go.moddeclaresgo 1.26.0. The firstgo get helm.sh/helm/v4@latestyou run inside the scaffolded project will automatically bump yourgo.mod'sgodirective from the operator-sdk default (1.24.0) to1.26.0to satisfy this. If your local Go hasGOTOOLCHAIN=auto, it can download and use the newer toolchain automatically; otherwise install Go 1.26+ on the host. Either way, bump theFROM golang:1.24line in the scaffoldedDockerfiletogolang:1.26beforemake docker-build.
Spin up a kind cluster if you do not have one:
kind create cluster --name hybrid --image kindest/node:v1.31.0
kubectl config use-context kind-hybridImage distribution: this article builds a local Go operator image and needs to ship it into the kind cluster. We use ttl.sh — a public, ephemeral, zero-setup container registry — exactly as the pre-built Helm operator Part 1 and the prereq article do. Pattern:
make docker-build IMG="$IMG"→docker push "$IMG"→make deploy IMG="$IMG"withIMG=ttl.sh/demoapp-hybrid-$(uuidgen):24h. Anything pushed to ttl.sh is public — don't use it for proprietary code.
Part A - Scaffold the Go operator
Step 1 - operator-sdk init --plugins=go/v4
mkdir -p ~/hybrid-operator/demoapp
cd ~/hybrid-operator/demoapp
operator-sdk init --plugins=go/v4 --domain example.com --repo github.com/example/demoapp --owner "Demo Team"Three things just happened:
- A Go module was initialised (
go.modwith controller-runtime, client-go, ginkgo). - The Kubebuilder layout was scaffolded (
cmd/,api/placeholder,config/,Dockerfile,Makefile). - A
PROJECTfile was created recording the layout version (v4is the current Kubebuilder layout).
Step 2 - operator-sdk create api
Add the DemoApp Kind:
operator-sdk create api --group demo --version v1alpha1 --kind DemoApp --resource --controllerThis creates two new files:
api/v1alpha1/demoapp_types.go- skeleton Spec and Status structs.internal/controller/demoapp_controller.go- skeletonReconcilefunction andSetupWithManager.
Plus api/v1alpha1/groupversion_info.go and api/v1alpha1/zz_generated.deepcopy.go (the latter regenerated by make generate).
With current Operator SDK releases, create api also runs make generate automatically after creating the files. On a first run this may download controller-gen; the explicit make generate step below is still important after you edit *_types.go.
Step 3 - Project folder structure tour
Stripped to just the directories you will touch in this Part 1:
demoapp/
├── cmd/
│ └── main.go # operator entry point; wires manager + controllers
├── api/ # ← appears only AFTER `operator-sdk create api`
│ └── v1alpha1/
│ ├── demoapp_types.go # YOU EDIT: Spec, Status, Kubebuilder markers
│ ├── groupversion_info.go # generated
│ └── zz_generated.deepcopy.go # regenerated by 'make generate'
├── internal/
│ └── controller/ # ← also appears only after `create api`
│ ├── demoapp_controller.go # YOU EDIT: Reconcile() function
│ ├── demoapp_controller_test.go # generated test scaffold
│ └── suite_test.go # generated test suite
├── config/
│ ├── crd/
│ │ ├── kustomization.yaml
│ │ ├── kustomizeconfig.yaml
│ │ └── bases/ # appears after `make manifests`
│ ├── default/ # kustomize base for 'make deploy'
│ ├── manager/ # the operator Deployment
│ ├── rbac/ # ClusterRole, ServiceAccount, RoleBinding
│ ├── samples/ # generated sample CR YAML
│ ├── manifests/ # OLM bundle manifests (unused here)
│ ├── network-policy/ # default network-policy kustomization (unused here)
│ ├── prometheus/ # ServiceMonitor for the metrics endpoint (unused here)
│ └── scorecard/ # operator-sdk scorecard tests (unused here)
├── Dockerfile # multi-stage Go build (defaults to golang:1.24 — bump to 1.26 for Helm v4)
├── Makefile # generate / manifests / docker-build / deploy
├── PROJECT # operator-sdk metadata
├── hack/ # boilerplate header, etc.
└── go.mod / go.sumThe two files you edit during this Part 1 are: api/v1alpha1/demoapp_types.go (the API) and internal/controller/demoapp_controller.go (the reconcile logic). Everything else stays scaffolded.
Note:
operator-sdk v1.40+also scaffoldstest/,.devcontainer/,.github/,.golangci.yml, andREADME.md. They are unrelated to this tutorial; leave them alone.
Step 4 - How this differs from the pre-built helm plugin
| Differences | --plugins=helm.sdk.operatorframework.io/v1 (pre-built) |
--plugins=go/v4 (this article) |
|---|---|---|
api/v1alpha1/*_types.go |
Not generated - no Go API surface | Generated; you edit it |
internal/controller/*_controller.go |
Not generated - reconciler ships in image | Generated; you implement Reconcile |
watches.yaml |
Generated at scaffold time, lives in image | None - chart is wired in Go code |
helm-charts/<chart>/ |
Generated, copied from --helm-chart flag |
You add charts/<chart>/ yourself |
cmd/main.go |
Not generated - main is in pre-built binary | Generated; runs manager + your controller |
make manifests |
Useless (no Go types to derive from) | Generates strict CRD from your Go API types |
make generate |
Useless (no Go types) | Generates DeepCopy functions for your API types |
The single biggest mental shift is: in the pre-built path, the operator binary is the same for every Helm operator, configured by watches.yaml. In the hybrid path, the operator binary is your code, compiled with your reconciler and your embedded chart.
Part B - Define the CRD via Go types
Step 5 - Edit api/v1alpha1/demoapp_types.go
The scaffolded file has empty Spec and Status structs. Replace them with the demo-app chart's value shape:
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// DemoAppSpec defines the desired state of DemoApp.
type DemoAppSpec struct {
// Replicas is the number of nginx Pods to run.
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=10
// +kubebuilder:default=1
Replicas int32 `json:"replicaCount,omitempty"`
// Image is the container image for the nginx Deployment.
// +kubebuilder:validation:Pattern=`^[a-z0-9./:-]+$`
// +kubebuilder:default="nginx:1.27-alpine"
Image string `json:"image,omitempty"`
// Message is the HTML body served by the Pod.
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=200
Message string `json:"message"`
// ApiKey is mounted into the Pod as an env var (demonstration only).
// +kubebuilder:validation:MinLength=8
ApiKey string `json:"apiKey,omitempty"`
// Service controls the in-cluster Service in front of the Pods.
// +kubebuilder:default={type: ClusterIP, port: 80}
Service ServiceSpec `json:"service,omitempty"`
}
// ServiceSpec is the Service section of DemoApp.spec.
type ServiceSpec struct {
// +kubebuilder:validation:Enum=ClusterIP;NodePort;LoadBalancer
// +kubebuilder:default="ClusterIP"
Type string `json:"type,omitempty"`
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=65535
// +kubebuilder:default=80
Port int32 `json:"port,omitempty"`
}
// DemoAppStatus defines the observed state of DemoApp.
// Part 1 keeps this minimal; Part 2 adds custom status fields.
type DemoAppStatus struct {
// Conditions reflect the observed state of the DemoApp.
// +listType=map
// +listMapKey=type
Conditions []metav1.Condition `json:"conditions,omitempty"`
// ObservedGeneration tracks the last spec generation reconciled.
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Replicas",type=integer,JSONPath=".spec.replicaCount"
// +kubebuilder:printcolumn:name="Message",type=string,JSONPath=".spec.message"
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=".metadata.creationTimestamp"
type DemoApp struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec DemoAppSpec `json:"spec,omitempty"`
Status DemoAppStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
type DemoAppList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []DemoApp `json:"items"`
}
func init() {
SchemeBuilder.Register(&DemoApp{}, &DemoAppList{})
}The json tag names (replicaCount, image, message, apiKey, service) match the chart's values.yaml keys exactly - this is what makes the implicit "CR spec → Helm values" mapping work without any conversion code (we will rely on this in Part E).
Step 6 - Kubebuilder markers explained
| Marker | Effect |
|---|---|
+kubebuilder:validation:Minimum=1 |
Schema's minimum: 1 |
+kubebuilder:validation:Pattern="^[...]+$" |
Schema's pattern: "^[...]+$" |
+kubebuilder:validation:Enum=ClusterIP;... |
Schema's enum: [ClusterIP, ...] |
+kubebuilder:default=1 |
Schema's default: 1 (applied at admission) |
+kubebuilder:validation:MinLength=8 |
Schema's minLength: 8 |
+kubebuilder:object:root=true |
Top-level Kubernetes object (DeepCopy + runtime.Object) |
+kubebuilder:subresource:status |
Status is a separate subresource (split write path) |
+kubebuilder:printcolumn:name="..." |
kubectl get demoapps shows this column |
+listType=map, +listMapKey=type |
List is a map keyed by type (correct status merging) |
The +listType=map markers on Conditions are important - without them, status updates use list-replace semantics instead of map-merge, which causes condition flapping under concurrent reconciles. For the canonical status subresource and Conditions contract, see the language-neutral reference.
Step 7 - make generate (DeepCopy)
make generate
# bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."Open api/v1alpha1/zz_generated.deepcopy.go - controller-gen produced DeepCopyInto, DeepCopy, and DeepCopyObject for DemoApp, DemoAppList, DemoAppSpec, ServiceSpec, and DemoAppStatus. Controller-runtime needs these to safely cache and reconcile your resources without aliasing.
You re-run
make generatewhenever you edit*_types.go.
Step 8 - make manifests (CRD YAML)
make manifests
# bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/basesOpen config/crd/bases/demo.example.com_demoapps.yaml:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: demoapps.demo.example.com
spec:
group: demo.example.com
names:
kind: DemoApp
listKind: DemoAppList
plural: demoapps
singular: demoapp
scope: Namespaced
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required:
- message
properties:
replicaCount:
type: integer
minimum: 1
maximum: 10
default: 1
image:
type: string
pattern: '^[a-z0-9./:-]+$'
default: 'nginx:1.27-alpine'
message:
type: string
minLength: 1
maxLength: 200
apiKey:
type: string
minLength: 8
service:
type: object
default: {type: ClusterIP, port: 80}
properties:
type:
type: string
enum: [ClusterIP, NodePort, LoadBalancer]
default: ClusterIP
port:
type: integer
minimum: 1
maximum: 65535
default: 80
status:
type: object
# ... conditions schema generated from Go struct ...
subresources:
status: {}
additionalPrinterColumns:
- name: Replicas
type: integer
jsonPath: .spec.replicaCount
- name: Message
type: string
jsonPath: .spec.message
- name: Age
type: date
jsonPath: .metadata.creationTimestampThis is strict by default - the opposite of the pre-built operator's permissive x-kubernetes-preserve-unknown-fields: true. Bad CRs are rejected at admission, never reach your Reconcile. (For a language-neutral primer on how OpenAPI v3 validation maps from Go types, see the CRD explainer.)
You re-run make manifests whenever you edit *_types.go markers.
How this differs from the pre-built plugin's CRD generation
| Concern | Pre-built helm plugin | Hybrid (this article) |
|---|---|---|
| Source of CRD schema | CLI flags + permissive default | Go API types + Kubebuilder markers |
| Strictness | Permissive by default (you tighten by editing) | Strict by default (you loosen by removing markers) |
| Re-run trigger | Edit YAML directly | make manifests after editing types |
| Source of truth | YAML in config/crd/bases/ |
Go in api/v1alpha1/*_types.go |
additionalPrinterColumns |
Edit YAML manually | +kubebuilder:printcolumn: marker |
| Conditions schema | None (no Conditions in pre-built status) |
Auto-generated from []metav1.Condition |
The Go types are the canonical source. The CRD YAML is derived. Never hand-edit the generated CRD YAML in a hybrid operator - your edits get overwritten next make manifests.
Part C - Embed the chart in the operator binary
Step 9 - Add the chart under internal/controller/charts/
Critical:
//go:embedpatterns are resolved relative to the package directory containing the directive, not the module root. Because the//go:embeddirective lives ininternal/controller/chart.go(next step), the chart must live next to it — putting it at the repo-rootcharts/like the Kubebuilder docs suggest will fail at build time withpattern all:charts/demo-app: no matching files found.
Copy the chart into the package directory:
mkdir -p internal/controller/charts
cp -r ~/hybrid-operator/demo-app internal/controller/charts/demo-app
ls internal/controller/charts/demo-app/
# Chart.yaml templates values.yamlIf you do not have the chart from the pre-built tutorial, recreate the four templates per Part 1 of the pre-built tutorial.
Step 10 - //go:embed all:charts/demo-app
Create a small helper that embeds the chart and loads it from embed.FS. Add internal/controller/chart.go:
package controller
import (
"embed"
"fmt"
"io/fs"
"strings"
"helm.sh/helm/v4/pkg/chart/loader/archive"
chartv2 "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/chart/v2/loader"
)
//go:embed all:charts/demo-app
var chartFS embed.FS
const chartRoot = "charts/demo-app"
// loadChart loads the embedded demo-app chart at startup. The returned
// *chartv2.Chart is reusable across reconciles - it's cached in the controller.
func loadChart() (*chartv2.Chart, error) {
var files []*archive.BufferedFile
err := fs.WalkDir(chartFS, chartRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, err := fs.ReadFile(chartFS, path)
if err != nil {
return fmt.Errorf("read embedded chart file %s: %w", path, err)
}
// loader.LoadFiles expects chart-relative paths (e.g. "templates/deployment.yaml"),
// not embed-relative ("charts/demo-app/templates/deployment.yaml").
rel := strings.TrimPrefix(path, chartRoot+"/")
files = append(files, &archive.BufferedFile{Name: rel, Data: data})
return nil
})
if err != nil {
return nil, err
}
chrt, err := loader.LoadFiles(files)
if err != nil {
return nil, fmt.Errorf("load chart from embed: %w", err)
}
return chrt, nil
}
// LoadChart is exported for use from cmd/main.go.
func LoadChart() (*chartv2.Chart, error) { return loadChart() }Four notes on this:
//go:embed all:charts/demo-appis required (theall:prefix includes files that would normally be skipped, like_helpers.tplwhose name starts with_).- In Helm 4 the chart loader is versioned per chart-format. The types (
Chart,Metadata) live inhelm.sh/helm/v4/pkg/chart/v2. The chart-v2 loader (LoadFiles) lives inhelm.sh/helm/v4/pkg/chart/v2/loader. TheBufferedFiletype thatLoadFilesaccepts lives inhelm.sh/helm/v4/pkg/chart/loader/archive— note the path:chart/loader/archive, notchart/v2/archive(which doesn't exist as of Helm v4.2.0). - The chart we are loading uses the chart v2 format (
Chart.yaml: apiVersion: v2) — the format every existing Helm chart uses today. If/when chart v3 ships, swapchart/v2/loaderforchart/v3/loader;loader/archivestays the same. - The returned
*chartv2.Chartis loaded once at controller startup and reused across reconciles. Loading on every reconcile is wasteful.
Alternatives to go:embed
| Alternative | When to choose |
|---|---|
Dockerfile COPY chart to /charts |
When you want to swap the chart without rebuilding Go (build two images: one with code, one with chart). Closest to the pre-built operator's pattern. |
| ConfigMap mount via VolumeMount | Maximum flexibility: edit the chart via kubectl edit cm, restart the pod. Operationally heavy. |
| Fetch from a Helm repo at startup | Only if the chart is genuinely external (third-party). Adds a startup network dependency. |
For 95% of teams, //go:embed is the right default - it makes the chart immutable per operator image, which makes rollouts auditable and reproducible.
Part D - Wire the Helm v4 SDK
Step 11 - Required imports
The Helm 4 SDK is split into more sub-packages than v3 was. Here is the verified-against-v4.2.0 layout for everything the Helm hybrid operator needs in this article:
| Package | What you use |
|---|---|
helm.sh/helm/v4/pkg/action |
Configuration, Install, Upgrade, Uninstall, History |
helm.sh/helm/v4/pkg/chart/v2 |
Chart type for chart-format v2 (today's standard format; Chart.yaml apiVersion: v2) |
helm.sh/helm/v4/pkg/chart/v2/loader |
LoadFiles to build a chart from in-memory files |
helm.sh/helm/v4/pkg/chart/loader/archive |
BufferedFile (input type for LoadFiles). Note: NOT chart/v2/archive |
helm.sh/helm/v4/pkg/cli |
EnvSettings (defaults for KubeContext, Namespace), New(), RESTClientGetter |
helm.sh/helm/v4/pkg/kube |
WaitStrategy, HookOnlyStrategy/LegacyStrategy/StatusWatcherStrategy |
helm.sh/helm/v4/pkg/release/v1 |
Release (the concrete type returned by install/upgrade after type-assertion) |
helm.sh/helm/v4/pkg/storage/driver |
ErrReleaseNotFound sentinel for "no such release yet" |
Add them to your go.mod:
go get helm.sh/helm/v4@latest
go mod tidyHeads-up: this auto-bumps Go. As of Helm v4.2.0,
go get helm.sh/helm/v4@latestrewrites yourgo.mod'sgodirective fromgo 1.24.0(operator-sdk default) togo 1.26.0(Helm v4 requirement). In a tested run with Go 1.24.4 andGOTOOLCHAIN=auto, Go downloaded and usedgo1.26.4automatically (see Prerequisites for context onGOTOOLCHAIN). The samego getalso upgraded controller-runtime fromv0.21.0tov0.24.0and Kubernetes libraries tov0.36.0, which is expected with current Helm v4.
Do not skip
go mod tidy. Without it,make generatecan fail with missinggo.sumentries for Helm/Kubernetes transitive imports loaded bycontroller-gen.
Why v4 and not v3? Helm 4 (stable since 2026) is the version you should target for new work. The Go SDK import path is
helm.sh/helm/v4/...(notv3/...). If you have an existing v3-based operator you want to migrate, the main mechanical changes are:
- Rewrite imports
v3→v4.- Chart loader:
chart/loader→chart/v2/loader.BufferedFile:chart/loader→chart/loader/archive(the v3 type moved into a sub-package).action.Configuration.Init: drop the trailingfunc(format string, v ...any)logger argument — the v4 signature takes only(getter, namespace, helmDriver).Install.Run/Upgrade.Runnow returnrelease.Releaser(which isany); type-assert to*releasev1.Releaseto read fields likeName,Version,Manifest(see Step 17).Install.Wait/Upgrade.Waitbool fields are gone — replaced byWaitStrategy kube.WaitStrategy(andUpgrade.Runerrors at runtime ifWaitStrategyis unset; see Step 18).Upgrade.Atomicis gone — replaced byUpgrade.RollbackOnFailureandUpgrade.CleanupOnFail.
Step 12 - Build an action.Configuration per reconcile
Add internal/controller/helm.go:
package controller
import (
"context"
"fmt"
"helm.sh/helm/v4/pkg/action"
"helm.sh/helm/v4/pkg/cli"
)
// newActionConfig constructs a Helm action.Configuration scoped to namespace.
// The returned config can drive Install / Upgrade / Uninstall / History for
// any release in that namespace.
func newActionConfig(_ context.Context, namespace string) (*action.Configuration, error) {
settings := cli.New()
settings.SetNamespace(namespace)
cfg := new(action.Configuration)
err := cfg.Init(settings.RESTClientGetter(), namespace, "secret", // release storage driver - "secret" remains the default in Helm 4
)
if err != nil {
return nil, fmt.Errorf("helm action.Configuration.Init: %w", err)
}
return cfg, nil
}Three things to notice:
settings.RESTClientGetter()is the v4 idiom - the Helm 4 SDK example uses it directly and it picks up the in-cluster KUBECONFIG and namespace defaults without you having to build agenericclioptions.ConfigFlagsby hand (which was the older v3-era pattern)."secret"as the storage driver. Helm stores releases as Kubernetes Secrets namedsh.helm.release.v1.<release>.v<revision>- this storage format is unchanged between Helm 3 and Helm 4, so the releases your operator creates are interchangeable withhelm history,helm rollback, and the pre-built helm-operator (which still bundles the v3 SDK as of mid-2026).- No logger argument. Helm v3's
Configuration.Initaccepted afunc(format string, v ...any)logger as the fourth argument. Helm v4 dropped it; the SDK now logs via the globallog/sloghandler. If you want Helm logs in your controller-runtime logger, set the slog handler at startup incmd/main.go.
Helm 4 default: Server-Side Apply. Helm 4 uses Server-Side Apply (SSA) by default for new installs (
action.Install.ServerSideApplydefaults totrue; for upgrades,action.Upgrade.ServerSideApplyis a tri-state string"true"/"false"/"auto"). That means the resources youraction.NewInstall.Runcreates carrymanagedFieldsentries owned byhelm, and conflict semantics match SSA rules. For drift correction to work (Part 2), you must also setInstall.ForceConflicts = trueandUpgrade.ForceConflicts = true, otherwise akubectl patchagainst a chart-rendered field becomes "owned" bykubectl-patchand the next operator upgrade fails withconflict occurred while applying object … Apply failed with 1 conflict.
Step 13 - Load the chart at controller startup
In internal/controller/demoapp_controller.go, hold the chart on the reconciler struct so it loads once:
import (
chartv2 "helm.sh/helm/v4/pkg/chart/v2"
)
type DemoAppReconciler struct {
client.Client
Scheme *runtime.Scheme
// Chart is loaded once at startup; reused across reconciles.
Chart *chartv2.Chart
}Then in cmd/main.go, load it before starting the manager:
import (
democtrl "github.com/example/demoapp/internal/controller"
)
func main() {
// ... existing scaffold ...
chrt, err := democtrl.LoadChart() // export the helper from chart.go
if err != nil {
setupLog.Error(err, "load embedded chart")
os.Exit(1)
}
if err = (&democtrl.DemoAppReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Chart: chrt}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "DemoApp")
os.Exit(1)
}
// ... rest of scaffold ...
}Export LoadChart from chart.go:
// LoadChart is exported for use from cmd/main.go.
func LoadChart() (*chartv2.Chart, error) { return loadChart() }The chart is now in memory at startup and every reconcile reads from RAM, not disk.
Current Operator SDK scaffolds include additional metrics, webhook, and certificate-watcher setup in cmd/main.go; keep that code and only replace the DemoAppReconciler construction block with the chart-aware version above.
Part E - Implement Reconcile: install / upgrade
This is the core of Part 1. The full Reconcile is ~80 lines of focused Go. We will build it step by step, then show the assembled version.
Step 14 - The Reconcile skeleton
Open internal/controller/demoapp_controller.go and replace the scaffolded Reconcile:
func (r *DemoAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
// 1. Fetch the CR.
var demoapp demov1alpha1.DemoApp
if err := r.Get(ctx, req.NamespacedName, &demoapp); err != nil {
if apierrors.IsNotFound(err) {
// CR deleted - Helm uninstall path is covered in Part 2 (finalizer).
// For Part 1, rely on owner references for garbage collection.
return ctrl.Result{}, nil
}
return ctrl.Result{}, fmt.Errorf("get DemoApp: %w", err)
}
// 2. Skip if already reconciled at this generation (idempotency).
if demoapp.Status.ObservedGeneration == demoapp.Generation {
logger.V(1).Info("generation matches, skip reconcile", "generation", demoapp.Generation)
return ctrl.Result{}, nil
}
// 3. Build a Helm action.Configuration for this CR's namespace.
cfg, err := newActionConfig(ctx, demoapp.Namespace)
if err != nil {
return ctrl.Result{}, fmt.Errorf("helm config: %w", err)
}
// 4. Decide install vs upgrade and run it.
if err := r.installOrUpgrade(ctx, cfg, &demoapp); err != nil {
// status writes go here in Part 2; Part 1 just requeues on error.
return ctrl.Result{RequeueAfter: 30 * time.Second}, err
}
// 5. Mark this generation as observed.
demoapp.Status.ObservedGeneration = demoapp.Generation
if err := r.Status().Update(ctx, &demoapp); err != nil {
return ctrl.Result{}, fmt.Errorf("update status: %w", err)
}
return ctrl.Result{}, nil
}Five clear steps: fetch, idempotency check, build Helm config, install-or-upgrade, mark observed. Without a finalizer in Part 1, the orphan-cleanup story for chart resources falls back to owner references and garbage collection - Helm wires those up automatically when it applies the release. Part 2 will add finalizer detection, custom status writes, and richer error handling between these steps.
Step 15 - Check for an existing release (action.NewHistory)
func (r *DemoAppReconciler) installOrUpgrade(ctx context.Context, cfg *action.Configuration, demoapp *demov1alpha1.DemoApp) error {
releaseName := demoapp.Name
// Has Helm seen a release with this name in this namespace?
hist := action.NewHistory(cfg)
hist.Max = 1
_, err := hist.Run(releaseName)
if err == driver.ErrReleaseNotFound {
return r.install(ctx, cfg, demoapp, releaseName)
}
if err != nil {
return fmt.Errorf("helm history: %w", err)
}
return r.upgrade(ctx, cfg, demoapp, releaseName)
}driver.ErrReleaseNotFound is the canonical sentinel - any other error is genuinely unexpected and should propagate.
Step 16 - Convert CR.Spec to Helm values (JSON marshal trick)
The cleanest way to turn a Go struct into the map[string]interface{} Helm wants is round-trip through JSON. The chart's values.yaml keys match the json tags on DemoAppSpec exactly (we ensured this in Step 5), so the mapping is automatic:
func specToValues(spec demov1alpha1.DemoAppSpec) (map[string]interface{}, error) {
raw, err := json.Marshal(spec)
if err != nil {
return nil, fmt.Errorf("marshal spec: %w", err)
}
var values map[string]interface{}
if err := json.Unmarshal(raw, &values); err != nil {
return nil, fmt.Errorf("unmarshal to values: %w", err)
}
return values, nil
}For complex specs you might curate a subset (skipping operator-only fields), but for the demo-app spec, the whole shape goes through.
Step 17 - Install path
import (
"helm.sh/helm/v4/pkg/kube"
release "helm.sh/helm/v4/pkg/release/v1"
)
func (r *DemoAppReconciler) install(ctx context.Context, cfg *action.Configuration, demoapp *demov1alpha1.DemoApp, releaseName string) error {
logger := log.FromContext(ctx)
values, err := specToValues(demoapp.Spec)
if err != nil {
return err
}
inst := action.NewInstall(cfg)
inst.ReleaseName = releaseName
inst.Namespace = demoapp.Namespace
inst.CreateNamespace = false // CR cannot create its own namespace
inst.WaitStrategy = kube.HookOnlyStrategy // operator-friendly: only block on hooks
inst.ForceConflicts = true // SSA: take ownership over manual patches
resi, err := inst.RunWithContext(ctx, r.Chart, values)
if err != nil {
return fmt.Errorf("helm install: %w", err)
}
rel, ok := resi.(*release.Release)
if !ok {
return fmt.Errorf("helm install returned unexpected release type: %T", resi)
}
logger.Info("installed release",
"release", rel.Name, "namespace", rel.Namespace, "revision", rel.Version)
return nil
}Four v4-specific knobs to call out:
CreateNamespace: false- the CR cannot create its own namespace; namespace lifecycle is a cluster admin concern.WaitStrategy = kube.HookOnlyStrategy- the v3Wait boolfield is gone in v4. The replacement isWaitStrategy kube.WaitStrategywith three values:kube.StatusWatcherStrategy("watcher", kstatus-based),kube.LegacyStrategy("legacy", Helm 3-style polling), andkube.HookOnlyStrategy("hookOnly", wait only for hook Jobs). For an operator,HookOnlyStrategyis the right pick:Reconcileshould return fast and let the next reconcile observe rollout state, but you still want hook completion to block install (otherwise pre-install Jobs can race with the next reconcile).ForceConflicts = true- Helm 4 uses Server-Side Apply by default. Without this, the operator cannot overwrite fields a user has touched viakubectl patch, and the broader drift-detection patterns in operators demo in Part 2 depends on it.RunWithContextreturns(release.Releaser, error)whererelease.Releaseris literallytype Releaser anyin v4. To readName,Namespace,Version,Manifest, etc., you must type-assert to the concrete*releasev1.Release(imported as shown above asrelease "helm.sh/helm/v4/pkg/release/v1"). Helm's own SDK code does the same assertion — there is noreleaserToV1Releasepublic helper as of v4.2.0.
Step 18 - Upgrade path
func (r *DemoAppReconciler) upgrade(
ctx context.Context, cfg *action.Configuration,
demoapp *demov1alpha1.DemoApp, releaseName string,
) error {
logger := log.FromContext(ctx)
values, err := specToValues(demoapp.Spec)
if err != nil {
return err
}
up := action.NewUpgrade(cfg)
up.Namespace = demoapp.Namespace
up.MaxHistory = 10 // keep last 10 revisions; default 0 = unbounded
up.WaitStrategy = kube.HookOnlyStrategy // REQUIRED: Upgrade errors at runtime if unset (see below)
up.ServerSideApply = "true" // Upgrade.ServerSideApply is a tri-state STRING in v4
up.ForceConflicts = true // overwrite kubectl-patch'd fields (drift correction)
resi, err := up.RunWithContext(ctx, releaseName, r.Chart, values)
if err != nil {
return fmt.Errorf("helm upgrade: %w", err)
}
rel, ok := resi.(*release.Release)
if !ok {
return fmt.Errorf("helm upgrade returned unexpected release type: %T", resi)
}
logger.Info("upgraded release",
"release", rel.Name, "namespace", rel.Namespace, "revision", rel.Version)
return nil
}Four v4 gotchas worth memorising:
MaxHistoryis a real-world must - by default Helm keeps every revision Secret forever; with 1000 CRs and frequent updates you end up with thousands of stale Secrets and a slow API server.WaitStrategyis required for Upgrade. Unlike Install (which tolerates an unsetWaitStrategy),Upgrade.RunWithContexterrors withwait strategy not set. Choose one of: watcher, hookOnly, legacyif you forget. The error surfaces only at runtime —go buildwill not catch it.Upgrade.ServerSideApplyis astring, not abool. Valid values:"true","false","auto". (Install's equivalentInstall.ServerSideApplyIS abool— yes, the inconsistency is real.)Upgrade.Atomicis gone. The replacement in v4 isUpgrade.RollbackOnFailure bool(auto-rollback on failure) andUpgrade.CleanupOnFail bool(delete newly created resources on failure). Both default tofalse— i.e., the v3Atomic = falsebehaviour is now the default and requires no field.
Step 19 - Minimal error handling
For Part 1 we keep error handling simple: any Helm error bubbles up, Reconcile returns with RequeueAfter: 30s, and controller-runtime's exponential-backoff schedules the retry through the standard watches, events, and predicates pipeline. Part 2 will classify errors (retriable vs permanent) and write them into status.conditions.
if err := r.installOrUpgrade(ctx, cfg, &demoapp); err != nil {
logger.Error(err, "install/upgrade failed; will retry")
return ctrl.Result{RequeueAfter: 30 * time.Second}, err
}SetupWithManager for Part 1
Watch only the CR itself for now. Drift via Owns() on chart-rendered resources (the broader multi-resource reconciliation pattern) comes in Part 2:
func (r *DemoAppReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&demov1alpha1.DemoApp{}).
Complete(r)
}Imports for the assembled controller file
package controller
import (
"context"
"encoding/json"
"fmt"
"time"
"helm.sh/helm/v4/pkg/action"
chartv2 "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/kube"
release "helm.sh/helm/v4/pkg/release/v1"
"helm.sh/helm/v4/pkg/storage/driver"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
demov1alpha1 "github.com/example/demoapp/api/v1alpha1"
)Reconciler struct (already shown in Step 13):
type DemoAppReconciler struct {
client.Client
Scheme *runtime.Scheme
Chart *chartv2.Chart
}That is the full Part 1 reconciler. Total ~150 lines of Go across demoapp_controller.go, chart.go, and helm.go.
Part F - Build, deploy, demo install/upgrade
Step 20 - Dockerfile and make docker-build
The scaffolded Dockerfile is a multi-stage Go build. Before you can ship the Helm hybrid operator image, bump the builder image to match the Go version Helm v4 forced on your go.mod:
- FROM golang:1.24 AS builder
+ FROM golang:1.26 AS builder
The full file (after the bump and after our chart relocation in Step 9) looks like:
# Build the manager binary
FROM golang:1.26 AS builder
ARG TARGETOS
ARG TARGETARCH
WORKDIR /workspace
COPY go.mod go.mod
COPY go.sum go.sum
RUN go mod download
COPY cmd/main.go cmd/main.go
COPY api/ api/
COPY internal/ internal/ # picks up internal/controller/charts/demo-app for //go:embed
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go
# Use distroless as minimal base image
FROM gcr.io/distroless/static:nonroot
WORKDIR /
COPY --from=builder /workspace/manager .
USER 65532:65532
ENTRYPOINT ["/manager"]Note: because we placed the chart at internal/controller/charts/demo-app/ (Step 9), the existing COPY internal/ internal/ already brings it into the build context — no extra COPY charts/ line is needed. The //go:embed directive resolves at compile time, so the chart must be present when go build runs.
If you build behind a corporate proxy / TLS-intercepting proxy, two extra steps are usually needed: configure the Docker daemon's proxy via
/etc/systemd/system/docker.service.d/http-proxy.conf(sodocker pull golang:1.26reaches Docker Hub), and either trust the proxy's CA system-wide (update-ca-certificates) orgo mod vendoron the host and update the Dockerfile toCOPY vendor/ vendor/+go build -mod=vendor ...so the build container does not need to reachproxy.golang.org.
Build:
export IMG=ttl.sh/demoapp-hybrid-$(uuidgen):24h
make docker-build IMG="$IMG"
# docker build -t ttl.sh/demoapp-hybrid-<uuid>:24h .If Docker Hub returns 429 Too Many Requests while pulling golang:1.26 or the distroless base image, authenticate with docker login, use an internal mirror, or pre-pull the base images from a network that is not rate-limited. That failure happens before your operator code is compiled.
Step 21 - push to ttl.sh + make deploy
docker push "$IMG" # ship the image to ttl.sh
make install # apply just the CRD
make deploy IMG="$IMG" # apply CRD + RBAC + Deployment
kubectl -n demoapp-system rollout status deploy/demoapp-controller-manager # wait for the new pod before applying CRsRBAC note.
make deployships the scaffoldedClusterRolefromconfig/rbac/. For a production Helm hybrid operator you will want to lock that down to the minimum permissions an operator actually needs - the scaffold is intentionally generous.
See the prereq article's ttl.sh section for why we use ttl.sh (zero setup, works from any cluster) instead of
kind load docker-image(brittle on Docker 24+ with the containerd snapshotter) or a localregistry:2container (works but ~30 lines of cluster wiring).
Verify:
kubectl get crd demoapps.demo.example.com
# demoapps.demo.example.com ...
kubectl -n demoapp-system get pods
# NAME READY STATUS RESTARTS AGE
# demoapp-controller-manager-5b9d5d757d-xdgnz 1/1 Running 0 30s
kubectl -n demoapp-system logs deploy/demoapp-controller-manager -c manager | head -10
# "Starting workers" "controller":"demoapp"The pod shows
1/1 Running, not2/2. Operator-sdk v1.40+ removed thekube-rbac-proxysidecar from the default scaffold (the manager now serves authenticated operator metrics on:8443itself).
Step 22 - Apply CR, see your install
cat <<EOF | kubectl apply -f -
apiVersion: demo.example.com/v1alpha1
kind: DemoApp
metadata:
name: demoapp-sample
namespace: default
spec:
replicaCount: 1
message: "Hello from the hybrid operator"
apiKey: "verysecret"
EOFTail the operator log:
kubectl -n demoapp-system logs deploy/demoapp-controller-manager -c manager -f
# "installed release" "release":"demoapp-sample" "namespace":"default" "revision":1Check the cluster - same shape as the pre-built operator:
kubectl get deploy,svc,cm,secret -l app.kubernetes.io/name=demoapp-sample
# deployment.apps/demoapp-sample 1/1 1 1 8s
# service/demoapp-sample ClusterIP 10.96.12.177 <none> 80/TCP 8s
# configmap/demoapp-sample 1 8s
# secret/demoapp-sample Opaque 1 8s
kubectl get secret -l owner=helm
# NAME TYPE DATA AGE
# sh.helm.release.v1.demoapp-sample.v1 helm.sh/release.v1 1 8sThe Helm release Secret is created by your operator's action.NewInstall.Run - bit-for-bit identical to what the pre-built operator produces. From the user's perspective (or helm history's perspective), there is no way to tell which operator deployed it.
Curl the workload:
kubectl port-forward svc/demoapp-sample 8080:80 &
curl -s localhost:8080
# <html><body><h1>Hello from the hybrid operator</h1></body></html>
kill %1Step 23 - Patch CR, see your upgrade
kubectl patch demoapp demoapp-sample --type=merge \
-p '{"spec":{"replicaCount":3,"message":"Updated by hybrid operator"}}'
kubectl -n demoapp-system logs deploy/demoapp-controller-manager -c manager --tail=5
# "upgraded release" "release":"demoapp-sample" "revision":2
kubectl get secret -l name=demoapp-sample,owner=helm
# NAME TYPE DATA AGE
# sh.helm.release.v1.demoapp-sample.v1 helm.sh/release.v1 1 29s
# sh.helm.release.v1.demoapp-sample.v2 helm.sh/release.v1 1 7s
kubectl get deploy demoapp-sample -o jsonpath='{.spec.replicas}'
# 3Two release secrets (v1 install, v2 upgrade), Deployment replicas at 3, ConfigMap updated. The Reconcile path you wrote handled this end-to-end.
Common Helm v4 SDK gotchas (you will hit at least one)
These are the issues that produced opaque errors in our test runs while building this Helm hybrid operator. The fixes are already baked into the Step 14–18 code above; the table is for grep-finding when you read someone else's hybrid operator.
| Symptom you will see | Root cause | Fix |
|---|---|---|
Build error: pattern all:charts/demo-app: no matching files found |
Chart is at repo-root charts/ but go:embed is package-relative |
Move chart into internal/controller/charts/demo-app/ (Step 9) |
Build error: package helm.sh/helm/v4/pkg/chart/v2/archive is not in std |
chart/v2/archive does not exist in v4 |
Import from helm.sh/helm/v4/pkg/chart/loader/archive instead (Step 11) |
Compile error: too many arguments in call to cfg.Init … expected 3, got 4 |
v3's logger argument was removed in v4 | Drop the trailing func(format string, v ...any) arg (Step 12) |
Compile error: inst.Wait undefined (type *action.Install has no field or method Wait) |
Wait bool removed from Install/Upgrade in v4 |
Use WaitStrategy = kube.HookOnlyStrategy and import helm.sh/helm/v4/pkg/kube |
Compile error: up.Atomic undefined |
Atomic bool removed from Upgrade in v4 |
Use RollbackOnFailure/CleanupOnFail; default zero-value matches old Atomic: false |
Compile error: cannot use rel (type release.Releaser) as type *release.Release |
Install.Run/Upgrade.Run returns release.Releaser (= any) in v4 |
Type-assert: rel, ok := resi.(*release.Release) (Step 17) |
Runtime error from Upgrade: wait strategy not set. Choose one of: watcher, hookOnly, legacy |
WaitStrategy is required for Upgrade in v4 (Install tolerates the zero value) |
Set up.WaitStrategy = kube.HookOnlyStrategy explicitly (Step 18) |
Runtime error from Upgrade: conflict occurred while applying object … Apply failed with 1 conflict: conflict with "kubectl-patch" using v1 |
Helm v4 uses SSA; another field manager (e.g., kubectl patch) owns the field |
Set up.ForceConflicts = true and inst.ForceConflicts = true |
docker build fails: failed to resolve source metadata for docker.io/library/golang:1.24 |
go get helm.sh/helm/v4 auto-bumped go.mod to go 1.26.0; scaffolded Dockerfile still pins golang:1.24 |
Bump FROM golang:1.24 → golang:1.26 in Dockerfile (Step 20) |
What you have now (Helm hybrid operator equivalent of the pre-built)
A from-scratch Go operator that:
- Installs the demo-app chart on CR create.
- Upgrades on CR update.
- Stores releases as Helm Secrets (compatible with
helm history,helm rollback,helm get). - Validates CR input via a strict CRD generated from Go types.
- Idempotent: re-reconciling the same generation is a no-op.
What you do not have yet:
- A finalizer (deleting the CR right now leaves the Helm release orphaned).
- Custom status fields (the framework's
Initialized/Deployed/ReleaseFailedconditions are not even being written). - Drift detection (edit the rendered ConfigMap and your operator will not notice).
- Anything beyond the pre-built operator's capability.
That last bullet is the punch line. At the end of Part 1 of the Helm hybrid operator tutorial, you have rebuilt the pre-built operator. The Helm release looks the same, the Kubernetes resources look the same, the chart contents are the same. The work was non-trivial but the payoff so far is zero - you wrote ~200 lines of Go to do what operator-sdk init --plugins=helm.sdk.operatorframework.io/v1 would have done in one command.
The whole point of Part 2 is to use those 200 lines as a platform to do things the pre-built cannot.
Frequently Asked Questions
1. What is a Helm hybrid operator?
A Helm hybrid operator is a Go operator whoseReconcile function calls the Helm SDK (helm.sh/helm/v4/pkg/action) to install or upgrade a chart, instead of constructing Kubernetes resources by hand. The chart still does the heavy lifting (Deployments, Services, ConfigMaps), but the Go code surrounds it with whatever logic you want - custom validation, external API calls, custom status fields, real finalizers, cross-CR coordination. You essentially write a focused version of the pre-built helm-operator binary, but you own every line.2. Why write a hybrid operator when the pre-built helm-operator plugin exists?
The pre-built operator has a hard ceiling of features it cannot provide: custom finalizer logic against external systems, custom status fields beyond the framework's three conditions, custom install/upgrade decision logic (e.g., "only upgrade in maintenance windows"), reading external state during reconcile, cross-CR coordination, conditional resource rendering based on cluster state, and watching arbitrary non-chart resources. The moment you need any of these - and most production teams do - you outgrow the pre-built path. The hybrid approach keeps the chart for what it's good at (rendering manifests) and adds Go for everything else.3. How does the CRD get generated in a hybrid operator?
From Go API types viamake manifests. You author api/v1alpha1/demoapp_types.go with a DemoAppSpec struct, annotate fields with Kubebuilder markers (+kubebuilder:validation:Minimum=1, +kubebuilder:validation:Pattern=...), then make manifests produces a strict CRD YAML reflecting those constraints. This is the standard Kubebuilder workflow and is the opposite of the pre-built helm operator, where the CRD is permissive by default and tightened by editing YAML.4. How does the chart get into the operator binary?
The recommended pattern is//go:embed all:charts/demo-app which embeds the chart files into the compiled Go binary. The operator pod then loads the chart from embed.FS at startup; no separate chart distribution, no ConfigMap mount, no runtime fetch. Part 1 covers this pattern in detail and mentions two alternatives (Dockerfile COPY into /charts for a runtime-replaceable chart, ConfigMap mount for the most operational flexibility).5. Can I use the same chart as the pre-built helm-operator tutorial?
Yes - this article uses the exact samedemo-app chart from Part 1 of the pre-built tutorial, so the comparison is apples-to-apples. Same Deployment, Service, ConfigMap, Secret. The output the user sees (Helm release Secret, Kubernetes resources, helm history) is indistinguishable from the pre-built operator. The difference is everything between - which is precisely what Part 2 demonstrates by adding features the pre-built cannot.6. What does Part 1 leave for Part 2?
Part 1 ends with a working operator that installs and upgrades the chart - functionally equivalent to the pre-builthelm-operator binary, written from scratch. Part 2 adds the features the pre-built cannot provide: custom status fields (incl. things like LastUpgradeReason), a real finalizer that writes an audit ConfigMap to another namespace before helm uninstall, drift detection via Owns() (more flexible than the pre-built's watchDependentResources), and a worked cross-CR coordination demo. Plus pointers to reading external state in Reconcile, conditional template rendering, and other ceiling-breaking patterns.7. How much Go code do I have to write?
About 350 to 450 lines across the two parts. Part 1 is roughly 200 lines (CRD types, embed setup, Helm SDK wiring, install/upgrade Reconcile). Part 2 adds another ~200 lines (finalizer, custom status, drift viaOwns(), cross-CR demo). The volume is moderate by Go-operator standards because the chart still does the heavy lifting; you are not constructing every Deployment field in Go.What's next - Part 2 of 2 (the actual goldmine)
Helm Hybrid Operator Tutorial Part 2 of 2 - Beyond what pre-built can do picks up exactly here and adds:
- Custom status fields: a
LastUpgradeReasonfield that captures why the last upgrade happened (the CR changed? a periodic resync? a drift correction?). Impossible in the pre-built operator because the status struct is fixed. - A real finalizer: writes an audit ConfigMap to a
demoapp-auditnamespace beforehelm uninstallruns. Demonstrates the hybrid's killer feature - custom pre-uninstall logic with retries against another namespace (or in real life, an external billing API or service-mesh deregistration call). - Drift detection via
Owns(): add.Owns(&corev1.ConfigMap{})etc. toSetupWithManager, then edit a ConfigMap and watch your operator revert it. More flexible than the pre-built'swatchDependentResources- you can choose which resource types to watch and even add custom predicates to skip resourceVersion-only diffs. - Cross-CR coordination: a worked demo where
DemoApp Bwaits forDemoApp Ato beDeployedbefore installing. The kind of orchestration the pre-built cannot express because its reconciler treats CRs in isolation. - Pointer patterns: reading external state in
Reconcile(HTTP calls), conditional template rendering (enable Ingress only ifnginx-ingressCRD exists), and multi-CR lifecycle (one CR owns N child CRs).
If you stop after Part 1, you have a working Helm hybrid operator with no real-world differentiator. If you read Part 2, you have one with several.
Further reading
- Helm-based operator tutorial Part 1 (pre-built path) — the no-Go equivalent of this Helm hybrid operator tutorial.
- Helm-based operator tutorial Part 2 (lifecycle, drift, hooks, ceiling) — the exact list of pre-built ceiling items Part 2 of this series addresses.
- Helm operator vs Flux vs Argo CD — when each tool is the right pick.
- Finalizers explained — language-neutral finalizer reference; required reading before Part 2.
- Status subresource and Conditions — standard conditions contract; what you will implement in Part 2.
- controller-runtime architecture — the Go library powering
SetupWithManager,Owns(), and the cache. - Server-Side Apply in operators — the SSA semantics behind Helm 4's
ForceConflictsknob used in Steps 17 / 18. - Operator capability / maturity model — where the hybrid pattern sits on the Level I → V ladder.
- External: Helm 4 SDK godoc — upstream reference for
action,chart/v2,chart/v2/loader,chart/loader/archive,release/v1,kube. - External: Operator SDK — Go operator docs — the official Go operator tutorial (not Helm-specific).
- External:
helm-operator-pluginslibrary — the operator-framework "hybrid" path this article deliberately bypasses.
Summary
A Helm hybrid operator is the pre-built helm-operator binary, written by you in Go - a single Reconcile that calls the Helm v4 SDK directly, with no helm-operator-plugins dependency. Part 1 of 2 walks the foundation end-to-end: scaffold with operator-sdk init --plugins=go/v4, define the CRD via Go API types and Kubebuilder markers (make generate for DeepCopy, make manifests for the CRD YAML), embed the chart with //go:embed all:charts/demo-app, wire the Helm v4 SDK (action.Configuration, loader.LoadFiles from embed.FS), and implement a Reconcile that does install on first CR and upgrade on subsequent changes.
At the end of this Helm hybrid operator Part 1 you have a from-scratch operator that is functionally indistinguishable from the pre-built helm-operator - same chart, same Helm release Secret, same Kubernetes resources, same helm history output. The justification for writing those ~200 lines of Go is everything in Part 2 of 2: custom status fields, a real finalizer with pre-uninstall work, drift via Owns(), and cross-CR coordination - the features the pre-built operator cannot provide at any setting, and the rungs on the operator capability ladder the hybrid pattern was invented to climb.

