<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aroua KABOUBI</title>
    <description>The latest articles on DEV Community by Aroua KABOUBI (@aroua_kaboubi_82466ca9a52).</description>
    <link>https://dev.to/aroua_kaboubi_82466ca9a52</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4044151%2Feb4ba10c-b0b4-4fe0-8ab8-980ab51d90f3.png</url>
      <title>DEV Community: Aroua KABOUBI</title>
      <link>https://dev.to/aroua_kaboubi_82466ca9a52</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aroua_kaboubi_82466ca9a52"/>
    <language>en</language>
    <item>
      <title>Kubernetes Controllers: The Cache-Staleness Bug at Scale</title>
      <dc:creator>Aroua KABOUBI</dc:creator>
      <pubDate>Wed, 14 Oct 2026 09:40:37 +0000</pubDate>
      <link>https://dev.to/aroua_kaboubi_82466ca9a52/kubernetes-controllers-the-cache-staleness-bug-at-scale-1phb</link>
      <guid>https://dev.to/aroua_kaboubi_82466ca9a52/kubernetes-controllers-the-cache-staleness-bug-at-scale-1phb</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Structure reconcilers as ordered, independently testable steps, each reporting its own status condition, over one monolithic &lt;code&gt;Reconcile&lt;/code&gt; function.&lt;/li&gt;
&lt;li&gt;Informer caches lag the API server by tens of milliseconds at scale; a stale read inside a &lt;code&gt;MergeFrom&lt;/code&gt; patch can clobber a condition you set 49ms earlier.&lt;/li&gt;
&lt;li&gt;Fix status patching with an uncached reader inside &lt;code&gt;RetryOnConflict&lt;/code&gt;; Kubernetes v1.36 (April 2026) now lets controllers detect and skip behind-cache reconciles.&lt;/li&gt;
&lt;li&gt;Even a logging change is a production change: a duplicate log key once dropped every log line from the busiest controller in an environment.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;Everything below comes from operating a major European automotive manufacturer's internal Kubernetes platform, running a fleet of custom controllers on managed GKE. Names and identifiers are generic on purpose. The failure modes are exact.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the step-based reconciler pattern?
&lt;/h2&gt;

&lt;p&gt;The step-based reconciler pattern splits a controller's reconcile loop into an ordered sequence of small, composable steps. Each step reports its own status condition, instead of one monolithic function doing everything. In practice, this keeps provisioning logic table-testable and makes partial progress visible. Across our controller suite, it turned a 400-line reconcile body into a readable chain. It is the same reconciliation discipline behind the &lt;a href="https://dev.to/en/blog/build-kubernetes-as-a-service/"&gt;Kubernetes as a Service control plane&lt;/a&gt; we operate, where dozens of resource types share one contract.&lt;/p&gt;

&lt;p&gt;A monolithic &lt;code&gt;Reconcile&lt;/code&gt; becomes unreadable fast. Picture one custom resource that has to provision networking, IAM, namespaces, quotas, and DNS. Have you ever tried to unit-test a function like that? So we organize each controller as &lt;code&gt;controller.go&lt;/code&gt; plus &lt;code&gt;steps/creation/&lt;/code&gt; and &lt;code&gt;steps/deletion/&lt;/code&gt; packages. Every step embeds a shared &lt;code&gt;BaseStep&lt;/code&gt;. It holds the controller-runtime client, an uncached reader, the scheme, a logger, and an event recorder. Each step also exposes explicit Ready and Reconciling conditions.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;Environment&lt;/code&gt; resource, for example, runs a chain like this: &lt;code&gt;EnsureFinalizer, ResolveClusterConfigRef, ComputeTargetNamespaceName, EnsureNamespace, ApplyResourceQuotas, ApplyLimitRanges, ApplyServiceAccounts, ApplyRoleBindings, ApplyNetworkPolicies, DeleteFinalizer&lt;/code&gt;. Each step is small, ordered, and independently testable. As a result, partial-failure states show up as per-step conditions. You can read them straight from &lt;code&gt;kubectl get&lt;/code&gt; print columns.&lt;/p&gt;

&lt;p&gt;The single most valuable primitive we built was not the step interface itself. It was the shared status-patching helper underneath it. Every step calls one function to update conditions. So that function centralizes the conflict-safe read-then-patch dance in exactly one place. When we later found a cache bug, we fixed it once, not in fifteen call sites.&lt;/p&gt;

&lt;h3&gt;
  
  
  The shared conflict-safe status patch
&lt;/h3&gt;

&lt;p&gt;A status helper re-fetches the latest object, mutates it, and patches it with &lt;code&gt;client.MergeFrom&lt;/code&gt; inside a &lt;code&gt;retry.RetryOnConflict&lt;/code&gt; loop. So it avoids two reconciling steps racing each other's condition writes. And the generation-aware comparison means unchanged state does not re-patch. That keeps the API server and your audit log quiet.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;StepRuntime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;UpdateStatusFields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;mutate&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;retry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RetryOnConflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultRetry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// Read through the UNCACHED reader, not the informer cache.&lt;/span&gt;
        &lt;span class="n"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeepCopyObject&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UncachedClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ObjectKeyFromObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeepCopyObject&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;mutate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="c"&gt;// may be ErrNoOp; caller short-circuits&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MergeFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two small details carry weight. The &lt;code&gt;ErrNoOp&lt;/code&gt; sentinel lets a step's mutate function say "nothing changed", distinct from a real error. So callers short-circuit cleanly. And event correlation only emits a Kubernetes event when the visible condition state actually changed, not on every generation-only refresh. Without that guard, a controller reconciling every 20 seconds floods the event stream with duplicates.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; The uncached read in the snippet above was not the original design. It was a live-incident fix. The first version read through the informer cache, and that is exactly where the next section starts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why does a fresh status condition get overwritten?
&lt;/h2&gt;

&lt;p&gt;A fresh status condition gets overwritten when a step reads the object through the informer cache instead of the API server. The cache can lag by tens of milliseconds within a single reconcile. That stale read then rides along in a &lt;code&gt;MergeFrom&lt;/code&gt; patch and clobbers a condition another step set moments earlier (&lt;a href="https://github.com/kubernetes-sigs/controller-runtime/issues/741" rel="noopener noreferrer"&gt;controller-runtime #741&lt;/a&gt;, 2021).&lt;/p&gt;

&lt;p&gt;Here is the incident, exactly as it bit us. A routine release introduced new condition types on the &lt;code&gt;StandardCluster&lt;/code&gt; resource, replacing an older readiness condition with a more specific one. A fresh reconcile did three things in sequence. First, &lt;code&gt;MarkReconciling&lt;/code&gt; patched &lt;code&gt;Ready=False&lt;/code&gt; at T=0. Second, the next step, seeing no prior value for its new condition type, re-read the object to update status. Third, the informer cache still returned the pre-restart object carrying a stale &lt;code&gt;Ready=True&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That stale &lt;code&gt;Ready=True&lt;/code&gt; traveled inside the same &lt;code&gt;MergeFrom&lt;/code&gt; patch. So it overwrote the &lt;code&gt;Ready=False&lt;/code&gt; written 49 milliseconds earlier. Net effect: clusters never converged to Ready. The maddening part? Running the controller locally fixed it every time. A local run used a fresh client that bypassed the shared cache. It looked non-deterministic. It was not. It was a read-your-own-write violation hiding in the cache.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; If a bug disappears when you run the controller locally but persists in-cluster, suspect informer-cache staleness before you suspect a logic error. Local runs often bypass the shared cache path that production hits.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most articles explain the reconcile loop as a clean read-diff-write cycle and stop there. They never connect the informer cache to a correctness bug. The cache is usually framed as a pure performance optimization. But at scale it becomes a correctness hazard, much like a &lt;a href="https://dev.to/en/blog/gke-dataplane-v2-stale-ebpf-networkpolicy/"&gt;stale eBPF program on GKE Dataplane V2&lt;/a&gt; can keep enforcing a NetworkPolicy you already deleted. "Read" and "write" no longer target the same view of the world. The upstream root cause is a &lt;code&gt;DeltaFIFO&lt;/code&gt; backlog plus read-write mutex contention in the informer cache. Under load, that produces stale reads (&lt;a href="https://github.com/kubernetes/kubernetes/issues/130767" rel="noopener noreferrer"&gt;kubernetes/kubernetes #130767&lt;/a&gt;, 2025).&lt;/p&gt;

&lt;p&gt;The fix in our code was to read through an uncached reader inside the retry loop, exactly as the earlier snippet shows. Upstream, the fix arrived in Kubernetes v1.36 in April 2026. Controllers can now check the cache's &lt;code&gt;resourceVersion&lt;/code&gt;. Then they skip reconciliation when the cache is behind their own writes. That closes the read-your-own-write gap at the framework level (&lt;a href="https://kubernetes.io/blog/" rel="noopener noreferrer"&gt;kubernetes.io blog&lt;/a&gt;, 2026).&lt;/p&gt;

&lt;h2&gt;
  
  
  When should OperationResultUpdated requeue?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;OperationResultUpdated&lt;/code&gt; should almost never mean "requeue and come back later." It means something changed, and you must still fall through to the readiness check before deciding to requeue. Treating &lt;code&gt;Updated&lt;/code&gt; as terminal produced an infinite roughly 20-second reconcile loop across seven step files in our service-exposure controller.&lt;/p&gt;

&lt;p&gt;The shape of the bug is worth memorizing, because it is easy to reintroduce. A step's mutate function toggled an annotation on every reconcile. For example, a fast-reconcile-interval hint derived from current readiness. So the annotation changed each pass. As a result, &lt;code&gt;controllerutil.CreateOrPatch&lt;/code&gt; returned &lt;code&gt;OperationResultUpdated&lt;/code&gt; almost every time. Several steps then treated &lt;code&gt;Updated&lt;/code&gt; as "done for now, requeue". So they never reached the check for whether the underlying cloud resource is actually ready.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;controllerutil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OperationResultCreated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;controllerutil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OperationResultUpdated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;controllerutil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OperationResultNone&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="c"&gt;// All three fall through to the SAME readiness check.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;cloudResourceReady&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;RequeueAfter&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="c"&gt;// ready: stop requeuing&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We fixed this in seven sibling files. We collapsed the &lt;code&gt;Created&lt;/code&gt;, &lt;code&gt;Updated&lt;/code&gt;, and unchanged cases into one readiness check. Then we requeue only when the resource is still not ready. If you review a new step that wraps &lt;code&gt;CreateOrPatch&lt;/code&gt;, look for the anti-pattern &lt;code&gt;case OperationResultUpdated: return ctrl.Result{RequeueAfter: ...}, nil&lt;/code&gt; with no readiness check ahead of it. That single line is a self-perpetuating loop waiting to happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you handle read-after-create NotFound races?
&lt;/h2&gt;

&lt;p&gt;Handle a read-after-create &lt;code&gt;NotFound&lt;/code&gt; by retrying the transient error a few times, not by widening a cache window. The object exists on the API server but is not yet visible to your very next &lt;code&gt;Get&lt;/code&gt;. This is an API-server-side visibility gap, the mirror image of client-side cache staleness, so the fixes point in opposite directions.&lt;/p&gt;

&lt;p&gt;We hit this when a workspace-creation step patched owner references onto namespaces right after creating them. Occasionally the next &lt;code&gt;Get&lt;/code&gt; returned &lt;code&gt;NotFound&lt;/code&gt;, even though the namespace clearly existed. The instinct is to assume a longer cache-consistency wait would help. But the gap here is eventual server-side visibility, not a stale client cache. So a longer wait does nothing. Instead, the correct pattern is a short bounded retry on the transient &lt;code&gt;NotFound&lt;/code&gt;. Treat it as expected, not as step failure.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; A stale-cache bug and a read-after-create bug feel identical from a stack trace. One is fixed by reading past the cache; the other by retrying the read. Diagnose which side of the client-server boundary you are on before you reach for a fix.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How do you scale controllers with environment-based sharding?
&lt;/h2&gt;

&lt;p&gt;Environment-based sharding partitions reconciliation by a business dimension: deployment environment. It uses controller-runtime predicates, rather than splitting the CRD, API, or codebase. One controller instance reconciling every environment couples their fate. So a reconcile storm in &lt;code&gt;dev&lt;/code&gt; can degrade &lt;code&gt;ope&lt;/code&gt;. Sharding decouples them, while keeping a single schema.&lt;/p&gt;

&lt;p&gt;The mechanism is a release topology, not a code fork. So we deploy one Helm chart as several releases of the same version. The same fleet-shaped coordination shows up when you promote add-ons across many clusters, which we handle with &lt;a href="https://dev.to/en/blog/kubernetes-fleet-addon-promotion-sveltos/"&gt;Sveltos-driven fleet add-on promotion&lt;/a&gt;. A common release runs environment-agnostic singletons like the platform API and tenant controller. One release per environment runs only the environment-scoped controllers. Each gets a CLI argument declaring which single environment it owns.&lt;/p&gt;

&lt;p&gt;Each environment-scoped controller composes its existing predicates with one extra environment-matching predicate. That predicate admits an object into the work queue only when its environment matches the shard's. And it derives that environment from the existing source of truth, like a label or namespace. Filtering happens before objects enter the queue. So out-of-scope objects never trigger a reconcile, and the existing logic stays untouched.&lt;/p&gt;

&lt;p&gt;The design's real cost is not code. It is an operational invariant that lives outside the type system: the union of environment releases must cover every environment exactly once, no overlap and no gaps. The type checker cannot enforce this. Your release automation must. We also give each shard an independent leader-election identifier so releases never contend for the same lock. A missing environment argument falls back to non-sharded mode, which makes rollout incremental and reversible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is even a logging change a production change?
&lt;/h2&gt;

&lt;p&gt;A logging change is a production change. Why? Logs flow through parsers and agents that fail in ways your unit tests never see. In March 2026, two upgrades on our control plane both traced back to the same mistake: code duplicating behavior the framework already provided. And one of them silently deleted logs from the busiest controller in an environment.&lt;/p&gt;

&lt;p&gt;The first incident was an apimachinery upgrade. A dependency bump removed a generated &lt;code&gt;ProtoMessage()&lt;/code&gt; method from core meta types. Our generated proto file embedded those Kubernetes types directly. So the first time the gRPC server tried to marshal any response, the protobuf runtime panicked at lazy descriptor init. That took down the entire process. Because the panic fires at file-descriptor init rather than per field, one bad request crashed everything. The fix moved the HTTP gateway from endpoint-based registration to in-process registration. That bypassed the binary codec entirely, by reusing an existing JSON marshaler.&lt;/p&gt;

&lt;p&gt;The second incident is the one I retell most. We added a &lt;code&gt;reconcileID&lt;/code&gt; correlation field to all eight controllers with a &lt;code&gt;.WithValues("controller", ..., "reconcileID", uuid.New())&lt;/code&gt; call at the top of every &lt;code&gt;Reconcile&lt;/code&gt;. Two days later, every log line from the busiest controller in one environment had silently vanished from Cloud Logging.&lt;/p&gt;

&lt;p&gt;The root cause was a duplicate JSON key. Would your test suite have caught that? Ours did not. Controller-runtime already injects &lt;code&gt;controller&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;namespace&lt;/code&gt;, and &lt;code&gt;reconcileID&lt;/code&gt; into the context logger automatically. So our added call re-added the same keys. That produced JSON objects with duplicate keys. GKE's fluentbit log agent parses those logs via msgpack. And msgpack's Go implementation cannot hash a duplicate-key object. So it dropped the entire line, rather than erroring loudly. One environment escaped: its busiest controller used a logger variant that replaced keys instead of appending them. The fix was simple. We removed the redundant call, because the framework already carried every field we were trying to add.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; Before you add fields to a structured logger, check what your framework already injects. Duplicate keys are valid JSON to your program and poison to your log pipeline.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What keeps controllers reliable at scale
&lt;/h2&gt;

&lt;p&gt;Custom controllers fail in ways the tutorials rarely mention. And almost all of those failures live at one boundary: between your reconcile logic and the framework's caching. So structure reconcilers as ordered, independently testable steps. That keeps each unit of provisioning logic small and partial progress visible. Then centralize status patching in one conflict-safe helper. Read through an uncached reader in that path, so a stale cache read cannot overwrite a condition you just set.&lt;/p&gt;

&lt;p&gt;Watch the framework, not just your code. &lt;code&gt;OperationResultUpdated&lt;/code&gt; is not a signal to requeue. It is a signal to re-check readiness. A read-after-create &lt;code&gt;NotFound&lt;/code&gt; wants a retry, not a wider cache window. And a logging change can be as destructive as a schema change. So if you run controllers at scale, adopt the Kubernetes v1.36 cache-staleness detection early. And treat every framework upgrade as the production change it is.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>platformengineering</category>
      <category>sre</category>
      <category>cloudnative</category>
    </item>
    <item>
      <title>Local Kubernetes Development with Tilt: Fast Inner Loop</title>
      <dc:creator>Aroua KABOUBI</dc:creator>
      <pubDate>Tue, 06 Oct 2026 09:53:04 +0000</pubDate>
      <link>https://dev.to/aroua_kaboubi_82466ca9a52/local-kubernetes-development-with-tilt-fast-inner-loop-597f</link>
      <guid>https://dev.to/aroua_kaboubi_82466ca9a52/local-kubernetes-development-with-tilt-fast-inner-loop-597f</guid>
      <description>&lt;p&gt;Most Kubernetes teams debug their platform in the one place it does not run: their laptop. Production is 82% likely to be Kubernetes (&lt;a href="https://www.cncf.io/reports/the-cncf-annual-cloud-native-survey/" rel="noopener noreferrer"&gt;CNCF Annual Cloud Native Survey&lt;/a&gt;, 2025, up from 66% in 2023), yet the local dev loop is often a Docker Compose file that quietly disagrees with the real cluster on namespaces, RBAC, and service discovery. We rebuilt our inner loop around a real local Kubernetes cluster driven by &lt;a href="https://tilt.dev/" rel="noopener noreferrer"&gt;Tilt&lt;/a&gt;, and it changed how fast — and how honestly — we could iterate on a multi-service backend. This is the setup, the pattern, and the one bug it caught that a mock never would have.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run the actual deployment target locally: a real Kubernetes cluster on Kind reproduces namespaces, RBAC, and service discovery that Docker Compose only approximates.&lt;/li&gt;
&lt;li&gt;Tilt's Live Update syncs code into a running container "in seconds, not minutes" (&lt;a href="https://tilt.dev/" rel="noopener noreferrer"&gt;tilt.dev&lt;/a&gt;, 2026), which is what makes a heavier local substrate viable.&lt;/li&gt;
&lt;li&gt;Ship one self-contained Tiltfile per service that runs standalone &lt;em&gt;or&lt;/em&gt; composes into a root Tiltfile, so a new contributor can spin up just the service they touch.&lt;/li&gt;
&lt;li&gt;Test auth against the real identity stack with &lt;code&gt;curl&lt;/code&gt;, not a mock — it caught a client-config bug that mocked auth would have hidden.&lt;/li&gt;
&lt;li&gt;Skip Bitnami: its free stable images were deprecated in 2025, so pinning to them is silent rot.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why run a real cluster locally instead of Docker Compose?
&lt;/h2&gt;

&lt;p&gt;Because the platform you ship to is Kubernetes, and Kubernetes has surfaces Docker Compose cannot fake. Namespaces, RBAC bindings, service accounts, DNS-based service discovery, and admission control are all first-class in a cluster and simply absent in Compose. When they break, a Compose-based local loop stays green and the bug surfaces only after you deploy — the most expensive place to find it.&lt;/p&gt;

&lt;p&gt;Kubernetes is now the default production substrate: the &lt;a href="https://www.cncf.io/reports/the-cncf-annual-cloud-native-survey/" rel="noopener noreferrer"&gt;CNCF&lt;/a&gt; (2025) puts production use at 82% and cloud-native adoption at 98%. If that is your target, local-vs-prod parity is not a luxury; it is the difference between a fast inner loop and a slow, deploy-driven outer loop. The same &lt;a href="https://dev.to/en/blog/kubernetes-platform-engineering/"&gt;platform-engineering discipline&lt;/a&gt; that standardises production is worth applying to the laptop.&lt;/p&gt;

&lt;p&gt;The honest tradeoff: a real cluster is heavier than &lt;code&gt;docker compose up&lt;/code&gt;. You need a fast local cluster and a tool that keeps the edit-run cycle tight despite the extra substrate. That is exactly the gap Kind and Tilt fill.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Tilt, and how does it fix the Kubernetes inner loop?
&lt;/h2&gt;

&lt;p&gt;Tilt is an open-source toolkit "for fixing the pains of microservice development" (&lt;a href="https://github.com/tilt-dev/tilt" rel="noopener noreferrer"&gt;tilt-dev/tilt&lt;/a&gt;, 9.9k GitHub stars, v0.37.5, 2026). It watches your files, rebuilds container images, and brings your cluster up to date automatically — turning a manual build-push-deploy sequence into a single &lt;code&gt;tilt up&lt;/code&gt; that stays live as you edit.&lt;/p&gt;

&lt;p&gt;The feature that earns its keep is Live Update. Instead of rebuilding an image and rolling out a new pod on every save, Live Update syncs the changed files straight into the running container and re-runs the build step in place. Tilt's own framing is deploying code to running containers "in seconds not minutes" (&lt;a href="https://tilt.dev/" rel="noopener noreferrer"&gt;tilt.dev&lt;/a&gt;, 2026). For a compiled language like Go, that means the &lt;code&gt;.go&lt;/code&gt; files land in the pod and the binary recompiles there — no image round-trip.&lt;/p&gt;

&lt;p&gt;The Tiltfile itself is written in Starlark, a Python dialect, so it has real functions, loops, and conditionals rather than YAML templating. That matters once your local environment has more than one service and a few shared dependencies to wire together.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you bootstrap a local cluster with ctlptl and Kind?
&lt;/h2&gt;

&lt;p&gt;Start with a cluster that creates itself. We use &lt;a href="https://github.com/kubernetes-sigs/kind" rel="noopener noreferrer"&gt;Kind&lt;/a&gt; — "local Kubernetes clusters using Docker container nodes" (kubernetes-sigs/kind, 15.4k stars, a CNCF-certified conformant installer, 2026) — fronted by &lt;code&gt;ctlptl&lt;/code&gt; to attach a local image registry. A small helper script makes the whole thing idempotent, creating the cluster only if one is not already running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# scripts/ensure-cluster.sh — idempotent local cluster + registry&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;CLUSTER_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"klocal"&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;ctlptl get cluster &lt;span class="s2"&gt;"kind-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLUSTER_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"cluster kind-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLUSTER_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; already up — reusing"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# ctlptl creates the Kind cluster AND a local registry Tilt can push to&lt;/span&gt;
ctlptl create registry ctlptl-registry &lt;span class="nt"&gt;--port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5005
ctlptl create cluster kind &lt;span class="nt"&gt;--name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLUSTER_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--registry&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ctlptl-registry
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wiring this in as a Tilt &lt;code&gt;local_resource&lt;/code&gt; means a contributor runs &lt;code&gt;tilt up&lt;/code&gt; and gets a cluster, a registry, and their service — no README full of prerequisite commands to run by hand first.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; Let &lt;code&gt;ctlptl&lt;/code&gt; own both the cluster and the registry. If you create a Kind cluster without a registry Tilt can push to, every build falls back to &lt;code&gt;kind load&lt;/code&gt;, which is far slower than a pushed image and defeats the point of a tight loop.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What makes one Tiltfile per service the right pattern?
&lt;/h2&gt;

&lt;p&gt;Each service ships its own self-contained Tiltfile, designed to run standalone &lt;em&gt;or&lt;/em&gt; be folded into a root Tiltfile later via Tilt's &lt;code&gt;include()&lt;/code&gt;. This is the reusable core of the whole setup, and it rests on three rules.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;path independence&lt;/strong&gt;: every path in the Tiltfile is resolved relative to the Tiltfile's own directory, so it behaves identically whether run from the service root or included from a parent. Second, a &lt;strong&gt;dependency toggle&lt;/strong&gt;: a boolean flag groups shared infrastructure — Postgres, an identity stack, a gateway — so a future root Tiltfile can provide them once instead of each service starting its own copy. Third, &lt;strong&gt;standalone-first&lt;/strong&gt;: the default &lt;code&gt;tilt up&lt;/code&gt; in any service directory brings up a working environment for that service alone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Tiltfile — self-contained, includable, path-independent
&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define_bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;skip_deps&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# a root Tiltfile sets this to own shared deps
&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# ensure a cluster exists before anything else
&lt;/span&gt;&lt;span class="nf"&gt;local_resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cluster&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;./scripts/ensure-cluster.sh&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;00-bootstrap&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;skip_deps&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;k8s_yaml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;deploy/postgres.dev.yaml&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# plain upstream YAML, not Bitnami
&lt;/span&gt;    &lt;span class="nf"&gt;k8s_yaml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;deploy/ory.dev.yaml&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# identity stack for local auth
&lt;/span&gt;
&lt;span class="nf"&gt;docker_build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;local/klastro-api&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="n"&gt;dockerfile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Dockerfile.dev&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;k8s_yaml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;deploy/api.dev.yaml&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payoff is onboarding: a new contributor touching one service clones it, runs &lt;code&gt;tilt up&lt;/code&gt;, and is productive without standing up the entire platform. When they need the full stack, a root Tiltfile &lt;code&gt;include()&lt;/code&gt;s each child and sets &lt;code&gt;skip_deps=True&lt;/code&gt; so shared dependencies come up exactly once. It mirrors the structure of a &lt;a href="https://dev.to/en/blog/build-kubernetes-as-a-service/"&gt;submodule monorepo&lt;/a&gt; without coupling the services to each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does Live Update keep the Go loop fast?
&lt;/h2&gt;

&lt;p&gt;By syncing source and recompiling in the pod instead of rebuilding the image. The &lt;code&gt;docker_build&lt;/code&gt; step declares a &lt;code&gt;live_update&lt;/code&gt; block that copies changed &lt;code&gt;.go&lt;/code&gt; files into the container and runs the compile there, so a save-to-running-binary cycle drops from a full image build to a few seconds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;docker_build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;local/klastro-api&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;dockerfile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Dockerfile.dev&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;live_update&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nf"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/app&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;                      &lt;span class="c1"&gt;# push changed source into the pod
&lt;/span&gt;        &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;go build -o /app/server ./cmd/api&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="c1"&gt;# recompile in place
&lt;/span&gt;            &lt;span class="n"&gt;trigger&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;./**/*.go&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Forward Delve so you can attach a debugger to the in-cluster process
&lt;/span&gt;&lt;span class="nf"&gt;k8s_resource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;klastro-api&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port_forwards&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;8080:8080&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;2345:2345&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Port-forwarding Delve on &lt;code&gt;2345&lt;/code&gt; means you attach your IDE's debugger to a process running &lt;em&gt;inside the cluster&lt;/em&gt;, against real service DNS and real config — not a locally-linked binary that skips half the platform. You get breakpoints on the code path that will actually run in production. When the failure you are chasing is a non-deterministic one — the kind we hunt in &lt;a href="https://dev.to/en/blog/kubernetes-controller-reliability-cache-staleness/"&gt;controller cache staleness&lt;/a&gt; — debugging against the real substrate is the difference between reproducing it and guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why avoid Bitnami images and charts?
&lt;/h2&gt;

&lt;p&gt;Because the free, stable Bitnami catalog is going away. Effective August 28, 2025, Bitnami moved its versioned Docker Hub images to a &lt;code&gt;bitnamilegacy&lt;/code&gt; repository that receives no further updates, fixes, or support, with the public catalog narrowed to a latest-only community subset (&lt;a href="https://github.com/bitnami/charts/issues/35164" rel="noopener noreferrer"&gt;bitnami/charts #35164&lt;/a&gt;, 2025). The deletion of the old catalog was postponed to late September 2025 to give teams time to migrate (&lt;a href="https://northflank.com/blog/bitnami-deprecates-free-images-migration-steps-and-alternatives" rel="noopener noreferrer"&gt;Northflank&lt;/a&gt;, 2025).&lt;/p&gt;

&lt;p&gt;For a local environment, pinning Postgres or an identity stack to a Bitnami tag now means one of two failure modes: a frozen legacy image that silently stops getting patches, or a "latest-only" image that shifts under you between rebuilds. Neither is reproducible. We deploy shared dependencies from plain upstream YAML or the projects' own charts instead, which keeps the local stack pinnable and free of a subscription dependency.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; Bitnami charts and images were the path of least resistance for years, so they are baked into countless tutorials and starter repos. Audit your local &lt;code&gt;deploy/&lt;/code&gt; manifests for &lt;code&gt;docker.io/bitnami/*&lt;/code&gt; references before they turn into a legacy image that never updates again.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How do you test auth flows before a UI exists?
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;curl&lt;/code&gt; against the real identity stack — and it is worth doing precisely because it catches bugs a mock cannot. Before any frontend existed, we exercised protected API endpoints by minting a real session token from the identity provider and passing it as an &lt;code&gt;X-Session-Token&lt;/code&gt; header through the gateway, straight to the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Mint a real session token from the identity stack (Ory Kratos)&lt;/span&gt;
&lt;span class="nv"&gt;SESSION_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"http://localhost:4433/self-service/login/api"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"method":"password","identifier":"dev@example.test","password":"…"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.session_token'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;     &lt;span class="c"&gt;# ory_st_…&lt;/span&gt;

&lt;span class="c"&gt;# 2. Hit a protected endpoint THROUGH the gateway with that token&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"http://localhost:8080/v1/orgs"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Session-Token: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SESSION_TOKEN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running this against the real authorization stack rather than a stub surfaced a genuine integration bug: the permission-check client library was resolving the authorization service's address as though the host:port needed a URL scheme, and failing the lookup. A mocked-auth local setup would have returned a cheerful &lt;code&gt;200&lt;/code&gt; and hidden the defect until it hit a shared environment. Testing the real chain — the same reason we take &lt;a href="https://dev.to/en/blog/just-in-time-privileged-access-kubernetes/"&gt;privileged-access flows&lt;/a&gt; seriously — turned a would-be production incident into a five-minute local fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does this cost, and when is it worth it?
&lt;/h2&gt;

&lt;p&gt;The cost is real: a local cluster consumes more laptop RAM than Compose, and the initial Tiltfile-plus-&lt;code&gt;ctlptl&lt;/code&gt; plumbing is an afternoon of work. The parity is worth it when your production target is Kubernetes and your bugs live in the platform surfaces — auth, RBAC, service discovery, admission — that Compose cannot represent. For a single stateless service with no cluster-specific behaviour, Compose may still be the right, lighter choice.&lt;/p&gt;

&lt;p&gt;The break-even arrives fast for a multi-service backend. Once you have more than two services, shared dependencies, and any auth in the request path, the "works on my machine" gap between Compose and the cluster stops being an annoyance and starts being the source of your integration incidents. A per-service Tiltfile that composes into a whole-stack root gives each engineer the smallest environment they need and the full one when they want it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Local Kubernetes development is not about running the whole platform on your laptop for its own sake — it is about closing the gap between where you write code and where it runs. Tilt and Kind make a real cluster fast enough to be your inner loop, Live Update keeps the Go cycle in seconds, and a self-contained-but-includable Tiltfile per service keeps onboarding to a single &lt;code&gt;tilt up&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two habits carry most of the value. Test against the real thing — real cluster, real identity stack, real &lt;code&gt;curl&lt;/code&gt; — because mocks hide exactly the integration bugs that cost the most later. And keep your dependencies pinnable and free of surprise deprecations, which in 2025 means walking away from Bitnami's legacy catalog. Invest the afternoon in the Tiltfile; you buy back the parity every day after.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>go</category>
      <category>devops</category>
      <category>docker</category>
    </item>
    <item>
      <title>How to Build Kubernetes as a Service with Custom Controllers</title>
      <dc:creator>Aroua KABOUBI</dc:creator>
      <pubDate>Tue, 29 Sep 2026 08:28:07 +0000</pubDate>
      <link>https://dev.to/aroua_kaboubi_82466ca9a52/how-to-build-kubernetes-as-a-service-with-custom-controllers-1hm8</link>
      <guid>https://dev.to/aroua_kaboubi_82466ca9a52/how-to-build-kubernetes-as-a-service-with-custom-controllers-1hm8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes as a Service&lt;/strong&gt; turns a management cluster into a universal &lt;strong&gt;control plane&lt;/strong&gt;: developers declare infrastructure as custom resources, controllers reconcile the real cloud.&lt;/li&gt;
&lt;li&gt;The platform is modelled as &lt;strong&gt;cloud-agnostic KRM CRDs&lt;/strong&gt; — &lt;code&gt;LandingZone&lt;/code&gt;, &lt;code&gt;KubernetesCluster&lt;/code&gt;, &lt;code&gt;ServiceExposure&lt;/code&gt; — each backed by a &lt;strong&gt;custom Go controller&lt;/strong&gt; on controller-runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crossplane&lt;/strong&gt; plus the hyperscaler operators (&lt;strong&gt;Config Connector, ACK, Azure Service Operator&lt;/strong&gt;) translate those resources into real cloud APIs, while &lt;strong&gt;continuous reconciliation&lt;/strong&gt; corrects drift automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-tenancy&lt;/strong&gt; (isolated tenants), &lt;strong&gt;aggregated RBAC&lt;/strong&gt;, and &lt;strong&gt;federated identity&lt;/strong&gt; let teams self-serve without the platform team losing governance.&lt;/li&gt;
&lt;li&gt;Operationally, the control plane scales through &lt;strong&gt;per-environment controller sharding&lt;/strong&gt; and an &lt;strong&gt;API-first surface&lt;/strong&gt; (REST, gRPC, SDKs, and a Kubernetes API proxy).&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;In a previous article we argued &lt;a href="https://dev.to/en/blog/kubernetes-platform-engineering/"&gt;why Terraform falls short for platform engineering&lt;/a&gt; and why Kubernetes wins as the engine of a self-service platform. That post answered &lt;em&gt;why&lt;/em&gt;. This one answers &lt;em&gt;how&lt;/em&gt;: the concrete architecture behind a production &lt;strong&gt;Kubernetes as a Service&lt;/strong&gt; platform — the one pattern that lets product teams create and operate managed clusters on their own, while security, cost, and reliability stay centrally governed.&lt;/p&gt;

&lt;p&gt;Everything below is drawn from building real control planes. Names and identifiers are generic on purpose; the architecture is what matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Kubernetes as a Service" actually means
&lt;/h2&gt;

&lt;p&gt;Kubernetes as a Service is a self-service platform where a central &lt;strong&gt;management cluster&lt;/strong&gt; is the control plane for all infrastructure. A developer declares a cluster, a database, or an exposed service as a Kubernetes custom resource; a controller reconciles that declaration into real cloud resources. No tickets, no manual &lt;code&gt;apply&lt;/code&gt;, governance enforced in one place.&lt;/p&gt;

&lt;p&gt;The shift is subtle but decisive. Instead of treating Kubernetes as a place to &lt;em&gt;run&lt;/em&gt; containers, you treat it as the place to &lt;em&gt;describe and reconcile&lt;/em&gt; everything else. The &lt;a href="https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/" rel="noopener noreferrer"&gt;Kubernetes Resource Model (KRM)&lt;/a&gt; becomes the contract between the people who consume infrastructure and the platform that provisions it. That is the same idea we explored across &lt;a href="https://dev.to/en/blog/our-kubernetes-journey/"&gt;our decade-long Kubernetes journey&lt;/a&gt; — pushed to its logical conclusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The management cluster: one control plane to rule them all
&lt;/h2&gt;

&lt;p&gt;The management cluster is a dedicated Kubernetes cluster that owns no application workloads. Its only job is to host the platform API, authentication, custom controllers, and the resources that describe every downstream &lt;strong&gt;workload cluster&lt;/strong&gt;. It is the single point where requests arrive, identities are verified, and reconciliation happens.&lt;/p&gt;

&lt;p&gt;Centralising on one control plane pays off in four ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One API surface&lt;/strong&gt; — teams reach the platform through kubectl, a CLI, an API, or an internal portal, all hitting the same endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One reconciliation engine&lt;/strong&gt; — every resource is continuously driven toward its declared state by controllers running in the same place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One policy boundary&lt;/strong&gt; — RBAC, admission policies (for example &lt;a href="https://kyverno.io" rel="noopener noreferrer"&gt;Kyverno&lt;/a&gt;), and tenancy rules live together, not scattered across clusters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One lifecycle&lt;/strong&gt; — upgrades, add-on rollout, and drift correction for many clusters are orchestrated centrally.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Workload clusters — typically managed Kubernetes such as &lt;a href="https://cloud.google.com/kubernetes-engine" rel="noopener noreferrer"&gt;GKE&lt;/a&gt; — are &lt;em&gt;outputs&lt;/em&gt; of the control plane, registered back into a multi-cluster manager once they become ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modelling the platform as custom resources
&lt;/h2&gt;

&lt;p&gt;The platform's public API is a small set of &lt;strong&gt;cloud-agnostic&lt;/strong&gt; Custom Resource Definitions. Each CRD is a deliberate abstraction: it exposes the intent a team should control and hides the dozens of provider-specific primitives underneath. Two resources form the backbone of the model — &lt;code&gt;LandingZone&lt;/code&gt; and &lt;code&gt;KubernetesCluster&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;LandingZone&lt;/code&gt;: an insulated multi-tenant boundary
&lt;/h3&gt;

&lt;p&gt;A &lt;strong&gt;&lt;code&gt;LandingZone&lt;/code&gt;&lt;/strong&gt; is an enterprise-grade structural blueprint. It provisions a secure, insulated, compliant tenant boundary for a project, and its declarative controller loop performs the full multi-cloud reconciliation natively — abstracting away provider differences to stamp out and enforce organizational policy across three concerns at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Resource containment&lt;/strong&gt; — the top-level isolation unit for the target cloud: a &lt;strong&gt;GCP Project&lt;/strong&gt;, an &lt;strong&gt;AWS Account&lt;/strong&gt;, or an &lt;strong&gt;Azure Resource Group&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Networking topology&lt;/strong&gt; — VPCs, isolated subnets, firewall rules, and routing matrices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity &amp;amp; security guardrails&lt;/strong&gt; — IAM roles, policy attachments, and principal-group bindings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The spec declares intent, never a vendor SKU. The same manifest targets any provider by changing one field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;platform.example.com/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;LandingZone&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-dev&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;proj-1234-tenant-mgmt&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gcp&lt;/span&gt;                    &lt;span class="c1"&gt;# gcp | aws | azure&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dev&lt;/span&gt;
  &lt;span class="na"&gt;resourceContainer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-dev&lt;/span&gt;      &lt;span class="c1"&gt;# → GCP Project / AWS Account / Azure Resource Group&lt;/span&gt;
  &lt;span class="na"&gt;networking&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cidrBlock&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10.24.0.0/16&lt;/span&gt;
    &lt;span class="na"&gt;subnets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nodes&lt;/span&gt;
        &lt;span class="na"&gt;cidr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10.24.0.0/20&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pods&lt;/span&gt;
        &lt;span class="na"&gt;cidr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10.24.16.0/20&lt;/span&gt;
    &lt;span class="na"&gt;firewall&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;denyAllIngress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;identity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;guardrails&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;enforce-cmek&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;restrict-public-ip&lt;/span&gt;
    &lt;span class="na"&gt;roleBindings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;principalGroup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-platform-admins&lt;/span&gt;
        &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;platform-admin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The controller reconciles that intent into the right provider objects — a Project plus VPC on GCP, an Account plus VPC on AWS, a Resource Group plus VNet on Azure — and continuously enforces the guardrails as policy drifts.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;KubernetesCluster&lt;/code&gt;: managed clusters as declarative intent
&lt;/h3&gt;

&lt;p&gt;A &lt;strong&gt;&lt;code&gt;KubernetesCluster&lt;/code&gt;&lt;/strong&gt; is the headline abstraction — a whole managed cluster reduced to the handful of decisions that matter, with everything else defaulted to a secure baseline. Crucially, the spec is &lt;strong&gt;runtime-agnostic&lt;/strong&gt;: it describes &lt;em&gt;what&lt;/em&gt; the team wants, not &lt;em&gt;which&lt;/em&gt; managed engine backs it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;platform.example.com/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;KubernetesCluster&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cluster-1&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;proj-1234-dev&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;landingZoneRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payments-dev&lt;/span&gt;           &lt;span class="c1"&gt;# inherits provider, network, identity&lt;/span&gt;
  &lt;span class="na"&gt;exposure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;internal&lt;/span&gt;             &lt;span class="c1"&gt;# immutable: security boundary&lt;/span&gt;
  &lt;span class="na"&gt;location&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;europe-west1&lt;/span&gt;         &lt;span class="c1"&gt;# immutable: physical placement&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;channel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;REGULAR&lt;/span&gt;
  &lt;span class="na"&gt;gatewayApi&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;channel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;standard&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The control-plane engine parses this high-level intent and dynamically orchestrates the low-level managed primitive behind the scenes: &lt;strong&gt;GKE&lt;/strong&gt; when the referenced &lt;code&gt;LandingZone&lt;/code&gt; targets Google, &lt;strong&gt;EKS&lt;/strong&gt; on AWS, &lt;strong&gt;AKS&lt;/strong&gt; on Azure. The team never writes a provider-shaped cluster manifest — they declare a &lt;code&gt;KubernetesCluster&lt;/code&gt;, and the composer picks the engine.&lt;/p&gt;

&lt;p&gt;Two design rules keep the abstraction safe. &lt;strong&gt;Immutable fields&lt;/strong&gt; (exposure, location, network) are rejected on update by an admission webhook, because changing them would silently recreate or endanger the cluster. &lt;strong&gt;Secure defaults&lt;/strong&gt; — hardened dataplane networking, shielded nodes, sane maintenance windows — are applied automatically, so a minimal spec still produces a hardened cluster. The user makes five decisions; the controller makes five hundred.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reconciliation loop: how a controller provisions the cloud
&lt;/h2&gt;

&lt;p&gt;The engine behind every CRD is a &lt;strong&gt;custom controller&lt;/strong&gt; running a reconciliation loop on &lt;a href="https://github.com/kubernetes-sigs/controller-runtime" rel="noopener noreferrer"&gt;controller-runtime&lt;/a&gt;. The loop watches a resource, compares desired state (&lt;code&gt;spec&lt;/code&gt;) against observed state (&lt;code&gt;status&lt;/code&gt;), and creates or updates the downstream objects that map to real cloud APIs. Then it runs again — forever — correcting drift.&lt;/p&gt;

&lt;p&gt;At its core a reconciler is one idempotent function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;KubernetesClusterReconciler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Reconcile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cluster&lt;/span&gt; &lt;span class="n"&gt;platformv1alpha1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;KubernetesCluster&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NamespacedName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;cluster&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IgnoreNotFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// 1. Render downstream provider objects (Config Connector / ACK / ASO / Crossplane).&lt;/span&gt;
    &lt;span class="c"&gt;// 2. Wait for readiness; mirror observed state into .status.&lt;/span&gt;
    &lt;span class="c"&gt;// 3. On ready: mint a kubeconfig secret, register the cluster with the&lt;/span&gt;
    &lt;span class="c"&gt;//    multi-cluster add-on manager, and trigger add-on delivery.&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;clusterReady&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;cluster&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;RequeueAfter&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;cluster&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The controller never calls a cloud SDK to build a cluster directly. Instead it renders &lt;strong&gt;infrastructure-operator resources&lt;/strong&gt; and lets &lt;em&gt;those&lt;/em&gt; controllers talk to the cloud. This layering is what makes the platform declarative end to end: the reconcile function is pure Kubernetes bookkeeping, and the actual API calls are delegated to purpose-built providers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The infrastructure controller ecosystem
&lt;/h2&gt;

&lt;p&gt;Under a &lt;code&gt;LandingZone&lt;/code&gt; or &lt;code&gt;KubernetesCluster&lt;/code&gt; sits a layer of infrastructure controllers that turn Kubernetes objects into real cloud API calls. There are two families, and a serious multi-cloud platform understands both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloud-specific operators (the hyperscaler-native layer)
&lt;/h3&gt;

&lt;p&gt;Each hyperscaler ships its own operator that maps its services one-to-one to CRDs. These are faithful, high-fidelity representations of a single provider's API — ideal when the composer needs a direct, provider-native primitive:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Google Cloud — &lt;a href="https://cloud.google.com/config-connector/docs/overview" rel="noopener noreferrer"&gt;Config Connector (KCC)&lt;/a&gt;:&lt;/strong&gt; maps GCP services (Projects, subnets, IAM policies, GKE clusters, BigQuery) to CRDs, reconciled by the &lt;code&gt;cnrm&lt;/code&gt; controllers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS — &lt;a href="https://aws-controllers-k8s.github.io/community/" rel="noopener noreferrer"&gt;AWS Controllers for Kubernetes (ACK)&lt;/a&gt;:&lt;/strong&gt; a family of per-service controllers (EKS, VPC, IAM, S3, RDS) that manage AWS resources directly from Kubernetes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure — &lt;a href="https://azure.github.io/azure-service-operator/" rel="noopener noreferrer"&gt;Azure Service Operator (ASO)&lt;/a&gt;:&lt;/strong&gt; exposes Azure resources (Resource Groups, VNets, AKS, role assignments) as CRDs reconciled against Azure Resource Manager.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is authoritative for its own cloud, but each speaks only its own dialect. Build directly on them and your abstractions fragment along provider lines.&lt;/p&gt;

&lt;h3&gt;
  
  
  Provider-independent engines: Crossplane
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://crossplane.io" rel="noopener noreferrer"&gt;Crossplane&lt;/a&gt;&lt;/strong&gt; is the unifying layer. Its open-source provider ecosystem — &lt;code&gt;provider-gcp&lt;/code&gt;, &lt;code&gt;provider-aws&lt;/code&gt;, &lt;code&gt;provider-azure&lt;/code&gt; (and the newer provider families generated from each cloud's API) — wraps the same underlying cloud APIs the native operators expose. On top of that, &lt;strong&gt;Compositions&lt;/strong&gt; and &lt;strong&gt;Composite Resource Definitions (XRDs)&lt;/strong&gt; let you fold those fragmented, per-cloud managed resources into a single, uniform, high-level abstraction.&lt;/p&gt;

&lt;p&gt;This is exactly the mechanism behind our &lt;code&gt;LandingZone&lt;/code&gt; and &lt;code&gt;KubernetesCluster&lt;/code&gt; models: one composite claim fans out into a Project + VPC + IAM binding on GCP, or an Account + VPC + role on AWS, without the consumer ever seeing the provider seam. The platform controller orchestrates these engines — native operators for direct fidelity, Crossplane for cross-cloud composition — rather than replacing them. That separation keeps each layer testable and lets you onboard a new cloud by adding a provider, not by rewriting reconcile logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-tenancy and RBAC without losing control
&lt;/h2&gt;

&lt;p&gt;Multi-tenancy is enforced by the &lt;strong&gt;&lt;code&gt;LandingZone&lt;/code&gt;&lt;/strong&gt; boundary described above — the resource container, network topology, and identity guardrails that insulate each project — combined with an in-cluster tenant (for example &lt;a href="https://projectcapsule.dev" rel="noopener noreferrer"&gt;Capsule&lt;/a&gt;) that scopes namespaces, quotas, and network policies. On top of that boundary, RBAC decides who can do what, and the clever part is that it extends itself as the platform grows.&lt;/p&gt;

&lt;p&gt;The control plane defines three base roles — &lt;strong&gt;platform-admin&lt;/strong&gt;, &lt;strong&gt;platform-editor&lt;/strong&gt;, &lt;strong&gt;platform-viewer&lt;/strong&gt; — as &lt;em&gt;aggregated&lt;/em&gt; ClusterRoles. Every time a new service API is added, it ships its own role labelled to aggregate into the base ones, and Kubernetes merges the rules automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterRole&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;platform-admin&lt;/span&gt;
&lt;span class="na"&gt;aggregationRule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;clusterRoleSelectors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;rbac.platform.example.com/aggregate-to-platform-admin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[]&lt;/span&gt;   &lt;span class="c1"&gt;# filled automatically by the control plane&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterRole&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gke-platform-admin&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;rbac.platform.example.com/aggregate-to-platform-admin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform.example.com"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kubernetesclusters"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;create"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delete"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy a new service and its permissions flow into the existing personas — no central role edited by hand. Identities themselves come from your &lt;strong&gt;OIDC provider&lt;/strong&gt; (Okta, Entra ID, Keycloak) and are federated into the cloud through Workload and Workforce Identity Federation, then mapped to least-privilege bindings. A project owner administers their own tenant; the platform team never becomes the bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add-ons as a service
&lt;/h2&gt;

&lt;p&gt;A bare cluster is not a useful cluster. Every workload cluster needs security agents, policy engines, observability, cost optimisation, secret management, and ingress before a team can ship. Delivering that consistently across dozens of clusters is a first-class platform concern, not an afterthought.&lt;/p&gt;

&lt;p&gt;The pattern is GitOps-driven multi-cluster delivery. When the controller registers a new cluster with an add-on manager such as &lt;a href="https://projectsveltos.io" rel="noopener noreferrer"&gt;Sveltos&lt;/a&gt;, the manager matches the cluster against a set of &lt;code&gt;ClusterProfile&lt;/code&gt;s and rolls out the required Helm charts and manifests — endpoint security, policy enforcement, external secrets, autoscaling, backup, and gateways — in dependency order.&lt;/p&gt;

&lt;p&gt;The subtle challenge is &lt;strong&gt;visibility&lt;/strong&gt;. Add-on state lives on the management cluster as controller objects that ordinary users can't read. A production platform closes that gap by surfacing add-on status through the platform API: a read-only endpoint reads the underlying summaries on demand and returns a clean model — provisioned, provisioning, failed, waiting-for-dependencies — so a UI can render live health and even a dependency graph. The platform doesn't just install add-ons; it makes their state a supported, self-service API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exposing the platform as an API, not just YAML
&lt;/h2&gt;

&lt;p&gt;CRDs are a great internal contract, but not every consumer wants to write YAML. Because a CRD is an API definition, you can generate a typed API surface on top of it: derive &lt;strong&gt;&lt;a href="https://protobuf.dev" rel="noopener noreferrer"&gt;protobuf&lt;/a&gt;&lt;/strong&gt; from the resources, then expose &lt;strong&gt;REST and gRPC&lt;/strong&gt; endpoints and ship &lt;strong&gt;SDKs&lt;/strong&gt; in Go, Python, JavaScript, and Java. Teams call the platform like any other product API.&lt;/p&gt;

&lt;p&gt;For consuming live cluster state, a powerful addition is a &lt;strong&gt;transparent Kubernetes API proxy&lt;/strong&gt; hosted by the control plane. The user authenticates once to the platform; the platform mirrors a workload cluster's API server so standard tools work by changing only the server URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;--server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://platform.example.com/tenants/t1/landingzones/lz1/kubernetesclusters/c1/proxy &lt;span class="se"&gt;\&lt;/span&gt;
  get pods &lt;span class="nt"&gt;-n&lt;/span&gt; t1-lz1-dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Critically, this is &lt;strong&gt;not&lt;/strong&gt; an open pass-through. A mandatory, Kubernetes-aware policy engine classifies every request (group, version, resource, subresource, verb, namespace) and decides allow or deny &lt;strong&gt;before&lt;/strong&gt; a single byte is forwarded. It runs deny-by-default, with a compiled-in &lt;strong&gt;deny floor&lt;/strong&gt; — secrets, token minting, &lt;code&gt;exec&lt;/code&gt;, &lt;code&gt;attach&lt;/code&gt;, &lt;code&gt;port-forward&lt;/code&gt; — that no misconfiguration can override. Compatibility comes from mirroring the API path; safety comes from the policy engine in front of it. Users get native kubectl ergonomics without ever holding a workload cluster's credentials.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scaling the control plane with controller sharding
&lt;/h2&gt;

&lt;p&gt;A single controller reconciling every environment at once couples their fate together: a reconcile storm or bad rollout in &lt;code&gt;dev&lt;/code&gt; can degrade &lt;code&gt;int&lt;/code&gt; and &lt;code&gt;ope&lt;/code&gt; too. As the platform grows, that shared blast radius becomes the main operational risk.&lt;/p&gt;

&lt;p&gt;The fix is &lt;strong&gt;horizontal partitioning by environment&lt;/strong&gt;. The same controller binary is deployed as separate releases, each pinned to one environment through a startup flag, and each admits only its own resources using a controller-runtime &lt;strong&gt;predicate&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;EnvironmentPredicate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assigned&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;predicate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Predicate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;predicate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewPredicateFuncs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;landingZoneEnvOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;assigned&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filtering at the predicate layer keeps out-of-scope objects out of the work queue entirely, so reconcile logic stays untouched. Environment-agnostic components — the API server, the tenant controller — run once in a common release, while each environment gets its own controllers, its own resource limits, and its own independent lifecycle. One environment can be upgraded, paused, or rolled back without touching the others. Leader election is scoped per shard so releases never contend for the same lease.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the pattern buys you
&lt;/h2&gt;

&lt;p&gt;Put together, these pieces deliver the promise of platform engineering: &lt;strong&gt;autonomy for teams, control for the platform&lt;/strong&gt;. Product teams request a cluster, an exposed service, or a cloud resource with a single declarative call and get it in minutes. The platform team enforces security baselines, cost policy, and reliability standards centrally, and corrects drift automatically through reconciliation rather than through review meetings.&lt;/p&gt;

&lt;p&gt;The engineering discipline that makes it work is worth restating: model a &lt;strong&gt;small, opinionated CRD surface&lt;/strong&gt;; back each CRD with an &lt;strong&gt;idempotent reconciler&lt;/strong&gt;; delegate cloud calls to &lt;strong&gt;hyperscaler operators (KCC, ACK, ASO) and Crossplane&lt;/strong&gt;; make &lt;strong&gt;RBAC self-extending&lt;/strong&gt;; deliver &lt;strong&gt;add-ons via GitOps&lt;/strong&gt; and surface their state through the API; and &lt;strong&gt;shard controllers&lt;/strong&gt; so scale never means shared failure. None of these are exotic — they are the boring, durable choices that keep a control plane running for years.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion: how Edixos can support you
&lt;/h2&gt;

&lt;p&gt;At &lt;strong&gt;Edixos&lt;/strong&gt;, building control planes like this is our core craft. A cloud platform is more than a stack of tools — it must be built to last, to evolve, and to fit your organisation's real constraints. Our work goes well beyond deploying clusters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We write &lt;strong&gt;custom Kubernetes controllers&lt;/strong&gt; that model your provisioning workflows and business rules as reconciliation loops.&lt;/li&gt;
&lt;li&gt;We integrate composition engines like &lt;strong&gt;&lt;a href="https://crossplane.io" rel="noopener noreferrer"&gt;Crossplane&lt;/a&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;a href="https://kro.run/" rel="noopener noreferrer"&gt;Kro&lt;/a&gt;&lt;/strong&gt; to turn Kubernetes into a multi-cloud provisioning engine.&lt;/li&gt;
&lt;li&gt;We work with existing providers such as &lt;strong&gt;&lt;a href="https://github.com/GoogleCloudPlatform/k8s-config-connector" rel="noopener noreferrer"&gt;Config Connector (KCC)&lt;/a&gt;&lt;/strong&gt; to expose cloud services natively through CRDs.&lt;/li&gt;
&lt;li&gt;We build complete, &lt;strong&gt;API-first&lt;/strong&gt; platforms — REST, gRPC, and multi-language SDKs — with multi-tenancy, federated identity, and self-extending RBAC baked in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is exactly the kind of platform we described in &lt;a href="https://dev.to/en/blog/kubernetes-platform-engineering/"&gt;why Kubernetes beats Terraform for platform engineering&lt;/a&gt;, made concrete. If your ambition is a robust, self-service Kubernetes platform that aligns innovation, agility, and governance, we have the building blocks and the field experience to make it real.&lt;/p&gt;

&lt;h3&gt;
  
  
  Let's talk about your Kubernetes as a Service platform
&lt;/h3&gt;

&lt;p&gt;Are you looking to give your teams true self-service infrastructure while keeping security and cost under central control? Let's discuss your challenges and see how a custom control plane can bring you speed, reliability, and scale.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>architecture</category>
      <category>startup</category>
      <category>cloud</category>
    </item>
    <item>
      <title>provider-ovh 2.17.0: full OVHcloud schema coverage for Crossplane</title>
      <dc:creator>Aroua KABOUBI</dc:creator>
      <pubDate>Tue, 15 Sep 2026 13:10:38 +0000</pubDate>
      <link>https://dev.to/aroua_kaboubi_82466ca9a52/provider-ovh-2170-full-ovhcloud-schema-coverage-for-crossplane-4hdo</link>
      <guid>https://dev.to/aroua_kaboubi_82466ca9a52/provider-ovh-2170-full-ovhcloud-schema-coverage-for-crossplane-4hdo</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/edixos/provider-ovh" rel="noopener noreferrer"&gt;provider-ovh&lt;/a&gt; 2.17.0&lt;/strong&gt; wires the complete OVHcloud Terraform provider schema: &lt;strong&gt;165 managed resources&lt;/strong&gt; per API scope and &lt;strong&gt;335 CRDs&lt;/strong&gt;, up from 138 and 281.&lt;/li&gt;
&lt;li&gt;Four database engines disappeared upstream. &lt;strong&gt;Migrate or delete objects of the removed kinds before you upgrade&lt;/strong&gt; — the CRDs are gone, not deprecated.&lt;/li&gt;
&lt;li&gt;The 31 newly wired kinds are &lt;strong&gt;beta&lt;/strong&gt;: generated, built and unit-tested, but not yet exercised end to end against a live account.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://github.com/edixos/provider-ovh" rel="noopener noreferrer"&gt;provider-ovh&lt;/a&gt; is the Crossplane provider for OVHcloud that we build and maintain at Edixos. Version 2.17.0, released 25 July 2026, upgrades to &lt;a href="https://github.com/ovh/terraform-provider-ovh" rel="noopener noreferrer"&gt;OVHcloud Terraform provider 2.17.0&lt;/a&gt; and — for the first time — exposes &lt;em&gt;every&lt;/em&gt; resource in the upstream schema as a Crossplane managed resource.&lt;/p&gt;

&lt;p&gt;That is the headline. The part that needs your attention before you upgrade is the removals.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 2.17.0 actually contains
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;v2.13.2&lt;/th&gt;
&lt;th&gt;v2.17.0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OVHcloud Terraform provider&lt;/td&gt;
&lt;td&gt;2.13.1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.17.0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed resources per API scope&lt;/td&gt;
&lt;td&gt;138&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;165&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CRDs&lt;/td&gt;
&lt;td&gt;281&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;335&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every resource is available in both API groups the provider ships: the cluster-scoped &lt;code&gt;*.ovh.edixos.io&lt;/code&gt; and the namespaced &lt;code&gt;*.ovh.m.edixos.io&lt;/code&gt;. That doubling is why the CRD count is roughly twice the resource count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Breaking changes: read this before upgrading
&lt;/h2&gt;

&lt;p&gt;Upstream 2.17.0 removed the deprecated &lt;code&gt;cassandra&lt;/code&gt;, &lt;code&gt;m3db&lt;/code&gt;, &lt;code&gt;m3aggregator&lt;/code&gt; and &lt;code&gt;redis&lt;/code&gt; database engines, along with the long-deprecated &lt;code&gt;ovh_cloud_project_database_ip_restriction&lt;/code&gt; resource. The corresponding CRDs are &lt;strong&gt;gone&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Removed kind&lt;/th&gt;
&lt;th&gt;Migration path&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ProjectDatabaseIPRestriction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Declare restrictions inline on &lt;code&gt;ProjectDatabase.spec.forProvider.ipRestrictions&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ProjectDatabaseRedisUser&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ProjectDatabaseValkeyUser&lt;/code&gt; against a &lt;code&gt;valkey&lt;/code&gt; engine cluster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ProjectDatabaseM3DbNamespace&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;None — engine discontinued upstream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ProjectDatabaseM3DbUser&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;None — engine discontinued upstream&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Migrate or delete any objects of the removed kinds before upgrading.&lt;/strong&gt; Removing a CRD while managed objects still reference it leaves you with resources Crossplane can no longer reconcile, and finalizers that need clearing by hand.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ProjectDatabase&lt;/code&gt; now validates &lt;code&gt;engine&lt;/code&gt; client-side. The supported set is &lt;code&gt;clickhouse&lt;/code&gt;, &lt;code&gt;grafana&lt;/code&gt;, &lt;code&gt;kafka&lt;/code&gt;, &lt;code&gt;kafkaConnect&lt;/code&gt;, &lt;code&gt;kafkaMirrorMaker&lt;/code&gt;, &lt;code&gt;mongodb&lt;/code&gt;, &lt;code&gt;mysql&lt;/code&gt;, &lt;code&gt;opensearch&lt;/code&gt;, &lt;code&gt;postgresql&lt;/code&gt; and &lt;code&gt;valkey&lt;/code&gt; — anything else is rejected before it reaches the OVHcloud API, which is a faster failure than waiting for a remote error.&lt;/p&gt;

&lt;h3&gt;
  
  
  Moving IP restrictions inline
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;databases.ovh.edixos.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ProjectDatabase&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;example-postgres&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;forProvider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;serviceName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;service-name&amp;gt;&lt;/span&gt;
    &lt;span class="na"&gt;engine&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql&lt;/span&gt;
    &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;16"&lt;/span&gt;
    &lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;business&lt;/span&gt;
    &lt;span class="na"&gt;ipRestrictions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;ip&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10.0.0.0/24&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;platform-team&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Delete the standalone &lt;code&gt;ProjectDatabaseIPRestriction&lt;/code&gt; objects first, then apply the parent database with the rules inline.&lt;/p&gt;

&lt;h2&gt;
  
  
  31 new resources
&lt;/h2&gt;

&lt;p&gt;The full-schema pass adds coverage across most of the OVHcloud surface:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;New kinds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cloud&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;FloatingIP&lt;/code&gt;, &lt;code&gt;Quota&lt;/code&gt;, &lt;code&gt;SecurityGroup&lt;/code&gt;, &lt;code&gt;SSHKey&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gateway&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CloudGateway&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;network&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PrivateVrackNetwork&lt;/code&gt;, &lt;code&gt;PrivateVrackSubnet&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kms&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;KeyManagerContainer&lt;/code&gt;, &lt;code&gt;KeyManagerContainerConsumer&lt;/code&gt;, &lt;code&gt;KeyManagerSecret&lt;/code&gt;, &lt;code&gt;KeyManagerSecretConsumer&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;storage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;EFS&lt;/code&gt;, &lt;code&gt;BlockVolume&lt;/code&gt;, &lt;code&gt;BlockVolumeBackup&lt;/code&gt;, &lt;code&gt;BlockVolumeSnapshot&lt;/code&gt;, &lt;code&gt;FileShare&lt;/code&gt;, &lt;code&gt;FileShareNetwork&lt;/code&gt;, &lt;code&gt;FileShareSnapshot&lt;/code&gt;, &lt;code&gt;ProjectFileStorageShare&lt;/code&gt;, &lt;code&gt;ProjectFileStorageShareNetwork&lt;/code&gt;, &lt;code&gt;ProjectStorageLifecycleConfiguration&lt;/code&gt;, &lt;code&gt;ProjectStorageReplicationJob&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;databases&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ProjectDatabaseClickhouseUser&lt;/code&gt;, &lt;code&gt;ProjectDatabaseLogSubscription&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kube&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LogSubscription&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;logs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;LogsEncryptionKey&lt;/code&gt;, &lt;code&gt;LogsOutputGraylogStream&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;me&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IdentityUserToken&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;email&lt;/code&gt; &lt;em&gt;(new group)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DomainAccount&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vrack&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PublicRoutingPriority&lt;/code&gt;, &lt;code&gt;VrackServicesOrder&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One deliberate design decision worth calling out: &lt;code&gt;IdentityUserToken&lt;/code&gt; writes its &lt;code&gt;token&lt;/code&gt; to the &lt;strong&gt;connection secret&lt;/strong&gt; rather than to status, because the upstream attribute is marked sensitive. A credential in &lt;code&gt;status&lt;/code&gt; is readable by anything with get access on the resource; a connection secret is a separate object you can RBAC independently.&lt;/p&gt;

&lt;h3&gt;
  
  
  Honest scope note
&lt;/h3&gt;

&lt;p&gt;These 31 kinds are generated, built, linted and unit-tested, and their Terraform identifiers follow the upstream import documentation. They have &lt;strong&gt;not&lt;/strong&gt; been exercised end to end against a live OVHcloud account. Treat them as beta, and &lt;a href="https://github.com/edixos/provider-ovh/issues" rel="noopener noreferrer"&gt;open an issue&lt;/a&gt; if you hit something — that feedback is what promotes them out of beta.&lt;/p&gt;

&lt;h2&gt;
  
  
  Schema improvements inherited from 2.14.0–2.17.0
&lt;/h2&gt;

&lt;p&gt;Four upstream releases folded in at once, so several fields appear that were never announced here individually:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ovh_cloud_project_kube&lt;/code&gt;: &lt;code&gt;ip_allocation_policy&lt;/code&gt;, &lt;code&gt;customization_cilium&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ovh_cloud_project_volume&lt;/code&gt;: &lt;code&gt;encryption&lt;/code&gt;, for customer-managed-key encrypted volumes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ovh_cloud_project_storage&lt;/code&gt;: &lt;code&gt;tags&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ovh_dbaas_logs_output_graylog_stream&lt;/code&gt;: &lt;code&gt;encryption_keys_ids&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Behavioural fixes in the same span: in-place node scaling for &lt;code&gt;ovh_cloud_project_database&lt;/code&gt;; idempotent database creation on 409 and 500 responses; &lt;code&gt;desired_nodes&lt;/code&gt; capped when shrinking a nodepool; &lt;code&gt;UPDATING&lt;/code&gt; accepted as a pending Kubernetes state; and &lt;code&gt;weight: 0&lt;/code&gt; treated as a real value on load-balancer routes rather than as unset.&lt;/p&gt;

&lt;p&gt;That last one is the kind of bug that only shows up in production: a route weighted zero is a deliberate drain, and treating it as absent silently re-enables traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upgrading
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt; | kubectl apply -f -
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-ovh
spec:
  package: xpkg.upbound.io/edixos/provider-ovh:v2.17.0
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Order of operations that avoids trouble:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Inventory objects of the four removed kinds across every namespace.&lt;/li&gt;
&lt;li&gt;Move IP restrictions inline on the parent &lt;code&gt;ProjectDatabase&lt;/code&gt;, and Redis users to Valkey.&lt;/li&gt;
&lt;li&gt;Delete the leftover objects of removed kinds.&lt;/li&gt;
&lt;li&gt;Apply the provider upgrade above.&lt;/li&gt;
&lt;li&gt;Confirm the package became healthy: &lt;code&gt;kubectl get providers.pkg.crossplane.io provider-ovh&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you skip step 3, the CRDs vanish underneath live objects, and you clean up finalizers by hand afterwards. It is recoverable, but it is an outage-shaped afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full changelog:&lt;/strong&gt; &lt;a href="https://github.com/edixos/provider-ovh/compare/v2.13.2...v2.17.0" rel="noopener noreferrer"&gt;v2.13.2…v2.17.0&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;New to the provider? Start with the &lt;a href="https://dev.to/en/blog/crossplane-provider-ovh-guide/"&gt;step-by-step provider-ovh guide&lt;/a&gt; — install on kind, wire credentials, deploy a managed cluster. Then read &lt;a href="https://dev.to/en/blog/provider-ovh-2-18-0/"&gt;provider-ovh 2.18.0&lt;/a&gt;, which removes inline &lt;code&gt;accessRules&lt;/code&gt; from &lt;code&gt;FileShare&lt;/code&gt; in a way that fails quietly.&lt;/p&gt;

&lt;p&gt;We maintain provider-ovh, and we build OVHcloud control planes on it for a living: &lt;a href="https://dev.to/en/services/crossplane-consulting/"&gt;Crossplane consulting&lt;/a&gt; · &lt;a href="https://dev.to/en/services/platform-engineering/"&gt;platform engineering&lt;/a&gt; · &lt;a href="https://dev.to/en/contact/"&gt;talk to us&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>cloud</category>
      <category>opensource</category>
      <category>kubernetes</category>
      <category>sre</category>
    </item>
    <item>
      <title>provider-ovh 2.18.0: FileShare access rules become their own resource</title>
      <dc:creator>Aroua KABOUBI</dc:creator>
      <pubDate>Wed, 09 Sep 2026 10:14:24 +0000</pubDate>
      <link>https://dev.to/aroua_kaboubi_82466ca9a52/provider-ovh-2180-fileshare-access-rules-become-their-own-resource-26kk</link>
      <guid>https://dev.to/aroua_kaboubi_82466ca9a52/provider-ovh-2180-fileshare-access-rules-become-their-own-resource-26kk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/edixos/provider-ovh" rel="noopener noreferrer"&gt;provider-ovh&lt;/a&gt; 2.18.0&lt;/strong&gt; adds &lt;strong&gt;&lt;code&gt;FileShareACL&lt;/code&gt;&lt;/strong&gt; and removes inline &lt;code&gt;accessRules&lt;/code&gt; from &lt;code&gt;FileShare&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The removed field is &lt;strong&gt;silently pruned, not rejected&lt;/strong&gt;. An untouched &lt;code&gt;FileShare&lt;/code&gt; manifest keeps applying cleanly while its access rules quietly stop being managed. This is the one thing to check before you upgrade.&lt;/li&gt;
&lt;li&gt;Rules already provisioned in OVHcloud are &lt;strong&gt;not deleted&lt;/strong&gt; by the upgrade — adopt them with &lt;code&gt;crossplane.io/external-name&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;Version 2.18.0 of &lt;a href="https://github.com/edixos/provider-ovh" rel="noopener noreferrer"&gt;provider-ovh&lt;/a&gt;, our Crossplane provider for OVHcloud, was released on 1 August 2026. It tracks &lt;a href="https://github.com/ovh/terraform-provider-ovh/releases/tag/v2.18.0" rel="noopener noreferrer"&gt;OVHcloud Terraform provider 2.18.0&lt;/a&gt; and brings the provider to &lt;strong&gt;166 managed resources per API scope and 337 CRDs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It is a small release with one sharp edge, so we are leading with the edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The breaking change that does not announce itself
&lt;/h2&gt;

&lt;p&gt;Upstream removed the inline &lt;code&gt;access_rules&lt;/code&gt; attribute from &lt;code&gt;ovh_cloud_storage_file_share&lt;/code&gt; and replaced it with a standalone ACL resource. &lt;code&gt;FileShare&lt;/code&gt; therefore loses four paths, in &lt;strong&gt;both&lt;/strong&gt; API groups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;spec.forProvider.accessRules&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;spec.initProvider.accessRules&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;status.atProvider.accessRules&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;status.atProvider.currentState.accessRules&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is why this deserves more care than a normal removal. Once the CRDs are upgraded, &lt;code&gt;accessRules&lt;/code&gt; is &lt;strong&gt;not rejected&lt;/strong&gt;. The Kubernetes API server prunes unknown fields, so a &lt;code&gt;FileShare&lt;/code&gt; manifest you never touched will still apply, still report ready, and still look entirely healthy in &lt;code&gt;kubectl&lt;/code&gt; — while the access rules it used to declare are no longer managed by anything.&lt;/p&gt;

&lt;p&gt;A breaking change that errors is a nuisance. A breaking change that succeeds is a trap. Audit your &lt;code&gt;FileShare&lt;/code&gt; objects for &lt;code&gt;accessRules&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; you upgrade, not after.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get fileshares.storage.ovh.edixos.io &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{range .items[?(@.spec.forProvider.accessRules)]}{.metadata.namespace}{"\t"}{.metadata.name}{"\n"}{end}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Migrating to FileShareACL
&lt;/h2&gt;

&lt;p&gt;Declare one &lt;code&gt;FileShareACL&lt;/code&gt; per rule that was previously inline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;storage.ovh.edixos.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;FileShareACL&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;example-acl&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;forProvider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;serviceName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;service-name&amp;gt;&lt;/span&gt;
    &lt;span class="na"&gt;shareIdRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;example-share&lt;/span&gt;
    &lt;span class="na"&gt;accessTo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10.0.0.0/24&lt;/span&gt;
    &lt;span class="na"&gt;accessLevel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;READ_WRITE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;accessTo&lt;/code&gt; takes a single IP or CIDR. &lt;code&gt;accessLevel&lt;/code&gt; is &lt;code&gt;READ_WRITE&lt;/code&gt; or &lt;code&gt;READ_ONLY&lt;/code&gt;. &lt;code&gt;shareId&lt;/code&gt; resolves by reference or selector to a &lt;code&gt;FileShare&lt;/code&gt;, so the ACL follows the share rather than hard-coding an ID.&lt;/p&gt;

&lt;p&gt;One property to design around: &lt;strong&gt;every configurable field forces replacement.&lt;/strong&gt; There is no in-place update of an ACL — changing &lt;code&gt;accessTo&lt;/code&gt; or &lt;code&gt;accessLevel&lt;/code&gt; deletes and recreates it. For a rule governing live traffic, that is a brief window with no ACL in place, so schedule changes accordingly rather than discovering it during an incident.&lt;/p&gt;

&lt;h3&gt;
  
  
  Adopting rules that already exist
&lt;/h3&gt;

&lt;p&gt;The upgrade does not delete access rules already provisioned in OVHcloud. They keep working; they are simply unmanaged. To bring one under Crossplane without creating a duplicate, set the external name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;example-acl&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;crossplane.io/external-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;serviceName&amp;gt;/&amp;lt;shareId&amp;gt;/&amp;lt;aclId&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Given that every field forces replacement, adoption is strictly better than recreate-and-hope: it avoids a delete/create cycle on a rule that is currently allowing production traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also in 2.18.0
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;BlockVolume&lt;/code&gt; gains an optional &lt;code&gt;availabilityZone&lt;/code&gt;&lt;/strong&gt;, plus &lt;code&gt;status.atProvider.currentState.location.availabilityZone&lt;/code&gt;. If you run across OVHcloud AZs, you can now pin a volume rather than accepting placement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;BlockVolume&lt;/code&gt; no longer replaces the volume when &lt;code&gt;encryption&lt;/code&gt; is left unchanged.&lt;/strong&gt; Upstream now keeps the value from state. Previously an unchanged encryption field could trigger a replacement — on a block volume, meaning data movement. If you had a &lt;code&gt;BlockVolume&lt;/code&gt; that seemed to want recreating for no reason, this was likely why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The provider image now downloads the native Terraform provider binary from the upstream GitHub release assets&lt;/strong&gt; instead of &lt;code&gt;releases.hashicorp.com&lt;/code&gt;. The reason is concrete: that mirror only carries some versions of the OVHcloud provider and has no 2.18.0 at all. Sourcing from the upstream release assets means the provider is never blocked on a mirror that lags or skips a version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upgrading
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt; | kubectl apply -f -
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-ovh
spec:
  package: xpkg.upbound.io/edixos/provider-ovh:v2.18.0
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sequence that avoids the trap:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Audit &lt;code&gt;FileShare&lt;/code&gt; objects for &lt;code&gt;accessRules&lt;/code&gt; and record every rule — IP or CIDR, and access level.&lt;/li&gt;
&lt;li&gt;Collect the corresponding &lt;code&gt;aclId&lt;/code&gt; values from the OVHcloud API for adoption.&lt;/li&gt;
&lt;li&gt;Upgrade the provider.&lt;/li&gt;
&lt;li&gt;Apply one &lt;code&gt;FileShareACL&lt;/code&gt; per recorded rule, with &lt;code&gt;crossplane.io/external-name&lt;/code&gt; set so each adopts its existing rule.&lt;/li&gt;
&lt;li&gt;Confirm each ACL reaches &lt;code&gt;SYNCED&lt;/code&gt; and &lt;code&gt;READY&lt;/code&gt;, and that no duplicate rule appeared on the share.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Coming from 2.13.x rather than 2.17.0? Read &lt;a href="https://dev.to/en/blog/provider-ovh-2-17-0/"&gt;provider-ovh 2.17.0&lt;/a&gt; first — it removes four database engines and their CRDs, which is a larger migration than this one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full changelog:&lt;/strong&gt; &lt;a href="https://github.com/edixos/provider-ovh/compare/v2.17.0...v2.18.0" rel="noopener noreferrer"&gt;v2.17.0…v2.18.0&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;If you are new to the provider, the &lt;a href="https://dev.to/en/blog/crossplane-provider-ovh-guide/"&gt;step-by-step provider-ovh guide&lt;/a&gt; covers installation, credentials and a first managed cluster.&lt;/p&gt;

&lt;p&gt;We maintain provider-ovh and build OVHcloud control planes on it: &lt;a href="https://dev.to/en/services/crossplane-consulting/"&gt;Crossplane consulting&lt;/a&gt; · &lt;a href="https://dev.to/en/services/platform-engineering/"&gt;platform engineering&lt;/a&gt; · &lt;a href="https://dev.to/en/contact/"&gt;talk to us&lt;/a&gt;.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Provider-ovh 2.19.1: we removed the Terraform CLI from the provider</title>
      <dc:creator>Aroua KABOUBI</dc:creator>
      <pubDate>Fri, 04 Sep 2026 11:54:20 +0000</pubDate>
      <link>https://dev.to/aroua_kaboubi_82466ca9a52/provider-ovh-2191-we-removed-the-terraform-cli-from-the-provider-1la6</link>
      <guid>https://dev.to/aroua_kaboubi_82466ca9a52/provider-ovh-2191-we-removed-the-terraform-cli-from-the-provider-1la6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/edixos/provider-ovh" rel="noopener noreferrer"&gt;provider-ovh&lt;/a&gt; 2.19.1&lt;/strong&gt; drops the Terraform CLI entirely. All &lt;strong&gt;168 managed resources per API scope&lt;/strong&gt; reconcile &lt;strong&gt;in-process&lt;/strong&gt;, in the controller, as Go function calls.&lt;/li&gt;
&lt;li&gt;The image no longer carries the &lt;code&gt;terraform&lt;/code&gt; binary, the native OVHcloud provider binary, &lt;code&gt;bash&lt;/code&gt;, or a writable &lt;code&gt;/terraform&lt;/code&gt; tree. No &lt;code&gt;terraform init&lt;/code&gt;, no workspace on disk, &lt;strong&gt;no registry egress at runtime&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;It is a &lt;strong&gt;patch number carrying breaking CRD changes&lt;/strong&gt;. Read the migration section before you upgrade — one of them will reject existing objects.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;Version 2.19.1 of &lt;a href="https://github.com/edixos/provider-ovh" rel="noopener noreferrer"&gt;provider-ovh&lt;/a&gt;, our &lt;a href="https://dev.to/en/services/crossplane-consulting/"&gt;Crossplane provider for OVHcloud&lt;/a&gt;, was released on 3 September 2026. It tracks the same &lt;a href="https://github.com/ovh/terraform-provider-ovh/releases/tag/v2.19.0" rel="noopener noreferrer"&gt;OVHcloud Terraform provider 2.19.0&lt;/a&gt; as the release before it, and brings the provider to &lt;strong&gt;168 managed resources per API scope and 341 CRDs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The upstream surface did not move. What moved is how the provider talks to OVHcloud at all — and it is the largest architectural change the project has had.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Crossplane provider built on upjet actually did
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/crossplane/upjet" rel="noopener noreferrer"&gt;upjet&lt;/a&gt; generates a Crossplane provider from a Terraform provider. Historically it did that by running Terraform. For every managed object, on every reconcile, the controller would:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a workspace directory on disk and write a &lt;code&gt;.tf&lt;/code&gt; file and a state file into it.&lt;/li&gt;
&lt;li&gt;Fork and exec &lt;code&gt;terraform init&lt;/code&gt;, resolving the OVHcloud provider from a local filesystem mirror baked into the image.&lt;/li&gt;
&lt;li&gt;Fork and exec &lt;code&gt;terraform plan&lt;/code&gt;, then parse the JSON output to decide whether the object was up to date.&lt;/li&gt;
&lt;li&gt;Fork and exec &lt;code&gt;terraform apply&lt;/code&gt; when it was not.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That works. It also means a Kubernetes controller whose real execution engine is a subprocess, whose real state is a directory tree, and whose image has to ship two extra binaries to function.&lt;/p&gt;

&lt;p&gt;Here is what the 2.19.0 image needed just to boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; alpine:3.17.1&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apk &lt;span class="nt"&gt;--no-cache&lt;/span&gt; add ca-certificates bash

&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PLUGIN_DIR=/terraform/provider-mirror/registry.terraform.io/${TERRAFORM_PROVIDER_SOURCE}/...&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; TF_CLI_CONFIG_FILE=/terraform/.terraformrc&lt;/span&gt;

&lt;span class="k"&gt;ADD&lt;/span&gt;&lt;span class="s"&gt; https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_..._${TARGETOS}_${TARGETARCH}.zip /tmp&lt;/span&gt;
&lt;span class="k"&gt;ADD&lt;/span&gt;&lt;span class="s"&gt; ${TERRAFORM_PROVIDER_DOWNLOAD_URL_PREFIX}/..._${TARGETOS}_${TARGETARCH}.zip /tmp&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;unzip ... &lt;span class="nt"&gt;-d&lt;/span&gt; /usr/local/bin &lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;chmod&lt;/span&gt; +x /usr/local/bin/terraform &lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; unzip ... &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PLUGIN_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;USER_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;:&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;USER_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; /terraform
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A shell, a Terraform distribution, a vendored provider plugin, and a writable tree owned by the runtime user.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 2.19.1 does instead
&lt;/h2&gt;

&lt;p&gt;upjet has supported two in-process reconciliation modes for a while: one that calls a Terraform Plugin SDK v2 provider's CRUD functions directly, and one that speaks the Plugin Framework's interfaces in the same process. 2.19.1 moves every resource onto them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;106 resource types go through the SDKv2 path. 70 go through the Plugin Framework path. Zero go through the CLI.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is no subprocess. &lt;code&gt;Observe&lt;/code&gt;, &lt;code&gt;Create&lt;/code&gt;, &lt;code&gt;Update&lt;/code&gt; and &lt;code&gt;Delete&lt;/code&gt; are Go calls into the OVHcloud provider's own code, linked into the controller binary. The whole Dockerfile is now this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; alpine:3.24.1&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apk &lt;span class="nt"&gt;--no-cache&lt;/span&gt; add ca-certificates

&lt;span class="k"&gt;ADD&lt;/span&gt;&lt;span class="s"&gt; "bin/${TARGETOS}_${TARGETARCH}/provider" /usr/local/bin/provider&lt;/span&gt;

&lt;span class="c"&gt;## Terraform provider metadata consumed by the controller at startup.&lt;/span&gt;
&lt;span class="c"&gt;## All resources are reconciled in-process (Terraform Plugin SDK / Framework);&lt;/span&gt;
&lt;span class="c"&gt;## no Terraform CLI or native provider binary is shipped in this image.&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; TERRAFORM_VERSION=${TERRAFORM_VERSION}&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; TERRAFORM_PROVIDER_SOURCE=${TERRAFORM_PROVIDER_SOURCE}&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; TERRAFORM_PROVIDER_VERSION=${TERRAFORM_PROVIDER_VERSION}&lt;/span&gt;

&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; ${USER_ID}&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8080&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["provider"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One Go binary and a CA bundle. Even &lt;code&gt;bash&lt;/code&gt; is gone.&lt;/p&gt;

&lt;h3&gt;
  
  
  What that buys you in production
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;No writable working directory.&lt;/strong&gt; The reason the provider needed one was the workspace tree. It doesn't have one now, which removes the obstacle to running the controller with a read-only root filesystem, one of the baseline hardening steps we apply as part of &lt;a href="https://dev.to/en/services/platform-engineering/"&gt;platform engineering&lt;/a&gt; work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No registry egress at runtime.&lt;/strong&gt; The CLI path resolved a provider plugin on every &lt;code&gt;init&lt;/code&gt;, from a mirror that had to exist in the image. There is nothing to resolve any more. On a cluster with egress policy on the control plane namespace, that is one fewer exception to justify.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A smaller thing to scan.&lt;/strong&gt; Two third-party binaries left the image. Every CVE either of them accumulates used to be your CVE, whether or not the code path was reachable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No fork/exec per reconcile.&lt;/strong&gt; A &lt;code&gt;terraform init&lt;/code&gt; plus a &lt;code&gt;plan&lt;/code&gt; for every object on every reconcile loop is process creation, filesystem I/O and JSON parsing you were paying for on top of the API call you actually wanted. We are not publishing a benchmark here — a fair one needs a real fleet and real OVHcloud rate limits — but the work removed is not subtle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug this shook out, and why no test caught it
&lt;/h2&gt;

&lt;p&gt;Wiring the SDKv2 path exposed something worth writing down, because it is a trap for anyone doing the same migration on their own upjet provider.&lt;/p&gt;

&lt;p&gt;On the Plugin Framework path, upjet configures the provider itself from &lt;code&gt;terraform.Setup.Configuration&lt;/code&gt;. On the SDKv2 path it does not. It passes &lt;code&gt;terraform.Setup&lt;/code&gt;'s &lt;code&gt;Meta&lt;/code&gt; field straight through as the provider meta to &lt;code&gt;Diff&lt;/code&gt;, &lt;code&gt;RefreshWithoutUpgrade&lt;/code&gt; and &lt;code&gt;Apply&lt;/code&gt;, and never populates it. Every OVHcloud SDKv2 resource begins with a &lt;code&gt;meta.(*Config)&lt;/code&gt; type assertion.&lt;/p&gt;

&lt;p&gt;Leave &lt;code&gt;Meta&lt;/code&gt; nil, and all 106 SDK-backed resources panic on their first &lt;code&gt;Observe&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;interface conversion: interface {} is nil, not *ovh.Config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not a crash loop — controller-runtime recovers the panic — just every SDK resource permanently failing to reconcile, quietly, with a stack trace in the logs. No CI job in the repository would have caught it: the CRD diff job compares schemas, the build job compiles, and the deploy job installs the provider without credentials, so it never reconciles anything.&lt;/p&gt;

&lt;p&gt;2.19.1 configures the provider and sets &lt;code&gt;ps.Meta&lt;/code&gt;, and caches the result per ProviderConfig and per effective configuration. The caching is not premature optimisation: configuring the OVHcloud provider runs &lt;code&gt;loadAndValidate&lt;/code&gt; and calls &lt;code&gt;GET /auth/details&lt;/code&gt;, and &lt;code&gt;Connect&lt;/code&gt; runs on every reconcile of every object. A rotated OAuth access token produces a different cache key, so a cached client is never reused with credentials that have since changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating: the patch number is lying to you
&lt;/h2&gt;

&lt;p&gt;2.19.1 is a patch release with breaking CRD changes in it. That was a judgement call about not stranding people on a vulnerable image, and it means the version number will not warn you. These are the three changes that can bite.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Integer fields are now &lt;code&gt;integer&lt;/code&gt;, not &lt;code&gt;number&lt;/code&gt; (506 properties)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;terraform providers schema -json&lt;/code&gt; renders Terraform's &lt;code&gt;TypeInt&lt;/code&gt; as &lt;code&gt;number&lt;/code&gt;. That is what the generated CRDs carried, across 506 properties. None of those fields is ever fractional, so declaring them as &lt;code&gt;integer&lt;/code&gt; is a correction rather than a behaviour change.&lt;/p&gt;

&lt;p&gt;Manifests and stored objects are unaffected: &lt;code&gt;3&lt;/code&gt; was valid as a &lt;code&gt;number&lt;/code&gt; and is valid as an &lt;code&gt;integer&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The real break is in the generated Go types, where the corresponding fields move from &lt;code&gt;*float64&lt;/code&gt; to &lt;code&gt;*int64&lt;/code&gt;. &lt;strong&gt;This matters only if you import &lt;code&gt;apis/{cluster,namespaced}/...&lt;/code&gt; directly&lt;/strong&gt; — in a composition function, an operator, or a test harness. If you write YAML, there is nothing to do.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Two string maps become atomic for server-side apply
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;x-kubernetes-map-type: granular&lt;/code&gt; is no longer set on &lt;code&gt;User.openstackRc&lt;/code&gt; (in the &lt;code&gt;cloud&lt;/code&gt; group) and &lt;code&gt;PrivateNetwork.regionsOpenstackIds&lt;/code&gt; (in the &lt;code&gt;network&lt;/code&gt; group) — across &lt;code&gt;forProvider&lt;/code&gt;, &lt;code&gt;initProvider&lt;/code&gt; and &lt;code&gt;atProvider&lt;/code&gt;, in both API groups, eight paths in total.&lt;/p&gt;

&lt;p&gt;Those maps are now &lt;strong&gt;atomic&lt;/strong&gt;. A client that applies the map takes ownership of it whole. Two appliers can no longer each own individual keys, and a partial apply replaces the map instead of merging into it. If you have a controller and a human both writing keys into one of those maps, that arrangement stops working the way you expect.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. &lt;code&gt;vrack&lt;/code&gt; &lt;code&gt;CloudProject&lt;/code&gt; now requires &lt;code&gt;projectId&lt;/code&gt; — and will reject existing objects
&lt;/h3&gt;

&lt;p&gt;This is the one that will actually page someone. &lt;code&gt;CloudProject&lt;/code&gt; in the &lt;code&gt;vrack&lt;/code&gt; group gains a CEL validation rule making &lt;code&gt;spec.forProvider.projectId&lt;/code&gt; required whenever &lt;code&gt;managementPolicies&lt;/code&gt; includes &lt;code&gt;Create&lt;/code&gt;, &lt;code&gt;Update&lt;/code&gt; or &lt;code&gt;*&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Objects that do not set it and carry either policy are rejected &lt;strong&gt;on their next update&lt;/strong&gt;. They keep running until something touches them, which is exactly the kind of latency that turns an upgrade into an incident three weeks later.&lt;/p&gt;

&lt;p&gt;Find them before you upgrade:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# cluster-scoped&lt;/span&gt;
kubectl get cloudprojects.vrack.ovh.edixos.io &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.items[]
      | select((.spec.forProvider.projectId // .spec.initProvider.projectId) == null)
      | .metadata.name'&lt;/span&gt;

&lt;span class="c"&gt;# namespaced&lt;/span&gt;
kubectl get cloudprojects.vrack.ovh.m.edixos.io &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.items[]
      | select((.spec.forProvider.projectId // .spec.initProvider.projectId) == null)
      | "\(.metadata.namespace)/\(.metadata.name)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything printed needs &lt;code&gt;projectId&lt;/code&gt; set before the upgrade, or it will fail its next write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security
&lt;/h2&gt;

&lt;p&gt;2.19.1 also remediates everything Trivy reported against the 2.19.0 image. Go moves from 1.25.6 to 1.25.13, and the Alpine base from &lt;strong&gt;3.17.1 — which is end of life — to 3.24.1&lt;/strong&gt;, along with the affected Go dependencies.&lt;/p&gt;

&lt;p&gt;A Trivy scan of the resulting image reports &lt;strong&gt;no HIGH or CRITICAL findings&lt;/strong&gt;. &lt;code&gt;govulncheck&lt;/code&gt; reports the provider code as affected by none.&lt;/p&gt;

&lt;p&gt;One advisory stays visible and is worth naming rather than hiding: &lt;code&gt;golang.org/x/crypto/openpgp&lt;/code&gt; is flagged module-wide as GO-2026-5932. There is no fixed version, and the affected package is neither imported nor called by this provider.&lt;/p&gt;

&lt;p&gt;The build-time Terraform pin moves from 1.8.1 to 1.15.9. It is only used to generate &lt;code&gt;config/schema.json&lt;/code&gt; and is not shipped; regenerating with 1.15.9 produces byte-identical output, so no CRD change follows from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also worth knowing, from 2.19.0
&lt;/h2&gt;

&lt;p&gt;If you are coming from 2.18.0, 2.19.0 added two resources in both API groups:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Instance&lt;/code&gt;&lt;/strong&gt; wraps the new upstream &lt;code&gt;ovh_cloud_instance&lt;/code&gt;, covering &lt;code&gt;flavorId&lt;/code&gt; (which resizes in place), &lt;code&gt;imageId&lt;/code&gt;, &lt;code&gt;powerState&lt;/code&gt;, &lt;code&gt;networks&lt;/code&gt;, &lt;code&gt;securityGroupIds&lt;/code&gt;, &lt;code&gt;volumeIds&lt;/code&gt; and &lt;code&gt;shares&lt;/code&gt;. Two sharp edges: changing &lt;code&gt;imageId&lt;/code&gt; rebuilds the instance and &lt;strong&gt;wipes the root disk&lt;/strong&gt;, and omitting &lt;code&gt;securityGroupIds&lt;/code&gt; applies the project's &lt;code&gt;default&lt;/code&gt; group while an explicit empty list applies none — an instance that accepts no inbound traffic at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;InstanceGroup&lt;/code&gt;&lt;/strong&gt; wraps &lt;code&gt;ovh_cloud_instance_group&lt;/code&gt;: a placement group with an &lt;code&gt;AFFINITY&lt;/code&gt; or &lt;code&gt;ANTI_AFFINITY&lt;/code&gt; policy. It is immutable — changing &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;region&lt;/code&gt; or &lt;code&gt;policy&lt;/code&gt; replaces it — and membership is set only through an &lt;code&gt;Instance&lt;/code&gt;'s &lt;code&gt;groupId&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the first of the two OVHcloud instance APIs exposed as a distinct CRD. The pre-existing &lt;code&gt;ProjectInstance&lt;/code&gt; is untouched and keeps its own schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upgrading
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt; | kubectl apply -f -
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-ovh
spec:
  package: xpkg.upbound.io/edixos/provider-ovh:v2.19.1
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A sequence that avoids surprises:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run the two &lt;code&gt;kubectl&lt;/code&gt;/&lt;code&gt;jq&lt;/code&gt; commands above and fix every &lt;code&gt;CloudProject&lt;/code&gt; they print.&lt;/li&gt;
&lt;li&gt;Check whether anything applies keys into &lt;code&gt;User.openstackRc&lt;/code&gt; or &lt;code&gt;PrivateNetwork.regionsOpenstackIds&lt;/code&gt; alongside another writer.&lt;/li&gt;
&lt;li&gt;If you import the generated Go types, rebuild against 2.19.1 — the compiler will show you every &lt;code&gt;*float64&lt;/code&gt; that is now &lt;code&gt;*int64&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Upgrade the provider.&lt;/li&gt;
&lt;li&gt;Confirm your managed objects return to &lt;code&gt;SYNCED&lt;/code&gt; and &lt;code&gt;READY&lt;/code&gt;. Because reconciliation moved in-process, this is the step that validates the new path against your real credentials.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your cluster policy pinned the provider's working directory or allowed egress specifically for the Terraform registry, both can now be removed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full changelog:&lt;/strong&gt; &lt;a href="https://github.com/edixos/provider-ovh/compare/v2.19.0...v2.19.1" rel="noopener noreferrer"&gt;v2.19.0…v2.19.1&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Thanks
&lt;/h2&gt;

&lt;p&gt;Every change in this release came from the community rather than from me.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ekarlso" rel="noopener noreferrer"&gt;@ekarlso&lt;/a&gt; contributed &lt;a href="https://github.com/edixos/provider-ovh/pull/65" rel="noopener noreferrer"&gt;#65&lt;/a&gt; — the move to in-process reconciliation, the removal of the Terraform CLI from the image, and the SDKv2 provider meta fix and its cache. It is a large, careful change to the most load-bearing part of the provider.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/VeSeWe" rel="noopener noreferrer"&gt;@VeSeWe&lt;/a&gt; made their &lt;strong&gt;first contribution&lt;/strong&gt; with &lt;a href="https://github.com/edixos/provider-ovh/pull/63" rel="noopener noreferrer"&gt;#63&lt;/a&gt; — the Go, Alpine and dependency updates that take the image to zero HIGH and CRITICAL findings. Welcome, and thank you.&lt;/p&gt;

&lt;p&gt;If you run provider-ovh, &lt;a href="https://github.com/edixos/provider-ovh" rel="noopener noreferrer"&gt;issues and pull requests&lt;/a&gt; are the fastest way to shape where it goes next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;If you are new to the provider, the &lt;a href="https://dev.to/en/blog/crossplane-provider-ovh-guide/"&gt;step-by-step provider-ovh guide&lt;/a&gt; covers installation, credentials and a first managed cluster. Upgrading from 2.17.0 or earlier? Read &lt;a href="https://dev.to/en/blog/provider-ovh-2-18-0/"&gt;2.18.0&lt;/a&gt; and &lt;a href="https://dev.to/en/blog/provider-ovh-2-17-0/"&gt;2.17.0&lt;/a&gt; first — both remove fields, which is a heavier migration than this one.&lt;/p&gt;

&lt;p&gt;We maintain provider-ovh and build OVHcloud control planes on it: &lt;a href="https://dev.to/en/services/crossplane-consulting/"&gt;Crossplane consulting&lt;/a&gt; · &lt;a href="https://dev.to/en/services/platform-engineering/"&gt;platform engineering&lt;/a&gt; · &lt;a href="https://dev.to/en/contact/"&gt;talk to us&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>crossplane</category>
      <category>kubernetes</category>
      <category>ovhcloud</category>
      <category>cloudnative</category>
    </item>
    <item>
      <title>Kubernetes FinOps: A Field Guide to Cutting Cluster Cost Without Breaking Prod</title>
      <dc:creator>Aroua KABOUBI</dc:creator>
      <pubDate>Tue, 01 Sep 2026 11:20:22 +0000</pubDate>
      <link>https://dev.to/aroua_kaboubi_82466ca9a52/kubernetes-finops-a-field-guide-to-cutting-cluster-cost-without-breaking-prod-me5</link>
      <guid>https://dev.to/aroua_kaboubi_82466ca9a52/kubernetes-finops-a-field-guide-to-cutting-cluster-cost-without-breaking-prod-me5</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fibcuvc7695l97b82ywno.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fibcuvc7695l97b82ywno.webp" alt="Infographie Kubernetes FinOps" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kubernetes bills balloon because the two numbers that drive them, &lt;strong&gt;pod requests&lt;/strong&gt; and &lt;strong&gt;idle node capacity&lt;/strong&gt;, are invisible by default. You pay for reserved capacity, not usage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FinOps is a loop, not a project&lt;/strong&gt;: Inform (make spend visible per team), Optimize (rightsize, spot, autoscale), Operate (keep it from drifting back).&lt;/li&gt;
&lt;li&gt;The biggest single lever is &lt;strong&gt;rightsizing&lt;/strong&gt;. Cast AI's 2026 report puts CPU overprovisioning near &lt;strong&gt;69%&lt;/strong&gt; — most of that is pure waste.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spot plus autoscaling&lt;/strong&gt; takes 70–90% off the compute bill for the right workloads; &lt;strong&gt;governance-as-code&lt;/strong&gt; stops savings from eroding the moment attention moves on.&lt;/li&gt;
&lt;li&gt;Teams without a FinOps practice waste an estimated &lt;strong&gt;32–40% of cloud spend&lt;/strong&gt;; mature teams pull that down to 15–20%.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;Kubernetes made compute elastic. It also made the bill opaque. A cluster will happily schedule pods that ask for eight times the CPU they use, keep half-empty nodes running through the night, and never once tell finance which team is responsible. The result is a line item that grows faster than traffic, and a quarterly conversation nobody enjoys.&lt;/p&gt;

&lt;p&gt;This is not a tooling gap you buy your way out of. It's a discipline: FinOps applied to the specific mechanics of Kubernetes. According to &lt;a href="https://www.cncf.io" rel="noopener noreferrer"&gt;CNCF&lt;/a&gt;'s FinOps for Kubernetes research, 68% of organizations saw their Kubernetes costs rise, with roughly half reporting increases above 20%. The teams that reverse this don't find a magic dashboard. They make spend visible, act on the waste, and build guardrails so it stays gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Kubernetes bill is invisible by default
&lt;/h2&gt;

&lt;p&gt;You are billed for capacity you reserve, not capacity you use. A pod's &lt;code&gt;resources.requests&lt;/code&gt; reserves CPU and memory on a node whether the workload touches them or not. Set those requests as a comfortable safety margin, multiply across hundreds of pods, and the scheduler packs nodes around numbers that have little to do with reality. The gap between requested and used is the waste, and nothing in a default cluster surfaces it.&lt;/p&gt;

&lt;p&gt;The second invisible number is idle node capacity. When requests are inflated, the scheduler can't bin-pack tightly, so you run more nodes than the real workload needs. Each node is a full instance on the bill. Neither of these shows up in a cloud provider's invoice, which reports instances and disks, not namespaces and teams. That accounting mismatch is the core problem: the cloud bill speaks the language of infrastructure, while your cost owners think in the language of applications.&lt;/p&gt;

&lt;h2&gt;
  
  
  FinOps is a loop, not a one-off cleanup
&lt;/h2&gt;

&lt;p&gt;FinOps, as defined by the &lt;a href="https://www.finops.org" rel="noopener noreferrer"&gt;FinOps Foundation&lt;/a&gt;, runs on three phases that repeat: &lt;strong&gt;Inform, Optimize, Operate.&lt;/strong&gt; Inform is visibility: allocate spend to the teams and applications that generate it, so cost has an owner. Optimize is action: rightsize requests, move fault-tolerant work to spot, eliminate idle resources, apply commitment discounts. Operate is durability: track cost against business goals continuously so the savings don't decay.&lt;/p&gt;

&lt;p&gt;Most cost-cutting efforts fail because they stop after a single Optimize pass. Someone spends a sprint rightsizing, the graph dips, and six months later it's back where it started because nothing changed the behavior that created the waste. The loop is the point. A one-time cleanup is a snapshot; FinOps is the practice that holds the line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rightsizing: stop paying for requests you don't use
&lt;/h2&gt;

&lt;p&gt;Rightsizing is the highest-leverage move available, because overprovisioned requests are the root of both invisible costs. &lt;a href="https://cast.ai" rel="noopener noreferrer"&gt;Cast AI&lt;/a&gt;'s 2026 Kubernetes cost report found CPU overprovisioning reached roughly &lt;strong&gt;69%&lt;/strong&gt; across the clusters it analyzed: for every three cores reserved, two sit idle and billed. Bring requests down toward real usage and two things happen at once: the reservation waste disappears, and the scheduler bin-packs onto fewer nodes.&lt;/p&gt;

&lt;p&gt;The mechanism is straightforward but easy to get wrong by hand. The &lt;a href="https://github.com/kubernetes/autoscaler/tree/master/vertical-pod-autoscaler" rel="noopener noreferrer"&gt;Vertical Pod Autoscaler&lt;/a&gt; observes actual consumption and recommends requests from data instead of a nervous guess. Run it in recommendation mode first, read the numbers, then apply. Set requests to the workload's real steady-state, keep limits sane to protect noisy neighbors, and revisit as traffic patterns shift. This is unglamorous work, and it is where most of the money is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spot and autoscaling: 70–90% off the right workloads
&lt;/h2&gt;

&lt;p&gt;Spot instances deliver the largest per-unit discount in the cloud, typically &lt;strong&gt;70–90%&lt;/strong&gt; below on-demand, and in 2026 the interruption handling is mature enough to use in production. The catch is that the provider can reclaim the capacity on short notice, so spot is for work that tolerates a node vanishing: stateless services behind a load balancer, batch and CI, anything that reschedules cleanly. Stateful singletons and latency-critical paths stay on-demand.&lt;/p&gt;

&lt;p&gt;The tool that makes this practical is a workload-aware autoscaler. &lt;a href="https://karpenter.sh" rel="noopener noreferrer"&gt;Karpenter&lt;/a&gt; provisions nodes to fit pending pods, mixes spot and on-demand by policy, and drains interrupted nodes gracefully so a reclaim is a non-event rather than an incident. Pair spot with tight requests and you compound the wins: fewer nodes, and each node far cheaper. This is the combination that turns a scary bill into a boring one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Governance as code: keep the savings from drifting back
&lt;/h2&gt;

&lt;p&gt;Optimization erodes the moment attention moves on, unless the guardrails live in the cluster. This is where policy engines earn their place: &lt;a href="https://kyverno.io" rel="noopener noreferrer"&gt;Kyverno&lt;/a&gt; and Open Policy Agent let you encode cost rules as admission policy, so the cluster refuses waste before it's ever scheduled. Reject pods with no requests, cap oversized requests, block workloads that ask for a whole node's worth of memory without justification, require the cost-center label that makes allocation possible.&lt;/p&gt;

&lt;p&gt;The shift is from cleanup to prevention. A rightsizing sprint fixes yesterday's waste; an admission policy stops tomorrow's from being created. Policy-as-code is also what lets a small platform team hold the line across dozens of clusters and hundreds of developers without becoming a manual gate. The guardrail runs on every apply, forever, and never gets tired.&lt;/p&gt;

&lt;h2&gt;
  
  
  The metrics that actually move the bill
&lt;/h2&gt;

&lt;p&gt;Total cloud spend is a lagging number that tells you nothing actionable. The metrics that drive decisions are &lt;strong&gt;cost per namespace, cost per application, CPU and memory utilization, and idle-resource percentage.&lt;/strong&gt; Cost per namespace gives waste an owner. Utilization tells you whether requests match reality. Idle percentage is the headroom you're paying for and not using.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://opencost.io" rel="noopener noreferrer"&gt;OpenCost&lt;/a&gt;, a CNCF project, allocates real cloud spend down to the namespace, controller, and pod, translating the infrastructure invoice into the application language your teams think in. That translation is the whole game. Once a team can see its own number and is measured against it, behavior changes without anyone issuing a mandate. Visibility with ownership does more than any single optimization, because it makes the optimization someone's job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where teams get stuck
&lt;/h2&gt;

&lt;p&gt;The pattern we see repeatedly: a team knows the bill is too high, runs one heroic rightsizing pass, saves 30% for a quarter, and watches it climb back because nothing operationalized the discipline. The gap is rarely knowledge of what to do. It's the wiring that makes it durable: allocation every team trusts, autoscaling and spot configured for the actual workload mix, and policy that holds the line without a human in the loop.&lt;/p&gt;

&lt;p&gt;That wiring is platform engineering work, and it's what we build. We treat cost as a property of the platform, reconciled continuously alongside reliability and security, not a spreadsheet someone reconciles at quarter-end. The goal is a cluster where the cheap way and the right way are the same way, enforced by the system rather than by vigilance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dev.to/en/blog/kubernetes-platform-engineering/"&gt;Why Terraform falls short for platform engineering&lt;/a&gt; — the platform model that makes cost a system property, not a quarterly cleanup.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/en/blog/ai-sre-agents-autonomous-operations/"&gt;AI SRE agents for autonomous Kubernetes operations&lt;/a&gt; — reliability enforced on the same GitOps rails.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ready to operationalize it? Our &lt;a href="https://dev.to/en/services/kubernetes-finops/"&gt;Kubernetes FinOps engagement&lt;/a&gt; does exactly this — allocation, rightsizing, Karpenter and spot, then the governance that keeps it. It sits alongside our &lt;a href="https://dev.to/en/services/cloud-native/"&gt;Kubernetes expertise&lt;/a&gt; and &lt;a href="https://dev.to/en/services/support/"&gt;managed cloud operations&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>finops</category>
      <category>sre</category>
      <category>platformengineering</category>
    </item>
    <item>
      <title>Kubernetes expertise: how to choose a consulting partner in 2026</title>
      <dc:creator>Aroua KABOUBI</dc:creator>
      <pubDate>Tue, 18 Aug 2026 14:02:09 +0000</pubDate>
      <link>https://dev.to/aroua_kaboubi_82466ca9a52/kubernetes-expertise-how-to-choose-a-consulting-partner-in-2026-4hmd</link>
      <guid>https://dev.to/aroua_kaboubi_82466ca9a52/kubernetes-expertise-how-to-choose-a-consulting-partner-in-2026-4hmd</guid>
      <description>&lt;h1&gt;
  
  
  Key takeaways
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;Five delivery models all answer “yes” to “do you do Kubernetes?”. They are not selling the same thing, and three of them will be wrong for your problem.&lt;/li&gt;
&lt;li&gt;The deciding criterion is neither day rate nor firm size. It is &lt;strong&gt;&lt;em&gt;who actually writes the code on your cluster&lt;/em&gt;&lt;/strong&gt;, and whether that is the person you met in pre-sales.&lt;/li&gt;
&lt;li&gt;One question reveals incentive alignment: “what happens if we take operations back in two years?”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The market is not short of Kubernetes providers. It is short of ways to tell them apart, because five different businesses present themselves under the same phrase — “Kubernetes expertise” — and none will volunteer which one they actually are.&lt;/p&gt;

&lt;p&gt;This guide sets out those five models, what each is really selling, and the questions that separate a pitch from a capability. It names no companies: the model is the right level of analysis, because two firms sharing a delivery model resemble each other far more than two firms sharing a headcount.&lt;br&gt;
&lt;strong&gt;&lt;em&gt;Disclosure&lt;/em&gt;&lt;/strong&gt; :Edixos is a specialist cloud-native consultancy — model 3 — and also sells managed cloud operations. So we sit in two of the five models, including the one whose conflict of interest we flag. Put the same questions to us as to anyone else; we answer them at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five Kubernetes delivery models
&lt;/h2&gt;

&lt;h4&gt;
  
  
  1. The large systems integrator
&lt;/h4&gt;

&lt;p&gt;The large technology consultancies, from several hundred to several thousand people, covering cloud, data, security and mobile, with framework agreements at most large enterprises.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you are buying&lt;/strong&gt;: capacity and contractual coverage. If you need forty people staffed next quarter, or procurement only permits pre-approved suppliers, this is the only workable model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off&lt;/strong&gt; : pyramid staffing. The architect who convinces you in pre-sales is not the person who writes your controllers — the margin comes precisely from that gap. On work where depth beats volume, the gap is paid in months of drift.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. The cloud-native managed provider
&lt;/h4&gt;

&lt;p&gt;Firms that build the platform and then run it, with 24/7 on-call and sometimes their own sovereign cloud.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you are buying&lt;/strong&gt;: the problem going away. No platform team to hire, no rotation to staff, no upgrade window to plan. For many organisations this is the right call, and it deserves saying plainly: if Kubernetes is not a strategic asset for you, operating it yourself is a cost with no return.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off&lt;/strong&gt;: incentive alignment. A provider whose recurring revenue depends on running your platform has no economic reason to make you self-sufficient. Not bad faith — the structure of the model, and it can be contracted around. Ask directly: “contractually and technically, what happens if we want to take over operations in two years?“&lt;/p&gt;

&lt;h4&gt;
  
  
  3. The specialist cloud-native consultancy
&lt;/h4&gt;

&lt;p&gt;Small to mid-sized firms where Kubernetes and platform engineering are the business, not one line in a catalogue of thirty expertises.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you are buying&lt;/strong&gt; : depth, and a handover. These teams write controllers, publish code, and work on CRDs, Crossplane, multi-tenancy and bare metal — where generalists stop. The normal deliverable is a platform your team then operates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off&lt;/strong&gt;: capacity. Ten people will not staff forty roles, will not clear procurement demanding three years of minimum revenue, and will not cover your security workstream in parallel. If your binding constraint is volume or process, this model wastes your time.&lt;/p&gt;

&lt;h4&gt;
  
  
  4. The freelancer
&lt;/h4&gt;

&lt;p&gt;An independent expert, often excellent, with no overhead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you are buying&lt;/strong&gt;: one precise skill, immediately. For an audit, an unblocking, or reinforcement on a team that already exists, this is frequently the best value in the market.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off&lt;/strong&gt;: bus factor. No continuity, nobody to review the work. A control plane designed by one person without review becomes an asset you can neither evolve nor hand on. The risk is not competence — that is usually present.&lt;/p&gt;

&lt;h4&gt;
  
  
  5. Vendor-managed Kubernetes
&lt;/h4&gt;

&lt;p&gt;GKE Autopilot, EKS, AKS, OVHcloud Managed Kubernetes, Scaleway Kapsule, with vendor support.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you are buying&lt;/strong&gt;: the control plane, operated by the vendor, at a cost nowhere near a human engagement. Many organisations need nothing else, and paying a consultancy for what a managed service already does is a classic procurement mistake.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off&lt;/strong&gt;: support stops at the edge of the product. It will not design your golden path, will not write your abstractions, and will not say no to a team deploying whatever it likes. Everything that decides whether a platform gets adopted stays with you.&lt;/p&gt;

&lt;h3&gt;
  
  
  The decision table
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Large SI&lt;/th&gt;
&lt;th&gt;Managed Provider&lt;/th&gt;
&lt;th&gt;Specialist Consultancy&lt;/th&gt;
&lt;th&gt;Freelancer&lt;/th&gt;
&lt;th&gt;Vendor-managed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Who writes the code&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Supervised juniors&lt;/td&gt;
&lt;td&gt;Their engineers&lt;/td&gt;
&lt;td&gt;The people you met&lt;/td&gt;
&lt;td&gt;One person&lt;/td&gt;
&lt;td&gt;Nobody (product)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Seniority guaranteed in contract&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Rarely&lt;/td&gt;
&lt;td&gt;Often&lt;/td&gt;
&lt;td&gt;By construction&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scale to 40 people&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;⚠️ Limited&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Depth (CRDs, Crossplane, bare metal)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;⚠️ Varies&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅ Narrow&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Your team inherits the platform&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;⚠️ Varies&lt;/td&gt;
&lt;td&gt;❌ Rarely the goal&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;⚠️ Unreviewed&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;24/7 on-call&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;⚠️ Optional&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;⚠️ Varies&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅ Product scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fits enterprise procurement&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;⚠️ Varies&lt;/td&gt;
&lt;td&gt;⚠️ Often not&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Contract rewards finishing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌ Billed by time&lt;/td&gt;
&lt;td&gt;⚠️ Recurring revenue&lt;/td&gt;
&lt;td&gt;✅ Defined deliverable&lt;/td&gt;
&lt;td&gt;❌ Billed by time&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bus factor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;High&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This table describes models, never named companies. A single firm may sit in two models — Edixos included, which does both consulting and managed operations. When that is the case, ask which of the two carries the margin.&lt;/p&gt;

&lt;h3&gt;
  
  
  Seven questions to ask in pre-sales
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;1. “Who specifically will do the work, and can I speak to them before signing?”&lt;/em&gt;&lt;/strong&gt; The one question whose answer cannot be rehearsed. A polite refusal, or “we’ll confirm staffing after signature”, is itself an answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;2. “Show me public code.”&lt;/em&gt;&lt;/strong&gt; Controllers, operators, Crossplane providers, CNCF contributions, maintained Helm charts. Real Kubernetes expertise leaves public traces; slide-deck expertise leaves none.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;3. “Tell me about a production incident you caused.”&lt;/em&gt;&lt;/strong&gt; An unfair question, deliberately. A team that has operated clusters has one, tells it precisely, and explains what they changed afterwards. A team without one has never been on call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;4. “What exactly is the deliverable, and what does the end of the engagement look like?”&lt;/em&gt;&lt;/strong&gt; A platform engagement with no exit milestone quietly becomes open-ended staffing. Insist on the Git repository, documentation, runbooks, a handover session, and a date.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;5. “What happens if we take operations back in two years?”&lt;/em&gt;&lt;/strong&gt; The question that reveals alignment better than any reversibility clause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;6. “What are you not good at?”&lt;/em&gt;&lt;/strong&gt; A provider claiming to cover everything has disqualified itself. Across a surface as wide as cloud-native, no admitted boundary means no actual specialism.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;7. “What does the deliverable cost, not the day?”&lt;/em&gt;&lt;/strong&gt; An isolated day rate informs nothing. A cheaper profile who takes three times as long costs more, and costs you a quarter on top. The full calculation is in what each model really costs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Five red flags
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;em&gt;A cloud partner tier presented as Kubernetes expertise&lt;/em&gt;&lt;/strong&gt; . A partnership measures resale volume, not engineering capability.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;em&gt;The senior CV in the appendix, with no staffing commitment in the contract.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;em&gt;No public trace at all&lt;/em&gt;&lt;/strong&gt; — no repository, no talk, no technical writing, no post-mortem. In an ecosystem as open as the CNCF, that is abnormal.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;em&gt;A proposal that opens with a tool&lt;/em&gt;&lt;/strong&gt; — “we will deploy platform X” — before anyone has understood your golden path.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;em&gt;A quote with no discovery phase&lt;/em&gt;&lt;/strong&gt;. Nobody prices a Kubernetes migration without looking at what exists. An instant number means a change request is already planned.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Running the selection in three steps
&lt;/h3&gt;

&lt;p&gt;1- &lt;strong&gt;&lt;em&gt;Qualify the model before the firms&lt;/em&gt;&lt;/strong&gt;. Half a day internally: do we need capacity, operations, depth, reinforcement, or just a managed cluster? This eliminates two-thirds of the market and stops you comparing offers that were never comparable.&lt;/p&gt;

&lt;p&gt;2- &lt;strong&gt;&lt;em&gt;Buy an audit before you buy a project&lt;/em&gt;&lt;/strong&gt;. Two to five days of architecture review from two firms in the same model costs a fraction of the project and shows how they reason. It is the only test drive available.&lt;/p&gt;

&lt;p&gt;3- &lt;strong&gt;&lt;em&gt;Contract the exit at the start&lt;/em&gt;&lt;/strong&gt;. Repository on your side, documentation as a deliverable, a dated handover session. An aligned partner agrees without argument.&lt;/p&gt;

&lt;h3&gt;
  
  
  Edixos against its own checklist
&lt;/h3&gt;

&lt;p&gt;We are model 3: a small, senior consultancy building Kubernetes control planes, golden-path internal platforms, reusable Crossplane APIs and SRE agents. Here are our answers to the seven questions, in order&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;em&gt;Who will do the work?&lt;/em&gt;&lt;/strong&gt; Named engineers, with their record and certifications published on the team page. From the first conversation you are talking to the person who will write the code — there is no bench behind us.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;em&gt;Public code?&lt;/em&gt;&lt;/strong&gt; A Crossplane provider for OVHcloud that we maintain, 15,000+ downloads, running in production for French social ministries. Sveltos fleet work, contributions to the Tilt ecosystem.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;em&gt;A production incident?&lt;/em&gt;&lt;/strong&gt; Our field notes on Talos and bare metal and on a stale eBPF network policy on GKE are published, causes and fixes included.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;em&gt;Production tenure?&lt;/em&gt;&lt;/strong&gt; Kubernetes in production since version 1.2, in 2015 — before CRDs, before managed offerings existed.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;em&gt;Real systems?&lt;/em&gt;&lt;/strong&gt; Over a thousand applications migrated for an automotive manufacturer, 150+ CRDs for a bespoke PaaS, self-service platforms in production.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;em&gt;A clean end of engagement?&lt;/em&gt;&lt;/strong&gt; Repository, documentation and runbooks yours from day one, and a dated handover session written into the contract.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;em&gt;What are we not good at?&lt;/em&gt;&lt;/strong&gt; Volume. We will not staff forty roles, will not clear heavyweight procurement, and for operations at very large scale on a provider’s own sovereign cloud another model will serve you better. We say so in the first meeting rather than cost you a quarter.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And to answer our own alignment question: &lt;strong&gt;&lt;em&gt;we also sell managed operations&lt;/em&gt;&lt;/strong&gt; , as Managed Cloud Operations. The conflict of interest described in model 2 therefore applies to us too. What you can verify in the contract rather than take on trust: it is contracted separately, never as the obligatory sequel to a build, and everything you need in order to do without us is yours from day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;The lowest-risk next step is a two-to-five-day architecture review&lt;/em&gt;&lt;/strong&gt;. You leave with an assessment you can act on even if you never work with us afterwards.&lt;/p&gt;

&lt;h3&gt;
  
  
  Frequently asked questions
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;What kind of Kubernetes consulting partner should I choose?&lt;/em&gt;&lt;/strong&gt;&lt;br&gt;
The one whose model matches your binding constraint. Need headcount: a large systems integrator. Need never to operate the platform: a managed provider. Need technical depth and autonomy afterwards: a specialist consultancy. Vendor size is not the criterion; the delivery model is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;How do I verify a partner's real Kubernetes expertise?&lt;/em&gt;&lt;/strong&gt;&lt;br&gt;
Ask for public code: controllers, Crossplane providers, CNCF contributions, published post-mortems. Then ask to speak to the engineer who will do the work, not the pre-sales architect. The gap between those two people is the most useful signal in the whole procurement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;What questions should I ask a Kubernetes provider before signing?&lt;/em&gt;&lt;/strong&gt;&lt;br&gt;
Who specifically will do the work, and can I speak to them? Where is your public code? Tell me about a production incident you caused. What exactly is the deliverable and what does the end of the engagement look like? What happens if we take operations back in two years?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Does a certified cloud partnership prove Kubernetes expertise?&lt;/em&gt;&lt;/strong&gt;&lt;br&gt;
No. A partner tier measures resale volume and commercial certification counts, not the ability to design a control plane or write a controller. It is a procurement signal, not an engineering one, and it is routinely presented as the second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Specialist consultancy or vendor-managed Kubernetes?&lt;/em&gt;&lt;/strong&gt;&lt;br&gt;
If you need a reliable cluster and nothing more, vendor-managed is enough and costs far less than a human engagement. A consultancy earns its place when the value sits in the layer above: golden paths, abstractions, governance, and product-team autonomy.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>cloudnative</category>
      <category>platformengineering</category>
      <category>consulting</category>
    </item>
    <item>
      <title>Why Terraform Falls Short for Platform Engineering (and Kubernetes Wins)</title>
      <dc:creator>Aroua KABOUBI</dc:creator>
      <pubDate>Tue, 11 Aug 2026 15:26:03 +0000</pubDate>
      <link>https://dev.to/aroua_kaboubi_82466ca9a52/why-terraform-falls-short-for-platform-engineering-and-kubernetes-wins-26nl</link>
      <guid>https://dev.to/aroua_kaboubi_82466ca9a52/why-terraform-falls-short-for-platform-engineering-and-kubernetes-wins-26nl</guid>
      <description>&lt;h3&gt;
  
  
  Key takeaways:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Terraform is not the problem&lt;/strong&gt; — it’s the wrong tool for a self-service platform: manual apply per change, no native API, no continuous reconciliation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes + KRM + CRDs&lt;/strong&gt; turn infrastructure into a self-service API: request a database, bucket, or cache with a single YAML — no tickets.&lt;/li&gt;
&lt;li&gt;Even Kelsey Hightower has stated Terraform isn’t suited for building cloud-provider-style &lt;strong&gt;control planes&lt;/strong&gt; .
-Field-reported KPIs from teams that made the switch: &lt;strong&gt;+50% developer productivity, −36% lead time, +40% software quality&lt;/strong&gt; (Puppet State of DevOps, Gartner).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do you practice DevOps or Cloud today? Then you’ve lived this: you’re shipping a critical feature, but everything stops because you need a &lt;strong&gt;Jira&lt;/strong&gt; ticket to provision a database, an S3 bucket, or any other cloud service. Welcome to &lt;strong&gt;TicketOps&lt;/strong&gt; — a pattern that creates bottlenecks, silos, and quietly kills the agility your teams spent years building.&lt;/p&gt;

&lt;p&gt;Let’s be blunt: the celebrated principle “you build it, you run it” too often becomes “you build it, someone else queues it, and maybe one day you get to run it.” The result is wasted time, frustrated developers, and stalled innovation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Terraform is not suited for Platform Engineering
&lt;/h2&gt;

&lt;p&gt;Terraform is popular and works well for provisioning resources occasionally — but it was never designed to be the engine of a self-service platform. Three structural gaps explain why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Manual cycle&lt;/strong&gt; — a terraform apply is required for every change. Not viable for dynamic, constantly evolving resources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No native API&lt;/strong&gt; — you can’t easily expose infrastructure as self-service through a simple API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No continuous reconciliation&lt;/strong&gt; — Terraform doesn’t automatically drive infrastructure back to its desired state when it drifts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In short, for a genuine platform-as-a-service, Terraform hits its ceiling fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kubernetes + KRM + CRDs: the new self-service model
&lt;/h2&gt;

&lt;p&gt;Kubernetes is no longer just a container orchestrator — it’s a &lt;strong&gt;universal control plane&lt;/strong&gt; for your entire infrastructure. The mechanism is the Kubernetes Resource Model (KRM) plus Custom Resource Definitions (CRDs), which let you manage anything declaratively.&lt;/p&gt;

&lt;p&gt;Imagine this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Need a database? Apply a YAML.&lt;/li&gt;
&lt;li&gt;Need a Cloud Storage bucket? Another YAML.&lt;/li&gt;
&lt;li&gt;Want a Redis cache? That same YAML pattern again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is pure self-service, with zero tickets, powered by Kubernetes and projects like Crossplane, AWS Controllers for Kubernetes (ACK), and Google Config Connector.&lt;/p&gt;

&lt;p&gt;During his talk at Control Plane Day with Crossplane 2023, Kelsey Hightower — Developer Advocate at Google and a defining voice in the cloud-native community — put it plainly:&lt;/p&gt;

&lt;h4&gt;
  
  
  “Terraform is not suited for building control planes similar to those of major public cloud providers.”
&lt;/h4&gt;

&lt;p&gt;Coming from an engineer whose talks and live demos have guided modern infrastructure practice for a decade, that carries real weight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level up: expose everything via REST, gRPC, and SDKs
&lt;/h2&gt;

&lt;p&gt;The goal of this architecture is simple: &lt;strong&gt;give teams autonomy without losing control&lt;/strong&gt;. Kubernetes acts as the central control plane on top of KRM, and product, DevOps, and development teams provision what they need through a single API — exposed over REST, gRPC, or even WebSocket.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2poohn4hh2qjj6bl03ka.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2poohn4hh2qjj6bl03ka.png" alt="Diagram comparing TicketOps versus the Golden Path in Kubernetes with KRM and CRDs. Left side, TicketOps: a developer files Jira ticket #4821 and requests the DB team, which routes through the network team and security review, taking days to weeks — " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This API isn’t a gadget — it becomes the standard entry point, usable from client libraries (Go, Python, JavaScript, Java), CLI tools, or an internal portal like Backstage. Teams stop waiting on tickets, and delivery cycles accelerate.&lt;/p&gt;

&lt;p&gt;In the background, the platform continuously reconciles resources against your cloud providers and key tools (Google Cloud, GitLab, Kubernetes, Vault, Okta…). While teams self-serve, your Platform Engineering, SRE, and DevSecOps groups enforce company-wide security rules, reliability standards, and best practices. You industrialize the cloud at scale without friction: governance stays central, teams gain speed — the best of both worlds.&lt;/p&gt;

&lt;p&gt;Better still, you can expose your services as a first-class API. From your CRDs you can auto-generate protobuf definitions, then expose REST or gRPC endpoints and ship SDKs for every language your developers use. They won’t even need to write YAML — they’ll call your platform API directly. The modern developer’s dream becomes real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zero tickets&lt;/li&gt;
&lt;li&gt;Instant provisioning&lt;/li&gt;
&lt;li&gt;Transparent GitOps integration&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The numbers speak for themselves
&lt;/h2&gt;

&lt;p&gt;Here are concrete KPIs reported by organizations that made the leap from TicketOps to self-service platforms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🚀 +50% developer productivity&lt;/li&gt;
&lt;li&gt;🕒 −36% lead time between commit and production&lt;/li&gt;
&lt;li&gt;🧘 +40% improvement in software quality&lt;/li&gt;
&lt;li&gt;📈 75% of large enterprises expected to adopt this model by end of 2025 (Gartner)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These figures come from the field — notably the Puppet State of DevOps report and Gartner analysis. Your mileage will vary, but the direction is consistent: less waiting, faster delivery, higher quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Goodbye TicketOps, hello self-service
&lt;/h2&gt;

&lt;p&gt;The era of endless Jira tickets for simple provisioning is over. Adopt Kubernetes as the foundation of your Platform Engineering — with KRM and CRDs — and give your teams a genuinely self-service experience. Your infrastructure becomes agile, scalable, and API-first, and your developers will thank you (maybe even with cookies 🍪).&lt;/p&gt;

&lt;p&gt;So — ready to leave Terraform behind for the parts it was never built for, and step into an API-first future with Kubernetes? You won’t regret it. 🚀&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion: how Edixos can support you
&lt;/h2&gt;

&lt;p&gt;At &lt;strong&gt;Edixos&lt;/strong&gt;, we know a cloud platform is more than a stack of tools: it must be built to last, evolve, and fit each organization’s specific needs. Our expertise goes far beyond deploying Kubernetes clusters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We write custom Kubernetes controllers that automate your business processes and specific needs.&lt;/li&gt;
&lt;li&gt;We integrate powerful composition engines like Crossplane and Kro to turn Kubernetes into a true multi-cloud provisioning engine.&lt;/li&gt;
&lt;li&gt;We work with existing controllers such as Config Connector (KCC), which natively expose major cloud providers’ services through Kubernetes CRDs.&lt;/li&gt;
&lt;li&gt;We build complete, API-first platforms — exposing your services via REST, gRPC, and multi-language SDKs — to unify access and accelerate adoption.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What we offer is unique know-how to industrialize your cloud platforms: governance, security, and self-service at scale. This is exactly the kind of work behind our decade-long Kubernetes journey. If your ambition is a robust platform that aligns innovation, agility, and control, we have the building blocks and the experience to make it happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let’s talk about your advanced Kubernetes challenges
&lt;/h2&gt;

&lt;p&gt;Are you looking to automate operations, model your business processes, or industrialize deployments on Kubernetes? Let’s discuss your challenges and see how the API Machinery can bring you speed, reliability, and scalability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Kubernetes FinOps: cutting cluster cost without breaking prod — the same platform, measured on spend.&lt;/li&gt;
&lt;li&gt;AI SRE agents for autonomous Kubernetes operations — the same platform, measured on reliability.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Frequently asked questions:
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Is Terraform bad? Should we stop using it?
&lt;/h4&gt;

&lt;p&gt;No. Terraform is excellent for provisioning infrastructure occasionally and for one-shot changes. It falls short as the engine of a self-service platform because it needs a manual apply per change, exposes no native API, and doesn't continuously reconcile drift — the three things a platform control plane requires.&lt;/p&gt;

&lt;h4&gt;
  
  
  What is the Kubernetes Resource Model (KRM)?
&lt;/h4&gt;

&lt;p&gt;KRM is the declarative API pattern Kubernetes uses: you declare desired state in YAML, and controllers continuously reconcile reality to match. With Custom Resource Definitions (CRDs), KRM extends beyond containers to databases, buckets, and any cloud service — making Kubernetes a universal control plane.&lt;/p&gt;

&lt;h4&gt;
  
  
  How do Kubernetes and CRDs enable self-service infrastructure?
&lt;/h4&gt;

&lt;p&gt;CRDs let platform teams expose cloud resources as simple YAML APIs. A developer applies a manifest for a database or cache, and a controller (via Crossplane, ACK, or Config Connector) provisions and reconciles it automatically — no Jira ticket, no manual Terraform run, with RBAC and policy enforced centrally.&lt;/p&gt;

&lt;h4&gt;
  
  
  What is TicketOps and why is it a problem?
&lt;/h4&gt;

&lt;p&gt;TicketOps is the anti-pattern where developers must open a ticket to provision any infrastructure, then wait for another team to fulfill it. It breaks the 'you build it, you run it' principle, creates bottlenecks and silos, and slows delivery. Self-service platforms on Kubernetes eliminate it.&lt;/p&gt;

&lt;h4&gt;
  
  
  Can I expose Kubernetes infrastructure as a REST or gRPC API?
&lt;/h4&gt;

&lt;p&gt;Yes. Because CRDs are API definitions, you can generate protobuf from them and expose REST, gRPC, or WebSocket endpoints, plus multi-language SDKs (Go, Python, JavaScript, Java). Developers consume the platform through a single API — or tools like Backstage — instead of writing YAML.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>platformengineering</category>
      <category>terraform</category>
      <category>devops</category>
    </item>
    <item>
      <title>Crossplane and OVHcloud: A provider-ovh Step-by-Step Guide</title>
      <dc:creator>Aroua KABOUBI</dc:creator>
      <pubDate>Thu, 06 Aug 2026 13:46:11 +0000</pubDate>
      <link>https://dev.to/aroua_kaboubi_82466ca9a52/crossplane-and-ovhcloud-a-provider-ovh-step-by-step-guide-53dm</link>
      <guid>https://dev.to/aroua_kaboubi_82466ca9a52/crossplane-and-ovhcloud-a-provider-ovh-step-by-step-guide-53dm</guid>
      <description>&lt;h3&gt;
  
  
  Key takeaways:
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Crossplane&lt;/strong&gt; turns your Kubernetes cluster into a control plane that provisions cloud infrastructure through custom resources — no terraform apply, just YAML that a controller reconciles continuously.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;provider-ovh&lt;/strong&gt; is an open-source Crossplane provider built by &lt;strong&gt;Edixos&lt;/strong&gt;, generated with &lt;strong&gt;Upjet&lt;/strong&gt; on top of the official OVHcloud Terraform provider. It exposes &lt;strong&gt;135+ OVHcloud resources&lt;/strong&gt; as CRDs and is published on the Upbound Marketplace&lt;/p&gt;

&lt;p&gt;This guide is &lt;strong&gt;end to end&lt;/strong&gt;: a kind cluster, Crossplane, the provider, credential wiring, and a real managed Kubernetes cluster provisioned on OVHcloud.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Most teams still provision OVHcloud the imperative way: a Terraform run here, a console click there, a script nobody wants to touch. It works until you need self-service, drift correction, and RBAC — the things a platform team actually has to deliver.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Crossplane&lt;/strong&gt; flips the model. Your Kubernetes cluster becomes the control plane, and infrastructure becomes a set of custom resources that a controller keeps reconciled. At Edixos we built &lt;strong&gt;provider-ovh&lt;/strong&gt; to bring that model to OVHcloud, and this article walks through it from an empty machine to a running managed Kubernetes cluster on OVH.&lt;/p&gt;

&lt;p&gt;If you have used Crossplane with AWS or GCP, this will feel familiar. If you have not, you will have a working control plane in about fifteen minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is Crossplane?
&lt;/h3&gt;

&lt;p&gt;Crossplane is a CNCF open-source control plane that extends Kubernetes so it can provision and manage external infrastructure. You install a provider, which registers Custom Resource Definitions (CRDs) for a cloud’s resources, and from then on you declare that infrastructure as Kubernetes objects. A controller reconciles each object against the provider’s API, correcting drift and cleaning up on delete.&lt;/p&gt;

&lt;p&gt;The payoff is that infrastructure inherits everything Kubernetes already gives you: declarative YAML, RBAC, GitOps, admission control, and a single API surface across clouds. Instead of teams filing tickets, they apply a manifest — and the platform enforces the guardrails.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is provider-ovh?
&lt;/h3&gt;

&lt;p&gt;provider-ovh is the Crossplane provider for OVHcloud, built and maintained by Edixos. It exposes OVHcloud services — public cloud projects, managed Kubernetes, databases, private networks, IAM, object storage, and more — as Kubernetes managed resources. It is available on the Upbound Marketplace and the source lives on GitHub.&lt;/p&gt;

&lt;p&gt;The story behind it is worth telling, because it explains why the project exists at all. When we first looked, there was no official or community Crossplane provider for OVHcloud. We built the first one and offered to donate it — the response at the time was that community providers could not be considered official, and it would not be maintained internally. Facing other commitments, the project went dormant for nearly a year.&lt;/p&gt;

&lt;p&gt;Then three engineers — Nicolas, Kevin, and Ferréol from Theseus — got in touch. They were already running the provider in production, powering a Database-as-a-Service offering for the French public sector. Shortly after, Fabien opened a pull request bumping the provider to Crossplane 2.0. That was the catalyst. The provider was reborn as v2.9.0, with Endre Karlson and Alegrowin joining as co-maintainers, and it is now an actively maintained community project covering 135+ OVH resources and 90%+ of the Terraform provider surface, on both Crossplane 1.x and 2.x.&lt;/p&gt;

&lt;p&gt;The lesson we keep repeating: real users in production are what keep infrastructure projects alive.&lt;/p&gt;

&lt;h3&gt;
  
  
  How provider-ovh is built?
&lt;/h3&gt;

&lt;p&gt;provider-ovh is generated, not hand-written. It uses Upjet, the Crossplane code-generation toolkit, on top of the official OVHcloud Terraform provider — pinned in this release to version 2.13.1. Upjet reads the Terraform provider’s schema and generates XRM-conformant managed resources: Go API types, deepcopy functions, controllers, and CRD manifests.&lt;/p&gt;

&lt;p&gt;That approach is why coverage is so broad. Instead of implementing every OVH endpoint by hand, we regenerate the provider whenever OVHcloud ships a new Terraform version, and the new resources come along for free. The current release ships 281 CRD manifests — the 135+ resources appear in two flavors, cluster-scoped and namespaced, thanks to Crossplane v2 (more on that below). Bumping the underlying Terraform provider is a documented, four-command routine: update the Makefile and go.mod, run make generate, and commit the regenerated schema, types, and CRDs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;Before you start, install the following tools locally. Everything runs on your machine except the OVHcloud API calls, which need a real account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker&lt;/strong&gt; (or another container runtime kind supports)&lt;br&gt;
&lt;strong&gt;kind&lt;/strong&gt; — to run a throwaway Kubernetes cluster&lt;br&gt;
&lt;strong&gt;kubectl&lt;/strong&gt; — to talk to the cluster&lt;br&gt;
&lt;strong&gt;Helm&lt;/strong&gt; — to install Crossplane&lt;br&gt;
An OVHcloud account with API credentials (we generate them in Step 5)&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Create a local kind cluster
&lt;/h3&gt;

&lt;p&gt;Crossplane is a lightweight control plane, so a single-node kind cluster is plenty for this walkthrough. Create one:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fshgt9lugt4nxsyzpledk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fshgt9lugt4nxsyzpledk.png" alt="Terminal showing the kind create cluster --name crossplane-ovh command to create a local Kubernetes cluster, followed by verification with kubectl cluster-info" width="800" height="65"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Confirm the cluster is up and kubectl points at it:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftpoo75xnmeh6amnetbjy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftpoo75xnmeh6amnetbjy.png" alt="Terminal showing kubectl cluster-info --context kind-crossplane-ovh output confirming the kind cluster is running" width="800" height="78"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This cluster never runs your OVH workloads — it only hosts the Crossplane control plane that provisions them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Install Crossplane
&lt;/h3&gt;

&lt;p&gt;Install Crossplane with its official Helm chart into a dedicated crossplane-system namespace. This deploys the core Crossplane controllers and the package manager that installs providers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foslj3lwc17q9mpxgo72b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foslj3lwc17q9mpxgo72b.png" alt="Terminal showing helm repo add and helm install commands to install Crossplane into the crossplane-system namespace" width="800" height="259"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Wait for the pods to become ready, then verify the installation:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7c3pb4x1osrmepirbkz7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7c3pb4x1osrmepirbkz7.png" alt="Terminal showing kubectl get pods -n crossplane-system output with crossplane and crossplane-rbac-manager pods running" width="800" height="70"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You should see the crossplane and crossplane-rbac-manager pods running. Crossplane has also registered its own core CRDs — providers, configurations, providerconfigs — which the next step builds on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Install provider-ovh
&lt;/h3&gt;

&lt;p&gt;Installing a provider is itself a Kubernetes resource: you apply a Provider object pointing at the package image, and Crossplane’s package manager pulls it and registers its CRDs. Apply provider-ovh from the Upbound registry:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsxa85ouytau4h6gblhww.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsxa85ouytau4h6gblhww.png" alt="Terminal showing kubectl apply of a Provider manifest pointing to the provider-ovh package image from the Upbound registry" width="800" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pin an explicit version tag in production — check the Marketplace for the latest release rather than using :latest. Watch the provider become healthy:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn5bzjzw5vi2zkxxo6w98.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn5bzjzw5vi2zkxxo6w98.png" alt="Terminal showing kubectl get providers output with the provider-ovh INSTALLED and HEALTHY columns set to True" width="796" height="70"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once the INSTALLED and HEALTHY columns both read True, the provider’s controllers are running and every OVH CRD is registered in your cluster.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Discover the managed resources
&lt;/h3&gt;

&lt;p&gt;Because the provider registers its resources as standard CRDs, you explore them with plain kubectl — no special tooling. This is one of the quiet advantages of the Crossplane model: your infrastructure catalog is queryable from the same API as everything else.&lt;/p&gt;

&lt;p&gt;List the OVH resource types the provider added:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq00j8yd0ldgeoeiwgpud.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq00j8yd0ldgeoeiwgpud.png" alt="Terminal showing kubectl get crds output filtered by ovh.edixos.io, listing resource types grouped by service like kube, network, cloud, databases, and iam" width="800" height="69"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You will see resources grouped by service — kube.ovh.edixos.io, network.ovh.edixos.io, cloud.ovh.edixos.io, databases.ovh.edixos.io, iam.ovh.edixos.io, and more. To inspect the fields of any resource, use kubectl explain:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbac5tqjx3dtlnak059hk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbac5tqjx3dtlnak059hk.png" alt="Terminal showing kubectl explain cluster.kube.ovh.edixos.io --recursive output with the full forProvider spec fields for a managed OVHcloud Kubernetes cluster" width="800" height="72"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That prints the full spec for a managed OVHcloud Kubernetes cluster — every forProvider field you can set — straight from the CRD’s OpenAPI schema. The complete API reference is also published at doc.crds.dev.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Configure OVHcloud credentials
&lt;/h3&gt;

&lt;p&gt;The provider needs OVHcloud API credentials to reconcile anything real. You supply them through a Kubernetes Secret, then point a ProviderConfig at that Secret. First, generate an application key, application secret, and consumer key from the OVH API console, granting the API rights your resources require.&lt;/p&gt;

&lt;p&gt;Create the Secret in the crossplane-system namespace:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyvh8u4qhxhm8cvahtmt6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyvh8u4qhxhm8cvahtmt6.png" alt="Terminal showing kubectl apply of a Secret manifest containing OVHcloud endpoint, application_key, application_secret, and consumer_key credentials" width="800" height="559"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now create a ProviderConfig named default that tells the provider where to read those credentials:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3jey3xm0m0ok1ox5tx6t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3jey3xm0m0ok1ox5tx6t.png" alt="Terminal showing kubectl apply of a ProviderConfig manifest named default referencing the provider-ovh Secret" width="799" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Never commit real credentials to Git. In production, source the Secret from an external secret store — provider-ovh supports Crossplane’s external-secret-store integration for exactly this.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Provision your first OVHcloud resources
&lt;/h3&gt;

&lt;p&gt;With credentials wired up, provisioning is now a kubectl apply. Let’s build a real stack: a private network, a subnet inside it, and a managed Kubernetes cluster attached to both. This is the same flow that I walk through in the Platform Engineering with Crossplane &amp;amp; OVHcloud talk. Each manifest references the default ProviderConfig from the previous step.&lt;/p&gt;

&lt;p&gt;Start with a private network. Replace serviceName with your OVH public cloud project ID:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzy397rvyy1gvh6tj0022.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzy397rvyy1gvh6tj0022.png" alt="YAML manifest for a PrivateNetwork resource named sample-1 with serviceName and regions DE1" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Add a subnet inside that network — this is where your cluster’s nodes get their IP addresses. It references the private network by name with networkIdRef, so Crossplane waits for the network and wires the ID in for you:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7vomyrlyry06r4f64n8l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7vomyrlyry06r4f64n8l.png" alt="YAML manifest for a Subnet resource named subnet-1 referencing the private network with networkIdRef and defining an IP range" width="800" height="671"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now a managed Kubernetes cluster that references both the network and the subnet by name — Crossplane resolves each reference and injects the resulting IDs automatically:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi0whqmcm5022sl5qyxlc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi0whqmcm5022sl5qyxlc.png" alt="YAML manifest for a Cluster resource named hello-edixos referencing privateNetworkIdRef and nodesSubnetIdRef" width="800" height="529"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Apply them and watch Crossplane reconcile against the OVHcloud API:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffkkz8kos8urfo9k9a7xs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffkkz8kos8urfo9k9a7xs.png" alt="Terminal showing kubectl apply of the private network, subnet, and cluster manifests followed by kubectl get managed output" width="798" height="100"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The kubectl get managed command lists every external resource Crossplane is tracking, with READY and SYNCED columns. When both read True, your cluster exists on OVHcloud — provisioned entirely through the Kubernetes API. Add a NodePool the same way, referencing the cluster with kubeIdRef, and you have a complete managed Kubernetes environment described as YAML.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cluster-scoped vs namespaced resources
&lt;/h2&gt;

&lt;p&gt;provider-ovh ships every resource in two variants because it targets Crossplane 2.x. Cluster-scoped resources use the classic network.ovh.edixos.io group; namespaced resources use the *.m.ovh.edixos.io groups (the m marks the modern, namespaced API). Namespaced resources let you scope OVH infrastructure to a Kubernetes namespace, which is the foundation for safe multi-tenancy — each team gets its own namespace, its own RBAC, and its own slice of infrastructure. That doubling is why the release contains 281 CRD manifests for roughly 135 logical resources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Beyond raw resources: Compositions
&lt;/h2&gt;

&lt;p&gt;Applying individual managed resources is powerful, but it is not yet a platform. The real leverage comes from Compositions: you define a Composite Resource Definition (an XRD) that presents a simple, opinionated API — say, EdixosCluster — and a Composition that expands it into the private network, subnet, cluster, and node pool underneath. Developers request one high-level resource; the platform assembles the details with your standards baked in.&lt;/p&gt;

&lt;p&gt;The provider-ovh repository ships a complete Composition example that does exactly this. This is where Crossplane stops being a Terraform replacement and becomes a self-service platform — the pattern we build for clients at Edixos every week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;When a resource will not become ready, the reconcile loop tells you why. Describe the managed resource and read its conditions and events:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ctp43hppb4wl92uxx56.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ctp43hppb4wl92uxx56.png" alt="Terminal showing kubectl describe cluster.kube.ovh.edixos.io output with conditions and events explaining reconciliation status" width="800" height="66"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most failures fall into three buckets: credential errors (the Secret JSON is malformed, or the OVH token lacks the required API rights), reference errors (a *Ref points at a resource that does not exist or is not yet ready), and quota or region errors returned straight from the OVHcloud API. For controller-level issues, install the provider with the --debug flag via a DeploymentRuntimeConfig and check the provider pod logs in crossplane-system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;You now have the full loop: a kind cluster running Crossplane, provider-ovh installed and healthy, credentials wired through a ProviderConfig, and real OVHcloud infrastructure provisioned as Kubernetes resources. The same manifests drop straight into a Git repository and a GitOps pipeline — which is the whole point.&lt;/p&gt;

&lt;p&gt;provider-ovh started as a project OVHcloud declined to adopt and survived because engineers ran it in production and pushed it forward. It is open source, community-maintained, and built for teams who want OVHcloud to behave like every other cloud in a Crossplane control plane. Star it on GitHub, try it on your own project, and open an issue if something is missing — that feedback is exactly what keeps it moving.&lt;/p&gt;

&lt;p&gt;Crossplane and provider-ovh are one piece of a larger platform-engineering practice. For the wider context, read why Kubernetes beats Terraform for platform engineering and our field-tested blueprint for building Kubernetes as a Service — both grounded in the decade of production Kubernetes behind this provider.&lt;/p&gt;

&lt;h3&gt;
  
  
  Let’s talk about your Crossplane platform
&lt;/h3&gt;

&lt;p&gt;Do you want to expose OVHcloud — or any cloud — as self-service infrastructure your teams consume through the Kubernetes API? Let’s discuss your platform goals and see how a custom Crossplane control plane can bring you speed, governance, and scale.&lt;/p&gt;

&lt;h3&gt;
  
  
  Frequently asked questions:
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What is provider-ovh&lt;/strong&gt;?&lt;br&gt;
provider-ovh is a Crossplane provider built by Edixos that exposes OVHcloud resources as Kubernetes custom resources. It is generated with Upjet on top of the official OVHcloud Terraform provider, covers 135+ resources, and is published on the Upbound Marketplace under edixos/provider-ovh for Crossplane 1.x and 2.x.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need OVHcloud API credentials to use provider-ovh&lt;/strong&gt;?&lt;br&gt;
Yes. The provider authenticates to the OVHcloud API using an application key, application secret, and consumer key. You generate these tokens from the OVH API console, store them in a Kubernetes Secret, and reference that Secret from a ProviderConfig so Crossplane can reconcile your resources.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I run Crossplane and provider-ovh on a local kind cluster&lt;/strong&gt;?&lt;br&gt;
Yes. Crossplane is a lightweight control plane and runs fine on a local kind cluster for learning and development. It only provisions real OVHcloud infrastructure once you supply valid API credentials through a ProviderConfig, so the control plane itself has no special hosting requirement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How is provider-ovh different from the OVHcloud Terraform provider&lt;/strong&gt;?&lt;br&gt;
provider-ovh reuses the OVHcloud Terraform provider's schema under the hood but exposes it as Kubernetes CRDs reconciled continuously by Crossplane. Instead of running terraform apply, you declare resources as YAML and a controller keeps live infrastructure matching that declared state, with RBAC and GitOps built in.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>crossplane</category>
      <category>ovhcloud</category>
      <category>platformengineering</category>
    </item>
    <item>
      <title>Our Kubernetes Journey: 10 Years Building Cloud-Native Platforms</title>
      <dc:creator>Aroua KABOUBI</dc:creator>
      <pubDate>Mon, 27 Jul 2026 18:21:28 +0000</pubDate>
      <link>https://dev.to/aroua_kaboubi_82466ca9a52/our-kubernetes-journey-10-years-building-cloud-native-platforms-58a7</link>
      <guid>https://dev.to/aroua_kaboubi_82466ca9a52/our-kubernetes-journey-10-years-building-cloud-native-platforms-58a7</guid>
      <description>&lt;h2&gt;
  
  
  Key takeaways:
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Edixos ran &lt;strong&gt;Kubernetes 1.2 in production in 2015&lt;/strong&gt;, using Third Party Resources — the predecessors of today’s CRDs — well before managed Kubernetes was mainstream.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Our edge is the &lt;strong&gt;platform layer&lt;/strong&gt;: custom Go controllers, GitOps, and Crossplane that turn Kubernetes into an API-driven, self-service control plane.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Real production systems, not slideware: &lt;strong&gt;1,000+ apps migrated&lt;/strong&gt; for an automotive leader, &lt;strong&gt;150+ custom CRDs&lt;/strong&gt; for a bespoke PaaS, plus smart-city and utility platforms.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Introduction:
&lt;/h2&gt;

&lt;p&gt;Some tools permanently change how you design infrastructure. For Edixos, that tool is &lt;strong&gt;Kubernetes&lt;/strong&gt; — and our story with it started in &lt;strong&gt;2015&lt;/strong&gt;, when container orchestration was still in its infancy. A decade of trials, failures, and shipped production systems laid the foundation for the company we are today.&lt;/p&gt;

&lt;p&gt;We are not another consulting firm renting out slides. We are practitioners who have run Kubernetes in production since version 1.2, written the controllers, and operated the clusters at 3 a.m. This article takes you behind the scenes: from our first master node to the platforms that now run thousands of applications.&lt;/p&gt;

&lt;h2&gt;
  
  
  How did Edixos start with Kubernetes?
&lt;/h2&gt;

&lt;p&gt;It started in 2015 with kubeup.sh and &lt;strong&gt;Kubernetes 1.2 in production&lt;/strong&gt; — a bold bet at a time when almost no one ran containers at scale. We used Third Party Resources (TPR), the ancestors of CRDs, installed clusters on AWS with kops, and migrated a SaaS platform onto this new architecture.&lt;/p&gt;

&lt;p&gt;I still remember launching the kubeup.sh script and watching our first master node appear. It was more than a technical success — it was a defining moment.&lt;/p&gt;

&lt;p&gt;To automate environment creation, we even wrote our own &lt;strong&gt;Kubernetes controller in Python&lt;/strong&gt;. It was demanding, hand-crafted work, but incredibly formative. In 2016 I moved into Kubernetes consulting, helping major companies across France — from bare-metal setups to managed services. Every mission widened the expertise Edixos is built on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes Edixos different?
&lt;/h2&gt;

&lt;p&gt;We don’t just promise excellence — we build it daily, line by line, cluster by cluster. What sets us apart is turning complex problems into elegant, durable solutions: automating Kubernetes environments, designing custom controllers, and industrializing GitOps practices with a relentless focus on impact.&lt;/p&gt;

&lt;p&gt;Working with Edixos means partnering with seasoned practitioners who are genuinely passionate about cloud-native best practices — engineers who write production code, not PowerPoints, and who level up your teams as they go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real projects, not PowerPoints :
&lt;/h2&gt;

&lt;p&gt;At Edixos we write code, Kubernetes manifests, robust APIs, and custom controllers — not slides. Every mission is a real technical challenge and a complex business problem. Here are a few of the adventures that forged our reputation.&lt;/p&gt;

&lt;p&gt;🚗&lt;strong&gt;Cloud transformation for a French automotive leader&lt;/strong&gt;&lt;br&gt;
For a major French car manufacturer, we orchestrated the migration of more &lt;strong&gt;than 1,000 applications&lt;/strong&gt; to the public cloud. Kubernetes became the backbone, with a centralized control plane built on &lt;strong&gt;Crossplane and KRM&lt;/strong&gt; and multi-tenant custom controllers written by our team. The result: teams now consume infrastructure as a service through CRDs — with traceability, RBAC, and governance built in, and zero friction.&lt;/p&gt;

&lt;p&gt;🌐&lt;strong&gt;A Kubernetes-first Platform-as-a-Service&lt;/strong&gt;&lt;br&gt;
Inspired by KRM and GitOps, we designed a bespoke PaaS on Kubernetes for a client. Key features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A self-service &lt;strong&gt;REST and gRPC API&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Over 150 custom CRDs&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;In-house &lt;strong&gt;Go controllers&lt;/strong&gt; automating the creation of clusters, workloads, and managed services.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Built-in automatic reporting, policy enforcement, and &lt;strong&gt;FinOps&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not an off-the-shelf product. It’s a living platform, engineered for scalability, control, and team autonomy.&lt;/p&gt;

&lt;p&gt;🏙️&lt;strong&gt;Kubernetes serving smart cities&lt;/strong&gt;&lt;br&gt;
For a public-sector organization, we built a Kubernetes platform hosting critical components: web services, databases, Kafka brokers, and urban data collectors. Every microservice and every real-time pipeline is orchestrated resiliently. It was never just a “cluster” — it was a digital city operated continuously.&lt;/p&gt;

&lt;p&gt;💧&lt;strong&gt;Innovation flowing like water&lt;/strong&gt;&lt;br&gt;
Using Kubernetes and Kafka, we built a real-time water-consumption monitoring solution for a local operator: leak detection, live dashboards, and optimized storage. Proof that cloud-native and sustainability can go hand in hand.&lt;/p&gt;

&lt;p&gt;These projects show that at Edixos we build cloud-native platforms to engineering standards. If you’re looking for a partner who truly knows API Machinery, controllers, and GitOps — and speaks YAML and Go better than PowerPoint — you’re in the right place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion:
&lt;/h2&gt;

&lt;p&gt;Cloud-native is a field of execution, not theory. At Edixos we don’t sell promises; we ship architectures that run, pipelines that deploy, platforms that evolve, and APIs that simplify. What we build is designed to last, to scale, and to serve your teams.&lt;/p&gt;

&lt;p&gt;And when the official documentation isn’t enough, that usually means we need to write it ourselves — in Go, with tests, and a solid controller-runtime. Whether you want to industrialize GitOps, create custom CRDs, stand up an enterprise PaaS, or adopt Crossplane for infrastructure as code, our engineers have already been there. And they still are.&lt;/p&gt;

&lt;p&gt;Curious how these platforms actually work under the hood? Read why Terraform falls short for Platform Engineering and how Kubernetes + CRDs deliver true self-service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let’s talk about your advanced Kubernetes challenges:
&lt;/h2&gt;

&lt;p&gt;Are you looking to automate operations, model your business processes, or industrialize deployments on Kubernetes? Let’s discuss your challenges and see how the API Machinery can bring you speed, reliability, and scalability.&lt;/p&gt;

&lt;p&gt;Straight answers:&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions:
&lt;/h2&gt;

&lt;h3&gt;
  
  
  When did Edixos start working with Kubernetes?
&lt;/h3&gt;

&lt;p&gt;Edixos' founder ran Kubernetes 1.2 in production in 2015, using Third Party Resources (the ancestors of CRDs) and installing clusters on AWS with kops. That decade of hands-on experience predates most managed Kubernetes offerings and shaped the company's platform-engineering practice.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a Kubernetes custom controller and why does it matter?
&lt;/h3&gt;

&lt;p&gt;A custom controller is code that watches Custom Resources and continuously reconciles real infrastructure to match a declared state. It matters because it turns Kubernetes into an API-driven platform: teams request databases, clusters, or workloads through YAML instead of tickets, with RBAC and governance built in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Edixos only deploy Kubernetes clusters, or build platforms on top?
&lt;/h3&gt;

&lt;p&gt;Both, but the value is in the platform layer. Edixos writes custom controllers, integrates Crossplane and Config Connector, and exposes infrastructure as self-service APIs (REST, gRPC, SDKs) — so internal teams provision what they need without friction while SRE and security keep central governance.&lt;/p&gt;

&lt;h3&gt;
  
  
  What industries has Edixos built Kubernetes platforms for?
&lt;/h3&gt;

&lt;p&gt;Edixos has delivered cloud-native platforms across automotive (migrating 1,000+ applications to public cloud), the public sector (smart-city services on Kubernetes), and utilities (real-time water-consumption monitoring with Kafka), among others — each a production system, not a proof of concept.&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
