<?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: George Ezejiofor</title>
    <description>The latest articles on DEV Community by George Ezejiofor (@ezejioforog).</description>
    <link>https://dev.to/ezejioforog</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%2F989441%2Fa7a14354-4598-4802-93ab-1a4c95ff90f7.jpeg</url>
      <title>DEV Community: George Ezejiofor</title>
      <link>https://dev.to/ezejioforog</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ezejioforog"/>
    <language>en</language>
    <item>
      <title>Canary Releases for an ML Model on KServe: Replayed Traffic, Sidecar Metrics and Automatic Rollback</title>
      <dc:creator>George Ezejiofor</dc:creator>
      <pubDate>Thu, 29 Oct 2026 22:19:45 +0000</pubDate>
      <link>https://dev.to/ezejioforog/canary-releases-for-an-ml-model-on-kserve-replayed-traffic-sidecar-metrics-and-automatic-rollback-2iia</link>
      <guid>https://dev.to/ezejioforog/canary-releases-for-an-ml-model-on-kserve-replayed-traffic-sidecar-metrics-and-automatic-rollback-2iia</guid>
      <description>&lt;p&gt;&lt;em&gt;Part of the homelab build series. A football match-outcome model ships every day through Kubeflow Pipelines and KServe on RKE2. Each new model is staged next to the live one, tested, given 10% and then 50% of replayed traffic, measured revision by revision, and promoted or rolled back by the pipeline itself. The service stays highly available throughout: the serving revision keeps its traffic until the new one has earned it.&lt;/em&gt;&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%2Femyaxshe7ky86438wzjk.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%2Femyaxshe7ky86438wzjk.png" alt="ks" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Canary Releases for an ML Model on KServe: the new revision's share of replayed requests at each stage, from staged at 0% through 58 of 600 at the 10% step and 305 of 600 at the 50% step to promoted at 100%, with any failed check rolling back to 0%&lt;/p&gt;
&lt;/blockquote&gt;




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

&lt;p&gt;&lt;em&gt;A canary with no traffic is a coin you never flip.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The textbook canary goes like this. Send 10% of live requests to the new model, watch error rates and latency against the old one, step up, promote. Every guide says it, and every guide assumes one thing: that there are live requests.&lt;/p&gt;

&lt;p&gt;My model has almost none. It predicts Premier League results once a day. The pipeline writes the predictions to the bucket, and the website reads them from there. Nobody calls the model server in normal use. A 10% split of nothing is nothing, so a canary built from the guides would pass every time without measuring anything.&lt;/p&gt;

&lt;p&gt;So this canary brings its own traffic, measures each revision from its own sidecar, and decides on its own: promote, or put everything back the way it was. This article is how that works, what KServe actually does under the words "canary rollout", and the traps that look like model failures but aren't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it sits
&lt;/h2&gt;

&lt;p&gt;The model is an XGBoost classifier, served by KServe's built-in XGBoost server from a model directory on Ceph. A Kubeflow pipeline runs every morning:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ingest and publish&lt;/td&gt;
&lt;td&gt;new results into an Iceberg table through a Nessie branch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tune (most days skipped)&lt;/td&gt;
&lt;td&gt;Katib's best parameters from the last sweep&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;finalize&lt;/td&gt;
&lt;td&gt;retrain, compare the new recipe with the serving one on a walk-forward backtest, register the model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;deploy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;stage the new model next to the serving one, at 0% of traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;smoke test&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;one known request with a known answer, then a burst of 200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;canary&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10%, then 50% of replayed traffic, judged step by step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;promote&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;100% to the new model, the old one scales down&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last four are this article. They run only if finalize decided the new model is worth serving at all; a model that can't beat the old one on the backtest never gets staged.&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%2Fri66jf2r3of5mqwf57vt.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%2Fri66jf2r3of5mqwf57vt.png" alt="ks" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The rollout: the new model is staged at 0%, smoke tested, given 10% then 50% of replayed traffic, and promoted to 100%; any failure rolls it back and the old model keeps all the traffic&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What KServe means by "canary"
&lt;/h2&gt;

&lt;p&gt;Most of the confusion I ran into, mine included, comes from two different KServe APIs with the same word in them.&lt;/p&gt;

&lt;p&gt;The old &lt;code&gt;v1alpha2&lt;/code&gt; API had a separate &lt;code&gt;canary&lt;/code&gt; block next to the default predictor. The current &lt;code&gt;v1beta1&lt;/code&gt; API has no such block. A canary in &lt;code&gt;v1beta1&lt;/code&gt; is one field on the predictor, &lt;code&gt;canaryTrafficPercent&lt;/code&gt;, plus the fact that KServe keeps the last fully rolled-out revision around:&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;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;predictor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;canaryTrafficPercent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;modelFormat&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;xgboost&lt;/span&gt;
      &lt;span class="na"&gt;storageUri&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;s3://mlops-data-lake/football/models/&amp;lt;new-version&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change the model and set the field, and KServe creates a new revision for the new model, gives it 10%, and leaves the previous rolled-out revision serving the other 90%. Then the field means three different things depending on its value:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;canaryTrafficPercent&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the new revision exists and gets nothing: a holding position, and the rollback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;10&lt;/code&gt;, &lt;code&gt;50&lt;/code&gt;, …&lt;/td&gt;
&lt;td&gt;a traffic split between the new and the rolled-out revision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;removed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;promote&lt;/strong&gt;: the newest revision becomes the rolled-out one and takes 100%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the one to remember. Rolling back means setting the field to 0. Deleting it, which sounds like "cancel the canary", does the opposite and ships the new model to everyone. An AI assistant I tried got exactly this backwards, along with the &lt;code&gt;v1alpha2&lt;/code&gt; block, which would have shipped the bad model during an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payload: replayed matches
&lt;/h2&gt;

&lt;p&gt;Without callers, the canary needs a payload, and the right payload is real inputs with known answers. Finalize already has both. After training it takes the 500 most recent matches from the training set and writes three things per match to the bucket: the features the model sees, the real result, and the new model's own answer.&lt;/p&gt;

&lt;p&gt;Real inputs, artificial caller. The canary sends those rows to the model, 600 requests per step, 8 at a time.&lt;/p&gt;

&lt;p&gt;Two things this payload can and can't tell you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It can catch a broken model: one that loads but answers garbage, answers slowly, or errors on real feature rows.&lt;/li&gt;
&lt;li&gt;It can't tell you the new model forecasts better. Both models were trained on those matches, so comparing them there measures fit, not forecasting. That question is answered earlier, by finalize's walk-forward backtest, before anything is staged.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Staging: the new model at 0%
&lt;/h2&gt;

&lt;p&gt;The deploy step patches the InferenceService with the new model and &lt;code&gt;canaryTrafficPercent: 0&lt;/code&gt;, then waits until the new model is actually running. "Actually" is the hard part.&lt;/p&gt;

&lt;p&gt;Straight after the patch, the InferenceService still reports the previous revision as Ready, so a plain readiness check returns immediately and the pipeline goes green while the new model may never load. KServe's &lt;code&gt;observedGeneration&lt;/code&gt; is no help either; on this version it reported a number higher than the object's own generation. The check that works is to find the revision by the model it carries: KServe annotates every revision with its storage location, so staging is done when the latest ready revision carries the new model's location.&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="c1"&gt;# the annotation the deploy step matches on, per Knative revision&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;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;internal.serving.kserve.io/storage-initializer-sourceuri&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;s3://mlops-data-lake/football/models/&amp;lt;new-version&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The smoke test
&lt;/h2&gt;

&lt;p&gt;Before any traffic moves, a pod in the same namespace calls the new revision directly, on its private service, with one known request. The answer must match finalize's answer for that request within 1e-4. Then it sends a burst of 200 requests from 8 threads: all must succeed, all with the same answer, p95 under a second.&lt;/p&gt;

&lt;p&gt;If any of that fails, the pipeline stops there. The field stays at 0 and the old model keeps 100%. I tested this end to end with a one-off pipeline version whose tolerance could never be met: the smoke test failed, promotion never ran, and the serving revision kept all the traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The canary: two steps, measured per revision
&lt;/h2&gt;

&lt;p&gt;The canary step runs as one job and does four things before it moves any traffic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;asks both revisions directly for every replay row, and checks the new one gives exactly the trained model's answers;&lt;/li&gt;
&lt;li&gt;compares the two models' fit on the replay set (log loss and accuracy against the real results);&lt;/li&gt;
&lt;li&gt;finds each revision's pods;&lt;/li&gt;
&lt;li&gt;checks the shared route answers at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then, for 10% and then 50%:&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;step&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;canaryTrafficPercent = &amp;lt;step&amp;gt;&lt;/span&gt;
  &lt;span class="na"&gt;wait&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;until Knative reports the split, plus a few seconds for the sidecars&lt;/span&gt;
  &lt;span class="na"&gt;replay&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;600 requests through the shared route, 8 at a time&lt;/span&gt;
  &lt;span class="na"&gt;measure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;each revision's requests, errors and latency, from its own sidecar&lt;/span&gt;
  &lt;span class="na"&gt;judge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fail the step if any rule below is broken&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Ftq0ihr1w9xzfpju68m16.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%2Ftq0ihr1w9xzfpju68m16.png" alt="ks" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How the canary measures each revision: the canary Job replays past matches through Knative's weighted route to both revisions, reads each revision's Istio sidecar counters before and after every step, then rolls back to 0% or lets the promote step remove the field&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Measuring per revision without help from the response
&lt;/h3&gt;

&lt;p&gt;The response doesn't say which revision answered it. On a day with no new results, both revisions are the same model and give identical answers, so the answers can't tell them apart either.&lt;/p&gt;

&lt;p&gt;The sidecars can. Every model pod runs an Istio sidecar that counts the requests it receives, by caller. The canary reads each pod's counters just before and just after the replay and keeps only its own job's requests. The difference is exact: how many requests each revision got, how many failed, and their latency.&lt;/p&gt;

&lt;h3&gt;
  
  
  The rules
&lt;/h3&gt;

&lt;p&gt;Before any traffic moves, the run stops if the new revision's answers differ from the trained model's, or if its fit on the replay set is more than 0.02 log loss worse than the serving model's. After that, a step fails if any of these hold:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;any answer matches neither revision's own answer&lt;/td&gt;
&lt;td&gt;a third, unknown model is answering&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;requests failed and more than 1% never reached either revision&lt;/td&gt;
&lt;td&gt;the route is broken, not a model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the new revision got under a quarter of its expected share&lt;/td&gt;
&lt;td&gt;the split isn't working&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;its error rate is more than 1 point above the serving model's&lt;/td&gt;
&lt;td&gt;the new model errors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;its mean latency is over 3× the serving model's &lt;strong&gt;and&lt;/strong&gt; over 25 ms slower&lt;/td&gt;
&lt;td&gt;the new model is slow, by a margin that matters&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two-part latency rule matters at this scale. Both models answer in a few milliseconds, so "3× slower" alone would fail a release over a 4 ms difference.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rollback and promotion
&lt;/h3&gt;

&lt;p&gt;Once traffic has moved, every way out that isn't a pass rolls back: a broken rule, an exception, even the job being killed. Rollback sets the field to 0 and waits for Knative to confirm the serving revision has everything again.&lt;/p&gt;

&lt;p&gt;I tested the rollback the same way as the smoke test, with a one-off pipeline version whose error rule could never be met. The 10% step ran first: the new revision took 70 of the 600 requests, all answered correctly. Then the step failed, the job set the field back to 0, Knative confirmed the serving revision held 100% again, and the run failed before the promote step could start. The failure was forced, not caused by a genuinely bad model, but every move after the decision was the real one.&lt;/p&gt;

&lt;p&gt;On a pass, the promote step removes the field. It has to send an explicit &lt;code&gt;null&lt;/code&gt; for that:&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="c1"&gt;# JSON merge patch body, in YAML form&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;predictor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;canaryTrafficPercent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The KServe Python SDK can't send that. It leaves out every field that is unset when it builds the request, so "set to nothing" never reaches the cluster, and an SDK-based promote silently does nothing. The pipeline uses the plain Kubernetes client with a merge patch instead.&lt;/p&gt;

&lt;p&gt;Promote doesn't stop at "Ready" either. It waits until the revision carrying &lt;em&gt;its own&lt;/em&gt; model location holds 100% of the traffic, the same check staging uses. That one line turned out to matter more than I expected; see Two releases at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the inputs change
&lt;/h2&gt;

&lt;p&gt;The canary assumes both revisions read the same rows. Most releases keep that true. One didn't: adding bookmaker odds as features took the model from 23 inputs to 26, and the serving model rejected every 26-input replay row with an HTTP error. That isn't a failure of the new model. There is simply nothing to compare it with, and a 10% split would send a tenth of the traffic to a model that can only fail.&lt;/p&gt;

&lt;p&gt;So the canary checks the serving model before any traffic moves. Every revision KServe creates is annotated with the model location it serves, so the canary loads that model file and asks it how many inputs it takes. If the number differs from the replay rows, the input schema changed and the canary skips the split:&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;canary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SKIPPED:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;serving&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;revision&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;takes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;23&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;inputs&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;replay&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;rows&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;have&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;26,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;so&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;schema&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;changed"&lt;/span&gt;
&lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;canary=skipped&lt;/span&gt;
&lt;span class="na"&gt;promote&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;on the strength of the checks that did run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first version of this check didn't read the model at all. It skipped whenever the serving revision answered the replay rows with an HTTP error, and that turned out to be far too wide; see Two releases at once.&lt;/p&gt;

&lt;p&gt;The new revision has, by then, answered every replay row exactly as the trained model does, and passed the smoke test. That is what it is promoted on. It happens once, on the release that changes the inputs. From the next run, both revisions read the same rows again and the split comes back.&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%2Fuss9ttm29jjob26l5zjp.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%2Fuss9ttm29jjob26l5zjp.png" alt="ks" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Before any traffic moves: the canary's seven checks in order, which of them skip the split and which fail the run, and the serving model keeping 100% until the first split&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Two releases at once
&lt;/h2&gt;

&lt;p&gt;The pipeline runs once a day, so I never wrote a lock. Then the lab came up late after a cold boot, I started a manual run to stand in for the missed one, and two minutes later the scheduler started the missed run by itself. Two releases, one InferenceService.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;the manual run&lt;/td&gt;
&lt;td&gt;staged its model and passed the smoke test; the serving revision, not yet up after the cold boot, answered the canary's first requests with 503; the canary skipped the split and removed the field&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the scheduled run&lt;/td&gt;
&lt;td&gt;staged its own model a minute later, on top of the first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the scheduled run's canary&lt;/td&gt;
&lt;td&gt;found its own revision already rolled out, nothing earlier to compare with, and skipped the split&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the scheduled run's promote&lt;/td&gt;
&lt;td&gt;its model at 100%: passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the manual run's promote&lt;/td&gt;
&lt;td&gt;waited for &lt;em&gt;its&lt;/em&gt; model to reach 100%, saw the other run's revision there instead, and &lt;strong&gt;failed&lt;/strong&gt; after ten minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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%2Fmubluhhneyz45cvpyj6x.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%2Fmubluhhneyz45cvpyj6x.png" alt="ks" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Two releases at once: the manual run and the scheduler's catch-up run on one InferenceService, which run's canary skipped and why, the promote that failed, and the fixes&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That first skip was a bug of mine, not the race. The input-change check, as it was then, treated &lt;em&gt;any&lt;/em&gt; HTTP error from the serving revision as "different inputs". The real input change answered 500; this was a 503, a pod that wasn't ready yet. An escape hatch that wide skips the split whenever the serving model is briefly unavailable, which is exactly when a careful rollout should wait, not wave the new model through.&lt;/p&gt;

&lt;p&gt;The fix takes HTTP errors out of that decision altogether. Whether the inputs changed now comes from the serving model's own file, as described above. When the inputs match, the serving revision has to answer: a 502, 503 or 504, a refused connection or a timeout is retried with backoff for up to three minutes, and if it still can't answer, the canary fails before any traffic moves. Any other error fails at once. The serving model keeps 100% either way, and the run fails loudly instead of shipping without a split. The first release with the new check split normally: 76 of 600 requests to the new revision at 10%, 305 of 600 at 50%, no errors.&lt;/p&gt;

&lt;p&gt;Nothing broke for callers: a model that had passed its own smoke test served the whole time. The part that worked is the promote check. A promote that only waited for "Ready" and 100% would have reported success for a model that was never serving. The part that didn't is the canary: the second run skipped its split, because the first run had already moved the field. Both models were the same recipe on the same data that day, so nothing was lost. On a day with a real change, the second model would have shipped without a split.&lt;/p&gt;

&lt;p&gt;The fix is cheap and the order matters: check whether the scheduler is about to catch up &lt;em&gt;before&lt;/em&gt; starting a stand-in run. A lock on the InferenceService would close it properly, and it's on the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 403 that wasn't the model
&lt;/h2&gt;

&lt;p&gt;The first canary runs failed with 403 on every request through the shared route, while the same requests to each revision's private service worked. Nothing was wrong with either model.&lt;/p&gt;

&lt;p&gt;The namespace's mesh policy only admits callers from inside the namespace. The private service goes straight to the pod, carrying the canary job's own identity: allowed. The shared route, though, went through Knative's activator, the component that buffers requests while pods scale. Through the activator, a request arrives carrying the activator's identity, from another namespace: refused.&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%2Fv83kcmltw7z3334pkqki.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%2Fv83kcmltw7z3334pkqki.png" alt="ks" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The 403 that wasn't the model: the canary Job reaches a revision directly as its own namespace and is allowed, but through Knative's activator the request arrives as knative-serving and is refused, until target-burst-capacity "0" takes the activator out of the path&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fix is one annotation on the predictor, which takes the activator out of the path whenever a pod is up:&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;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;predictor&lt;/span&gt;&lt;span class="pi"&gt;:&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;autoscaling.knative.dev/target-burst-capacity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The staging step sets it on every new revision. A serving revision staged before the annotation existed still had the activator in front of it, so the first run after the change skipped the split and said so, and every run since has split normally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who owns the InferenceService: YAML or the pipeline
&lt;/h2&gt;

&lt;p&gt;The InferenceService is not in ArgoCD, and that's deliberate. A canary is a loop: set 10%, measure, decide, set 50%, measure, decide. Git describes an end state, not a loop. If ArgoCD owned this object with self-healing on, it would put the Git values back in the middle of a run.&lt;/p&gt;

&lt;p&gt;So the pipeline owns it after the first deploy, and the YAML file in the repo is a first-deploy file only, with a loud header saying so. It matters: before I rewrote it, that file pointed at a three-week-old model and lacked the activator annotation. One &lt;code&gt;kubectl apply&lt;/code&gt; would have rolled the model back three weeks and broken every canary run with 403s. The file now matches the live object except for the model location, which the pipeline moves on every release. So &lt;code&gt;kubectl diff&lt;/code&gt; against it should show exactly one changed line, &lt;code&gt;storageUri&lt;/code&gt;, and anything more is drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watching it in Grafana
&lt;/h2&gt;

&lt;p&gt;The decision is the canary job's, from exact before-and-after sidecar counts. Prometheus is for looking back afterwards, so the Model Serving dashboard has a "Canary runs" row: the canary's error share and p95 latency by revision, one point per run.&lt;/p&gt;

&lt;p&gt;Two traps made those panels lie before they were right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Empty is not zero.&lt;/strong&gt; An error-share query divides errors by requests. On a clean run there are no error samples, so the division returns nothing, and a clean run looks exactly like a broken query. The query now falls back to zero wherever the revision had canary requests, and the same formula pointed at known-bad traffic shows 100%, so it can show errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not every error is the model's.&lt;/strong&gt; The activator's own requests, all failing with &lt;code&gt;503 URX,UF&lt;/code&gt; (nothing listening yet) or &lt;code&gt;0 DC&lt;/code&gt; (it hung up), come in bursts whenever a pod starts. They are cold boots, not model errors. Counting them would show 100% errors on every revision, so the canary panels count only the canary job's requests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dashboard's top row is now a set of traffic lights, green, orange and red, for 5xx share and p95 latency across callers. The first trap came straight back there: with zero errors, the 5xx tile said "no traffic" until its numerator fell back to zero too. A second one was new:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Last is not now.&lt;/strong&gt; A stat tile over a seven-day range, reduced to "last value", kept showing a p95 of 488 ms hours after traffic had stopped. Every tile is now an instant query, and "no requests in the last ten minutes" reads as "no traffic" rather than as an old number.&lt;/li&gt;
&lt;/ul&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%2Fpvze6ir74zjkzf3w97k8.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%2Fpvze6ir74zjkzf3w97k8.png" alt="ks" width="800" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The Model Serving dashboard over the half hour of a scheduled run: the top row reads no real traffic, and the traffic split shows the canary's replay reaching both revisions, 00074 and 00075, with xgboost-canary as the caller&lt;/p&gt;
&lt;/blockquote&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%2F2r0eo2jf3ibgkyhuodt7.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%2F2r0eo2jf3ibgkyhuodt7.png" alt="ks" width="800" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The Canary runs row for the same run: error share 0% on both revisions, and p95 latency between 23.6 and 24.3 ms per revision&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A real run
&lt;/h2&gt;

&lt;p&gt;A scheduled daily run, end to end, with nobody watching. It promoted revision 00075 over 00074.&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%2Frzcwp5pajkef9fokmubg.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%2Frzcwp5pajkef9fokmubg.png" alt="ks" width="800" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The scheduled run in Kubeflow Pipelines, every step green: backfill matches, publish to Iceberg, Katib sweep, final train and register, stage on KServe at 0%, smoke test, canary at 10% then 50%, promote to 100%&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;smoke test&lt;/td&gt;
&lt;td&gt;known answer matched; 200 requests from 8 threads in 1.6 s, all identical, p95 103 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fit on the 500 replay matches&lt;/td&gt;
&lt;td&gt;new 0.8779 log loss, serving 0.8779; accuracy 62.8% both (no new results that day, same recipe)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the two revisions' answers&lt;/td&gt;
&lt;td&gt;the same on 500 of 500 rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;step 10%&lt;/td&gt;
&lt;td&gt;new revision 58 requests, serving 542; 0 errors; mean 6.8 ms vs 7.7 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;step 50%&lt;/td&gt;
&lt;td&gt;new revision 305, serving 295; 0 errors; mean 9.7 ms vs 10.2 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;all 1,200 replayed answers&lt;/td&gt;
&lt;td&gt;matched one of the two revisions; none foreign&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;promote&lt;/td&gt;
&lt;td&gt;new revision at 100%, previous one scaled down&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prometheus, the canary's requests that day&lt;/td&gt;
&lt;td&gt;not one non-200 response; p95 under 25 ms on both revisions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The measured split tracked the requested one: 58 of 600 requests at 10%, 305 of 600 at 50%. Knative splits by weight per request, so a few requests either way is normal; the rule only fails a step below a quarter of the expected share.&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%2F12wcyz4asxus6qb4qgi3.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%2F12wcyz4asxus6qb4qgi3.png" alt="ks" width="800" height="141"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The canary step's own log for that run: the replay set, equal fit for both models, the same answer on 500 of 500 rows, 58 then 305 of 600 requests to the new revision with no errors, and canary=passed&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How reliable is it?
&lt;/h2&gt;

&lt;p&gt;The model service stays available through every release: the serving revision keeps its traffic until the new one has passed every check, and a failure at any stage leaves it exactly as it was.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If this happens&lt;/th&gt;
&lt;th&gt;What the platform does&lt;/th&gt;
&lt;th&gt;Evidence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;the new model can't beat the serving one on the backtest&lt;/td&gt;
&lt;td&gt;it is never staged; the serving model is untouched&lt;/td&gt;
&lt;td&gt;finalize's gate decides before deploy runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the new model fails to load&lt;/td&gt;
&lt;td&gt;staging times out with the reason; the serving revision keeps 100%&lt;/td&gt;
&lt;td&gt;the deploy step waits for the revision that carries the new model, not for "Ready"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the new model answers wrongly or slowly&lt;/td&gt;
&lt;td&gt;the smoke test fails; nothing is promoted&lt;/td&gt;
&lt;td&gt;live negative test: smoke failed, promote never ran, old revision kept 100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the new model is worse under split traffic&lt;/td&gt;
&lt;td&gt;the canary rolls back to 0% and the run fails&lt;/td&gt;
&lt;td&gt;live negative test: failed at 10% after 70 requests had reached the new revision; field back to 0, serving revision 100%, promote never ran&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the canary job is killed mid-step&lt;/td&gt;
&lt;td&gt;it rolls back on the way out&lt;/td&gt;
&lt;td&gt;rollback runs on every exit that isn't a pass, including termination&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Knative's activator sits in front of the serving revision&lt;/td&gt;
&lt;td&gt;the canary skips the split instead of failing on 403s&lt;/td&gt;
&lt;td&gt;seen once, on the first run after the annotation change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the new model takes different inputs from the serving one&lt;/td&gt;
&lt;td&gt;the canary skips the split once and promotes on the smoke test and the replay check&lt;/td&gt;
&lt;td&gt;seen live when the inputs went from 23 to 26 columns; decided from the serving model's own input count&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the serving revision is briefly unavailable&lt;/td&gt;
&lt;td&gt;the canary retries for up to three minutes, then fails with no traffic moved; the serving revision keeps 100%&lt;/td&gt;
&lt;td&gt;covered by tests; before the fix, a 503 after a cold boot skipped the split instead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;two releases run at once&lt;/td&gt;
&lt;td&gt;the later one wins; the earlier one's promote refuses to report success for a model that isn't serving&lt;/td&gt;
&lt;td&gt;seen live after a cold boot: one run passed, the other failed in promote, callers were served throughout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the namespace quota refuses the smoke or canary Job&lt;/td&gt;
&lt;td&gt;the step fails within five minutes and prints the quota's used and hard figures; the serving revision keeps 100%&lt;/td&gt;
&lt;td&gt;the Job runner reads the quota's FailedCreate events instead of waiting for a pod that never comes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;someone re-applies the YAML from Git&lt;/td&gt;
&lt;td&gt;prevented by the file's ownership note; drift shows in &lt;code&gt;kubectl diff&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the diff shows only &lt;code&gt;storageUri&lt;/code&gt;, which the pipeline moves on every release&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The wait-then-fail path for an unavailable serving revision hasn't happened live yet.&lt;/strong&gt; Tests cover it; the live case so far is the one from before the fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both failure paths were tested with forced failures.&lt;/strong&gt; The smoke test and the canary each failed a live run on an impossible threshold. A genuinely bad model would take the same path, but none has come along yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It catches broken models, not worse forecasts.&lt;/strong&gt; Both models have seen the replayed matches. Forecasting quality is decided earlier, by the walk-forward gate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On a day with no new results, both revisions are the same model.&lt;/strong&gt; The canary then tests the rollout machinery more than the model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A release needs room for two predictors.&lt;/strong&gt; The new revision runs next to the serving one, so the namespace quota must fit both for the length of a run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One release at a time is assumed, not enforced.&lt;/strong&gt; There's no lock on the InferenceService. Two concurrent runs end with one model serving and the other run failed, which is safe, but the second run's canary is skipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some releases skip the split by design.&lt;/strong&gt; A first deploy and a change of inputs both promote on the smoke test and the replay check alone. The input change is read from the serving model's file, so a serving revision that is briefly unavailable no longer counts as one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One pod per revision.&lt;/strong&gt; Each revision runs a single pod, two pods in total while a canary runs, and nothing has been load-tested beyond the canary's own 600 requests. That suits a model called by its own pipeline, not a public endpoint.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;To follow the rest of this build, 🚀 &lt;strong&gt;&lt;a href="https://www.linkedin.com/in/george-ezejiofor-89615a8a/" rel="noopener noreferrer"&gt;Follow me on LinkedIn&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Author: George Ezejiofor&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>machinelearning</category>
      <category>mlops</category>
      <category>kserve</category>
    </item>
    <item>
      <title>GRC Open-Source Tools: Falco, Wazuh &amp; Probo (CrowdStrike/Vanta Alternative, Zero SaaS Bills)</title>
      <dc:creator>George Ezejiofor</dc:creator>
      <pubDate>Thu, 29 Oct 2026 17:49:59 +0000</pubDate>
      <link>https://dev.to/ezejioforog/grc-open-source-tools-falco-wazuh-probo-crowdstrikevanta-alternative-zero-saas-bills-23j8</link>
      <guid>https://dev.to/ezejioforog/grc-open-source-tools-falco-wazuh-probo-crowdstrikevanta-alternative-zero-saas-bills-23j8</guid>
      <description>&lt;p&gt;&lt;em&gt;Part of the homelab build series. Governance, risk and compliance on open source only: Kyverno blocks, Falco detects live threats, Wazuh correlates and maps them to MITRE ATT&amp;amp;CK and SOC 2, and Probo holds the evidence. A brownfield build on an RKE2 cluster that already runs Istio, Keycloak, Ceph, Argo CD, Kyverno, KubeArmor and Trivy. Highly available: three Wazuh indexers, one per Proxmox host, and three Wazuh managers.&lt;/em&gt;&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%2Ficemhl3o1pldl45mk53s.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%2Ficemhl3o1pldl45mk53s.png" alt="wz" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Open-source GRC: Kyverno, Falco, Wazuh and Probo&lt;/p&gt;
&lt;/blockquote&gt;




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

&lt;p&gt;My cluster had 41 Kyverno policies, KubeArmor enforcing AppArmor profiles and Trivy scanning every image. If someone had opened a shell inside one of my containers, none of it would have told me.&lt;/p&gt;

&lt;p&gt;That gap is what companies pay CrowdStrike to close. They pay Vanta to turn it into audit evidence, and Azure Policy to govern the whole thing, as long as it lives in Azure. All three are good products, and all three keep your data on their platform and bill you every year to keep it there.&lt;/p&gt;

&lt;p&gt;I wanted the same capability on hardware I already own, with every rule and mapping in Git. So I added three open-source tools to the platform. Falco watches every system call on the nodes. Wazuh collects those alerts next to the host logs and tags each one with a MITRE technique and a SOC 2 criterion. Probo tracks the SOC 2 controls and which part of the platform answers each of them. Kyverno, already running, stays the gate at admission.&lt;/p&gt;

&lt;p&gt;It works. A shell opened in a container now reaches the SIEM in under a second, posts to Slack and maps to SOC 2 control CC7.2. Getting there took more tuning than building. The first hour produced about 18,500 alerts, and I had to bring that down to 176 without hiding a single real attack.&lt;/p&gt;

&lt;p&gt;To be clear about what this is: it replaces the tools, not the people behind a managed service. I'll come back to what that costs you near the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you are locked into
&lt;/h2&gt;

&lt;p&gt;With CrowdStrike Falcon you pay per device, billed annually and up front, so every node you add raises the bill. Your telemetry lives in their cloud. You can write your own detections, but you can't read theirs, and the threat intelligence stops the day you cancel.&lt;/p&gt;

&lt;p&gt;Vanta doesn't publish prices. Buyers say the quote grows with headcount and with every framework you add. Over time your control mappings, your evidence and your audit history all build up inside it, collected through its integrations, so leaving means rebuilding evidence collection somewhere else.&lt;/p&gt;

&lt;p&gt;Azure Policy governs Azure. A cluster outside Azure can join through Azure Arc, which installs Azure Policy as an extension running Gatekeeper, but Azure's control plane stays in the loop and the policies are written in Azure's JSON format with Azure resource aliases. They don't come with you.&lt;/p&gt;

&lt;p&gt;In this build every rule is Falco YAML, every mapping is Wazuh XML and every control is a line in a Git file. If I moved clusters tomorrow, all of it would come along.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&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%2Fl37wjijuees7fb03cpgi.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%2Fl37wjijuees7fb03cpgi.png" alt="wz" width="800" height="505"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Architecture: Falco on each node, Wazuh agents on the hosts, the Istio gateway, the Wazuh cluster, Slack and Probo&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I think of it as four layers, each answering one question:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Stands in for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prevent&lt;/td&gt;
&lt;td&gt;Kyverno&lt;/td&gt;
&lt;td&gt;May this run at all?&lt;/td&gt;
&lt;td&gt;Azure Policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Detect&lt;/td&gt;
&lt;td&gt;Falco&lt;/td&gt;
&lt;td&gt;What is running right now, and is it an attack?&lt;/td&gt;
&lt;td&gt;CrowdStrike&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Correlate&lt;/td&gt;
&lt;td&gt;Wazuh&lt;/td&gt;
&lt;td&gt;What does it mean, and which technique and criterion does it hit?&lt;/td&gt;
&lt;td&gt;the SIEM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attest&lt;/td&gt;
&lt;td&gt;Probo&lt;/td&gt;
&lt;td&gt;Which control does it satisfy, and where's the evidence?&lt;/td&gt;
&lt;td&gt;Vanta&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Kyverno came earlier in this series, so I don't rebuild it here. Its policy reports don't reach Wazuh yet, which is why that arrow is dashed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the agents run
&lt;/h2&gt;

&lt;p&gt;Wazuh documents two ways to run its agent in Kubernetes, as a DaemonSet or as a sidecar. I didn't use either. Ansible installs the agent on the host itself, on all 20 RKE2 VMs and the 3 Proxmox hosts.&lt;/p&gt;

&lt;p&gt;The reasoning is simple: whatever watches the cluster shouldn't fall over when the cluster does. A DaemonSet agent only sees its own container unless you give it the host filesystem, privileged mode and the host PID namespace, at which point your security agent is the most privileged pod on every node. If the cluster goes down, a DaemonSet agent goes with it, and a dead node just looks like silence. The documented DaemonSet also mounts the Docker socket, and RKE2 runs containerd. And the Proxmox hosts aren't Kubernetes nodes at all, so only a host install reaches them.&lt;/p&gt;

&lt;p&gt;So Falco covers the containers, and the host agent covers the host and carries Falco's alerts out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying Wazuh into a brownfield cluster
&lt;/h2&gt;

&lt;p&gt;I vendored Wazuh's Kubernetes manifests at v4.14.9 without touching them, and change them only through patches in a Kustomize overlay that Argo CD applies. Upstream assumes a cloud and an empty cluster. Mine is neither, and several things broke.&lt;/p&gt;

&lt;p&gt;The first surprise was four &lt;code&gt;type: LoadBalancer&lt;/code&gt; Services. With MetalLB in the cluster, the dashboard, the manager API, the workers and the indexer API on 9200 would each have taken an address on my LAN. All four became ClusterIP, and only the Istio gateway is exposed. The manifests also ship default credentials, &lt;code&gt;admin:SecretPassword&lt;/code&gt; and an enrolment password of &lt;code&gt;password&lt;/code&gt;, plus four demo users with known hashes. Terraform generates every certificate, password and hash into Vault instead, and the Vault Secrets Operator syncs them into Secrets with the names upstream expects. Upstream's secret files never make it into the overlay.&lt;/p&gt;

&lt;p&gt;Then the service mesh broke Wazuh's own clustering. With Istio sidecars, every connection reached the master from &lt;code&gt;127.0.0.6&lt;/code&gt;, so the two workers looked like one, only two of the three indexers joined, and Filebeat failed TLS. Wazuh already encrypts every hop, so I took the namespace out of the mesh. Upstream also sets &lt;code&gt;vm.max_map_count&lt;/code&gt; from a privileged init container. Ansible sets it on the database nodes now, the init container is gone, and the namespace enforces Pod Security baseline. Restricted isn't possible, because the manager runs as root.&lt;/p&gt;

&lt;p&gt;The one that cost me most was disk. Each manager downloads a 9.2 GB vulnerability feed, and upstream gives it 500Mi. I'd raised that to 10Gi, and within 15 minutes all three volumes were full, &lt;code&gt;wazuh-db&lt;/code&gt; couldn't open its database, and the master stopped taking events. They have 40Gi each on Ceph now, on &lt;code&gt;ceph-block&lt;/code&gt; instead of upstream's cloud StorageClass.&lt;/p&gt;

&lt;p&gt;Agents reach the managers through the same Istio ingress gateway as everything else, with ports 1514 (events) and 1515 (enrolment) added as TCP routes. On the first enrolment, agents registered with the master and then connected to a worker that didn't have their key yet. They got "Invalid ID", tried again, were refused as duplicates, and settled after a few minutes.&lt;/p&gt;

&lt;p&gt;At the end of it I had 23 agents active, 3 indexers green, a master and 2 workers.&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%2Fhipe33qkztcfyltdnq7c.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%2Fhipe33qkztcfyltdnq7c.png" alt="wz" width="800" height="358"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Wazuh Endpoints: 23 agents active, 20 Ubuntu RKE2 VMs and 3 Debian Proxmox hosts, all on Wazuh v4.14.9&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Single sign-on with Keycloak
&lt;/h2&gt;

&lt;p&gt;The dashboard signs in through Keycloak, like every other app on the platform. Terraform creates a &lt;code&gt;wazuh&lt;/code&gt; realm with Google and GitHub sign-in and an email allow-list, so any other account is turned away at first login. The indexer maps the allowed emails to &lt;code&gt;all_access&lt;/code&gt;, and the internal admin from Vault stays as a break-glass login.&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%2Fo3317ge798bzjlap7eu1.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%2Fo3317ge798bzjlap7eu1.png" alt="wz" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The Wazuh login page: the internal login stays as a break-glass, and "Sign in with Keycloak" is how people get in&lt;/p&gt;
&lt;/blockquote&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%2F90vahpegaapmt7uti1ee.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%2F90vahpegaapmt7uti1ee.png" alt="wz" width="800" height="369"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Keycloak realm wazuh: sign in with Google or GitHub; only allow-listed emails get through&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Bringing Falco back
&lt;/h2&gt;

&lt;p&gt;Falco had been switched off on this cluster for a while. It was using 875 to 956 millicores per node against a 100m request, and I needed the CPU.&lt;/p&gt;

&lt;p&gt;When I turned it back on, every pod crash-looped. Falco itself started fine, but its container plugin (0.6.3, bundled with chart 8.0.2) panicked on a nil pointer: containerd 2.3 leaves a field empty that the plugin reads without checking. Pinning the plugin to 0.7.4 fixed it without a chart upgrade.&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;collectors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;containerEngine&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;pluginRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/falcosecurity/plugins/plugin/container:0.7.4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A changed ConfigMap doesn't restart a DaemonSet, so I had to restart the pods before they picked it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Routing Falco alerts to Wazuh
&lt;/h2&gt;

&lt;p&gt;My first idea was Falcosidekick posting straight to Wazuh. That doesn't work, because the manager listens for syslog, not HTTP, and every alert would arrive with no idea which host it came from.&lt;/p&gt;

&lt;p&gt;What I did instead needs no new component. Falco writes each alert as one JSON line to a file on the node, and the Wazuh agent that's already on that host reads it. Every alert reaches Wazuh tagged with the right host, through the same gateway and TLS as everything else.&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;falco&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;file_output&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&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;keep_alive&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;filename&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/log/falco/events.json&lt;/span&gt;

&lt;span class="na"&gt;mounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;volumes&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;falco-events&lt;/span&gt;
      &lt;span class="na"&gt;hostPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/log/falco&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DirectoryOrCreate&lt;/span&gt;
  &lt;span class="na"&gt;volumeMounts&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;falco-events&lt;/span&gt;
      &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/log/falco&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I didn't edit 23 hosts to point the agents at that file. Wazuh's shared configuration does it from the master:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;agent_config&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;localfile&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;log_format&amp;gt;&lt;/span&gt;json&lt;span class="nt"&gt;&amp;lt;/log_format&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;location&amp;gt;&lt;/span&gt;/var/log/falco/events.json&lt;span class="nt"&gt;&amp;lt;/location&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/localfile&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/agent_config&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A small set of rules, IDs 100300 to 100399, turns Falco's priorities into Wazuh levels and tags each alert with a MITRE technique and a SOC 2 criterion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;rule&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"100311"&lt;/span&gt; &lt;span class="na"&gt;level=&lt;/span&gt;&lt;span class="s"&gt;"8"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;if_sid&amp;gt;&lt;/span&gt;100302&lt;span class="nt"&gt;&amp;lt;/if_sid&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;field&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"rule"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;^Read sensitive file untrusted$|^Read Sensitive File in Container$&lt;span class="nt"&gt;&amp;lt;/field&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;description&amp;gt;&lt;/span&gt;Falco: sensitive file read in $(output_fields.k8s.ns.name)/$(output_fields.k8s.pod.name)&lt;span class="nt"&gt;&amp;lt;/description&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;mitre&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;id&amp;gt;&lt;/span&gt;T1003.008&lt;span class="nt"&gt;&amp;lt;/id&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/mitre&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;group&amp;gt;&lt;/span&gt;tsc_CC7.2,tsc_CC7.3,&lt;span class="nt"&gt;&amp;lt;/group&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/rule&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;keep_alive: false&lt;/code&gt; in the Falco config looks like a detail. It isn't, and the tuning section explains why. Two other things caught me out here. The master syncs new rules to the workers, but the workers keep using the old ones until &lt;code&gt;analysisd&lt;/code&gt; reloads (&lt;code&gt;PUT /cluster/analysisd/reload&lt;/code&gt;), so for a while half the cluster looked silent. And the file needs rotating, which logrotate does with &lt;code&gt;copytruncate&lt;/code&gt;, rolled out by Ansible. The agent notices the truncation and reads from the top again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alerts in Slack
&lt;/h2&gt;

&lt;p&gt;Anything at level 8 or above goes to Slack, into the channel Alertmanager already posts to. Wazuh has a built-in Slack integration, so the message carries the mapped alert: host, pod, MITRE technique and SOC 2 criterion.&lt;/p&gt;

&lt;p&gt;The webhook is a secret, and &lt;code&gt;ossec.conf&lt;/code&gt; can't read secrets from a file. So the Vault Secrets Operator renders the whole integration block, and an init container appends it to the manager's config when the pod starts:&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;transformation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;excludeRaw&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;templates&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;slack.conf&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|-&lt;/span&gt;
        &lt;span class="s"&gt;&amp;lt;ossec_config&amp;gt;&lt;/span&gt;
          &lt;span class="s"&gt;&amp;lt;integration&amp;gt;&lt;/span&gt;
            &lt;span class="s"&gt;&amp;lt;name&amp;gt;slack&amp;lt;/name&amp;gt;&lt;/span&gt;
            &lt;span class="s"&gt;&amp;lt;hook_url&amp;gt;{{ get .Secrets "hook_url" | trim }}&amp;lt;/hook_url&amp;gt;&lt;/span&gt;
            &lt;span class="s"&gt;&amp;lt;level&amp;gt;8&amp;lt;/level&amp;gt;&lt;/span&gt;
            &lt;span class="s"&gt;&amp;lt;alert_format&amp;gt;json&amp;lt;/alert_format&amp;gt;&lt;/span&gt;
          &lt;span class="s"&gt;&amp;lt;/integration&amp;gt;&lt;/span&gt;
        &lt;span class="s"&gt;&amp;lt;/ossec_config&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Terraform fills that Vault value from the same variable as Alertmanager's webhook, so rotating one rotates both. While setting this up I also worked out why I'd once missed an Alertmanager alert. A Slack incoming webhook posts to the channel it was created for and ignores the &lt;code&gt;channel&lt;/code&gt; in your config. My alerts had been arriving all along, just not in the channel the config named, and the one I needed was buried among routine warnings.&lt;/p&gt;

&lt;p&gt;Something to know before you copy this: Wazuh writes the full webhook URL to &lt;code&gt;/var/ossec/logs/integrations.log&lt;/code&gt; every time it sends. Don't screenshot that file, and assume anyone who can exec into the managers has the URL.&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%2Fv3t0w3j3n6n8vy21bl2w.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%2Fv3t0w3j3n6n8vy21bl2w.png" alt="wz" width="800" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;h1&gt;
  
  
  monitoring-stack-alert: the shell test as Wazuh rule 100310, level 8, with agent and location. Below it, one of the manager start-up false alarms that the wazuh-syscheckd exemption later removed
&lt;/h1&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Validation
&lt;/h2&gt;

&lt;p&gt;Nothing here runs on a schedule. Falco looks at every system call as it happens. To check the whole chain, I ran three real attacks against a pod that was already in the cluster:&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;# Read the password hashes&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; probo &lt;span class="nb"&gt;exec &lt;/span&gt;deploy/probo-chrome &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; /etc/shadow

&lt;span class="c"&gt;# An interactive shell inside a container&lt;/span&gt;
script &lt;span class="nt"&gt;-q&lt;/span&gt; /dev/null kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; probo &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; deploy/probo-chrome &lt;span class="nt"&gt;--&lt;/span&gt; sh

&lt;span class="c"&gt;# Tamper with the container's configuration&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; probo &lt;span class="nb"&gt;exec &lt;/span&gt;deploy/probo-chrome &lt;span class="nt"&gt;--&lt;/span&gt; sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo t &amp;gt; /etc/falco-write-test; rm -f /etc/falco-write-test'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three arrived in Wazuh with the right host, pod and namespace, and so did a real privileged container launch:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Falco rule&lt;/th&gt;
&lt;th&gt;Wazuh rule&lt;/th&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;MITRE&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cat /etc/shadow&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read sensitive file untrusted&lt;/td&gt;
&lt;td&gt;100311&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;T1003.008&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kubectl exec -it … sh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Terminal shell in container&lt;/td&gt;
&lt;td&gt;100310&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;T1059&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write below &lt;code&gt;/etc&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Write to /etc in Container&lt;/td&gt;
&lt;td&gt;100302&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Privileged pod replaced&lt;/td&gt;
&lt;td&gt;Launch Privileged Container in Homelab&lt;/td&gt;
&lt;td&gt;100322&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;T1611&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;From Falco seeing the system call to Wazuh storing the alert took 0.72 seconds.&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%2Fwmmxbft2takkccc4bu5o.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%2Fwmmxbft2takkccc4bu5o.png" alt="wz" width="800" height="302"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Wazuh Discover, filtered to Falco alerts from the probo namespace: the shadow read (100311, T1003.008), the interactive shell (100310, T1059) and the /etc write (100302), each tagged with the host agent that shipped it&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Tuning the noise
&lt;/h2&gt;

&lt;p&gt;The first hour was about 18,500 Falco alerts, nearly all of them the platform going about its normal business. Nobody can read that, so most of this project ended up being tuning.&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%2Fo4ifqiko9z7s3gqxedg6.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%2Fo4ifqiko9z7s3gqxedg6.png" alt="wz" width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Falco alerts per hour before and after tuning, and the 332 s to 0.72 s delay fix&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I used the same loop every time. Find the top image and namespace for each rule in Wazuh, decide whether it's a threat or a known workload, fix it in Git, then compare the hour before with the hour after.&lt;/p&gt;

&lt;p&gt;The biggest single fix was embarrassing. Our own "Read Sensitive File in Container" rule listed &lt;code&gt;/etc/passwd&lt;/code&gt;, which fired about 3,600 times an hour as MySQL, Postgres, Ceph and Trino looked up user names. That file is world-readable on purpose. I took it out and put &lt;code&gt;/etc/gshadow&lt;/code&gt; in. Close behind it was Wazuh's own rootcheck reading &lt;code&gt;/etc/shadow&lt;/code&gt; on every host, which is the security agent tripping the security rule. I exempted it by its executable path, &lt;code&gt;/var/ossec/bin/wazuh-modulesd&lt;/code&gt;, rather than a process name anything could copy. Later the managers' file-integrity daemon needed the same treatment, after every manager restart posted three false alarms to Slack.&lt;/p&gt;

&lt;p&gt;Two rules were broken in ways I'd never have guessed. "Launch Privileged Container" had no event type in its condition, so it matched every system call of a privileged container that was already running, about 3,600 times an hour from one CSI driver. Falco even warns about this at load time (&lt;code&gt;LOAD_NO_EVTTYPE&lt;/code&gt;). It now matches only a container's first process, and deleting one of those CSI pods produced exactly one alert. The kubectl rule excluded &lt;code&gt;(ci_cd_images, kubectl_images)&lt;/code&gt;, but neither list was defined anywhere, and Falco quietly reads an undefined name as a plain string. That exclusion had never matched anything, which is why the kube-apiserver's own health probe was firing thousands of times an hour.&lt;/p&gt;

&lt;p&gt;For everything else I exempted by namespace and image together. Controllers that talk to the Kubernetes API by design, such as Tekton, Kiali, Kyverno and Argo CD, are allowed to only in their own namespace with their own image. KubeArmor, the Prometheus config reloader and Istio's &lt;code&gt;pilot-agent&lt;/code&gt; may write their own config below &lt;code&gt;/etc&lt;/code&gt;, but only in their own directory. A reverse-shell rule that matched &lt;code&gt;-i&lt;/code&gt; anywhere in a bash command had to be narrowed too, because Rook-Ceph's health probes run &lt;code&gt;curl -i&lt;/code&gt; and produced over a thousand false Critical alerts in three hours. The last one turned up while I was writing this. Our GitHub Actions self-hosted runners download &lt;code&gt;kubectl&lt;/code&gt;, &lt;code&gt;crane&lt;/code&gt; and &lt;code&gt;yq&lt;/code&gt; during a job and run them, which is exactly what Falco's "drop and execute a new binary" rule looks for. One CI run raised 594 Critical alerts, every one of which would have gone to Slack. The runners are now exempt for binaries under their own home directory, and only there.&lt;/p&gt;

&lt;p&gt;After every change I ran the attack tests again, and they all still fired. That mattered more to me than the drop in numbers.&lt;/p&gt;

&lt;p&gt;The same story is on a dashboard, kept in Git like everything else. A small generator writes the saved objects, and a Job imports them after every Argo CD sync.&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%2F2xi0xln7na21i3yn1cm8.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%2F2xi0xln7na21i3yn1cm8.png" alt="wz" width="800" height="182"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Falco runtime security: every Falco alert carries SOC 2 CC7.2; the 2,607 at level 8 and above also carry CC7.3 and go to Slack&lt;/p&gt;
&lt;/blockquote&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%2Fcaoti5tufjzy9w7207qq.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%2Fcaoti5tufjzy9w7207qq.png" alt="wz" width="800" height="364"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The last 24 hours in half-hour bars: about 10,000 alerts every 30 minutes before tuning, then a trickle. The MITRE techniques sit beside them&lt;/p&gt;
&lt;/blockquote&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%2F8dtfp3vwwxpzzvjxkcap.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%2F8dtfp3vwwxpzzvjxkcap.png" alt="wz" width="800" height="369"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Where the alerts came from, by namespace and image, and the level 8+ events with their MITRE technique&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Getting rid of the noise exposed a problem I hadn't seen before. I wrote a test file below &lt;code&gt;/etc&lt;/code&gt; and watched it appear on Falco's stdout straight away, then waited. It reached Wazuh 332 seconds later, and only because I restarted that Falco pod. With &lt;code&gt;keep_alive: true&lt;/code&gt;, Falco keeps the output file open and buffers its writes, even with &lt;code&gt;buffered_outputs: false&lt;/code&gt;. At 18,500 alerts an hour the buffer filled every few seconds, so I never noticed. At a few dozen an hour, a real alert could sit there for minutes. Switching to &lt;code&gt;keep_alive: false&lt;/code&gt; makes Falco write each alert as it happens, and the same test took 0.72 seconds. If you tune a pipeline like this, measure the timing again afterwards.&lt;/p&gt;

&lt;p&gt;There was a nice side effect, too. Before tuning, Falco averaged 294 millicores per node. After, the same 14 pods average 28.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compliance mapping in Wazuh
&lt;/h2&gt;

&lt;p&gt;This is where the security work turns into compliance evidence. Wazuh's own ruleset tags its checks with PCI DSS, NIST 800-53, HIPAA, GDPR and SOC 2, so a failed check arrives already attached to named controls.&lt;/p&gt;

&lt;p&gt;Here's a real one. Wazuh runs the CIS Ubuntu 24.04 benchmark on every host agent, and this check fails on all 20 of my RKE2 VMs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Check&lt;/td&gt;
&lt;td&gt;CIS Ubuntu Linux 24.04 LTS 3.3.5: Ensure ICMP redirects are not accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wazuh rule&lt;/td&gt;
&lt;td&gt;19007, level 7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SOC 2&lt;/td&gt;
&lt;td&gt;CC6.3, CC6.6, CC7.1, CC7.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PCI DSS v4&lt;/td&gt;
&lt;td&gt;1.2.5, 2.2.4, 6.4.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ISO 27001:2013&lt;/td&gt;
&lt;td&gt;A.13.1.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NIST 800-53&lt;/td&gt;
&lt;td&gt;CM.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MITRE ATT&amp;amp;CK&lt;/td&gt;
&lt;td&gt;T1557, mitigations M1030 and M1042&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's as far as Wazuh goes. It produces the mapped technical evidence, but it doesn't track who owns a control or what an auditor has already seen. For that I use Probo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evidence in Probo
&lt;/h2&gt;

&lt;p&gt;Probo is an open-source compliance tool. It runs in the same cluster and signs in through Keycloak with SAML. Its frameworks come with their controls already written, and I added all 13 to my organisation, 1,008 controls in total:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;Controls&lt;/th&gt;
&lt;th&gt;Measures linked&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SOC 2&lt;/td&gt;
&lt;td&gt;61&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PCI DSS&lt;/td&gt;
&lt;td&gt;331&lt;/td&gt;
&lt;td&gt;not yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ISO 27001 (2022)&lt;/td&gt;
&lt;td&gt;123&lt;/td&gt;
&lt;td&gt;not yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ISO 27701 (2025)&lt;/td&gt;
&lt;td&gt;108&lt;/td&gt;
&lt;td&gt;not yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ISO 42001 (2023)&lt;/td&gt;
&lt;td&gt;70&lt;/td&gt;
&lt;td&gt;not yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DORA&lt;/td&gt;
&lt;td&gt;65&lt;/td&gt;
&lt;td&gt;not yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GDPR&lt;/td&gt;
&lt;td&gt;62&lt;/td&gt;
&lt;td&gt;not yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HIPAA&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;not yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HDS&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;not yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FERPA&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;not yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;21 CFR Part 11&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;not yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CCPA&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;not yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NIS 2&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;not yet&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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%2Fyk5qp7zb3ipwblbhasx5.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%2Fyk5qp7zb3ipwblbhasx5.png" alt="wz" width="800" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Keycloak realm Probo: sign in with Google or GitHub; only allow-listed emails get through&lt;/p&gt;
&lt;/blockquote&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%2Fu4m7edspji5r2nrdy6dd.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%2Fu4m7edspji5r2nrdy6dd.png" alt="wz" width="800" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Created George Homelab as organisation&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I started with SOC 2, because Wazuh already tags every alert with its criteria.&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%2Faal2c7000jfko51qe236.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%2Faal2c7000jfko51qe236.png" alt="wz" width="800" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The 13 frameworks added to my Probo organisation; only SOC 2 has measures linked so far&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In Probo, each control the platform really runs becomes a measure, linked to the criteria it answers. I didn't want to click those together in a UI, so they live in a YAML file in Git:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IC-MON-01&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;Runtime threat detection (Falco → Wazuh)&lt;/span&gt;
  &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Monitoring&lt;/span&gt;
  &lt;span class="na"&gt;control_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DETECTIVE&lt;/span&gt;
  &lt;span class="na"&gt;operating_frequency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CONTINUOUS&lt;/span&gt;
  &lt;span class="na"&gt;implementation_status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OPERATING&lt;/span&gt;
  &lt;span class="na"&gt;soc2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CC7.2&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CC7.3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A short script, &lt;code&gt;probo-sync.py&lt;/code&gt;, reads that file and talks to Probo's MCP API. It shows a plan first, only applies with &lt;code&gt;--apply&lt;/code&gt;, and never deletes anything. The first run created 11 measures and 19 links, covering runtime detection, host assessment, sign-in, secrets, network segmentation, admission policy, GitOps change control, the image supply chain and tested backups. Where I know a measure has a gap, it says "in progress". When I add ISO 27001, I'll link the same measures to its controls. The evidence doesn't change, only the framework it's mapped to.&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%2Ff89jah210r9cd7dq14qk.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%2Ff89jah210r9cd7dq14qk.png" alt="wz" width="800" height="330"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;SOC 2 control CC7.2 in Probo, answered by three measures: runtime threat detection and the Wazuh host assessment, both implemented, and Prometheus alerting, still in progress&lt;/p&gt;
&lt;/blockquote&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%2F4vkxxl64rd1r2niv84cb.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%2F4vkxxl64rd1r2niv84cb.png" alt="wz" width="799" height="359"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;All 11 measures in Probo, created from soc2-mapping.yaml: seven implemented, four still in progress where I know there's a gap&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  One event, end to end
&lt;/h2&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%2F9p5s81ct24jyfwf7incd.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%2F9p5s81ct24jyfwf7incd.png" alt="wz" width="800" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One shell in a container, from the system call to Slack and the SOC 2 control&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Following one shell through the whole chain: Falco matches "Terminal shell in container" on the node where the pod runs. The Wazuh agent on that node reads the JSON line and sends it through the Istio gateway on port 1514. The manager applies rule 100310, which makes it level 8 and tags it T1059 and SOC 2 CC7.2 and CC7.3. The alert is stored 0.72 seconds after the system call and posted to Slack. In Probo, CC7.2 is answered by the runtime detection measure, which names this pipeline as its evidence.&lt;/p&gt;

&lt;p&gt;The last step is the weak one. The measure shows the control exists and runs, but one alert doesn't flow into Probo by itself. That's what I'll build next.&lt;/p&gt;

&lt;h2&gt;
  
  
  How reliable is it?
&lt;/h2&gt;

&lt;p&gt;The lab powers off every night and cold-boots every morning, so every part of this restarts from scratch daily, which turns out to be a decent test in itself.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What goes wrong&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Evidence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;All three hosts cold-boot&lt;/td&gt;
&lt;td&gt;Wazuh, Falco and Probo come back with no manual step&lt;/td&gt;
&lt;td&gt;23 agents active and 3 indexers green after the morning boot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A Wazuh manager restarts&lt;/td&gt;
&lt;td&gt;Its agents reconnect to the managers still running&lt;/td&gt;
&lt;td&gt;All three managers restarted one after another: all 23 agents reconnected, and every test alert sent during the restarts arrived&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Falco restarts on every node (14 pods)&lt;/td&gt;
&lt;td&gt;Each pod is replaced in turn and picks up where it stopped&lt;/td&gt;
&lt;td&gt;Several rolling restarts during tuning; every attack test run afterwards fired&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An indexer pod dies&lt;/td&gt;
&lt;td&gt;The cluster drops to yellow, never red, and alerts keep landing. The pod is back in about 10 seconds&lt;/td&gt;
&lt;td&gt;Green again 60 seconds after the delete; an attack test sent during the outage was stored 0.21 s after Falco saw it. The first run went red: Wazuh creates its inventory and vulnerability indices with 0 replicas. An hourly job now keeps one replica on them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One Proxmox host goes down&lt;/td&gt;
&lt;td&gt;[PENDING: shut down one host, check agents fail over to the remaining workers and the indexer keeps 2 of 3]&lt;/td&gt;
&lt;td&gt;[PENDING]&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What this doesn't do
&lt;/h2&gt;

&lt;p&gt;CrowdStrike sells more than a sensor. You also get managed threat hunting and threat intelligence drawn from their whole customer base. Falco and Wazuh give me the detection, but none of that service.&lt;/p&gt;

&lt;p&gt;Detection is automatic here, and response isn't. When an alert lands in Slack, a person decides what happens next. Falco Talon could kill a pod on its own, but I haven't deployed it.&lt;/p&gt;

&lt;p&gt;Vanta collects evidence through hundreds of integrations. Here, only the measures are synced from Git, and linking evidence is still manual. Only SOC 2 is mapped so far. The other 12 frameworks are in Probo with all their controls, waiting. Kyverno's policy reports don't reach Wazuh yet either, so I can't show a compliance score per framework the way Azure Policy can, and the admission measure stays "in progress". Falco also isn't running on the database and GPU nodes yet; it covers 14 of the 20 VMs until I add tolerations for those pools.&lt;/p&gt;

&lt;p&gt;And you run all of it yourself. Upgrades, storage, certificates and noise tuning are your job. The tuning section above is a fair picture of what that takes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost and sovereignty
&lt;/h2&gt;

&lt;p&gt;There's no licence fee. What it costs is the capacity it uses, measured on the live cluster:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Footprint&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wazuh: 3 indexers, 3 managers, dashboard&lt;/td&gt;
&lt;td&gt;about 7 GiB memory, under 0.1 CPU core idle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wazuh storage on Ceph&lt;/td&gt;
&lt;td&gt;3 × 30Gi indexer, 3 × 40Gi manager&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Falco on 14 nodes&lt;/td&gt;
&lt;td&gt;about 28 millicores per node after tuning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Probo with its Postgres&lt;/td&gt;
&lt;td&gt;about 0.5 GiB memory&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here's roughly what the same coverage would cost from the vendors, at their published or reported prices:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Product&lt;/th&gt;
&lt;th&gt;Price basis&lt;/th&gt;
&lt;th&gt;For this lab&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CrowdStrike Falcon Enterprise (the tier with full EDR)&lt;/td&gt;
&lt;td&gt;$184.99 per device per year, list price (&lt;a href="https://www.crowdstrike.com/en-us/pricing/" rel="noopener noreferrer"&gt;CrowdStrike pricing&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;23 hosts: about $4,250 a year. Container and Kubernetes protection is priced separately and isn't published&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vanta&lt;/td&gt;
&lt;td&gt;No published price. Median contract $20,000 a year; $12,000 to $28,000 for 1 to 50 employees on one framework (&lt;a href="https://www.vendr.com/marketplace/vanta" rel="noopener noreferrer"&gt;Vendr, from anonymised purchases&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;at least $12,000 a year, plus the auditor: $8,000 to $25,000+ for SOC 2, not included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure Arc + Azure Policy&lt;/td&gt;
&lt;td&gt;Kubernetes configuration: first 6 vCPUs free per subscription; Azure Policy guest configuration $6 per server per month outside Azure; other Azure Policy at no extra cost (&lt;a href="https://azure.microsoft.com/en-us/pricing/details/azure-arc/core-control-plane/" rel="noopener noreferrer"&gt;Azure Arc pricing&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;the smallest line, but every policy and audit log then lives in Azure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That comes to $16,000 or more a year for a homelab, before anyone has paid an auditor. Self-hosting doesn't get rid of the auditor either.&lt;/p&gt;

&lt;p&gt;It doesn't get rid of the penetration test, either. PCI DSS still wants one every year, plenty of customers ask for the report, and third-party testers aren't cheap. In my experience, even moving a booked pentest to another date can cost 25% of the fee. What this stack changes is what you're paying the tester for. The findings a scanner would hand you, like the ICMP-redirect check failing on every node, are fixed before anyone arrives, so the hours go into the problems only a person can find. And while they work, every technique they try should show up in Wazuh and in Slack, which turns the pentest into a test of your detection as well.&lt;/p&gt;

&lt;p&gt;For me, though, the stronger reason is sovereignty. My security telemetry and compliance evidence never leave my hardware. No third party processes them, so the question of cross-border transfers never comes up.&lt;/p&gt;

&lt;p&gt;That carries more weight in the EU. DORA makes financial firms manage the risk of every ICT provider they rely on and keep a register of them. NIS 2 asks essential and important entities to secure their supply chain and to report a significant incident with an early warning within 24 hours and a fuller notification within 72. A self-hosted stack means fewer providers on that register, and the logs you'd report from stay on your own hardware. Probo ships both frameworks, too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;The next piece closes the gap in that last step: a small job that pushes Wazuh alert counts for each SOC 2 criterion into Probo as evidence, so the audit view updates itself. After that I'll send Kyverno's policy reports into Wazuh, give Falco tolerations for the database and GPU nodes, and link ISO 27001 to the measures that already exist.&lt;/p&gt;

&lt;p&gt;This is the part of the platform that proves the rest of it to someone looking in from outside: the Terraform, RKE2 with Ansible, the hardening series, the signed supply chain, storage, Vault and observability. It does live detection, mapped evidence and audit tracking, all in Git, all on hardware I control.&lt;/p&gt;

&lt;p&gt;To follow the rest of this build, 🚀 &lt;strong&gt;&lt;a href="https://www.linkedin.com/in/george-ezejiofor-89615a8a/" rel="noopener noreferrer"&gt;Follow me on LinkedIn&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Author: George Ezejiofor&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>devsecops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Brownfield Kubeflow on RKE2: Swapping Dex &amp; SeaweedFS for Keycloak &amp; Ceph RGW</title>
      <dc:creator>George Ezejiofor</dc:creator>
      <pubDate>Sat, 17 Oct 2026 19:16:43 +0000</pubDate>
      <link>https://dev.to/ezejioforog/brownfield-kubeflow-on-rke2-swapping-dex-seaweedfs-for-keycloak-ceph-rgw-4bdl</link>
      <guid>https://dev.to/ezejioforog/brownfield-kubeflow-on-rke2-swapping-dex-seaweedfs-for-keycloak-ceph-rgw-4bdl</guid>
      <description>&lt;p&gt;&lt;em&gt;Upstream Kubeflow assumes a greenfield cluster and ships its own Dex, SeaweedFS and MySQL, with its own oauth2-proxy in front. My homelab already had better versions of most of those. This is the **brownfield&lt;/em&gt;* deploy on an HA RKE2 cluster: every reference component I ripped out (and the one I haven't yet), what I wired in its place, and the auth chain that ate most of the debugging time.*&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%2F1ndqqwgu3xhta6nxbpzr.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%2F1ndqqwgu3xhta6nxbpzr.png" alt="kt" width="200" height="200"&gt;&lt;/a&gt;&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%2Fh55emmv6hfyeumquaf1a.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%2Fh55emmv6hfyeumquaf1a.png" alt="kf" width="799" height="370"&gt;&lt;/a&gt;&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%2Fyc7gdx7vcn0yr257i84n.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%2Fyc7gdx7vcn0yr257i84n.png" alt="kf" width="800" height="372"&gt;&lt;/a&gt;&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%2Fuvcoaub66pu49kq1sld8.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%2Fuvcoaub66pu49kq1sld8.png" alt="kf" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Kubeflow Central Dashboard, logged in through Keycloak SSO (not Dex), with the whole platform iframed into one tab. &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%2Frqne9qcbxt8im3hctg3p.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%2Frqne9qcbxt8im3hctg3p.png" alt="kf" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;




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

&lt;p&gt;&lt;em&gt;When the platform assumes it's the only thing in the cluster&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Kubeflow was designed as if it would have the cluster to itself, and it still shows. Point the reference installer at an empty cluster and it works well: nine controllers, four web UIs, two workflow engines, an inference layer and a per-user namespace system, all iframed into one dashboard and running in twenty minutes.&lt;/p&gt;

&lt;p&gt;Point it at a cluster that already has an identity provider, an object store, a database fleet, a service mesh and an SSO layer guarding ten other apps, and you're in for a fight. Kubeflow never asks what you already run. The v1.11 multi-user install brings Dex for identity (behind its own oauth2-proxy, wired into Istio as an external authorizer), SeaweedFS for object storage, MySQL for state, and a mesh-wide trust model that doesn't match any real cluster I know of.&lt;/p&gt;

&lt;p&gt;That's the gap between a greenfield deploy and a &lt;strong&gt;brownfield&lt;/strong&gt; one. Greenfield, you accept whatever the platform brings. Brownfield, the platform moves into a neighbourhood that was there first. Mine already had Keycloak issuing every realm, Rook-Ceph serving S3 to half the stack, CloudNativePG holding everyone's relational state, and oauth2-proxy in front of ten apps.&lt;/p&gt;

&lt;p&gt;I could run two of every backend, or rewrite Kubeflow's integration layer to talk to what was already in the building. I picked the rewrite. It cost roughly six weekends and a kustomize overlay per component, and the platform now fits the cluster it runs on.&lt;/p&gt;

&lt;p&gt;Here's the full list: every reference component upstream Kubeflow ships, what I put in its place, and why the stock one didn't fit.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stock Kubeflow manifests ship…&lt;/th&gt;
&lt;th&gt;I substituted…&lt;/th&gt;
&lt;th&gt;Why the stock one didn't fit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Dex&lt;/strong&gt; (bundled OIDC identity provider)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Keycloak&lt;/strong&gt;, central realm &lt;code&gt;kubeflow&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;One IdP already issues every app's realm. A Kubeflow-only Dex is a second identity island to run, patch and secure.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;oauth2-proxy + Dex as Istio ext_authz&lt;/strong&gt; (every request checked by an external authorizer at the gateway)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;My own &lt;code&gt;oauth2-proxy-kubeflow&lt;/code&gt; in reverse-proxy mode&lt;/strong&gt; against Keycloak, with ext_authz kept only for &lt;code&gt;/notebook/*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;It's the same OIDC pattern that already gates ~10 apps on per-app cookie scopes, and proxy mode lets one instance route all the static-URL UIs.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;SeaweedFS&lt;/strong&gt; (KFP artifact store, exposed behind the &lt;code&gt;minio-service&lt;/code&gt; name; MinIO in older releases)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Rook-Ceph RGW&lt;/strong&gt; (S3) via an &lt;code&gt;ObjectBucketClaim&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;One storage floor already serves S3 to MLflow and the rest of the data stack. Every KFP S3 client points straight at &lt;code&gt;https://s3.georgehomelab.com:443&lt;/code&gt; (RGW is TLS-only); VSO maps &lt;code&gt;AWS_*&lt;/code&gt; → &lt;code&gt;accesskey&lt;/code&gt;/&lt;code&gt;secretkey&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;MySQL&lt;/strong&gt; (KFP metadata DB)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Not swapped yet: still the bundled MySQL.&lt;/strong&gt; CloudNativePG &lt;code&gt;kubeflow_metadata_db&lt;/code&gt; is provisioned and waiting&lt;/td&gt;
&lt;td&gt;CNPG already hosts everyone's relational state with backups to Ceph RGW. The database and its Vault credentials exist; the KFP Deployments don't point at them yet (I checked live).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Principal-scoped AuthorizationPolicies&lt;/strong&gt; (allow only the ingress-gateway service account)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Path-based AP rules&lt;/strong&gt; (&lt;code&gt;paths: ["/*"]&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;A PERMISSIVE mesh doesn't populate the SPIFFE principal reliably, so principal-scoped rules silently deny everything.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bundled cert-manager + self-signed Issuer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Existing &lt;strong&gt;cert-manager&lt;/strong&gt; + Let's Encrypt (Cloudflare DNS-01)&lt;/td&gt;
&lt;td&gt;Real, browser-trusted certs are already automated cluster-wide; no self-signed CA to hand out.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kubeflow's Spark-on-K8s overlay&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Existing &lt;strong&gt;mlops-spark-operator&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;The MLOps stack already runs a Spark operator; two of them fight over the same CRDs.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Istio bundled by the installer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Existing &lt;strong&gt;RKE2 Istio 1.30&lt;/strong&gt; mesh (PERMISSIVE)&lt;/td&gt;
&lt;td&gt;The mesh predates Kubeflow and runs everything else. Kubeflow gets injected into it; an ML installer doesn't get to replace the service mesh.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rest of this piece walks through those swaps, the "huge CRD" trap that breaks client-side apply, the per-Profile governance Kyverno generates, and the Auth Chain Saga, where &lt;code&gt;RBAC: access denied&lt;/code&gt; turned up at five different layers over several days before login finally worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Kubeflow, and which release
&lt;/h2&gt;

&lt;p&gt;I pinned to &lt;code&gt;v1.11.0&lt;/code&gt; of the &lt;code&gt;kubeflow/manifests&lt;/code&gt; repo, the most recent immutable release tag when I started. Don't be fooled by &lt;code&gt;releases/release-1.11/&lt;/code&gt; on master: it's a release-process tracker (a README about dates and the team). The real installer entry point is &lt;code&gt;example/kustomization.yaml&lt;/code&gt;, and that differs between master HEAD (a moving target) and &lt;code&gt;v1.11.0&lt;/code&gt; (immutable). I wanted a build I could reproduce, so I took the tag.&lt;/p&gt;

&lt;p&gt;Most of that time went on the auth chain (the Auth Chain Saga and the notebook routing pivot below). The heavy KFP overlay and the Model Registry integration were the runners-up.&lt;/p&gt;

&lt;h3&gt;
  
  
  The honest head-to-head: Kubeflow vs SageMaker/Vertex, Flyte, Metaflow, Ray, and MLflow
&lt;/h3&gt;

&lt;p&gt;People ask me "should I use Kubeflow or MLflow?" a lot, and the question doesn't quite work. It's like asking whether to buy a kitchen or a knife. &lt;strong&gt;Kubeflow is a nine-component platform, and almost everything people compare it to replaces exactly one of those components.&lt;/strong&gt; To compare fairly you have to sort the field by the layer each tool occupies:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Whole self-hosted platform&lt;/strong&gt; (notebooks + pipelines + training + HPO + serving + registry + multi-tenant portal): &lt;strong&gt;Kubeflow&lt;/strong&gt;, plus the open all-in-ones &lt;strong&gt;MLRun&lt;/strong&gt; and &lt;strong&gt;Determined AI&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed cloud platform&lt;/strong&gt; (all of the above, on someone else's hardware): &lt;strong&gt;AWS SageMaker, GCP Vertex AI, Azure ML&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pipeline / orchestration only&lt;/strong&gt;: &lt;strong&gt;Flyte, Metaflow, Airflow, Prefect, Dagster&lt;/strong&gt;. These compete with KFP, which is one of the nine boxes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Experiment tracking + registry only&lt;/strong&gt;: &lt;strong&gt;MLflow, Weights &amp;amp; Biases, ClearML&lt;/strong&gt;. A component, not a platform (I run MLflow inside this Kubeflow).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serving only&lt;/strong&gt;: &lt;strong&gt;KServe&lt;/strong&gt; (which is part of Kubeflow), &lt;strong&gt;Seldon, BentoML, Triton&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distributed compute only&lt;/strong&gt;: &lt;strong&gt;Ray&lt;/strong&gt; (KubeRay), which runs alongside Kubeflow rather than replacing it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In other words, MLflow, Seldon and Ray are things you run inside Kubeflow, not alternatives to it. The only real peers to the whole platform are the managed clouds and a couple of open all-in-ones:&lt;/p&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;Notebooks&lt;/th&gt;
&lt;th&gt;Pipelines&lt;/th&gt;
&lt;th&gt;HPO&lt;/th&gt;
&lt;th&gt;Serving&lt;/th&gt;
&lt;th&gt;Registry&lt;/th&gt;
&lt;th&gt;Multi-tenant&lt;/th&gt;
&lt;th&gt;Self-hosted&lt;/th&gt;
&lt;th&gt;Best at&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kubeflow&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅ KFP&lt;/td&gt;
&lt;td&gt;✅ Katib&lt;/td&gt;
&lt;td&gt;✅ KServe&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅ Profiles&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Breadth&lt;/strong&gt;, on your own hardware, no lock-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SageMaker / Vertex&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌ cloud&lt;/td&gt;
&lt;td&gt;Zero-ops DX, if you'll pay + go off-prem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Flyte&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅✅ typed&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Best &lt;em&gt;pipeline&lt;/em&gt; authoring: typed, reproducible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Metaflow&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅ tracking&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Data-scientist Python DX; hide the infra&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Ray&lt;/strong&gt; (KubeRay)&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;✅ tasks&lt;/td&gt;
&lt;td&gt;✅ Tune&lt;/td&gt;
&lt;td&gt;✅ Serve&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Distributed &lt;em&gt;compute&lt;/em&gt; (train/tune at scale)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MLflow&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;✅✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Experiment tracking + model registry&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Where each one wins:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SageMaker / Vertex / Azure ML&lt;/strong&gt;: the best managed DX, zero platform ops, elastic GPU infra, wired into cloud data and IAM. If you already live in a cloud and care more about time-to-value than control, cost and data sovereignty, managed wins clearly. Kubeflow's answer is the flip side: no cloud lock-in, no per-GPU-hour rental markup, data never leaves my rack, and it runs on hardware I own. Think of it as a self-hosted SageMaker for people who want to own the platform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flyte&lt;/strong&gt;: honestly, a better pipeline engine than KFP. Strongly-typed Python, first-class versioning and caching, cleaner local dev. If pipelines are the whole job and you don't need notebooks, serving, HPO or a multi-tenant portal, Flyte is the nicer tool. Kubeflow wins on breadth: the nine boxes plus the portal that puts them in one tab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metaflow&lt;/strong&gt;: a pleasant Python DX from Netflix, aimed at data scientists who don't want to think about infrastructure. It covers orchestration and tracking, not serving, HPO or multi-tenancy, and its Kubernetes support is thinner than its AWS support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ray (KubeRay)&lt;/strong&gt;: the best tool for distributed compute: training big models, hyperparameter tuning, RL, distributed data. It doesn't replace Kubeflow, it sits under it (Katib overlaps Ray Tune; KServe can serve Ray). If your bottleneck is Python-native distributed compute, run Ray alongside Kubeflow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MLflow / W&amp;amp;B&lt;/strong&gt;: the best experiment tracking and registry around. That's why MLflow is a tile in my Central Dashboard rather than something I picked instead of Kubeflow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seldon / BentoML / Triton&lt;/strong&gt;: serving specialists. KServe covers most cases, but for advanced inference graphs or GPU-optimized Triton runtimes they can beat it on serving alone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why Kubeflow here, then? It's the one open-source platform that gives me all nine boxes (Jupyter notebooks, KFP pipelines, Trainer V2, Katib HPO, KServe serving, Model Registry and per-user Profiles) on my &lt;strong&gt;own RKE2 + GPU hardware&lt;/strong&gt;, integrated with the stacks I already run (Keycloak, Ceph, CNPG, Istio), with no cloud lock-in. I couldn't find anything else in open source that complete and also self-hosted.&lt;/p&gt;

&lt;p&gt;The catch is complexity. Kubeflow has a lot of moving parts, an auth chain that took several days to untangle, and an installer that assumes a greenfield cluster. If all you need is pipelines, Flyte has a fraction of the operational surface. If you'll pay for managed, SageMaker takes the ops away entirely.&lt;/p&gt;

&lt;p&gt;Kubeflow only makes sense when you want the full platform, self-hosted, on your own hardware, and you're prepared to pay the complexity cost once. If you don't need all that, a specialist tool or a managed service on a credit card is the better call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cluster Kubeflow is landing on
&lt;/h2&gt;

&lt;p&gt;The facts that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RKE2&lt;/strong&gt; with five masters and a mix of worker pools (app, data, GPU). HA control plane via kube-vip. The whole lab powers off every night and cold-boots in the morning, which matters more than I expected (see the cold-boot race below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cilium&lt;/strong&gt; in eBPF mode, with Hubble enabled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Istio 1.30&lt;/strong&gt; as the service mesh. The default mode is PERMISSIVE, and per-namespace PeerAuthentication CRs flip to STRICT where it matters. &lt;code&gt;istio-system&lt;/code&gt;'s default stays PERMISSIVE because the API server has to reach admission webhooks in injected namespaces without holding a client cert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rook-Ceph&lt;/strong&gt; for storage, with three SCs: &lt;code&gt;ceph-block&lt;/code&gt; (RBD, RWO), &lt;code&gt;ceph-filesystem&lt;/code&gt; (CephFS, RWX), &lt;code&gt;ceph-bucket&lt;/code&gt; (RGW, S3 via OBC).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudNativePG&lt;/strong&gt; for relational state. The MLOps cluster &lt;code&gt;postgres-mlops-cnpg-cluster&lt;/code&gt; hosts &lt;code&gt;kubeflow_metadata_db&lt;/code&gt;, ready for Kubeflow Pipelines but not in use yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vault&lt;/strong&gt; + Vault Secrets Operator, auto-unsealed by a separate transit Vault in its own &lt;code&gt;vault-transit&lt;/code&gt; namespace (Shamir, 1 share).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keycloak&lt;/strong&gt; as the OIDC provider for every app. Realms and clients are managed by Terraform; there are no Keycloak operator CRDs on this cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ArgoCD&lt;/strong&gt;, driven by AppSets. Each major area (mlops-stack, kubeflow-stack, cicd-stack, security-stack, monitoring-stack) has its own AppSet generating a child App per component.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kyverno&lt;/strong&gt; for admission policy and &lt;code&gt;generate&lt;/code&gt; rules. At the time, the cluster-wide &lt;code&gt;make-configmaps-immutable&lt;/code&gt; policy was the one that bit hardest on this build.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;kubeflow-stack.yaml&lt;/code&gt; AppSet in &lt;code&gt;kubernetes-addons/0-georgehomelab-argocd-projects/2-argocd-applicationsets/&lt;/code&gt; phases the rollout. Sync waves run from 1 (foundation) to 8 (the SSO front door), across 24 child Applications. Each component is its own Application, so I can debug it in isolation.&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%2Ffc401c4l2xkp85r6fpe5.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%2Ffc401c4l2xkp85r6fpe5.png" alt="kf" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Kubeflow on RKE2, what I replaced from upstream: the cluster's Istio gateway, oauth2-proxy as a reverse proxy and Keycloak in front of the Central Dashboard, its six apps, and a foundation of GPUs, bundled MySQL, Ceph object storage and CloudNativePG, each marked replaced, upstream or added&lt;/p&gt;

&lt;p&gt;&lt;a href="https://george-kubeflow.terranetes.workers.dev/" rel="noopener noreferrer"&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%2Fnfmnalldat6su62r6xey.png" alt="kf" width="662" height="302"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Kubeflow as deployed: oauth2-proxy → Central dashboard → apps, MLflow beside it, and every swap from upstream tagged.&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%2Fbbrgi0l3nrwl1caq2omd.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%2Fbbrgi0l3nrwl1caq2omd.png" alt="kf" width="800" height="373"&gt;&lt;/a&gt;&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%2Fq0bixurloitxe0xqr0ak.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%2Fq0bixurloitxe0xqr0ak.png" alt="kf" width="800" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The ArgoCD UI filtered to &lt;code&gt;kubeflow-*&lt;/code&gt;: the 24 child Applications from the &lt;code&gt;kubeflow-stack&lt;/code&gt; AppSet, all Synced/Healthy, in sync-wave order.&lt;/p&gt;

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

&lt;p&gt;Phase 0 is everything that has to exist before Phase 1's AppSet even sees the cluster. Five things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The CNPG &lt;code&gt;kubeflow_metadata_db&lt;/code&gt; Database CR.&lt;/strong&gt; It lives in &lt;code&gt;kubernetes-addons/Data-platform-stacks/mlops-cnpg/postgres-mlops-cnpg-cluster.yaml&lt;/code&gt; next to the other MLOps databases. The owner is the &lt;code&gt;kubeflow&lt;/code&gt; Postgres role, with its password in Vault. Applying the Database CR creates the database on the already-running CNPG cluster. It's there for the MySQL → CNPG move, which hasn't happened yet (see the KFP overlay section).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Vault paths, populated by Terraform.&lt;/strong&gt; Three matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;secret/homelab/kubeflow/kubeflow-metadata-db&lt;/code&gt;: &lt;code&gt;{username: kubeflow, password: ...}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;secret/homelab/kubeflow/oauth2-proxy&lt;/code&gt;: &lt;code&gt;{client-id, client-secret, cookie-secret}&lt;/code&gt; for the Keycloak &lt;code&gt;kubeflow&lt;/code&gt; realm client.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;secret/homelab/kubeflow/centraldashboard-session&lt;/code&gt; (optional): the Central Dashboard session secret.
Terraform writes these from &lt;code&gt;.secrets.auto.tfvars&lt;/code&gt;. No human writes Vault paths by hand.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The Keycloak &lt;code&gt;kubeflow&lt;/code&gt; realm.&lt;/strong&gt; Terraform creates the realm, its Google and GitHub identity providers and the oauth2-proxy client, and it reads that client's secret from the same Vault path oauth2-proxy uses, so the two can't drift. Before the SSO front door goes in, I check two things directly: the realm's &lt;code&gt;.well-known/openid-configuration&lt;/code&gt; returns 200 with a valid issuer, and the Vault path holds all three keys.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Google OAuth client.&lt;/strong&gt; Add &lt;code&gt;kubeflow.georgehomelab.com/oauth2/callback&lt;/code&gt; to the authorized redirect URIs on the existing OAuth client.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DNS and a dedicated certificate.&lt;/strong&gt; &lt;code&gt;kubeflow.georgehomelab.com → 10.0.1.21&lt;/code&gt; (the kube-vip ingress VIP), managed in Cloudflare. Kubeflow gets its own cert-manager &lt;code&gt;Certificate&lt;/code&gt; (&lt;code&gt;kubeflow-georgehomelab&lt;/code&gt;, issued via Let's Encrypt DNS-01), which writes the &lt;code&gt;kubeflow-georgehomelab-tls-cert&lt;/code&gt; Secret the Kubeflow Gateway serves.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With those five in place, Phase 1 can fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Laying the foundation
&lt;/h2&gt;

&lt;p&gt;Phase 1 is five Apps at sync waves 1 and 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;kubeflow-namespace&lt;/code&gt;&lt;/strong&gt; creates the &lt;code&gt;kubeflow&lt;/code&gt; and &lt;code&gt;kubeflow-system&lt;/code&gt; namespaces with &lt;code&gt;pod-security.kubernetes.io/enforce: privileged&lt;/code&gt;. Upstream uses &lt;code&gt;restricted&lt;/code&gt;, and my cluster default is also &lt;code&gt;restricted&lt;/code&gt;. But every istio-injected namespace needs &lt;code&gt;privileged&lt;/code&gt;, because &lt;code&gt;istio-init&lt;/code&gt; runs with &lt;code&gt;CAP_NET_ADMIN&lt;/code&gt; + &lt;code&gt;CAP_NET_RAW&lt;/code&gt; to program iptables. Without &lt;code&gt;privileged&lt;/code&gt;, istio-init fails admission and every pod in the namespace hits &lt;code&gt;FailedCreate&lt;/code&gt; with no obvious cause. CNPG's initdb Job hits the same trap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;kubeflow-cert-issuer&lt;/code&gt;&lt;/strong&gt; installs a selfSigned &lt;code&gt;ClusterIssuer&lt;/code&gt; for Kubeflow's internal admission-webhook certs. Public TLS doesn't use it; that comes from the dedicated Let's Encrypt certificate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;kubeflow-roles&lt;/code&gt;&lt;/strong&gt; holds the &lt;code&gt;kubeflow-admin&lt;/code&gt; / &lt;code&gt;kubeflow-edit&lt;/code&gt; / &lt;code&gt;kubeflow-view&lt;/code&gt; ClusterRole aggregates. The Profiles controller binds them in per-user namespaces when it reconciles a Profile CR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;kubeflow-istio-resources&lt;/code&gt;&lt;/strong&gt; is the dedicated &lt;code&gt;kubeflow-gateway&lt;/code&gt; Gateway, which I put in &lt;code&gt;istio-system&lt;/code&gt; rather than in the &lt;code&gt;kubeflow&lt;/code&gt; namespace where upstream puts it. It binds &lt;code&gt;kubeflow.georgehomelab.com&lt;/code&gt; specifically and serves the &lt;code&gt;kubeflow-georgehomelab-tls-cert&lt;/code&gt; Secret, so I can rotate the Kubeflow cert without touching anything else.&lt;/p&gt;

&lt;p&gt;The plan was failure-domain isolation from the shared &lt;code&gt;homelab-gateway&lt;/code&gt;, and that only half worked. &lt;code&gt;homelab-gateway&lt;/code&gt;'s &lt;code&gt;*.georgehomelab.com&lt;/code&gt; server overlaps this host on the same ingress gateway port 443, and Istio picks one per ingress replica. It showed up as intermittent 404s: curl worked, the browser didn't. The front-door VirtualService now binds &lt;em&gt;both&lt;/em&gt; gateways, so the route exists whichever one Istio picks.&lt;/p&gt;

&lt;p&gt;The same App later picked up the notebook ext_authz policy and its redirect EnvoyFilter (see the notebook routing section), but in Phase 1 it's just the Gateway and the RBAC aggregates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;kubeflow-kfp-infrastructure&lt;/code&gt;&lt;/strong&gt; holds KFP's prerequisites. It creates the &lt;code&gt;kfp-artifacts&lt;/code&gt; &lt;code&gt;ObjectBucketClaim&lt;/code&gt; (Rook-Ceph provisions a bucket on RGW with its own access keys), plus the &lt;code&gt;VaultAuth&lt;/code&gt; + &lt;code&gt;VaultStaticSecret&lt;/code&gt; that sync the CNPG &lt;code&gt;kubeflow&lt;/code&gt; user's credentials into a &lt;code&gt;kubeflow-metadata-db&lt;/code&gt; Secret in the &lt;code&gt;kubeflow&lt;/code&gt; namespace.&lt;/p&gt;

&lt;p&gt;Those credentials are ready and unused. The &lt;code&gt;ml-pipeline&lt;/code&gt; Deployment that lands in Phase 3 pulls the OBC-produced &lt;code&gt;kfp-artifacts-bucket&lt;/code&gt; Secret via &lt;code&gt;envFrom&lt;/code&gt;, but still reads its database credentials from the bundled MySQL's &lt;code&gt;mysql-secret&lt;/code&gt; (I checked live). The same App also carries a daily backup CronJob that dumps both bundled MySQL servers (KFP and Katib) and ships them to a &lt;code&gt;kubeflow-db-backups&lt;/code&gt; bucket on RGW. I only added it after noticing that neither database had a backup at all.&lt;/p&gt;

&lt;p&gt;After Phase 1 there are no application pods yet, just CRDs, OBCs, Secrets and the gateway. That's on purpose. Phase 1 only lays the substrate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Knative Serving and Eventing
&lt;/h2&gt;

&lt;p&gt;Phase 2 is Knative. Serving is a KServe dependency (KServe's &lt;code&gt;InferenceService&lt;/code&gt; Pod is built on Knative's &lt;code&gt;Service&lt;/code&gt; CR). Eventing is in for completeness, in case a later workload wants &lt;code&gt;Trigger&lt;/code&gt; / &lt;code&gt;Broker&lt;/code&gt; to fan events out.&lt;/p&gt;

&lt;p&gt;The deploy itself is uneventful; upstream's overlays land cleanly. The trouble is the CRD &lt;code&gt;spec.conversion&lt;/code&gt; blocks. Some Knative CRDs (&lt;code&gt;pingsources.sources.knative.dev&lt;/code&gt;, &lt;code&gt;eventtypes.eventing.knative.dev&lt;/code&gt;) ship with &lt;code&gt;webhookClientConfig&lt;/code&gt; set but &lt;code&gt;spec.conversion.strategy&lt;/code&gt; missing. ArgoCD's ServerSideDiff dry-run rejects that with "Required value / Forbidden", and the App sits in ComparisonError, stuck on Unknown.&lt;/p&gt;

&lt;p&gt;The fix goes in the AppSet template: &lt;code&gt;argocd.argoproj.io/compare-options: ServerSideDiff=false&lt;/code&gt;. Falling back to the legacy 3-way merge skips the dry-run. You lose mutation-webhook injection in the diff, which I can live with because those webhooks are already in the broader &lt;code&gt;ignoreDifferences&lt;/code&gt; block.&lt;/p&gt;

&lt;p&gt;The second Knative trap: the controller rewrites the entire &lt;code&gt;.webhooks[].rules[]&lt;/code&gt; array at runtime. It consolidates per-resource entries into wildcards, adds status subresources and uses apiVersion &lt;code&gt;*&lt;/code&gt;, while the source manifest's rules block is a stub. Result: permanent &lt;code&gt;OutOfSync&lt;/code&gt;. The fix is &lt;code&gt;ignoreDifferences&lt;/code&gt; on the whole &lt;code&gt;.webhooks&lt;/code&gt; array, scoped to the named Knative webhooks (&lt;code&gt;webhook.serving.knative.dev&lt;/code&gt;, &lt;code&gt;validation.webhook.serving.knative.dev&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;A third one was on the risk register but never bit: the &lt;code&gt;cluster-local-gateway&lt;/code&gt;. Knative needs an internal Istio gateway for in-cluster routing, and the homelab already had one in &lt;code&gt;istio-system&lt;/code&gt; from an earlier deploy (30+ day uptime, helm-managed). Installing it again from the Knative manifests would fight the existing helm field-manager, so the Knative phase leaves the cluster-local-gateway out and relies on the one that's there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The operators
&lt;/h2&gt;

&lt;p&gt;Phase 3 is seven Apps at sync waves 4 and 5: &lt;code&gt;admission-webhook&lt;/code&gt;, &lt;code&gt;trainer&lt;/code&gt;, &lt;code&gt;katib&lt;/code&gt;, &lt;code&gt;pipelines&lt;/code&gt; (KFP), &lt;code&gt;kserve&lt;/code&gt;, &lt;code&gt;kserve-models-web-app&lt;/code&gt; and &lt;code&gt;model-registry&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;admission-webhook&lt;/code&gt; is the PodDefaults admission controller. It mutates pods at admission to inject volumes, env vars and secrets. Tiny footprint, no surprises, and it turns out to be the piece users notice most (more on that in the governance section).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;trainer&lt;/code&gt; is Kubeflow Trainer V2 (formerly Training Operator V2). In 1.11 it's pure backend with no UI: the &lt;code&gt;TrainJob&lt;/code&gt; / &lt;code&gt;TrainingRuntime&lt;/code&gt; / &lt;code&gt;ClusterTrainingRuntime&lt;/code&gt; CRDs and a controller manager. Users create TrainJob CRs from notebooks with the Python SDK (&lt;code&gt;kubeflow.trainer&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Trainer has a kustomize trap. The upstream overlay sets &lt;code&gt;app.kubernetes.io/part-of: kubeflow-training&lt;/code&gt; on both the Deployment's &lt;code&gt;selector&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; the pod template. My overlay's &lt;code&gt;labels:&lt;/code&gt; block (which stamps &lt;code&gt;app.kubernetes.io/part-of: kubeflow-stack&lt;/code&gt;) with &lt;code&gt;includeSelectors:false includeTemplates:true&lt;/code&gt; only touches the template, so selector and template disagree and the apiserver rejects the Deployment. The fix was to drop the &lt;code&gt;labels:&lt;/code&gt; block from the trainer overlay.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;katib&lt;/code&gt; is the hyperparameter optimization service: &lt;code&gt;Experiment&lt;/code&gt; / &lt;code&gt;Suggestion&lt;/code&gt; / &lt;code&gt;Trial&lt;/code&gt; CRDs, a controller, a UI and a bundled MySQL backend. I left that MySQL alone. Katib only stores short-lived experiment metadata, and I saw no reason to put it on CNPG.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kserve&lt;/code&gt; and &lt;code&gt;kserve-models-web-app&lt;/code&gt; are the inference layer. KServe's controller ships ~30 CRDs (InferenceService, InferenceGraph, ClusterServingRuntime and so on), and the Models Web App is a UI for listing and configuring InferenceService CRs. &lt;code&gt;model-registry&lt;/code&gt; gets its own section further down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The KFP heavy overlay
&lt;/h2&gt;

&lt;p&gt;This is the most invasive overlay in the whole deploy. Upstream's &lt;code&gt;applications/pipeline/upstream/env/cert-manager/platform-agnostic-multi-user&lt;/code&gt; overlay assumes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;SeaweedFS&lt;/strong&gt; for KFP artifacts: a bundled S3-compatible store that upstream exposes under the old &lt;code&gt;minio-service&lt;/code&gt; Service name, so KFP's MinIO-era config keeps working. (Older releases shipped MinIO itself.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MySQL&lt;/strong&gt; for the &lt;code&gt;ml-pipeline&lt;/code&gt; metadata database: bundled Deployment, Service and PVC.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;&lt;code&gt;mlpipeline-minio-artifact&lt;/code&gt;&lt;/strong&gt; Secret with hardcoded &lt;code&gt;minio&lt;/code&gt; / &lt;code&gt;minio123&lt;/code&gt; creds.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I deleted the SeaweedFS workloads and replaced the Secret. MySQL is still running (more on that below).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SeaweedFS → RGW.&lt;/strong&gt; Every KFP component that talks S3 now goes straight to RGW's public TLS endpoint, &lt;code&gt;s3.georgehomelab.com:443&lt;/code&gt;. There are five of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ml-pipeline&lt;/code&gt; (the API server) and &lt;code&gt;cache-server&lt;/code&gt;, through env vars;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pipeline-install-config&lt;/code&gt; (&lt;code&gt;minioServiceHost&lt;/code&gt;);&lt;/li&gt;
&lt;li&gt;the Argo &lt;code&gt;artifactRepository&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;the pipelines profile-controller's &lt;code&gt;sync.py&lt;/code&gt;, through &lt;code&gt;AWS_ENDPOINT_URL&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first version of this overlay worked differently. It turned upstream's &lt;code&gt;minio-service&lt;/code&gt; into an &lt;code&gt;ExternalName&lt;/code&gt; pointing at the in-cluster RGW Service on plain port 80. That broke the day RGW went TLS-only and port 80 disappeared.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;ExternalName&lt;/code&gt; is still there, now pointing at &lt;code&gt;s3.georgehomelab.com:443&lt;/code&gt;, but it's vestigial. Its only job is to stop upstream's &lt;code&gt;minio-service&lt;/code&gt;, whose selector targets the SeaweedFS pod I deleted, from dangling. Nothing should depend on it. The &lt;code&gt;minio-service.kubeflow*&lt;/code&gt; names were dropped from RGW's &lt;code&gt;hosting.dnsNames&lt;/code&gt;, so a path-style request through that alias gets a 403.&lt;/p&gt;

&lt;p&gt;Host and port alone aren't enough. &lt;code&gt;ml-pipeline&lt;/code&gt; and &lt;code&gt;cache-server&lt;/code&gt; read &lt;code&gt;OBJECTSTORECONFIG_*&lt;/code&gt; straight from their env and ignore the ConfigMap's &lt;code&gt;minioServicePort&lt;/code&gt;. Point them at port 443 without turning TLS on and they speak plain HTTP to a TLS port, and &lt;code&gt;ensureBucketExists&lt;/code&gt; dies with &lt;code&gt;upstream connect error or disconnect/reset before headers&lt;/code&gt;. The patch sets all three:&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="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;OBJECTSTORECONFIG_HOST&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3.georgehomelab.com"&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;OBJECTSTORECONFIG_PORT&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443"&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;OBJECTSTORECONFIG_SECURE&lt;/span&gt;
  &lt;span class="na"&gt;value&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The artifact Secret.&lt;/strong&gt; Upstream ships &lt;code&gt;mlpipeline-minio-artifact&lt;/code&gt; with &lt;code&gt;minio&lt;/code&gt;/&lt;code&gt;minio123&lt;/code&gt; in it. If you leave that Secret in the kustomize render, VSO's &lt;code&gt;overwrite: true&lt;/code&gt; only &lt;em&gt;merges&lt;/em&gt;. The placeholder keys survive, KFP reads &lt;code&gt;accesskey=minio&lt;/code&gt;, and RGW answers "access key ID does not exist".&lt;/p&gt;

&lt;p&gt;So the overlay deletes the upstream Secret with &lt;code&gt;$patch: delete&lt;/code&gt;, and VSO creates it fresh from Vault with only the two keys KFP reads, &lt;code&gt;accesskey&lt;/code&gt; and &lt;code&gt;secretkey&lt;/code&gt;. The KFP Deployments reference the Secret by name, so they don't care who created it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MySQL → CNPG (planned, not live).&lt;/strong&gt; The cluster still runs the bundled &lt;code&gt;mysql&lt;/code&gt; Deployment, and &lt;code&gt;pipeline-install-config&lt;/code&gt; still says &lt;code&gt;dbType=mysql&lt;/code&gt;. Upstream ships no multi-user Postgres overlay and the data is small, so the daily dump to RGW closed the real gap (no backups at all) first.&lt;/p&gt;

&lt;p&gt;The CNPG database and its credentials are provisioned, and the patch below is the plan. The &lt;code&gt;ml-pipeline&lt;/code&gt;, &lt;code&gt;cache-server&lt;/code&gt;, &lt;code&gt;metadata-grpc-deployment&lt;/code&gt;, &lt;code&gt;ml-pipeline-persistenceagent&lt;/code&gt;, &lt;code&gt;ml-pipeline-scheduledworkflow&lt;/code&gt;, &lt;code&gt;ml-pipeline-ui&lt;/code&gt; and &lt;code&gt;ml-pipeline-viewer-crd&lt;/code&gt; Deployments all take the database from env vars, and each gets patched to use the CNPG-provisioned creds:&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;env&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;DBCONFIG_USER&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubeflow&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;DBCONFIG_PASSWORD&lt;/span&gt;
    &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;secretKeyRef&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;kubeflow-metadata-db&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;password&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;DBCONFIG_HOST&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres-mlops-cnpg-cluster-rw.mlops.svc.cluster.local&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;DBCONFIG_PORT&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5432"&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;DBCONFIG_NAME&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubeflow_metadata_db&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;kubeflow-metadata-db&lt;/code&gt; Secret is VSO-synced from &lt;code&gt;secret/homelab/kubeflow/kubeflow-metadata-db&lt;/code&gt;. Once this lands, the bundled MySQL Deployment goes via &lt;code&gt;$patch: delete&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The istio init race.&lt;/strong&gt; &lt;code&gt;ml-pipeline-api-server&lt;/code&gt; calls &lt;code&gt;glog.Fatalf&lt;/code&gt; on its first S3 connection failure, with no retry. Without &lt;code&gt;proxy.istio.io/config: holdApplicationUntilProxyStarts: true&lt;/code&gt;, the apiserver gets "connection refused" because istio-proxy hasn't opened its outbound listeners yet, the app exits, and you have a CrashLoop. The annotation makes the main container wait for the sidecar's readiness probe. The same trap comes back with Model Registry's golang-migrate flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cold-boot race.&lt;/strong&gt; The lab powers off every night, so every morning Kubeflow starts from nothing. KFP's scheduler came up before ML Metadata, saw that the daily run's slot had been missed, and fired it straight away. The run's first step needs ML Metadata, so it failed before doing any work. I added an init container to the scheduler that waits for ML Metadata, MySQL, the cache server, Kyverno and a healthy API server, then gives everything two more minutes.&lt;/p&gt;

&lt;p&gt;The first version of that wait didn't wait at all. It opened a TCP connection to each Service, and inside the mesh that always succeeds: the pod's own Envoy sidecar accepts the connection, whether or not anything is behind the Service. I only caught it by pointing the check at a Service with no endpoints and watching it pass. The fix is &lt;code&gt;traffic.sidecar.istio.io/excludeOutboundPorts&lt;/code&gt; on the scheduler pod, so those checks go straight to the real Service. On the next cold boot it held the scheduler for 220 seconds while ML Metadata came up, and the run that followed succeeded.&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;template&lt;/span&gt;&lt;span class="pi"&gt;:&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;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;traffic.sidecar.istio.io/excludeOutboundPorts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080,3306,443"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fpw11q47noj477fsd49ij.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%2Fpw11q47noj477fsd49ij.png" alt="kf" width="800" height="372"&gt;&lt;/a&gt;&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%2F1elsb2blfy6ia16arl7z.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%2F1elsb2blfy6ia16arl7z.png" alt="kf" width="799" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A KFP run's artifact panel showing an &lt;code&gt;s3://kfp-artifacts/...&lt;/code&gt; URI (served by Ceph RGW, not SeaweedFS), or the Ceph dashboard → Object Gateway → Buckets view with &lt;code&gt;kfp-artifacts&lt;/code&gt; listed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "huge CRD" trap
&lt;/h2&gt;

&lt;p&gt;Kubeflow CRDs are big. &lt;code&gt;clustertrainingruntimes.trainer.kubeflow.org&lt;/code&gt; weighs ~280KB, &lt;code&gt;trainjobs.trainer.kubeflow.org&lt;/code&gt; ~220KB, and the InferenceService and Knative ones aren't far behind.&lt;/p&gt;

&lt;p&gt;The Kubernetes apiserver caps &lt;code&gt;metadata.annotations&lt;/code&gt; at 262144 bytes (256KB). ArgoCD's default CSA-to-SSA migration writes a &lt;code&gt;kubectl.kubernetes.io/last-applied-configuration&lt;/code&gt; annotation during the handover, and on these CRDs that annotation alone is &amp;gt;256KB. You get &lt;code&gt;metadata.annotations: Too long: may not be more than 262144 bytes&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I stacked two fixes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Per-CRD SSA via a kustomize patch.&lt;/strong&gt; Put the &lt;code&gt;argocd.argoproj.io/sync-options: ServerSideApply=true&lt;/code&gt; annotation on each problem CRD. ArgoCD then skips CSA and goes straight to SSA, which doesn't write the annotation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App-level &lt;code&gt;ClientSideApplyMigration=false&lt;/code&gt;.&lt;/strong&gt; This turns the migration off entirely; it's set in the AppSet template's &lt;code&gt;syncOptions&lt;/code&gt;. The CRD becomes argocd-controller-owned on first sync and stays SSA-managed.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Workspace UIs
&lt;/h2&gt;

&lt;p&gt;Phase 4 is six Apps at sync wave 6: notebook-controller, jupyter-web-app, volumes-web-app, tensorboard-controller, tensorboards-web-app and pvcviewer-controller.&lt;/p&gt;

&lt;p&gt;The web apps are SPAs and the controllers are headless. Between them, users can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;spawn JupyterLab notebooks from the Jupyter UI;&lt;/li&gt;
&lt;li&gt;browse and create PVCs in the Volumes UI;&lt;/li&gt;
&lt;li&gt;launch TensorBoard instances against TensorBoard log dirs;&lt;/li&gt;
&lt;li&gt;view files in a PVC through the PVCViewer's per-PVC file browser.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two integration points matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every UI reads a &lt;code&gt;USERID_HEADER&lt;/code&gt; env to find out who's making the request. Upstream's default is &lt;code&gt;kubeflow-userid&lt;/code&gt;; I override it to &lt;code&gt;X-Forwarded-Email&lt;/code&gt; (the Auth Chain Saga explains why).&lt;/li&gt;
&lt;li&gt;Each UI's &lt;code&gt;AuthorizationPolicy&lt;/code&gt; originally allowed requests only from &lt;code&gt;cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account&lt;/code&gt;. Under a PERMISSIVE mesh that principal-based ALLOW matches nothing, so I replaced it with a path-based ALLOW.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The notebook-controller's &lt;code&gt;ISTIO_GATEWAY&lt;/code&gt; env defaults to &lt;code&gt;kubeflow/kubeflow-gateway&lt;/code&gt;, a stub Gateway in the kubeflow ns that isn't bound to any ingress IP in my setup. I override it to &lt;code&gt;istio-system/kubeflow-gateway&lt;/code&gt; with a kustomize configMapGenerator using behavior:merge. The per-notebook VirtualService the controller emits then binds to the real ingress, and traffic to &lt;code&gt;/notebook/&amp;lt;ns&amp;gt;/&amp;lt;name&amp;gt;/&lt;/code&gt; routes correctly.&lt;/p&gt;

&lt;p&gt;Ten surfaces read the user header: centraldashboard, kfam, manager, jupyter-web-app, katib-ui, kserve-models-web-app, volumes-web-app, tensorboards-web-app, ml-pipeline-ui and ml-pipeline-api-server. Most use &lt;code&gt;USERID_HEADER&lt;/code&gt;; ml-pipeline-ui and ml-pipeline-api-server use &lt;code&gt;KUBEFLOW_USERID_HEADER&lt;/code&gt;. Get it wrong on any single component and that UI answers "no user identity in header" on every request, even though oauth2-proxy has "authenticated" the user.&lt;/p&gt;

&lt;p&gt;One more quirk. jupyter-web-app's &lt;code&gt;/api/namespaces/&amp;lt;ns&amp;gt;/notebooks&lt;/code&gt; listing endpoint returns a 500 for any Notebook CR with a container that has no &lt;code&gt;volumeMounts&lt;/code&gt; field. The web UI always adds a workspace volume to the notebooks it spawns, so you only see this with directly-applied CRs with bare containers. The bug is at &lt;code&gt;utils.py:145&lt;/code&gt;, which does &lt;code&gt;cntr["volumeMounts"]&lt;/code&gt; without a key check. Workaround: always add at least one volumeMount, even an emptyDir.&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%2F2rkkaejhmovfttraqj8k.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%2F2rkkaejhmovfttraqj8k.png" alt="kf" width="799" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Jupyter web app's notebook list in a Profile namespace, with a running GPU notebook (&lt;code&gt;nvidia.com/gpu: 1&lt;/code&gt;) and its Connect button.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two notebooks, one shared volume
&lt;/h3&gt;

&lt;p&gt;A notebook's storage is only as useful as your ability to get at it from somewhere else. A teammate's notebook, a second kernel, a training job that needs the same dataset. So I tested the thing the Jupyter form quietly offers: a volume that more than one notebook can mount and write to at the same time.&lt;/p&gt;

&lt;p&gt;The storage underneath is CephFS, through the &lt;code&gt;ceph-filesystem&lt;/code&gt; storage class with &lt;code&gt;ReadWriteMany&lt;/code&gt;. The default block class is &lt;code&gt;ReadWriteOnce&lt;/code&gt;, which gives one pod its own disk and nobody else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Create the first notebook with two shared volumes.&lt;/strong&gt; In the "New notebook" form, untick "Use default class" on the workspace volume, pick &lt;code&gt;ceph-filesystem&lt;/code&gt; and &lt;code&gt;ReadWriteMany&lt;/code&gt;, then add one data volume the same way:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Volume&lt;/th&gt;
&lt;th&gt;Storage class&lt;/th&gt;
&lt;th&gt;Access mode&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Mounted at&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pod-nfs-shared-nb-workspace&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ceph-filesystem&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ReadWriteMany&lt;/td&gt;
&lt;td&gt;5Gi&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/home/jovyan&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pod-nfs-shared-nb-shared&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ceph-filesystem&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ReadWriteMany&lt;/td&gt;
&lt;td&gt;10Gi&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/home/jovyan/pod-nfs-shared-nb-shared&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ignore the "nfs" in the name. I named the notebook before I'd settled on the backend, and there is no NFS server in this cluster.&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%2Fj7k5zt4h50n1antc2uvk.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%2Fj7k5zt4h50n1antc2uvk.png" alt="kf" width="799" height="370"&gt;&lt;/a&gt;&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%2Ft0telbp3x27qj27xd2b4.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%2Ft0telbp3x27qj27xd2b4.png" alt="kf" width="799" height="370"&gt;&lt;/a&gt;&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%2Fn17r8nwsioyuxsetj1tz.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%2Fn17r8nwsioyuxsetj1tz.png" alt="kf" width="799" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The "New notebook" form with the workspace volume set to &lt;code&gt;ceph-filesystem&lt;/code&gt; and &lt;code&gt;ReadWriteMany&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check that both volumes are mounted, and that they really are two volumes.&lt;/strong&gt; Open a terminal in JupyterLab:&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="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-hT&lt;/span&gt; /home/jovyan /home/jovyan/pod-nfs-shared-nb-shared
&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="s"&gt;Filesystem                                                       Type  Size  Used Avail Use% Mounted on&lt;/span&gt;
&lt;span class="s"&gt;csi-cephfs-node@&amp;lt;cluster-id&amp;gt;.ceph-filesystem=/volumes/csi/csi-vol-ebf4d59a-...  ceph  5.0G     0  5.0G   0% /home/jovyan&lt;/span&gt;
&lt;span class="s"&gt;csi-cephfs-node@&amp;lt;cluster-id&amp;gt;.ceph-filesystem=/volumes/csi/csi-vol-198d5e31-...  ceph   10G     0   10G   0% /home/jovyan/pod-nfs-shared-nb-shared&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two different &lt;code&gt;csi-vol&lt;/code&gt; IDs and two different sizes. It isn't one volume with a subfolder in it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write a marker file from the first notebook:&lt;/strong&gt;&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="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"written by &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; at &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /home/jovyan/pod-nfs-shared-nb-shared/proof.txt
&lt;span class="nb"&gt;cat&lt;/span&gt; /home/jovyan/pod-nfs-shared-nb-shared/proof.txt
&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="s"&gt;written by pod-nfs-shared-nb-0 at &amp;lt;timestamp&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Create a second notebook and attach the same volume.&lt;/strong&gt; I called it &lt;code&gt;share-test&lt;/code&gt;. Under Data Volumes, choose "Attach existing volume" and pick &lt;code&gt;pod-nfs-shared-nb-shared&lt;/code&gt;. The dropdown lists every claim in the namespace next to its access mode, which is a quiet but useful piece of UI: you can see which volumes are shareable before you try.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read the file from the second notebook, then reply.&lt;/strong&gt; Both notebooks are 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="nb"&gt;cat&lt;/span&gt; /home/jovyan/pod-nfs-shared-nb-shared/proof.txt
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"reply from &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /home/jovyan/pod-nfs-shared-nb-shared/proof.txt
&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="s"&gt;written by pod-nfs-shared-nb-0 at &amp;lt;timestamp&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Back in the first notebook, read it again:&lt;/strong&gt;&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="nb"&gt;cat&lt;/span&gt; /home/jovyan/pod-nfs-shared-nb-shared/proof.txt
&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="s"&gt;written by pod-nfs-shared-nb-0 at &amp;lt;timestamp&amp;gt;&lt;/span&gt;
&lt;span class="s"&gt;reply from share-test-0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines, two hostnames, one file, and neither notebook was restarted in between.&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%2Flup4e5cpz466hjnilfcs.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%2Flup4e5cpz466hjnilfcs.png" alt="kf" width="799" height="370"&gt;&lt;/a&gt;&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%2Fzcq28dzwe2jw9lgb5h3a.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%2Fzcq28dzwe2jw9lgb5h3a.png" alt="kf" width="799" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The two JupyterLab terminals side by side, each showing both lines of &lt;code&gt;proof.txt&lt;/code&gt;. Crop out the browser tabs and bookmarks bar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask Kubernetes who is holding the claim:&lt;/strong&gt;&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%2F11c844savbu9yvb8ieyq.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%2F11c844savbu9yvb8ieyq.png" alt="kf" width="800" height="65"&gt;&lt;/a&gt;&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;-n&lt;/span&gt; terranetes describe pvc pod-nfs-shared-nb-shared | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A2&lt;/span&gt; &lt;span class="s2"&gt;"Used By"&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;Used By&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;       &lt;span class="s"&gt;pod-nfs-shared-nb-0&lt;/span&gt;
               &lt;span class="s"&gt;share-test-0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things worth knowing before you hand this to a team:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You don't need to own the namespace.&lt;/strong&gt; The &lt;code&gt;kubeflow-edit&lt;/code&gt; role, which is what a contributor gets, can create notebooks and create and delete volume claims. &lt;code&gt;kubeflow-view&lt;/code&gt; can only look.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The quota is what stops you.&lt;/strong&gt; My Kyverno-generated quota gives each user namespace 10 claims and 100Gi. Every notebook with its own workspace eats into that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every notebook here asks for a GPU.&lt;/strong&gt; With three cards in the cluster, a forgotten &lt;code&gt;share-test&lt;/code&gt; notebook is a third of the GPU capacity doing nothing. Stop it when you're done.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Central Dashboard, Profiles and Kyverno governance
&lt;/h2&gt;

&lt;p&gt;Phase 5 brings the portal and multi-user setup. Central Dashboard is the iframe-style UI portal. Its sidebar tile list lives in a ConfigMap that the centraldashboard pod reads at runtime through the K8s API. Tiles come in four kinds: &lt;code&gt;menuLinks&lt;/code&gt; (iframed sub-UIs), &lt;code&gt;externalLinks&lt;/code&gt; (opened in new tabs), &lt;code&gt;documentationItems&lt;/code&gt; and &lt;code&gt;quickLinks&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Profiles handle multi-user, run by the Profiles controller and KFAM (Kubeflow Access Manager). A &lt;code&gt;Profile&lt;/code&gt; CR defines a user. The controller creates a namespace with the same name as the Profile's &lt;code&gt;metadata.name&lt;/code&gt;, binds the appropriate ClusterRoles, generates a &lt;code&gt;ns-owner-access-istio&lt;/code&gt; AuthorizationPolicy and registers the namespace with KFAM.&lt;/p&gt;

&lt;p&gt;My Kyverno governance policies fire on &lt;code&gt;app.kubernetes.io/part-of: kubeflow-profile&lt;/code&gt; namespaces and generate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ResourceQuota&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;kubeflow-user-quota&lt;/code&gt;): cpu 4 requested / 16 limit, memory 8/16 Gi, storage 100 Gi, 20 pods, 10 PVCs. The CPU limit started at 8 and that was too tight: a running notebook holds about 2 CPUs of limit, and the pipeline's training Jobs were refused for the rest. A refused Job gets no pod and no error, only a &lt;code&gt;FailedCreate&lt;/code&gt; event, so the step simply waited until it timed out. The pipeline step now reads those events and fails within five minutes with the quota's numbers in the message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;LimitRange&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;kubeflow-user-limits&lt;/code&gt;): default cpu 500m / mem 512Mi, max cpu 4 / mem 8 Gi.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two &lt;code&gt;NetworkPolicies&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;default-deny-all&lt;/code&gt; as the baseline, then &lt;code&gt;allow-in-ns-and-kubeflow&lt;/code&gt;, which lets traffic in from the namespace itself and the Kubeflow control plane (kubeflow, kubeflow-system, knative-serving, istio-system), and out to that same control plane plus &lt;code&gt;mlops&lt;/code&gt; (MLflow), &lt;code&gt;rook-ceph&lt;/code&gt; (RGW) and DNS in kube-system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two &lt;code&gt;CiliumNetworkPolicies&lt;/code&gt;&lt;/strong&gt;: one allowing egress to the kube-apiserver (a plain NetworkPolicy can't express that reliably under Cilium) and one allowing egress to PyPI by FQDN, so &lt;code&gt;pip install&lt;/code&gt; works in a notebook and nothing else on the internet does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A separate Kyverno mutate policy, &lt;code&gt;mutate-pss-privileged-kubeflow-user-ns&lt;/code&gt;, stamps every Profile namespace with PSS &lt;code&gt;privileged&lt;/code&gt; at admission. User namespaces are istio-injected too, so istio-init needs the same capabilities there as in the control plane.&lt;/p&gt;

&lt;p&gt;So user namespaces are bounded by default. A user can't reach anyone else's namespace, oversubscribe the cluster, or deploy anything bigger than the LimitRange allows. They can talk to the Kubeflow platform services, MLflow, Ceph RGW and PyPI.&lt;/p&gt;

&lt;p&gt;The same policy file also generates three &lt;strong&gt;PodDefaults&lt;/strong&gt; in every Profile namespace, and they're the best user-facing feature of the whole build. A PodDefault is a tick-box in the Jupyter spawn form: one for KFP access (a projected service-account token so the KFP SDK can call the API from a notebook), one for S3 (the Ceph RGW endpoint and bucket credentials as &lt;code&gt;AWS_*&lt;/code&gt; env vars), and one for MLflow (&lt;code&gt;MLFLOW_TRACKING_URI&lt;/code&gt; and the RGW endpoint). A user ticks "MLflow" and their notebook can log runs without anyone pasting a URL or a key into a cell.&lt;/p&gt;

&lt;p&gt;Central Dashboard runs with &lt;code&gt;REGISTRATION_FLOW=true&lt;/code&gt;, so a first-time user with no Profile gets a "pick a namespace" form instead of &lt;code&gt;RBAC: access denied&lt;/code&gt;. I still pre-create Profiles in Git (&lt;code&gt;22-user-namespace/profile-&amp;lt;alias&amp;gt;.yaml&lt;/code&gt;), because a Git-managed Profile carries the &lt;code&gt;kubeflow-profile&lt;/code&gt; label the governance policies key on, and it's reviewed like any other change. KFAM provisions the namespace and RBAC, and Kyverno fires the generate policies.&lt;/p&gt;

&lt;p&gt;The one in the repo looks like this (owner changed for the article):&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;kubeflow.org/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;Profile&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;kubeflow-user-george&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;app.kubernetes.io/part-of&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubeflow-profile&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;owner&lt;/span&gt;&lt;span class="pi"&gt;:&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;User&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;user@example.com&lt;/span&gt;
  &lt;span class="na"&gt;resourceQuotaSpec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;   &lt;span class="c1"&gt;# Kyverno generates this; leave empty here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole onboarding flow: add the user in Keycloak, add a Profile YAML, commit and push. ArgoCD reconciles, and by the next refresh the user has a namespace with quotas, limits, network isolation and PodDefaults.&lt;/p&gt;

&lt;p&gt;Live, there are three Profiles: upstream's default &lt;code&gt;kubeflow-user-example-com&lt;/code&gt;, the Git-managed &lt;code&gt;kubeflow-user-george&lt;/code&gt;, and &lt;code&gt;terranetes&lt;/code&gt;, which I created by hand early on and which isn't in Git yet. That last one is where the real ML work runs.&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%2F3e90ca291iew2g9o53z5.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%2F3e90ca291iew2g9o53z5.png" alt="kt" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Kubeflow login redirecting to the &lt;strong&gt;Keycloak&lt;/strong&gt; realm login page (proof Dex is gone), then landing on the Central Dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SSO front door
&lt;/h2&gt;

&lt;p&gt;Phase 6, the last piece. The oauth2-proxy that gates the whole platform lives in the &lt;code&gt;kubeflow-sso&lt;/code&gt; namespace and is an ordinary AppSet element, &lt;code&gt;central-dashboard-sso&lt;/code&gt;, at sync wave 8. It was briefly parked while I re-verified the Keycloak realm and the Vault secret, and re-enabled a day later.&lt;/p&gt;

&lt;p&gt;The overlay sits at &lt;code&gt;kubernetes-addons/0-boostrap/oauth2-proxy/overlays/kubeflow-centraldashboard/&lt;/code&gt;. It builds on the base oauth2-proxy chart and adds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;code&gt;VirtualService&lt;/code&gt; for host &lt;code&gt;kubeflow.georgehomelab.com&lt;/code&gt;, bound to both &lt;code&gt;istio-system/kubeflow-gateway&lt;/code&gt; and &lt;code&gt;istio-system/homelab-gateway&lt;/code&gt; (the overlapping-SNI story from Phase 1);&lt;/li&gt;
&lt;li&gt;a multi-upstream config (below);&lt;/li&gt;
&lt;li&gt;the cookie domain set to &lt;code&gt;kubeflow.georgehomelab.com&lt;/code&gt;, with cookie name &lt;code&gt;_oauth2_proxy_kubeflow&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a Redis session store, so the browser cookie is a small session ID rather than the whole token set (Failure 9 below explains why that matters);&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;VaultStaticSecret&lt;/code&gt; syncing the client secret and cookie secret from &lt;code&gt;secret/homelab/kubeflow/oauth2-proxy&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;oauth2-proxy-kubeflow-userid-header&lt;/code&gt; EnvoyFilter (Lua), which aliases &lt;code&gt;X-Forwarded-Email&lt;/code&gt; → &lt;code&gt;kubeflow-userid&lt;/code&gt; on outbound (see the Auth Chain Saga);&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;oauth2-proxy-kubeflow-authz-path-normalize&lt;/code&gt; EnvoyFilter, which only matters for notebooks (see the notebook routing section).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The multi-upstream config is the heart of it. Central Dashboard iframes every sub-UI at a fixed path: &lt;code&gt;/jupyter/&lt;/code&gt;, &lt;code&gt;/katib/&lt;/code&gt;, &lt;code&gt;/pipeline/&lt;/code&gt;, &lt;code&gt;/kserve-endpoints/&lt;/code&gt;, &lt;code&gt;/tensorboards/&lt;/code&gt;, &lt;code&gt;/volumes/&lt;/code&gt;, &lt;code&gt;/model-registry/&lt;/code&gt;. Each one is a separate backend Service, and oauth2-proxy has to know about all of them:&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;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--upstream=http://centraldashboard.kubeflow.svc.cluster.local:80/&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--upstream=http://jupyter-web-app-service.kubeflow.svc.cluster.local:80/jupyter/&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--upstream=http://katib-ui.kubeflow.svc.cluster.local:80/katib/&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--upstream=http://ml-pipeline-ui.kubeflow.svc.cluster.local:80/pipeline/&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--upstream=http://kserve-models-web-app.kubeflow.svc.cluster.local:80/kserve-endpoints/&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--upstream=http://tensorboards-web-app-service.kubeflow.svc.cluster.local:80/tensorboards/&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--upstream=http://volumes-web-app-service.kubeflow.svc.cluster.local:80/volumes/&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--upstream=http://profiles-kfam.kubeflow.svc.cluster.local:8081/kfam/&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--upstream=http://metadata-envoy-service.kubeflow.svc.cluster.local:9090/ml_metadata.MetadataStoreService/&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--upstream=http://model-registry-ui-service.kubeflow.svc.cluster.local:8080/model-registry/&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--pass-host-header=false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Upstream's per-component overlays each ship a &lt;code&gt;VirtualService&lt;/code&gt; bound to &lt;code&gt;kubeflow-gateway&lt;/code&gt; with &lt;code&gt;hosts: ['*']&lt;/code&gt; and &lt;code&gt;prefix: /jupyter/&lt;/code&gt; (and the equivalent for every other component). With my kubeflow-gateway bound to &lt;code&gt;kubeflow.georgehomelab.com&lt;/code&gt;, those wildcard-host VSes collide with my specific-host oauth2-proxy VS. Istio's route selection picks the wildcard route, requests skip oauth2-proxy and hit the components directly with no auth headers, and you get an "RBAC: access denied" body with HTTP 403.&lt;/p&gt;

&lt;p&gt;The fix is to delete every upstream &lt;code&gt;hosts: ['*']&lt;/code&gt; VS with a kustomize &lt;code&gt;$patch: delete&lt;/code&gt; and let oauth2-proxy do the path-based routing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://george-kubeflow.terranetes.workers.dev/" rel="noopener noreferrer"&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%2Fnfmnalldat6su62r6xey.png" alt="kf" width="662" height="302"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Login (no Dex)&lt;/strong&gt; view: People → kubeflow-gateway → oauth2-proxy → Keycloak → Google, then oauth2-proxy → Central dashboard → each app with &lt;code&gt;X-Forwarded-Email&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Auth Chain Saga
&lt;/h2&gt;

&lt;p&gt;I'll be re-reading this section every time I deploy another OIDC-fronted, multi-component platform. It's the longest in the article because the debugging took the longest.&lt;/p&gt;

&lt;p&gt;Here's the auth flow end to end. Every step is a place it can break:&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%2Ff8q4obus3uqyz85lld4i.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%2Ff8q4obus3uqyz85lld4i.png" alt="kf" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One request from the browser to jupyter-web-app, with the failure points pinned to the stage each one hits.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The browser asks for &lt;code&gt;kubeflow.georgehomelab.com/_/jupyter/?ns=...&lt;/code&gt;, and TLS terminates at the istio-ingressgateway.&lt;/li&gt;
&lt;li&gt;Istio routes it to the &lt;code&gt;oauth2-proxy-kubeflow&lt;/code&gt; VirtualService (catch-all prefix &lt;code&gt;/&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;oauth2-proxy checks the &lt;code&gt;_oauth2_proxy_kubeflow&lt;/code&gt; cookie. No cookie means a 302 to &lt;code&gt;/oauth2/start&lt;/code&gt;, the Keycloak login, the callback, and a fresh cookie. With a cookie, the request goes upstream.&lt;/li&gt;
&lt;li&gt;With &lt;code&gt;--pass-host-header=false&lt;/code&gt;, oauth2-proxy rewrites &lt;code&gt;Host&lt;/code&gt; to the upstream service FQDN.&lt;/li&gt;
&lt;li&gt;Istio sends the outbound request through an internal rewrite VS that strips &lt;code&gt;/jupyter/&lt;/code&gt; to &lt;code&gt;/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;An EnvoyFilter Lua script reads &lt;code&gt;X-Forwarded-Email&lt;/code&gt; and adds the &lt;code&gt;kubeflow-userid&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;The request reaches the jupyter-web-app pod, which reads the &lt;code&gt;USERID_HEADER&lt;/code&gt; env to find the identity header.&lt;/li&gt;
&lt;li&gt;An Istio AuthorizationPolicy checks the path, and the request is handled.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's eight steps, and between them they produced nine distinct failures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure 1: the wrong upstream Host header.&lt;/strong&gt; With the default &lt;code&gt;--pass-host-header=true&lt;/code&gt;, oauth2-proxy forwards &lt;code&gt;Host: kubeflow.georgehomelab.com&lt;/code&gt; to every backend. Istio's outbound listener looks up the vhost by Host header, and the public FQDN matches no in-cluster cluster, so traffic falls through to &lt;code&gt;PassthroughCluster&lt;/code&gt; (raw TCP, no HTTP filters).&lt;/p&gt;

&lt;p&gt;The rewrite VS never fires, the backend receives &lt;code&gt;/jupyter/static/main.js&lt;/code&gt; unrewritten, and the SPA serves &lt;code&gt;index.html&lt;/code&gt; (text/html) as a 200 OK. The browser gets HTML where it expected JS and shows a blank page. Fix: &lt;code&gt;--pass-host-header=false&lt;/code&gt;, so Host gets rewritten to the upstream service FQDN.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure 2: the wrong header name (X-Forwarded-Email vs X-Auth-Request-Email).&lt;/strong&gt; I first patched the Kubeflow components to read &lt;code&gt;USERID_HEADER=X-Auth-Request-Email&lt;/code&gt;. That's the &lt;em&gt;response&lt;/em&gt; header oauth2-proxy sets when it's answering an auth check. In proxy mode, which is what the dashboard path runs, the request going upstream carries &lt;code&gt;X-Forwarded-Email&lt;/code&gt; (set by &lt;code&gt;--pass-user-headers=true&lt;/code&gt;). A component reading the wrong header logs &lt;code&gt;no user identity in request&lt;/code&gt; and every API call 401s. Login looks fine; the UI is blank.&lt;/p&gt;

&lt;p&gt;If you spot &lt;code&gt;X-Auth-Request-Email&lt;/code&gt; later in the notebook section, that isn't a contradiction. Proxy mode passes &lt;code&gt;X-Forwarded-Email&lt;/code&gt; upstream; the ext_authz path only gets &lt;code&gt;X-Auth-Request-Email&lt;/code&gt;, because that's the header the Istio provider copies onto the allowed request through &lt;code&gt;headersToUpstreamOnAllow&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure 3: principal-based APs under a PERMISSIVE mesh.&lt;/strong&gt; The upstream AuthorizationPolicies require &lt;code&gt;principals: cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account&lt;/code&gt;. Under my PERMISSIVE mesh the principal isn't reliably populated, the AP rule doesn't match, and you get 403 RBAC: access denied. Fix: replace it with a path-based ALLOW (&lt;code&gt;paths: ["/*"]&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure 4: KUBEFLOW_USERID_HEADER vs USERID_HEADER.&lt;/strong&gt; Most components read &lt;code&gt;USERID_HEADER&lt;/code&gt;. ml-pipeline-ui reads &lt;code&gt;KUBEFLOW_USERID_HEADER&lt;/code&gt;, a different env name. And ml-pipeline-api (the backend) has its own &lt;code&gt;KUBEFLOW_USERID_HEADER&lt;/code&gt; to override, which is the easy one to miss. The symptom: the KFP UI loads, but &lt;code&gt;/apis/v1beta1/{pipelines,experiments,runs,recurringruns}&lt;/code&gt; returns "Request header error: there is no user identity header."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure 5: kustomize vs VSO on the secret.&lt;/strong&gt; The oauth2-proxy base used to ship the Secret with placeholder values (&lt;code&gt;REPLACE_VIA_SETUP_SECRET&lt;/code&gt;) for the client secret and cookie secret. Every sync put the placeholders back, VSO's next reconcile put the real values back, and in between oauth2-proxy was reading a placeholder. Keycloak answered &lt;code&gt;Invalid client credentials&lt;/code&gt; on the callback.&lt;/p&gt;

&lt;p&gt;The fix was to stop shipping the placeholders. The base generator now creates the Secret with &lt;code&gt;client-id&lt;/code&gt; only, and VSO (&lt;code&gt;create: false&lt;/code&gt;, &lt;code&gt;overwrite: true&lt;/code&gt;) is the sole writer of &lt;code&gt;client-secret&lt;/code&gt; and &lt;code&gt;cookie-secret&lt;/code&gt;. With nothing in Git to revert to, the tug-of-war between ArgoCD and VSO stopped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure 6: Vault sealed, so every secret is stale.&lt;/strong&gt; The transit Vault was sealed, which left main Vault crashlooping and VSO unable to sync. Every Secret stayed at its last-known value, which at the time meant the placeholders from Failure 5. Unseal transit, main Vault recovers, VSO catches up. There's a second trap inside this one: &lt;code&gt;kubectl exec -it ... vault operator unseal&lt;/code&gt; quietly sends empty stdin, so use the positional-argument form.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure 7: Katib only serves at /katib/.&lt;/strong&gt; Most Kubeflow web apps serve at both &lt;code&gt;/&lt;/code&gt; and &lt;code&gt;/&amp;lt;prefix&amp;gt;/&lt;/code&gt;. Katib serves only at &lt;code&gt;/katib/&lt;/code&gt;, and its index.html emits absolute asset paths (&lt;code&gt;src="/katib/static/main.js"&lt;/code&gt;). The Istio rewrite VS that strips &lt;code&gt;/katib/&lt;/code&gt; → &lt;code&gt;/&lt;/code&gt; broke it (404 at &lt;code&gt;/&lt;/code&gt;). Fix: no rewrite VS for katib. oauth2-proxy forwards &lt;code&gt;/katib/&lt;/code&gt; to the backend unchanged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure 8: the EnvoyFilter Lua reading the wrong header.&lt;/strong&gt; The Lua filter that aliases the user email to &lt;code&gt;kubeflow-userid&lt;/code&gt; (to satisfy Model Registry's hardcoded header) originally read &lt;code&gt;x-auth-request-email&lt;/code&gt;. Same bug as Failure 2, in EnvoyFilter form. model-registry-ui returned 400 "missing required kubeflow-userid header" on every API call and the SPA showed "Error communicating with server". Fix: read &lt;code&gt;x-forwarded-email&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure 9: HTTP 431, the cookie that was too big.&lt;/strong&gt; This one hid for a long time because the dashboard shell always loaded. Notebooks, Endpoints, Tensorboards and Volumes didn't. Those four are Tornado-based web apps that cap request headers at about 8 KB, and a Keycloak session stored in the cookie (access, ID and refresh tokens) came to 12–16 KB. They answered &lt;code&gt;431 Request Header Fields Too Large&lt;/code&gt; before ever looking at auth.&lt;/p&gt;

&lt;p&gt;I measured it by sending each upstream Service the same request with different cookie sizes: those four flipped to 431 above 8 KB, while the dashboard, Katib, KFP, KFAM and Model Registry stayed at 200. The fix was moving oauth2-proxy's session store to Redis, so the browser only carries a short session ID. The overlay's &lt;code&gt;deployment-patch.yaml&lt;/code&gt; keeps the measurement table as evidence.&lt;/p&gt;

&lt;p&gt;Each of those took me somewhere between a few hours and half a day to find. I wrote every one down as a trap note the day it was fixed, which is the only reason this section exists in this much detail.&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%2Flx7u5uf45wxum7mw9l7n.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%2Flx7u5uf45wxum7mw9l7n.png" alt="kt" width="800" height="377"&gt;&lt;/a&gt;&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%2Fwksg2etdt4yuq98zsi8a.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%2Fwksg2etdt4yuq98zsi8a.png" alt="kt" width="800" height="377"&gt;&lt;/a&gt;&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%2Fqvweg71o14v44ejws404.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%2Fqvweg71o14v44ejws404.png" alt="kt" width="800" height="377"&gt;&lt;/a&gt;&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%2F9qamxkf03svlkz2axcrr.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%2F9qamxkf03svlkz2axcrr.png" alt="kf" width="800" height="377"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Model Registry UI listing a RegisteredModel + its versions, reached from inside a Profile namespace via the Kyverno-generated alias Service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding Model Registry
&lt;/h2&gt;

&lt;p&gt;Model Registry is five pieces, all in the &lt;code&gt;kubeflow&lt;/code&gt; namespace: &lt;code&gt;model-registry-deployment&lt;/code&gt; (REST + gRPC API server), &lt;code&gt;model-registry-db&lt;/code&gt; (bundled postgres 16), &lt;code&gt;model-registry-ui&lt;/code&gt; (SPA + BFF), &lt;code&gt;model-catalog-server&lt;/code&gt; (catalog API) and &lt;code&gt;model-catalog-postgres&lt;/code&gt; (catalog DB). It's a Wave-5 App, registered in the AppSet.&lt;/p&gt;

&lt;p&gt;The deploy is three upstream overlays (&lt;code&gt;overlays/postgres&lt;/code&gt; for core + postgres, &lt;code&gt;options/ui/overlays/kubeflow&lt;/code&gt; for the UI in kubeflow mode, &lt;code&gt;options/catalog/base&lt;/code&gt; for the catalog) plus kustomize patches for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;appProtocol: http&lt;/code&gt; on three Services (model-registry-ui-service, model-registry-service, model-catalog). Without it Istio routes them through PassthroughCluster, the same trap as Failure 1 in the Auth Chain Saga.&lt;/li&gt;
&lt;li&gt;Deleting the upstream &lt;code&gt;hosts: ['*']&lt;/code&gt; VS (the collision pattern).&lt;/li&gt;
&lt;li&gt;A path-based AP in place of the principal-based ALLOW.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;holdApplicationUntilProxyStarts: true&lt;/code&gt; on &lt;code&gt;model-registry-deployment&lt;/code&gt; and &lt;code&gt;model-catalog-server&lt;/code&gt;. Both use golang-migrate, which sets &lt;code&gt;schema_migrations.dirty=true&lt;/code&gt; the moment a migration starts. If the pod gets killed mid-migration (say, istio-proxy isn't ready yet), the next start aborts with &lt;code&gt;Dirty database version N. Fix and force version.&lt;/code&gt; and CrashLoops forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The UI is the hard part. In &lt;code&gt;--deployment-mode=kubeflow&lt;/code&gt; it scopes service discovery to the user's Profile namespace via SubjectAccessReview, then filters Services by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;label &lt;code&gt;component=model-registry&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a port named &lt;code&gt;http-api&lt;/code&gt; (or &lt;code&gt;https-api&lt;/code&gt;); port 8080 alone isn't enough;&lt;/li&gt;
&lt;li&gt;a valid ClusterIP (ExternalName Services are rejected);&lt;/li&gt;
&lt;li&gt;and it hardcodes the auth header to &lt;code&gt;kubeflow-userid&lt;/code&gt;, so it isn't &lt;code&gt;USERID_HEADER&lt;/code&gt;-configurable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hardcoded header is what made the EnvoyFilter Lua essential, and it's how Failure 8 in the Auth Chain Saga finally got noticed once MR was deployed.&lt;/p&gt;

&lt;p&gt;Then the discovery filter. My Model Registry is one shared instance in the &lt;code&gt;kubeflow&lt;/code&gt; namespace. The UI looks for MR Services in the user's own Profile namespace, finds none, and shows the "No registries found" empty state.&lt;/p&gt;

&lt;p&gt;The fix is a Kyverno generate policy that creates, in every Profile namespace, a &lt;code&gt;Service&lt;/code&gt; named &lt;code&gt;model-registry-service&lt;/code&gt; (ClusterIP, no selector, port name &lt;code&gt;http-api&lt;/code&gt;, the right labels) plus an &lt;code&gt;Endpoints&lt;/code&gt; object pointing at the cluster-wide MR's ClusterIP. Kube-proxy chains it: user-ns ClusterIP, DNAT to the kubeflow-ns ClusterIP, DNAT to the real pod IP. The shared MR's ClusterIP survives pod restarts (pod IPs don't), so the chain holds.&lt;/p&gt;

&lt;p&gt;The policy lives in &lt;code&gt;kubernetes-addons/security-stack/kyverno/policies/kubeflow-user-namespace-governance.yaml&lt;/code&gt;, next to the quota, limit, network and PodDefault generators. Its &lt;code&gt;apiCall&lt;/code&gt; context resolves the kubeflow-ns ClusterIP at evaluation time, so the Endpoints carries the actual IP rather than a placeholder.&lt;/p&gt;

&lt;p&gt;Wiring this turned up one more Kyverno trap. Kyverno's &lt;code&gt;validate-policy&lt;/code&gt; admission webhook rejects generate rules if the kyverno-background-controller lacks RBAC for the &lt;em&gt;target&lt;/em&gt; resource. For Services and Endpoints, that meant aggregating a new ClusterRole into the background-controller's compiled role with the label &lt;code&gt;rbac.kyverno.io/aggregate-to-background-controller: "true"&lt;/code&gt;. The AuthorizationPolicy generator in the notebook routing section needed the same treatment.&lt;/p&gt;

&lt;p&gt;End-to-end test from a notebook pod over REST:&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="nv"&gt;MR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://model-registry-service:8080
&lt;span class="c"&gt;# 1. Register the model&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MR&lt;/span&gt;&lt;span class="s2"&gt;/api/model_registry/v1alpha3/registered_models"&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;"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;'{"name":"iris-classifier", "owner":"user@example.com"}'&lt;/span&gt;
&lt;span class="c"&gt;# RegisteredModel id=1&lt;/span&gt;

&lt;span class="c"&gt;# 2. Add a version (registeredModelId required in body too)&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MR&lt;/span&gt;&lt;span class="s2"&gt;/api/model_registry/v1alpha3/registered_models/1/versions"&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;"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;'{"registeredModelId":"1", "name":"v1.0.0", "author":"user@example.com"}'&lt;/span&gt;
&lt;span class="c"&gt;# ModelVersion id=2&lt;/span&gt;

&lt;span class="c"&gt;# 3. Add the artifact URI&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MR&lt;/span&gt;&lt;span class="s2"&gt;/api/model_registry/v1alpha3/model_versions/2/artifacts"&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;"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;'{"artifactType":"model-artifact", "name":"iris-classifier-onnx",
       "uri":"s3://kfp-artifacts/sample-models/iris/v1/model.onnx",
       "modelFormatName":"onnx"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three POSTs return 200 from a notebook pod in the user namespace, and the model shows up in the UI straight away. The SDK path doesn't need an SSO round-trip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notebook routing: from bypass to ext_authz
&lt;/h2&gt;

&lt;p&gt;This is where the architecture changed direction halfway through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bypass.&lt;/strong&gt; Per-notebook VirtualServices (emitted by notebook-controller) bind to &lt;code&gt;istio-system/kubeflow-gateway&lt;/code&gt; and route &lt;code&gt;/notebook/&amp;lt;ns&amp;gt;/&amp;lt;name&amp;gt;/&lt;/code&gt; straight to the user's notebook pod. They have to, because oauth2-proxy's &lt;code&gt;--upstream&lt;/code&gt; is a static prefix-to-host map, while the notebook URL has a dynamic &lt;code&gt;&amp;lt;ns&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;name&amp;gt;&lt;/code&gt;. There's no fixed backend, and no wildcard upstream entry can work out where to send the request.&lt;/p&gt;

&lt;p&gt;The easy route was to send notebook traffic directly through Istio and skip oauth2-proxy entirely. The per-notebook VS already existed and only needed binding to the right gateway. So I did that, and added a Kyverno-generated path-based AP in user namespaces to let the traffic in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem.&lt;/strong&gt; With that bypass, &lt;code&gt;/notebook/&amp;lt;ns&amp;gt;/&amp;lt;name&amp;gt;/&lt;/code&gt; URLs are reachable with no auth check at all. Anyone on the LAN who guesses the URL pattern can open anyone's notebook. Tolerable for a single-user homelab, not for multi-tenant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt; is ext_authz, using Istio's &lt;code&gt;extensionProviders&lt;/code&gt; MeshConfig feature:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Register a new &lt;code&gt;extensionProvider&lt;/code&gt; named &lt;code&gt;oauth2-proxy-kubeflow&lt;/code&gt; in MeshConfig, pointing at &lt;code&gt;oauth2-proxy-kubeflow.kubeflow-sso.svc:80&lt;/code&gt; with &lt;code&gt;pathPrefix: /oauth2/auth&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Create a CUSTOM &lt;code&gt;AuthorizationPolicy&lt;/code&gt; on &lt;code&gt;istio-ingressgateway&lt;/code&gt; (in istio-system), scoped to &lt;code&gt;/notebook/*&lt;/code&gt; paths and calling the &lt;code&gt;oauth2-proxy-kubeflow&lt;/code&gt; provider.&lt;/li&gt;
&lt;li&gt;Add an EnvoyFilter Lua that turns ext_authz 401s into a 302 with &lt;code&gt;Location: /oauth2/start?rd=&amp;lt;original-url&amp;gt;&lt;/code&gt;, so unauthenticated users bounce through Keycloak login and back to their notebook.&lt;/li&gt;
&lt;li&gt;Tighten the Kyverno-generated user-ns AP from path-only to &lt;em&gt;requiring&lt;/em&gt; the &lt;code&gt;X-Auth-Request-Email&lt;/code&gt; header. That header is in &lt;code&gt;headersToUpstreamOnAllow&lt;/code&gt; on the provider, so its presence proves the request went through ext_authz.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Where the pieces live:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;MeshConfig: &lt;code&gt;kubernetes-addons/0-boostrap/istio-service-mesh/istiod/...-values.yaml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;CUSTOM AP + redirect EnvoyFilter: &lt;code&gt;kubernetes-addons/Data-platform-stacks/kubeflow/04-istio-resources/notebook-ext-authz.yaml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Method-normalising EnvoyFilter: &lt;code&gt;envoyfilter-authz-path-normalize.yaml&lt;/code&gt; in the oauth2-proxy overlay&lt;/li&gt;
&lt;li&gt;Kyverno tightening: the same &lt;code&gt;kubeflow-user-namespace-governance.yaml&lt;/code&gt; as before.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two follow-ups came after the first version shipped. The redirect Lua originally rewrote &lt;em&gt;every&lt;/em&gt; 401 on the ingress gateway into a login redirect, which was fine until I found it had been breaking Harbor image pushes (the Docker registry protocol relies on a real 401 challenge). It's now scoped to &lt;code&gt;/notebook/&lt;/code&gt; only.&lt;/p&gt;

&lt;p&gt;The second was terminals and kernels. Envoy builds the auth check by appending the original path to &lt;code&gt;/oauth2/auth&lt;/code&gt; and keeping the original method, so opening a terminal sent &lt;code&gt;POST /oauth2/auth/notebook/...&lt;/code&gt; to oauth2-proxy. That missed the auth endpoint, fell through to the dashboard upstream, and came back 404, which ext_authz treated as a deny. The notebook UI loaded, but every terminal and kernel start failed. The path-normalize EnvoyFilter rewrites the method of those checks to GET, which the authenticated dashboard answers with a 200, so POST and PUT now pass.&lt;/p&gt;

&lt;p&gt;After the change, the flow looks like this:&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%2Fhsj759sxp54akajo04h2.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%2Fhsj759sxp54akajo04h2.png" alt="kf" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;the notebook path after the pivot, with the ext_authz side call to oauth2-proxy.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The browser asks for &lt;code&gt;/notebook/&amp;lt;ns&amp;gt;/&amp;lt;name&amp;gt;/lab&lt;/code&gt;, and the notebook VS matches at the istio-ingressgateway.&lt;/li&gt;
&lt;li&gt;The CUSTOM AuthorizationPolicy fires: the gateway makes an ext_authz call to oauth2-proxy's &lt;code&gt;/oauth2/auth&lt;/code&gt;, forwarding the cookie.&lt;/li&gt;
&lt;li&gt;oauth2-proxy answers 200 OK, and &lt;code&gt;X-Auth-Request-Email&lt;/code&gt; is attached to the upstream request.&lt;/li&gt;
&lt;li&gt;The request goes over mTLS to the notebook pod.&lt;/li&gt;
&lt;li&gt;The user-namespace AP allows it only when &lt;code&gt;X-Auth-Request-Email&lt;/code&gt; is present.&lt;/li&gt;
&lt;li&gt;The notebook serves &lt;code&gt;/lab&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the build is hybrid: proxy mode for apps with static URLs, ext_authz for apps with dynamic ones. Going all-in on ext_authz would mean an extra hop on every request, losing cookie isolation and rebuilding the UX. A single uniform pattern isn't worth that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Central Dashboard tiles: MLflow
&lt;/h2&gt;

&lt;p&gt;The dashboard tiles live in a ConfigMap that centraldashboard reads at runtime through the K8s API (not a volume mount). At the time, two facts made updating it annoying:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Kyverno's &lt;code&gt;make-configmaps-immutable&lt;/code&gt; policy marked every new CM immutable, so you couldn't &lt;code&gt;kubectl edit&lt;/code&gt; or &lt;code&gt;kubectl apply&lt;/code&gt; your way to new data.&lt;/li&gt;
&lt;li&gt;The kubelet treats immutable as "cache forever", so even if you get past Kyverno somehow, pods that mount the CM (centraldashboard doesn't, other components do) won't see the update.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What I did instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a separate CM (&lt;code&gt;centraldashboard-tiles&lt;/code&gt;) with the merged tile list (Kubeflow defaults plus the MLflow external link). A different name from &lt;code&gt;centraldashboard-config&lt;/code&gt; means I can manage it independently.&lt;/li&gt;
&lt;li&gt;Patch the centraldashboard Deployment's &lt;code&gt;DASHBOARD_CONFIGMAP&lt;/code&gt; env to point at the new CM.&lt;/li&gt;
&lt;li&gt;Generate the CM with &lt;code&gt;disableNameSuffixHash: true&lt;/code&gt; (a stable name) and the &lt;code&gt;argocd.argoproj.io/sync-options: Replace=true,Force=true&lt;/code&gt; annotation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;Replace=true,Force=true&lt;/code&gt; is the pair that makes it work. &lt;code&gt;Replace=true&lt;/code&gt; alone runs &lt;code&gt;kubectl replace -f&lt;/code&gt;, which is still an UPDATE and gets rejected on immutable resources. &lt;code&gt;Replace=true,Force=true&lt;/code&gt; runs &lt;code&gt;kubectl replace --force -f&lt;/code&gt;, which deletes and recreates, and that works. It took me an hour to find. The error (&lt;code&gt;field is immutable when immutable is set&lt;/code&gt;) pointed clearly enough at the cause that I expected the obvious fix to work. It didn't.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;kubeflow&lt;/code&gt; namespace is now excluded from that Kyverno policy (Profile namespaces too, through a namespaceSelector), so new ConfigMaps there stay editable. ConfigMaps that were already made immutable stay frozen, which is why the replace-and-force pattern is still worth knowing.&lt;/p&gt;

&lt;p&gt;MLflow is the only external link in the sidebar. There used to be an Airflow tile too, but Airflow was retired, so it went.&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%2Fk2kv94rkdq5o3e9n0ohi.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%2Fk2kv94rkdq5o3e9n0ohi.png" alt="kt" width="800" height="372"&gt;&lt;/a&gt;&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%2Fwnh234i45ghncmwkmt28.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%2Fwnh234i45ghncmwkmt28.png" alt="kt" width="800" height="372"&gt;&lt;/a&gt;&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%2Fyb37piga40givbi4oxf3.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%2Fyb37piga40givbi4oxf3.png" alt="kt" width="800" height="372"&gt;&lt;/a&gt;&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%2Feg04blmzn7fgvi5tgve7.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%2Feg04blmzn7fgvi5tgve7.png" alt="kt" width="800" height="372"&gt;&lt;/a&gt;&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%2Ftm4rl5ghht46a2yeg6ln.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%2Ftm4rl5ghht46a2yeg6ln.png" alt="kt" width="800" height="372"&gt;&lt;/a&gt;&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%2Fn68nwmeuv6vwi2wzcngn.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%2Fn68nwmeuv6vwi2wzcngn.png" alt="kt" width="800" height="372"&gt;&lt;/a&gt;&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%2Fy0mrlml3v9puhqrnr0ub.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%2Fy0mrlml3v9puhqrnr0ub.png" alt="kt" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Central Dashboard sidebar showing the MLflow external link.&lt;/p&gt;

&lt;h2&gt;
  
  
  How reliable is it?
&lt;/h2&gt;

&lt;p&gt;The cluster is HA. Kubeflow on it mostly isn't, and I'd rather say that than imply otherwise. Here's what the live cluster shows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What runs&lt;/th&gt;
&lt;th&gt;What that means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes control plane&lt;/td&gt;
&lt;td&gt;Five masters, etcd on each, kube-vip for the API address&lt;/td&gt;
&lt;td&gt;Survives losing a master&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The front door&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;oauth2-proxy-kubeflow&lt;/code&gt; with 2 replicas, sessions in Redis&lt;/td&gt;
&lt;td&gt;Survives losing one proxy pod; nobody gets logged out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubeflow itself&lt;/td&gt;
&lt;td&gt;33 Deployments in &lt;code&gt;kubeflow&lt;/code&gt;, one replica each, no PodDisruptionBudgets&lt;/td&gt;
&lt;td&gt;A lost node means a reschedule and a short gap for the apps on it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitOps&lt;/td&gt;
&lt;td&gt;24 ArgoCD Applications, all Synced and Healthy&lt;/td&gt;
&lt;td&gt;Drift is put back by ArgoCD's self-heal within seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KFP and Katib databases&lt;/td&gt;
&lt;td&gt;The bundled MySQL, one pod each, dumped to Ceph RGW every day&lt;/td&gt;
&lt;td&gt;The latest daily dump succeeded; a restore loses at most a day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The daily training run&lt;/td&gt;
&lt;td&gt;A KFP RecurringRun every morning&lt;/td&gt;
&lt;td&gt;The last three runs succeeded. The failures before them were the namespace quota and the cold-boot race, both fixed above&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold boots&lt;/td&gt;
&lt;td&gt;The whole lab, every morning&lt;/td&gt;
&lt;td&gt;The scheduler now waits for its dependencies instead of firing into a half-started platform&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The single replicas are a choice for a one-person homelab, not an accident: the Kubeflow controllers reconcile from Kubernetes state, so a restart costs a gap, not data. The databases are the part I'd fix first, which is why the CNPG move is at the top of the list below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kubeflow traps that will bite you
&lt;/h2&gt;

&lt;p&gt;If you're putting Kubeflow on a cluster that isn't the reference one, these are the ones I'd check first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;--pass-host-header=false&lt;/code&gt; on oauth2-proxy.&lt;/strong&gt; The default forwards the public Host, Istio's vhost lookup fails, traffic lands in PassthroughCluster, and you get blank pages with 200s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete every upstream &lt;code&gt;hosts: ['*']&lt;/code&gt; VirtualService.&lt;/strong&gt; They outrank your specific-host route and send requests around the proxy with no identity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replace principal-based AuthorizationPolicies with path-based ones&lt;/strong&gt; under a PERMISSIVE mesh, or everything is &lt;code&gt;RBAC: access denied&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set the user header on all ten surfaces&lt;/strong&gt;, and remember KFP's UI and API read &lt;code&gt;KUBEFLOW_USERID_HEADER&lt;/code&gt;, not &lt;code&gt;USERID_HEADER&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the session out of the cookie.&lt;/strong&gt; Keycloak tokens push it past the ~8 KB header limit of the Tornado-based UIs and you get HTTP 431.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Huge CRDs need per-CRD ServerSideApply and &lt;code&gt;ClientSideApplyMigration=false&lt;/code&gt;&lt;/strong&gt;, or the 256KB annotation limit stops the sync.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every istio-injected namespace needs PSS &lt;code&gt;privileged&lt;/code&gt;&lt;/strong&gt;, Profile namespaces included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch for overlapping wildcard Gateways on the same port.&lt;/strong&gt; If another Gateway's &lt;code&gt;*.domain&lt;/code&gt; server covers your host, bind your VirtualService to both or live with intermittent 404s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't trust a TCP check inside the mesh.&lt;/strong&gt; The pod's own sidecar accepts the connection, so "the port is open" proves nothing. Exclude those ports from the sidecar, or check at the HTTP level, and test the check against a Service with nothing behind it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size the Profile quota for notebooks and pipelines together.&lt;/strong&gt; A Job the quota refuses never gets a pod and never errors; it just waits.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Here's what's running today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Five master nodes, HA control plane, and three RTX A1000 GPUs available to notebooks and workloads.&lt;/li&gt;
&lt;li&gt;Knative Serving + Eventing.&lt;/li&gt;
&lt;li&gt;Kubeflow admission webhook, Trainer V2 (jobset-controller + trainer-controller), Katib, KFP (api-server + ui + persistenceagent + scheduledworkflow + viewer-crd + metadata-grpc + metadata-envoy + cache-server + workflow-controller), with a daily backup of KFP's and Katib's MySQL to RGW.&lt;/li&gt;
&lt;li&gt;KServe controller + LocalModel controller + Models Web App, serving a real model: &lt;code&gt;football-match-outcome&lt;/code&gt; in the &lt;code&gt;terranetes&lt;/code&gt; namespace, retrained and redeployed by a daily XGBoost KFP RecurringRun.&lt;/li&gt;
&lt;li&gt;Notebook controller + Jupyter Web App + Volumes Web App + TensorBoard controller + TensorBoards Web App + PVCViewer controller.&lt;/li&gt;
&lt;li&gt;Central Dashboard + Profiles (kfam + manager containers) + per-user namespace governance and PodDefaults via Kyverno generate policies.&lt;/li&gt;
&lt;li&gt;Model Registry + Model Registry UI + Model Catalog server + their two postgres Deployments.&lt;/li&gt;
&lt;li&gt;oauth2-proxy-kubeflow in the &lt;code&gt;kubeflow-sso&lt;/code&gt; ns, plus the per-component config (multi-upstream + pass-host-header=false + Redis sessions + EnvoyFilter Lua + ext_authz for /notebook/*).&lt;/li&gt;
&lt;li&gt;Three Profiles: the upstream default, &lt;code&gt;kubeflow-user-george&lt;/code&gt; from Git, and the hand-made &lt;code&gt;terranetes&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The MLflow external link in the Central Dashboard sidebar.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How that served model gets trained, gated and promoted is its own story, in my separate article on the EPL match-prediction pipeline.&lt;/p&gt;

&lt;p&gt;What isn't done yet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;KFP metadata on CNPG (still the bundled MySQL, backed up daily).&lt;/li&gt;
&lt;li&gt;More than one replica for the Kubeflow components (see the reliability table).&lt;/li&gt;
&lt;li&gt;Spark on Kubeflow (I use the mlops-spark-operator from the MLOps stack instead of Kubeflow's bundled overlay; they share infrastructure).&lt;/li&gt;
&lt;li&gt;A real multi-tenant test. The Profiles exist, but I'm the only person using them, so isolation between two real users hasn't been exercised.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;terranetes&lt;/code&gt; in Git. It works, but it's the one Profile that would vanish in a rebuild.&lt;/li&gt;
&lt;li&gt;Network Policies on the Kubeflow control plane (user namespaces have them via Kyverno; the platform namespaces are open).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I did this build again, I'd change five things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Start with the auth chain, not finish with it.&lt;/strong&gt; In the plan, Phase 6 was the front door. In practice it's where most of the debugging time went. Knowing the shape of the auth chain up front would have shaped every component overlay: the USERID_HEADER choices, the AP structure and the EnvoyFilter Lua would all have been settled in advance instead of fought one component at a time.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Build the EnvoyFilter Lua early.&lt;/strong&gt; The Lua that aliases X-Forwarded-Email to kubeflow-userid is what holds cross-component auth together. I built it once, hit the wrong-header bug, fixed it, and now it works. Building it on day 1 with the right header would have saved half a dozen "why doesn't X have a user" sessions.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Write the trap notes as I go, not afterwards.&lt;/strong&gt; Most of my notes on this build were written after the bug was fixed. Writing them during the debug, even as rough notes, would have made the next bug quicker to find.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test with more than one user earlier.&lt;/strong&gt; A single user (me) hides every cross-namespace issue. The Kyverno generate policies, the MR alias services and the user-ns governance have only ever been exercised by one person.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Lean on what I already knew about the substrate.&lt;/strong&gt; At the start I had notes covering Vault, Keycloak, oauth2-proxy patterns, Istio STRICT-vs-PERMISSIVE and Kyverno, and none covering Kubeflow. Most of the surprises in this build came from the substrate, not from Kubeflow itself, so the old notes were more useful than I expected.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Practical takeaways if you're putting Kubeflow Platform 1.11 on a non-reference substrate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pin to &lt;code&gt;v1.11.0&lt;/code&gt;, not master.&lt;/strong&gt; A reproducible build beats the newest commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design the auth chain first.&lt;/strong&gt; Settle the OIDC provider, the proxy mode, the header names, the session store and the AP shape before you patch a single component overlay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PSS=privileged on every Kubeflow namespace, user namespaces included.&lt;/strong&gt; istio-init needs CAP_NET_ADMIN/NET_RAW wherever the sidecar is injected, and Profile namespaces are injected too. I enforce it with a Kyverno mutate policy so a new Profile can't come up &lt;code&gt;restricted&lt;/code&gt; and fail every pod.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disable the ClientSideApply migration at the AppSet level.&lt;/strong&gt; Kubeflow CRDs are too big for the 256KB annotation limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put Kyverno governance on Profile namespaces from the start.&lt;/strong&gt; The generated quota, limits, network policies, PodDefaults, Model Registry alias and notebook AP all live in one governance file. Bolting them on afterwards is much harder than having them in place at Phase 5.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the traps down.&lt;/strong&gt; The next platform with substrate swaps will hit a good share of the same ones.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;The main thing I took from this build: &lt;strong&gt;a platform's reference architecture is a starting point, and you don't have to keep it.&lt;/strong&gt; Kubeflow ships Dex, SeaweedFS and MySQL because it has to ship something. Each of them can be swapped out and wired to the stacks you already trust. Brownfield costs more up front, and in return the platform fits the cluster you already run.&lt;/p&gt;

&lt;p&gt;The model-serving loop is already working, and it's covered in the EPL match-prediction pipeline article. What's left on this platform, roughly in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Move KFP metadata to CNPG.&lt;/strong&gt; The database and credentials are waiting; the work is writing the Postgres patches upstream doesn't ship for multi-user mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Onboard a second tenant and test isolation properly.&lt;/strong&gt; A second real user, with their own Profile in Git, trying to reach my namespace, my notebooks and my models, and failing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wire up OpenLineage&lt;/strong&gt; so every pipeline run shows its inputs and outputs in a lineage graph, not just in MLflow tags.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're facing Kubeflow on a cluster that already has opinions of its own, the swap table at the top is a good place to start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.linkedin.com/in/george-ezejiofor-89615a8a/" rel="noopener noreferrer"&gt;Follow me on LinkedIn&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Author: George Ezejiofor&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>kubeflow</category>
      <category>mlops</category>
      <category>devops</category>
    </item>
    <item>
      <title>Self-Hosted Databricks Alternative: Homelab Data Lakehouse (AI/ML Data Factory)</title>
      <dc:creator>George Ezejiofor</dc:creator>
      <pubDate>Fri, 16 Oct 2026 23:23:18 +0000</pubDate>
      <link>https://dev.to/ezejioforog/self-hosted-databricks-alternative-homelab-data-lakehouse-aiml-data-factory-226f</link>
      <guid>https://dev.to/ezejioforog/self-hosted-databricks-alternative-homelab-data-lakehouse-aiml-data-factory-226f</guid>
      <description>&lt;p&gt;&lt;code&gt;Self-Hosted Databricks Alternative: Homelab Data Lakehouse&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part of the homelab build series. Trino, Apache Iceberg, Project Nessie, dbt, Spark, MLflow&lt;br&gt;
and OpenMetadata on RKE2, with highly available state on Ceph and CloudNativePG, secrets from&lt;br&gt;
Vault and sign-in through Keycloak. Raw data comes in at one door, gets built and inspected&lt;br&gt;
on the line, and leaves as tables and models I can trust. Nobody sends an invoice.&lt;/em&gt;&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%2Fcicp3jsqjxbeq21b2npu.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%2Fcicp3jsqjxbeq21b2npu.png" alt="lh" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;


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

&lt;p&gt;&lt;em&gt;A factory you rent by the hour, or one you own&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Raw data is worth about as much as raw steel. Nobody wants it until someone has cut it,&lt;br&gt;
joined it, checked it and stamped it.&lt;/p&gt;

&lt;p&gt;Snowflake, Databricks and BigQuery will rent you an excellent factory for that, and the&lt;br&gt;
meter runs for as long as the lights are on. I wanted the factory without the meter, so I&lt;br&gt;
built one on three mini-PCs in my homelab, on the same RKE2 cluster as everything else in&lt;br&gt;
this series. Every station is a real open-source tool doing a real job.&lt;/p&gt;

&lt;p&gt;It's built to make one thing: machine-learning models trained on data I can name down to&lt;br&gt;
the exact version. One is already serving, a football predictor that a scheduled pipeline&lt;br&gt;
refits and re-tests every day.&lt;/p&gt;

&lt;p&gt;Here is the floor, in the order the material moves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The loading dock: Ceph object storage.&lt;/strong&gt; Everything that arrives lands here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The stock ledger: Project Nessie.&lt;/strong&gt; Every table is an &lt;strong&gt;Apache Iceberg&lt;/strong&gt; table, and
Nessie records every version it has ever had. It's Git for data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The assembly line and quality control: dbt.&lt;/strong&gt; Tables are built and tested on a branch,
so a batch that fails inspection never reaches the shelf.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The front counter: Trino.&lt;/strong&gt; Anyone with access asks for what they need in plain SQL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The showroom: Superset.&lt;/strong&gt; Charts and dashboards over the finished tables, for people
who'd rather not write SQL. It asks for data in their name, never its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The records office: OpenMetadata.&lt;/strong&gt; It traces any table back to its raw material.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The machine shop: Kubeflow.&lt;/strong&gt; Notebooks, pipelines and hyperparameter searches, on
GPUs when a job needs them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Finished goods: MLflow and KServe.&lt;/strong&gt; Every run is tracked, and the model that passes
its checks is served over HTTP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The utilities were already in the building: Ceph, CloudNativePG, Vault, Keycloak, Istio and&lt;br&gt;
ArgoCD.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's highly available where it holds state.&lt;/strong&gt; Postgres runs three instances, one in each&lt;br&gt;
datacenter, the ledger runs two replicas and search runs three nodes. Trino, Superset,&lt;br&gt;
OpenMetadata and MLflow run a single replica each, and I come back to that below. One bay is still&lt;br&gt;
empty: a &lt;strong&gt;Hermes AI copilot&lt;/strong&gt; that will read the lineage over OpenMetadata's &lt;strong&gt;MCP&lt;/strong&gt;&lt;br&gt;
interface, waiting on a DGX Spark that hasn't arrived.&lt;/p&gt;

&lt;p&gt;If you know Databricks, you already know the layout:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Databricks&lt;/th&gt;
&lt;th&gt;This homelab lakehouse&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Databricks Runtime (Spark)&lt;/td&gt;
&lt;td&gt;Kubeflow &lt;strong&gt;spark-operator&lt;/strong&gt; (installed, not on the daily line yet)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delta Lake&lt;/td&gt;
&lt;td&gt;Apache &lt;strong&gt;Iceberg&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unity Catalog&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Nessie&lt;/strong&gt; (catalog + Git-for-data) + &lt;strong&gt;OpenMetadata&lt;/strong&gt; (governance/lineage)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Databricks SQL / Photon&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Trino&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dbt&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;dbt&lt;/strong&gt; (dbt-trino)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed MLflow&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;MLflow&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notebooks&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Kubeflow&lt;/strong&gt; notebooks / Jupyter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workflows&lt;/td&gt;
&lt;td&gt;dbt/OpenMetadata CronJobs + Kubeflow Pipelines RecurringRuns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 / ADLS&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Ceph RGW&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSO / SCIM&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Keycloak&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Databricks Assistant / agents&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Hermes &lt;code&gt;mlops-copilot&lt;/code&gt;&lt;/strong&gt; over OpenMetadata &lt;strong&gt;MCP&lt;/strong&gt; (designed, not running yet)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It's an open-source equivalent, not Databricks, which is a product and a trademark, and&lt;br&gt;
there's no affiliation. This is the second design: the first used Hive Metastore and&lt;br&gt;
Airflow, and I say below why I replaced both.&lt;/p&gt;


&lt;h2&gt;
  
  
  The brief
&lt;/h2&gt;

&lt;p&gt;The brief I set myself: an MLOps and big-data platform on the existing homelab RKE2 cluster&lt;br&gt;
that a real data team could plausibly use. The homelab's existing backends (Rook-Ceph,&lt;br&gt;
CloudNativePG, Vault, Keycloak) had to thread through every component, and each piece had&lt;br&gt;
to land in GitOps and come back after a node reboot with nobody touching it.&lt;/p&gt;

&lt;p&gt;What landed is two tiers. &lt;strong&gt;The lakehouse:&lt;/strong&gt; Ceph RGW object storage, &lt;strong&gt;Apache Iceberg&lt;/strong&gt;&lt;br&gt;
tables, a &lt;strong&gt;Project Nessie&lt;/strong&gt; catalog (state in CNPG &lt;code&gt;nessie_db&lt;/code&gt;), &lt;strong&gt;Trino&lt;/strong&gt; from Stackable's&lt;br&gt;
coordinated 25.3.0 release, &lt;strong&gt;dbt-trino&lt;/strong&gt; for tested marts, and &lt;strong&gt;OpenMetadata&lt;/strong&gt; for&lt;br&gt;
governance, with the Kubeflow &lt;strong&gt;spark-operator&lt;/strong&gt; installed for the day the data outgrows&lt;br&gt;
SQL. &lt;strong&gt;MLOps on top:&lt;/strong&gt; MLflow Tracking and the Model&lt;br&gt;
Registry, backed by CNPG &lt;code&gt;mlflow_backend_db&lt;/code&gt; and the &lt;code&gt;mlflow-artifacts&lt;/code&gt; bucket, behind&lt;br&gt;
oauth2-proxy for the browser and reachable directly over in-cluster DNS from Kubeflow&lt;br&gt;
notebooks.&lt;/p&gt;

&lt;p&gt;The constraints that shaped every decision:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No HDFS, no ZooKeeper.&lt;/strong&gt; Ceph RGW is my S3, and nothing in the lakehouse needs ZK.
The RAM saving alone justified leaving both out; the complexity avoided mattered more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No bundled databases.&lt;/strong&gt; Every chart wants to ship its own MySQL or Postgres or
Mongo. I ripped each one out of the lakehouse and MLflow charts and pointed them at CNPG.
There is &lt;em&gt;one&lt;/em&gt; Postgres cluster for this stack (&lt;code&gt;postgres-mlops-cnpg-cluster&lt;/code&gt;, three
instances across three DCs, each on its own zone-local volume) and it hosts the lakehouse and MLflow
databases. (Kubeflow is the exception; it keeps its bundled MySQL, &lt;code&gt;katib-mysql&lt;/code&gt; and Model
Registry's Postgres for now.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;v1/Secret&lt;/code&gt; literals in Git.&lt;/strong&gt; Every credential (application user passwords,
OAuth2 client secrets, OIDC cookie keys) flows from Terraform into a
Vault path, then into a &lt;code&gt;VaultStaticSecret&lt;/code&gt; CR, then into the destination namespace. Kustomize
may declare a &lt;em&gt;placeholder&lt;/em&gt; Secret to keep references valid, but VSO always wins on
the data values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mirror production patterns, even when it's harder.&lt;/strong&gt; Real OIDC, real mTLS,
real RBAC. No "developer convenience" backdoors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rest of this article covers how each piece lands, what I cut, and which&lt;br&gt;
traps cost the most time.&lt;/p&gt;
&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;If the word is new, the lakehouse is the third step in a short history. A warehouse&lt;br&gt;
gives fast SQL over the tables you planned for, in a format you rent. A lake keeps any&lt;br&gt;
file cheaply but has no table rules, so teams copy the data into a warehouse anyway and&lt;br&gt;
end up with two versions of the truth. A lakehouse puts the table rules (Iceberg) and&lt;br&gt;
the versions (Nessie) on the open files themselves, so every engine reads one copy.&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%2F9eo0mdn3ipg84fqku8b5.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%2F9eo0mdn3ipg84fqku8b5.png" alt="lk" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Warehouse, lake, lakehouse: a warehouse sells storage and engine together, a lake plus a warehouse keeps two copies, and the lakehouse built here keeps one copy of open files in Ceph with Iceberg tables and Nessie versions read by Trino, dbt, Kubeflow and MLflow&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Here is how this one is put together:&lt;/strong&gt;&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%2Fba3jcjavxhzy4emp506f.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%2Fba3jcjavxhzy4emp506f.png" alt="lk" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;The data factory:** dbt, Trino, Nessie, Kubeflow, MLflow and KServe on the line, Superset and OpenMetadata above it behind Keycloak, and Ceph, CloudNativePG, Vault, Istio and Argo CD underneath&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://george-lakehouse.terranetes.workers.dev/" rel="noopener noreferrer"&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%2F8jfew87qx2zokc3m7v00.png" alt="lk" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With five guided views: the query path, the daily pipeline, governance, train-and-serve,&lt;br&gt;
and where secrets come from. Blog platforms can't embed the HTML, so host it and link it.&lt;/p&gt;

&lt;p&gt;Kubeflow appears here as one station, the machine shop; how I changed it from upstream&lt;br&gt;
is in the SSO section. Read the diagram bottom-up, because that's the order the&lt;br&gt;
dependencies run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Foundation.&lt;/strong&gt; &lt;strong&gt;Ceph RGW&lt;/strong&gt; holds every byte of table data (&lt;code&gt;s3://mlops-data-lake&lt;/code&gt;,
TLS-only at &lt;code&gt;s3.georgehomelab.com&lt;/code&gt;). One CloudNativePG cluster holds the lakehouse and
MLflow stores (&lt;code&gt;nessie_db&lt;/code&gt;, &lt;code&gt;mlflow_backend_db&lt;/code&gt;, &lt;code&gt;openmetadata_db&lt;/code&gt;). Vault + VSO hand out
every credential.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catalog.&lt;/strong&gt; &lt;strong&gt;Project Nessie&lt;/strong&gt; answers "where does table X live, and at which commit?"
over Iceberg REST, versioning the whole catalog like Git.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query and transform.&lt;/strong&gt; &lt;strong&gt;Trino&lt;/strong&gt; reads and writes the Iceberg tables; dbt builds and
tests the marts through Trino every morning, on a Nessie branch &lt;code&gt;dbt-audit&lt;/code&gt; that only
merges into &lt;code&gt;main&lt;/code&gt; if the tests pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Govern.&lt;/strong&gt; &lt;strong&gt;OpenMetadata 2.0.2&lt;/strong&gt; (search on its own OpenSearch 3.4.0) crawls Trino at
10:00 and ingests dbt's artifacts at 10:15. That's where the lineage comes from.
People reach it through Keycloak (Google or GitHub).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consume.&lt;/strong&gt; &lt;strong&gt;Superset 6.1&lt;/strong&gt; gives people charts, dashboards and a SQL editor over
Trino. It signs people in through Keycloak and asks Trino to run each query as the
person, so Trino's rules and its audit table see a name, never a shared login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Train and serve.&lt;/strong&gt; &lt;strong&gt;MLflow&lt;/strong&gt; tracks runs in Postgres and models in RGW; a KServe
InferenceService serves the promoted model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything in the foundation layer predates this work. Most of my effort went into&lt;br&gt;
making sure the upper layers didn't reinvent any of it. The one deliberate exception is&lt;br&gt;
OpenMetadata's own OpenSearch; the going-live section explains why that stopped being&lt;br&gt;
optional.&lt;/p&gt;
&lt;h2&gt;
  
  
  Stackable operators, and what I dropped
&lt;/h2&gt;

&lt;p&gt;Stackable ships a coordinated release of operators for Hadoop-ecosystem services. It's&lt;br&gt;
tempting to take all of them. I only took the parts that fit:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stackable component&lt;/th&gt;
&lt;th&gt;Use it?&lt;/th&gt;
&lt;th&gt;Why / Why not&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;commons-operator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;Hard prerequisite: ships &lt;code&gt;S3Connection&lt;/code&gt; / &lt;code&gt;AuthenticationClass&lt;/code&gt; CRDs that every workload operator references.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;listener-operator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;Hard prerequisite: ships &lt;code&gt;ListenerClass&lt;/code&gt; CRDs the workload operators reference.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;secret-operator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes (minimal)&lt;/td&gt;
&lt;td&gt;Hard prerequisite for CRD presence, because the workload admission webhooks reference &lt;code&gt;SecretClass&lt;/code&gt;. The chart's default &lt;code&gt;tls&lt;/code&gt; SecretClass issues Trino's internal TLS. No credentials go through it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;trino-operator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The SQL query engine over Iceberg. It was "deferred" in v1; the lakehouse pivot promoted it to the primary query surface.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hive-operator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no (retired)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Was the metastore/catalog; &lt;strong&gt;Project Nessie replaced it&lt;/strong&gt; (Iceberg REST + Git-for-data).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;airflow-operator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no (retired)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Orchestration dropped. dbt CronJobs and Kubeflow Pipelines handle scheduling. One fewer stateful service to babysit.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spark-k8s-operator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;I run the &lt;strong&gt;Kubeflow spark-operator&lt;/strong&gt; instead: same &lt;code&gt;SparkApplication&lt;/code&gt; shape, already deployed for the ML platform.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;zookeeper-operator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Nothing in the lakehouse needs ZK.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hdfs-operator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ceph RGW is my S3. HDFS would just duplicate storage and chew RAM.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kafka-operator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;deferred&lt;/td&gt;
&lt;td&gt;No broker runs today; both Strimzi and Redpanda are parked. Skip until there's a real streaming workload.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Stackable operators pin to &lt;code&gt;25.3.0&lt;/code&gt; and release as a set, so I bump them as a set&lt;br&gt;
(never Trino alone). &lt;strong&gt;Project Nessie and OpenMetadata are not Stackable products&lt;/strong&gt;; they&lt;br&gt;
deploy from their own Helm charts, the price of a best-of-breed catalog and governance layer.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;secret-operator&lt;/code&gt; needs explaining. The workload operators' admission webhooks&lt;br&gt;
reference the &lt;code&gt;SecretClass&lt;/code&gt; CRD, and without it workload CR creation fails. So I install&lt;br&gt;
the operator but create no &lt;code&gt;SecretClass&lt;/code&gt; of my own. The chart's default &lt;code&gt;tls&lt;/code&gt; SecretClass&lt;br&gt;
issues Trino's internal (autoTls) certificates, and the dbt and ingestion pods mount its&lt;br&gt;
CA to verify them. Credentials never go through it; they're plain &lt;code&gt;v1/Secret&lt;/code&gt;s synced by&lt;br&gt;
VSO from Vault.&lt;/p&gt;

&lt;p&gt;Stackable workloads also brought the first Istio trap. Under STRICT mTLS (the cluster&lt;br&gt;
default), the API server's webhook calls can't reach the operator pods, because the API&lt;br&gt;
server has no mesh client cert. The fix is a &lt;code&gt;PeerAuthentication: PERMISSIVE&lt;/code&gt; in the&lt;br&gt;
&lt;code&gt;mlops&lt;/code&gt; namespace. Don't turn off injection; relax the mode for that namespace and&lt;br&gt;
nothing else.&lt;/p&gt;
&lt;h2&gt;
  
  
  Storage: Rook-Ceph all the way down
&lt;/h2&gt;

&lt;p&gt;The homelab already runs Rook-Ceph with four storage classes: &lt;code&gt;ceph-block&lt;/code&gt; (RBD, RWO,&lt;br&gt;
three copies, the default for notebook PVCs and most stateful pods), &lt;code&gt;ceph-block-pg-local&lt;/code&gt;&lt;br&gt;
(RBD, one copy, pinned to a datacenter, for Postgres only), &lt;code&gt;ceph-filesystem&lt;/code&gt; (CephFS, RWX,&lt;br&gt;
only when a workload really needs shared scratch) and &lt;code&gt;ceph-bucket&lt;/code&gt; (RGW, S3), the data lake.&lt;/p&gt;

&lt;p&gt;RGW is &lt;strong&gt;TLS-only&lt;/strong&gt;. Clients use &lt;code&gt;https://s3.georgehomelab.com&lt;/code&gt;,&lt;br&gt;
which terminates on the Istio gateway with a public Let's Encrypt certificate (no private&lt;br&gt;
CA to distribute), and path-style addressing still works. The old plain-HTTP&lt;br&gt;
&lt;code&gt;…rook-ceph.svc:80&lt;/code&gt; endpoint is gone.&lt;/p&gt;

&lt;p&gt;Buckets come from Rook's &lt;code&gt;ObjectBucketClaim&lt;/code&gt; CR. The OBC controller provisions the bucket&lt;br&gt;
on RGW and creates a per-bucket &lt;code&gt;Secret&lt;/code&gt; with &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt; /&lt;br&gt;
&lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt; keys, named after the claim: Spark reads &lt;code&gt;mlops-data-lake&lt;/code&gt;, MLflow&lt;br&gt;
reads &lt;code&gt;mlflow-artifacts&lt;/code&gt;, KFP reads &lt;code&gt;kfp-artifacts&lt;/code&gt;. Nothing fans out from a single root key.&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;objectbucket.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;ObjectBucketClaim&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;mlops-data-lake&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;mlops&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;bucketName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mlops-data-lake&lt;/span&gt;
  &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ceph-bucket&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply that and the bucket, its owner user and the Secret all exist, ready for &lt;code&gt;envFrom&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The Ceph dashboard → Object Gateway → Buckets: &lt;code&gt;mlops-data-lake&lt;/code&gt;, &lt;code&gt;mlflow-artifacts&lt;/code&gt; and &lt;code&gt;kfp-artifacts&lt;/code&gt;, each with its own OBC-issued owner.&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%2F1ud11ch8ow3x5gvtshz3.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%2F1ud11ch8ow3x5gvtshz3.png" alt="lk" width="800" height="374"&gt;&lt;/a&gt;&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%2Fecj1q1ckjfx8o9stmaz2.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%2Fecj1q1ckjfx8o9stmaz2.png" alt="lk" width="800" height="374"&gt;&lt;/a&gt;&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%2Flsykg1suqn0e026zwnp9.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%2Flsykg1suqn0e026zwnp9.png" alt="lk" width="800" height="374"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Databases: one CNPG cluster, many logical databases
&lt;/h2&gt;

&lt;p&gt;The easy path is to leave every chart's &lt;code&gt;postgresql.enabled: true&lt;/code&gt; on and accept the&lt;br&gt;
extra StatefulSets. I didn't: backups, upgrades, monitoring and HA belong on one tier, not&lt;br&gt;
nine. The homelab's CNPG pattern already runs Keycloak, SonarQube, Tekton and others, so&lt;br&gt;
one more cluster meant copying an existing manifest (trimmed here; the real one adds&lt;br&gt;
managed roles, affinity and resources):&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;postgresql.cnpg.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;Cluster&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;postgres-mlops-cnpg-cluster&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;mlops&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;instances&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;imageName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/cloudnative-pg/postgresql:17&lt;/span&gt;
  &lt;span class="na"&gt;bootstrap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;initdb&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;database&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mlops_placeholder&lt;/span&gt;       &lt;span class="c1"&gt;# real DBs come from Database CRs&lt;/span&gt;
      &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;secret&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;mlops-cnpg-bootstrap&lt;/span&gt;      &lt;span class="c1"&gt;# VSO-synced from Vault&lt;/span&gt;
  &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;storageClass&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ceph-block-pg-local&lt;/span&gt;   &lt;span class="c1"&gt;# one Ceph copy, in the instance's own DC&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;40Gi&lt;/span&gt;
  &lt;span class="na"&gt;postgresql&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;max_connections&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;200"&lt;/span&gt;
  &lt;span class="na"&gt;backup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;retentionPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;7d"&lt;/span&gt;
    &lt;span class="na"&gt;barmanObjectStore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;destinationPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3://cnpg-backups/postgres-mlops-cnpg-cluster"&lt;/span&gt;   &lt;span class="c1"&gt;# Ceph RGW&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three instances, one per DC, automatic failover, and continuous WAL archival to RGW.&lt;/p&gt;

&lt;p&gt;The storage class is worth a sentence. Postgres already keeps three copies of the data,&lt;br&gt;
one per instance. On the default &lt;code&gt;ceph-block&lt;/code&gt;, Ceph kept three copies of each of those,&lt;br&gt;
nine in all, and every commit waited on a write to another datacenter. So Postgres now&lt;br&gt;
sits on &lt;code&gt;ceph-block-pg-local&lt;/code&gt;: one Ceph copy, in the same datacenter as the instance.&lt;br&gt;
Postgres does the replicating and Ceph just holds the disk. A 4K synced write on a&lt;br&gt;
database node went from about 34 ms to between 15 and 24 ms. The price is that losing a&lt;br&gt;
drive loses that instance, and the operator has to clone a new one from the primary.&lt;br&gt;
CNPG's &lt;code&gt;Database&lt;/code&gt; CR (its per-logical-DB declaration) provisions one logical database per&lt;br&gt;
app, each with its own owner role whose password lives at its own Vault path:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vault path&lt;/th&gt;
&lt;th&gt;Owner of&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;secret/homelab/mlops/cnpg-bootstrap&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;postgres&lt;/code&gt; superuser (used only by &lt;code&gt;initdb&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;secret/homelab/mlops/nessie-db&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;nessie_db&lt;/code&gt; (Project Nessie catalog version store)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;secret/homelab/mlops/mlflow-db&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mlflow_backend_db&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;secret/homelab/mlops/openmetadata-db&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;openmetadata_db&lt;/code&gt; (OpenMetadata knowledge graph)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;secret/homelab/kubeflow/kubeflow-metadata-db&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kubeflow_metadata_db&lt;/code&gt; (lives in Kubeflow scope; provisioned, not yet used by KFP)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The lakehouse pivot added &lt;code&gt;nessie_db&lt;/code&gt; and &lt;code&gt;openmetadata_db&lt;/code&gt; with a short &lt;code&gt;Database&lt;/code&gt; CR&lt;br&gt;
each, and no new stateful tier. The old &lt;code&gt;hive_metastore_db&lt;/code&gt; and &lt;code&gt;airflow_metadata_db&lt;/code&gt;&lt;br&gt;
(and their &lt;code&gt;hive&lt;/code&gt; / &lt;code&gt;airflow&lt;/code&gt; roles) are retained but no longer used: their &lt;code&gt;Database&lt;/code&gt;&lt;br&gt;
CRs set &lt;code&gt;databaseReclaimPolicy: retain&lt;/code&gt;, so nothing drops them by accident.&lt;/p&gt;

&lt;p&gt;Each app sees only its own DB, with its own creds. The superuser never lands on&lt;br&gt;
an application Pod.&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%2Fxy73319kmc9zhgcbwun3.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%2Fxy73319kmc9zhgcbwun3.png" alt="lk" width="800" height="106"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kubectl get database.postgresql.cnpg.io -n mlops&lt;/code&gt;: one &lt;code&gt;Database&lt;/code&gt; per app on the single &lt;code&gt;postgres-mlops-cnpg-cluster&lt;/code&gt;, all &lt;code&gt;APPLIED true&lt;/code&gt;, including the retained (unused) &lt;code&gt;hive_metastore_db&lt;/code&gt; and &lt;code&gt;airflow_metadata_db&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two Istio nuances are specific to CNPG. First, its istio-injected pods cycle their&lt;br&gt;
sidecars whenever Istio config changes, so the &lt;code&gt;READY&lt;/code&gt; column bounces between &lt;code&gt;1/2&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;2/2&lt;/code&gt; and the operator reports &lt;code&gt;READY &amp;lt; INSTANCES&lt;/code&gt;. Postgres is fine; it's sidecar churn.&lt;br&gt;
Don't triage CNPG; bump the sidecar resources or ignore it.&lt;/p&gt;

&lt;p&gt;Second, any istio-injected namespace must run at &lt;code&gt;PSS=privileged&lt;/code&gt;, because istio-init&lt;br&gt;
needs NET_ADMIN/NET_RAW. CNPG's initdb Job lives in such a namespace. If PSS is&lt;br&gt;
&lt;code&gt;baseline&lt;/code&gt;, the Job goes &lt;code&gt;FailedCreate&lt;/code&gt; with no obvious cause. That's why the &lt;code&gt;mlops&lt;/code&gt;&lt;br&gt;
namespace is PSS=privileged.&lt;/p&gt;
&lt;h2&gt;
  
  
  Secrets: Vault all the way down
&lt;/h2&gt;

&lt;p&gt;Vault is the single source of truth for credentials, and Terraform is the only thing that&lt;br&gt;
writes to it. The chain for any credential: &lt;strong&gt;Terraform&lt;/strong&gt; reads the value from a gitignored&lt;br&gt;
&lt;code&gt;.secrets.auto.tfvars&lt;/code&gt; and writes a &lt;strong&gt;Vault KV-v2&lt;/strong&gt; path; a &lt;strong&gt;&lt;code&gt;VaultStaticSecret&lt;/code&gt;&lt;/strong&gt; CR names&lt;br&gt;
that path, the Vault role and the &lt;code&gt;Secret&lt;/code&gt; to produce; &lt;strong&gt;VSO&lt;/strong&gt; reconciles it hourly into a&lt;br&gt;
plain &lt;code&gt;v1/Secret&lt;/code&gt; (optionally reshaping keys with a template); the workload mounts 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;secrets.hashicorp.com/v1beta1&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;VaultStaticSecret&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;mlops-mlflow-db&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;mlops&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;vaultAuthRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mlops-mlflow&lt;/span&gt;
  &lt;span class="na"&gt;mount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secret&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kv-v2&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;homelab/mlops/mlflow-db&lt;/span&gt;
  &lt;span class="na"&gt;refreshAfter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
  &lt;span class="na"&gt;destination&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;mlops-mlflow-db&lt;/span&gt;
    &lt;span class="na"&gt;create&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;overwrite&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't drop the &lt;code&gt;overwrite: true&lt;/code&gt;. If the same Secret is referenced from both a kustomize&lt;br&gt;
&lt;code&gt;secretGenerator&lt;/code&gt; (with placeholder data) and a VSO sync, every &lt;code&gt;kubectl apply -k&lt;/code&gt; briefly&lt;br&gt;
stomps VSO's data with the kustomize placeholders. With &lt;code&gt;overwrite: true&lt;/code&gt; on the VSS, VSO&lt;br&gt;
puts the real values back on its next reconcile. Without it, oauth2-proxy gets&lt;br&gt;
&lt;code&gt;unauthorized_client&lt;/code&gt; from Keycloak and you spend two hours blaming the realm import.&lt;/p&gt;

&lt;p&gt;Each app has its own ServiceAccount, its own Vault role (Kubernetes auth, policy scoped to&lt;br&gt;
its own paths) and its own VSS, so no credential crosses an app boundary.&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%2F36lsjcba8g0inwa3jxml.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%2F36lsjcba8g0inwa3jxml.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&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%2F7u9j3sxj7dpr1d8xdq64.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%2F7u9j3sxj7dpr1d8xdq64.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&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%2Fdqbpoh66prvkwulbll0s.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%2Fdqbpoh66prvkwulbll0s.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Vault UI → &lt;code&gt;secret/&lt;/code&gt; → &lt;code&gt;homelab/mlops/&lt;/code&gt;: the list of per-app paths (&lt;code&gt;mlflow-db&lt;/code&gt;, &lt;code&gt;nessie-db&lt;/code&gt;, &lt;code&gt;openmetadata-db&lt;/code&gt;, …). ⚠️ Show the path list only; .&lt;/p&gt;
&lt;h2&gt;
  
  
  SSO: per-app oauth2-proxy with Keycloak upstream
&lt;/h2&gt;

&lt;p&gt;Every UI in this stack sits behind Keycloak, using the standard&lt;br&gt;
Authorization-Code-with-PKCE flow. For MLflow: the Istio gateway routes&lt;br&gt;
&lt;code&gt;mlflow.georgehomelab.com&lt;/code&gt; to its oauth2-proxy, which looks for the &lt;code&gt;_oauth2_proxy_mlflow&lt;/code&gt;&lt;br&gt;
cookie. Without it, the browser goes to&lt;br&gt;
&lt;code&gt;https://keycloak.georgehomelab.com/realms/mlflow/protocol/openid-connect/auth?...&lt;/code&gt;, signs in&lt;br&gt;
with Google, and comes back to &lt;code&gt;/oauth2/callback&lt;/code&gt;. oauth2-proxy validates the token, sets the&lt;br&gt;
cookie, and from then on reverse-proxies to &lt;code&gt;mlflow.mlops.svc.cluster.local:5000&lt;/code&gt; with&lt;br&gt;
&lt;code&gt;X-Forwarded-Email&lt;/code&gt; added.&lt;/p&gt;

&lt;p&gt;Each app gets its own realm too: MLflow's proxy uses realm &lt;code&gt;mlflow&lt;/code&gt;, and OpenMetadata&lt;br&gt;
(which speaks OIDC natively) uses realm &lt;code&gt;openmetadata&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The decision that isn't obvious is running one oauth2-proxy per app rather than one&lt;br&gt;
shared proxy. One shared proxy means one shared cookie. A leaked MLflow session becomes a&lt;br&gt;
leaked session for OpenMetadata, Kiali, Hubble UI and every other gated app at once.&lt;br&gt;
Per-app instances cost a little RAM (oauth2-proxy is ~30 MiB) and buy real session&lt;br&gt;
isolation. Each overlay sets &lt;code&gt;OAUTH2_PROXY_COOKIE_NAME&lt;/code&gt; (&lt;code&gt;_oauth2_proxy_&amp;lt;app&amp;gt;&lt;/code&gt;) and&lt;br&gt;
&lt;code&gt;OAUTH2_PROXY_COOKIE_DOMAINS&lt;/code&gt; (its own host). The base values use a default that collides,&lt;br&gt;
so without the override the first app to set a cookie "captures" it and later apps return&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The per-app client secret follows the usual chain: Terraform writes Vault, a
&lt;code&gt;VaultStaticSecret&lt;/code&gt; syncs it into the namespace, and oauth2-proxy reads it via &lt;code&gt;envFrom&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two more lessons came out of this. In oauth2-proxy's &lt;em&gt;proxy&lt;/em&gt; mode (which I use for&lt;br&gt;
MLflow), the user email reaches the upstream as &lt;code&gt;X-Forwarded-Email&lt;/code&gt;, not&lt;br&gt;
&lt;code&gt;X-Auth-Request-Email&lt;/code&gt;. The latter is a &lt;em&gt;response&lt;/em&gt; header for &lt;code&gt;auth_request&lt;/code&gt;-style&lt;br&gt;
integrations. Get it wrong and the upstream app sees no user identity even though&lt;br&gt;
authentication "worked."&lt;/p&gt;

&lt;p&gt;And Keycloak doesn't substitute environment variables at realm import. The realm JSON&lt;br&gt;
includes the oauth2-proxy client, but its secret is the literal placeholder string&lt;br&gt;
&lt;code&gt;${TOOL_OAUTH2_PROXY_CLIENT_SECRET}&lt;/code&gt;. Every new realm needs a post-import&lt;br&gt;
&lt;code&gt;kcadm update clients/&amp;lt;id&amp;gt; -s secret=&amp;lt;actual&amp;gt;&lt;/code&gt; patch with the same value that's in Vault.&lt;br&gt;
&lt;code&gt;post-deploy-fixes.sh&lt;/code&gt; automates it, but "Invalid client credentials" on a fresh realm&lt;br&gt;
still catches you out the first time.&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%2Fe069xh7utrlk264qatv1.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%2Fe069xh7utrlk264qatv1.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Keycloak sign-in page for the &lt;code&gt;mlflow&lt;/code&gt; realm, with the "Sign in with Google" and "GitHub" buttons. Every UI in this stack sends you to a page like this first.&lt;/p&gt;
&lt;h3&gt;
  
  
  Brownfield Kubeflow: the same pattern, applied to a stack that ships its own auth
&lt;/h3&gt;

&lt;p&gt;Upstream Kubeflow ships its own login stack: Dex as the identity provider, with&lt;br&gt;
oauth2-proxy acting as an Istio ext_authz check in front of it. I don't run Dex at all.&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%2Fy9q5zgkztv61x3dt9jc4.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%2Fy9q5zgkztv61x3dt9jc4.png" alt="lk" width="800" height="450"&gt;&lt;/a&gt;&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%2F1rwllo664e0avldo17l4.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%2F1rwllo664e0avldo17l4.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&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%2Fzmyeqs4izekgc2pfrzp8.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%2Fzmyeqs4izekgc2pfrzp8.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&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%2F519byci3mev4dahausre.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%2F519byci3mev4dahausre.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What changed, checked against the live cluster:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dex → Keycloak.&lt;/strong&gt; Login goes Google → Keycloak realm &lt;code&gt;kubeflow&lt;/code&gt; → oauth2-proxy
(&lt;code&gt;kubeflow-sso/oauth2-proxy-kubeflow&lt;/code&gt;, 2 replicas) in reverse-proxy mode, with one
&lt;code&gt;--upstream&lt;/code&gt; per app. The upstream per-app &lt;code&gt;hosts: ['*']&lt;/code&gt; VirtualServices are deleted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The user header.&lt;/strong&gt; Every app reads &lt;code&gt;X-Forwarded-Email&lt;/code&gt; instead of upstream's
&lt;code&gt;kubeflow-userid&lt;/code&gt;. That's nine separate places to patch, and all of them must agree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorization.&lt;/strong&gt; The AuthorizationPolicies are path-based (&lt;code&gt;paths: ["/*"]&lt;/code&gt;) instead
of principal-based, because the mesh is PERMISSIVE. &lt;code&gt;/notebook/*&lt;/code&gt; is gated by a CUSTOM
ext_authz policy on the gateway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Istio.&lt;/strong&gt; The cluster's shared Istio, with its own &lt;code&gt;istio-system/kubeflow-gateway&lt;/code&gt; and a
Let's Encrypt certificate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage.&lt;/strong&gt; KFP artifacts go to an ObjectBucketClaim on Ceph RGW instead of the bundled
SeaweedFS/MinIO, with the secret from Vault via VSO; every PVC is on &lt;code&gt;ceph-block&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPUs.&lt;/strong&gt; The NVIDIA gpu-operator runs three RTX A1000 cards, and notebooks claim one
each.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Still upstream.&lt;/strong&gt; KFP and ML Metadata run on the bundled MySQL (a CNPG database exists
for them but nothing uses it yet), Katib keeps &lt;code&gt;katib-mysql&lt;/code&gt;, and Model Registry keeps
its own Postgres. The bundled MySQL is at least backed up now: a daily CronJob dumps it
to its own bucket on RGW.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The catalog: Project Nessie (Iceberg REST + Git-for-data)
&lt;/h2&gt;

&lt;p&gt;The catalog is the small service everything else depends on. It remembers that "table &lt;code&gt;events_curated&lt;/code&gt;&lt;br&gt;
is an Iceberg table whose current metadata lives at &lt;code&gt;s3://mlops-data-lake/warehouse/...&lt;/code&gt;,&lt;br&gt;
with these columns, these snapshots, these partitions." Replacing the Hive Metastore with&lt;br&gt;
&lt;strong&gt;Project Nessie&lt;/strong&gt; was the main reason for the pivot.&lt;/p&gt;

&lt;p&gt;Why Nessie over HMS:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's an Iceberg REST catalog&lt;/strong&gt;, the modern standard. Trino, Spark and dbt all
speak Iceberg REST; HMS's Thrift is the legacy path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Git-for-data.&lt;/strong&gt; Nessie versions the entire catalog: branches, tags, commits,
merges. You can cut a branch, run an ETL job, validate it, and merge atomically,
which gives you multi-table transactions HMS can't do. On an ML platform that means
&lt;strong&gt;reproducible data&lt;/strong&gt;: tag the exact catalog state a model trained on. Both halves
are in real use: dbt builds on a branch, and the football model
trains on a tag (more on both below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credential vending.&lt;/strong&gt; Nessie holds the S3 creds and vends them to the engines when
they load a table. (Without STS on Ceph, "vending" hands out the same bucket-owner key,
and as the going-live section shows, vending alone isn't enough for Trino to &lt;em&gt;create&lt;/em&gt; a table.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nessie deploys from its own Helm chart (&lt;code&gt;charts.projectnessie.org&lt;/code&gt;), not Stackable. Its&lt;br&gt;
version store is Postgres (CNPG &lt;code&gt;nessie_db&lt;/code&gt;), and its warehouse is the same&lt;br&gt;
&lt;code&gt;mlops-data-lake&lt;/code&gt; bucket Spark writes to:&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;versionStoreType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;JDBC2&lt;/span&gt;
&lt;span class="na"&gt;jdbc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jdbcUrl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jdbc:postgresql://postgres-mlops-cnpg-cluster-rw.mlops.svc:5432/nessie_db"&lt;/span&gt;
  &lt;span class="na"&gt;secret&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;mlops-nessie-db&lt;/span&gt;
    &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;username&lt;/span&gt;
    &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;password&lt;/span&gt;
&lt;span class="na"&gt;catalog&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&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;iceberg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;defaultWarehouse&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warehouse&lt;/span&gt;
    &lt;span class="na"&gt;warehouses&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;warehouse&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;s3://mlops-data-lake/warehouse&lt;/span&gt;
  &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;s3&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;defaultOptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://s3.georgehomelab.com&lt;/span&gt;
        &lt;span class="na"&gt;pathStyleAccess&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;accessKeySecret&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;mlops-data-lake&lt;/span&gt;
          &lt;span class="na"&gt;awsAccessKeyId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;
          &lt;span class="na"&gt;awsSecretAccessKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trino reaches it at &lt;code&gt;http://nessie.mlops.svc:19120/iceberg&lt;/code&gt;. Spark uses&lt;br&gt;
&lt;code&gt;org.apache.iceberg.nessie.NessieCatalog&lt;/code&gt; against &lt;code&gt;.../api/v2&lt;/code&gt; and can create and merge&lt;br&gt;
branches from a job.&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%2Fv596bk1dg6imibbr19sa.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%2Fv596bk1dg6imibbr19sa.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&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%2Fuhps14agugydxjyswtr1.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%2Fuhps14agugydxjyswtr1.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&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%2Fm7c0xspa4sq8lodn23rl.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%2Fm7c0xspa4sq8lodn23rl.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&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%2F6cbpqmlj1iarrue3jk7r.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%2F6cbpqmlj1iarrue3jk7r.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Nessie UI on &lt;code&gt;main&lt;/code&gt;: the commit log and the namespaces (&lt;code&gt;raw&lt;/code&gt;, &lt;code&gt;raw_football&lt;/code&gt;, &lt;code&gt;marts_staging&lt;/code&gt;, &lt;code&gt;marts_marts&lt;/code&gt;, &lt;code&gt;marts_football&lt;/code&gt;) that dbt and Trino have created.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Nessie and not lakeFS?&lt;/strong&gt; Nessie already gives the Iceberg tables Git-style&lt;br&gt;
branches, tags and atomic multi-table commits; a second version-control layer over the&lt;br&gt;
same tables would be two sources of truth for one question. lakeFS earns its place for&lt;br&gt;
unstructured files (I have none yet), and its Iceberg REST catalog, SSO and RBAC are&lt;br&gt;
paid-edition features. So I'll revisit it as a &lt;em&gt;replacement&lt;/em&gt;, never an addition, if&lt;br&gt;
Nessie stalls or unstructured data shows up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Garbage collection.&lt;/strong&gt; Versioning has a cost that's easy to miss: every dbt build and&lt;br&gt;
pipeline publish replaces table snapshots, and the old files stay forever unless something&lt;br&gt;
removes them. Nessie's separate GC tool runs as a weekly CronJob (Sundays, 13:00 UTC).&lt;/p&gt;

&lt;p&gt;It marks the files still reachable (30 days of history per branch, and just the tagged&lt;br&gt;
commit for each &lt;code&gt;train-football-…&lt;/code&gt; tag) and treats the rest of each table's files as&lt;br&gt;
candidates. It only looks under Iceberg table locations, so the landing Parquet is out of&lt;br&gt;
its reach. It runs with &lt;code&gt;--defer-deletes&lt;/code&gt; for now, recording candidates in &lt;code&gt;nessie_db&lt;/code&gt;&lt;br&gt;
until I've reviewed a few runs. The first walked 3 references and 131 commits and found 8&lt;br&gt;
candidates, all old table metadata.&lt;/p&gt;

&lt;p&gt;A daily tag-retention job sits in front of it, so the pins don't pile up either. It&lt;br&gt;
keeps any tag a Model Registry version names, plus the last 30 days, and is also in&lt;br&gt;
dry-run mode for now.&lt;/p&gt;
&lt;h2&gt;
  
  
  MLflow: tracking and model registry
&lt;/h2&gt;

&lt;p&gt;MLflow is the simplest part of the stack to deploy and the one I use most day to day.&lt;br&gt;
The chart is the official one from the MLflow repo, vendored at &lt;code&gt;mlflow/charts/mlflow&lt;/code&gt;&lt;br&gt;
(chart 0.1.0, appVersion 3.12.0). It takes a Postgres backend and an S3 artifact store.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;values.yaml&lt;/code&gt; excerpt (trimmed):&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;mlflow&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backendStoreUriFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;secretKeyRef&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;mlops-mlflow-db&lt;/span&gt;          &lt;span class="c1"&gt;# VSO builds the `uri` key from Vault&lt;/span&gt;
      &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uri&lt;/span&gt;
  &lt;span class="na"&gt;defaultArtifactRoot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mlflow-artifacts:/&lt;/span&gt;       &lt;span class="c1"&gt;# clients use the proxy scheme&lt;/span&gt;
  &lt;span class="na"&gt;artifactsDestination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;s3://mlflow-artifacts/&lt;/span&gt;  &lt;span class="c1"&gt;# the server does the S3 I/O&lt;/span&gt;

&lt;span class="na"&gt;env&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;MLFLOW_SERVER_ENABLE_JOB_EXECUTION&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&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;AWS_ACCESS_KEY_ID&lt;/span&gt;
    &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;secretKeyRef&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;mlflow-artifacts&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS_ACCESS_KEY_ID&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;AWS_SECRET_ACCESS_KEY&lt;/span&gt;
    &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;secretKeyRef&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;mlflow-artifacts&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS_SECRET_ACCESS_KEY&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;MLFLOW_S3_ENDPOINT_URL&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://s3.georgehomelab.com&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;MLFLOW_S3_IGNORE_TLS&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Artifacts are proxied: clients log to &lt;code&gt;mlflow-artifacts:/&lt;/code&gt; and the server writes to the&lt;br&gt;
bucket with its own OBC credentials, so notebooks only need the tracking URI, not S3 keys.&lt;br&gt;
The &lt;code&gt;MLFLOW_S3_ENDPOINT_URL&lt;/code&gt; override points the server at RGW instead of AWS. The&lt;br&gt;
endpoint has a public certificate, so &lt;code&gt;MLFLOW_S3_IGNORE_TLS&lt;/code&gt; stays &lt;code&gt;false&lt;/code&gt;: TLS is&lt;br&gt;
genuinely verified. Path-style addressing works because the host is in the&lt;br&gt;
CephObjectStore's &lt;code&gt;hosting.dnsNames&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;MLFLOW_SERVER_ENABLE_JOB_EXECUTION=false&lt;/code&gt; is there because MLflow 3.x's job subsystem is&lt;br&gt;
on by default and starts eight huey consumers, which ate about 1.5Gi while completely idle&lt;br&gt;
and kept OOM-killing the pod. I don't use server-side jobs, so turning it off beat raising&lt;br&gt;
the limit.&lt;/p&gt;

&lt;p&gt;The MLflow UI sits behind an oauth2-proxy in the &lt;code&gt;mlflow-sso&lt;/code&gt; namespace, with the&lt;br&gt;
per-app cookie scoping from the SSO section. Inside the cluster (a Kubeflow notebook&lt;br&gt;
in a &lt;code&gt;kubeflow-user-*&lt;/code&gt; ns), the tracking client talks to the service directly:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;mlflow&lt;/span&gt;
&lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_tracking_uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://mlflow.mlops.svc.cluster.local:5000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_experiment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;homelab-iris-v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_run&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log_param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1e-3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log_metric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;val_acc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.97&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sklearn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&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;There's no SSO round-trip on the SDK path; any pod the Network Policy allow-lists can reach&lt;br&gt;
the in-cluster service. Only the browser UI goes through Keycloak.&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%2Fe069xh7utrlk264qatv1.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%2Fe069xh7utrlk264qatv1.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&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%2F65d6vld52z83up1wzmip.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%2F65d6vld52z83up1wzmip.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&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%2Fjyl4f0f58ow273n9pj6k.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%2Fjyl4f0f58ow273n9pj6k.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&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%2Fjtxfpqhbo4owofhzb71e.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%2Fjtxfpqhbo4owofhzb71e.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&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%2Fmzk9bwnb4lz413inc2dp.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%2Fmzk9bwnb4lz413inc2dp.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&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%2Fyj2r9dj6e2clzipm26rh.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%2Fyj2r9dj6e2clzipm26rh.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The MLflow UI → experiment &lt;code&gt;football-match-outcome&lt;/code&gt;: the daily runs with &lt;code&gt;wf_logloss&lt;/code&gt; and &lt;code&gt;deploy_approved&lt;/code&gt; as columns.&lt;/p&gt;
&lt;h2&gt;
  
  
  Trino, dbt and OpenMetadata: query, transform, govern
&lt;/h2&gt;

&lt;p&gt;v1 had an Airflow tier here, which the pivot deleted. Airflow isn't bad, but on a homelab&lt;br&gt;
with one operator a persistent scheduler is a lot of surface area for what's really three&lt;br&gt;
jobs a day. In its place is a query/transform/govern tier that does more and needs less&lt;br&gt;
babysitting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trino: SQL over Iceberg.&lt;/strong&gt; The Stackable &lt;code&gt;TrinoCluster&lt;/code&gt; runs coordinator-only: workers&lt;br&gt;
are scaled to 0 and &lt;code&gt;node-scheduler.include-coordinator&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;, so the coordinator&lt;br&gt;
runs the splits itself. No app node has room for a 4Gi/1-CPU worker right now; workers&lt;br&gt;
come back with more hardware. Trino queries Iceberg through the Nessie REST catalog. In&lt;br&gt;
25.3.0 the first-class &lt;code&gt;iceberg&lt;/code&gt; connector models the &lt;em&gt;Hive&lt;/em&gt; metastore, so the REST wiring&lt;br&gt;
goes through the &lt;code&gt;generic&lt;/code&gt; connector.&lt;/p&gt;

&lt;p&gt;Nessie vends credentials for reads, but Trino also needs &lt;strong&gt;its own&lt;/strong&gt; S3 keys to create&lt;br&gt;
tables (more on that in the going-live section). They come from the Rook bucket Secret via&lt;br&gt;
&lt;code&gt;valueFromSecret&lt;/code&gt;, never as literals:&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;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;connector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;generic&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;connectorName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iceberg&lt;/span&gt;
      &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;iceberg.catalog.type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rest"&lt;/span&gt;
        &lt;span class="na"&gt;iceberg.rest-catalog.uri&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://nessie.mlops.svc:19120/iceberg/"&lt;/span&gt;
        &lt;span class="na"&gt;iceberg.rest-catalog.vended-credentials-enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;value&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;s3.endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://s3.georgehomelab.com"&lt;/span&gt;
        &lt;span class="na"&gt;s3.path-style-access&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;value&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;s3.aws-access-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;valueFromSecret&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;mlops-data-lake&lt;/span&gt;
            &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;
        &lt;span class="na"&gt;s3.aws-secret-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;valueFromSecret&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;mlops-data-lake&lt;/span&gt;
            &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;dbt: tested transformations.&lt;/strong&gt; &lt;code&gt;dbt-trino&lt;/code&gt; turns raw Iceberg tables into modeled,&lt;br&gt;
tested, documented marts. It runs as a &lt;code&gt;CronJob&lt;/code&gt; at &lt;strong&gt;09:30 UTC&lt;/strong&gt;. The project ships&lt;br&gt;
as a kustomize-generated &lt;code&gt;dbt-project&lt;/code&gt; ConfigMap (no git clone at runtime), and a&lt;br&gt;
non-root container runs &lt;code&gt;dbt build&lt;/code&gt;. dbt's &lt;code&gt;not_null&lt;/code&gt;, &lt;code&gt;unique&lt;/code&gt;, &lt;code&gt;accepted_values&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;accepted_range&lt;/code&gt; tests are the data-quality gate; there's no separate Soda/GE.&lt;/p&gt;

&lt;p&gt;The dbt profile needs &lt;strong&gt;no secret at all&lt;/strong&gt;. dbt proves who it is with a short-lived&lt;br&gt;
Kubernetes token for its own service account, mounted into the pod and sent as a JWT,&lt;br&gt;
and Trino's rules then decide what &lt;code&gt;dbt-runner&lt;/code&gt; may read and write (how that works is&lt;br&gt;
the subject of the RBAC article in this series). It also verifies Trino's certificate&lt;br&gt;
against the Stackable &lt;code&gt;tls&lt;/code&gt; CA, mounted per pod. After each run it publishes &lt;code&gt;manifest.json&lt;/code&gt;, &lt;code&gt;catalog.json&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;run_results.json&lt;/code&gt; to a small CephFS volume for OpenMetadata.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;dbt writes on a branch, then publishes (write-audit-publish).&lt;/strong&gt; At first, each&lt;br&gt;
dbt build landed in &lt;code&gt;main&lt;/code&gt; as roughly 15 separate Nessie commits (&lt;code&gt;__dbt_tmp&lt;/code&gt; renames,&lt;br&gt;
&lt;code&gt;__dbt_backup&lt;/code&gt; drops), so a query at the wrong moment could catch a half-built mart. Now&lt;br&gt;
the 09:30 CronJob does three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It resets a Nessie branch, &lt;code&gt;dbt-audit&lt;/code&gt;, to &lt;code&gt;main&lt;/code&gt;'s head through Nessie's REST API v2.&lt;/li&gt;
&lt;li&gt;It runs &lt;code&gt;dbt build --target audit&lt;/code&gt; through a second Trino catalog, &lt;code&gt;audit&lt;/code&gt;, whose REST
URI ends in &lt;code&gt;.../iceberg/dbt-audit/&lt;/code&gt;. Nessie reads the ref from the URI path, so the
same tables appear on a different branch. (It's &lt;code&gt;audit&lt;/code&gt; and not &lt;code&gt;dbt_audit&lt;/code&gt; because
Kubernetes names can't contain &lt;code&gt;_&lt;/code&gt;, and Stackable uses &lt;code&gt;metadata.name&lt;/code&gt; as the Trino
catalog name.)&lt;/li&gt;
&lt;li&gt;Only if the build passes does it merge &lt;code&gt;dbt-audit&lt;/code&gt; into &lt;code&gt;main&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;main&lt;/code&gt; now gets one atomic "Merged dbt-audit" commit per build. A failure leaves &lt;code&gt;main&lt;/code&gt;&lt;br&gt;
untouched and the branch sitting there for me to inspect. A normal run ends &lt;code&gt;PASS=23&lt;/code&gt;&lt;br&gt;
(3 models and 20 tests) and merges cleanly.&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;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;connector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;generic&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;connectorName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iceberg&lt;/span&gt;
      &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;iceberg.catalog.type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rest"&lt;/span&gt;
        &lt;span class="na"&gt;iceberg.rest-catalog.uri&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://nessie.mlops.svc:19120/iceberg/dbt-audit/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details kept this from leaking into the rest of the stack. OpenMetadata's manifest&lt;br&gt;
still comes from &lt;code&gt;dbt docs generate --target prod&lt;/code&gt;, so lineage keeps its &lt;code&gt;iceberg.*&lt;/code&gt;&lt;br&gt;
names, and its Trino crawl only includes &lt;code&gt;^iceberg$&lt;/code&gt;, so the audit catalog never&lt;br&gt;
shows up as a duplicate. The dbt runner got an Istio AuthorizationPolicy&lt;br&gt;
rule that allows only &lt;code&gt;/api/v2/trees*&lt;/code&gt;, not the Iceberg REST path that vends S3&lt;br&gt;
credentials. And because adding a Trino catalog restarts the coordinator under Stackable,&lt;br&gt;
I rolled it out between the scheduled runs, not during 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%2Fh5u0g3mut740x9johjy7.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%2Fh5u0g3mut740x9johjy7.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bronze, silver, gold.&lt;/strong&gt; The tables fall into the three layers Databricks calls a&lt;br&gt;
medallion architecture: bronze is data as it lands, silver is typed and cleaned, gold is&lt;br&gt;
tested and ready to use. Here every layer is a set of Iceberg tables, and the gate&lt;br&gt;
between them is the &lt;code&gt;dbt-audit&lt;/code&gt; branch. OpenMetadata labels each table with its layer&lt;br&gt;
(its Certification field), so a reader can filter the whole catalog down to Gold.&lt;/p&gt;

&lt;p&gt;This is ELT rather than the warehouse's ETL: data is loaded first, almost as it arrives,&lt;br&gt;
and transformed afterwards inside the lakehouse. Keeping the raw layer is what makes that&lt;br&gt;
safe: when a dbt model is wrong, I fix the model and rebuild from bronze, without fetching&lt;br&gt;
anything again.&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%2Fq3c883vjse1oke5t8nwn.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%2Fq3c883vjse1oke5t8nwn.png" alt="lk" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Bronze, silver, gold: raw_football.matches and raw.events are bronze, stg_events is silver, the two marts are gold; dbt builds silver and gold on the dbt-audit branch, the football model trains on bronze at a pinned tag, and people read gold through Trino and Superset, with lineage in OpenMetadata.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two details I'd rather show than smooth over. Football has no silver step: dbt builds&lt;br&gt;
the gold mart straight from the raw table. And the football model trains on &lt;strong&gt;bronze&lt;/strong&gt;,&lt;br&gt;
at a pinned tag, because it needs the exact rows it saw, not a cleaned view that could&lt;br&gt;
change underneath it. So &lt;code&gt;raw_football.matches&lt;/code&gt; is Bronze and Tier 1 at the same time:&lt;br&gt;
the layer says how refined a table is, and the tier says how much depends on 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%2Fihny9b9f1yhexbuwy0o1.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%2Fihny9b9f1yhexbuwy0o1.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&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%2F9v4623sr3zeuef7redy7.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%2F9v4623sr3zeuef7redy7.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&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%2Fwxmdxnzqh2ziaw5tb6cu.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%2Fwxmdxnzqh2ziaw5tb6cu.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&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%2Fmn6rmsuqxbk6tk76nhvg.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%2Fmn6rmsuqxbk6tk76nhvg.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&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%2Frz9oeb61t8fvtk2gwtd8.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%2Frz9oeb61t8fvtk2gwtd8.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&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%2F71gjmeq3sh83fl0ni4zm.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%2F71gjmeq3sh83fl0ni4zm.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;OpenMetadata → Explore → Data Assets: Table, Certification: Gold. The two gold marts with their badge, tier and owner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenMetadata: governance, lineage and MCP.&lt;/strong&gt; The top layer is a metadata knowledge&lt;br&gt;
graph that crawls Trino and ingests dbt's artifacts to produce end-to-end lineage,&lt;br&gt;
descriptions and data-quality results. It runs &lt;strong&gt;2.0.2&lt;/strong&gt;, with CNPG for its store, its&lt;br&gt;
own 3-node OpenSearch 3.4.0 for search (operator-managed, on the database node pool),&lt;br&gt;
and Keycloak realm &lt;code&gt;openmetadata&lt;/code&gt; for OIDC; login redirects straight to Keycloak's&lt;br&gt;
Google/GitHub page.&lt;/p&gt;

&lt;p&gt;Ingestion runs as two &lt;strong&gt;CronJobs&lt;/strong&gt; (&lt;code&gt;om-ingest-trino&lt;/code&gt; at 10:00, &lt;code&gt;om-ingest-dbt&lt;/code&gt; at&lt;br&gt;
10:15), &lt;em&gt;not&lt;/em&gt; the bundled Airflow (I'm not bringing back what I just removed). They&lt;br&gt;
authenticate with the ingestion-bot token Terraform writes to Vault. OpenMetadata is in&lt;br&gt;
this homelab at all because it ships an &lt;strong&gt;MCP server&lt;/strong&gt;, which lets the Hermes agents query&lt;br&gt;
certified, lineage-aware data in natural language. The rest of the stack is building&lt;br&gt;
towards that.&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%2Fy37zh2063o1u7k4dh3xv.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%2Fy37zh2063o1u7k4dh3xv.png" alt="lk" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;OpenMetadata → &lt;code&gt;marts_marts.daily_active_users&lt;/code&gt; → Data Quality tab: the dbt tests with their latest results.&lt;/p&gt;
&lt;h2&gt;
  
  
  Spark: installed, and not on the line yet
&lt;/h2&gt;

&lt;p&gt;Every lakehouse diagram has Spark in the middle, so I should be straight about mine. The&lt;br&gt;
Kubeflow spark-operator is installed and has run test jobs, but nothing on the daily line&lt;br&gt;
uses it. My tables are small and Trino builds them in seconds, so Spark on that line would&lt;br&gt;
be a forklift carrying one box. It joins in its own article, on a job that needs it:&lt;br&gt;
real-time fraud detection with Kafka, once the cluster has more memory. It will read and&lt;br&gt;
write the same Iceberg tables through Nessie, so nothing about the catalog or the storage&lt;br&gt;
has to change.&lt;/p&gt;
&lt;h3&gt;
  
  
  SPARK JOB DEMO WITH SPARK CUSTOM IMAGE ON TEKTON AND HARBOR BUILT THROUGH SECURED CI/CD SUPPLY CHAIN
&lt;/h3&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%2Fg8xxp8iwiasgxix7r5te.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%2Fg8xxp8iwiasgxix7r5te.png" alt="lk" width="800" height="86"&gt;&lt;/a&gt;&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%2Fnt2x5lvxqd8f239197gw.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%2Fnt2x5lvxqd8f239197gw.png" alt="lk" width="800" height="373"&gt;&lt;/a&gt;&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%2Fm4bu2061kdpyw2mvnd77.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%2Fm4bu2061kdpyw2mvnd77.png" alt="lk" width="800" height="373"&gt;&lt;/a&gt;&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%2Fgjzm8j561bm0zv1onicf.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%2Fgjzm8j561bm0zv1onicf.png" alt="lk" width="800" height="373"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  How reliable is it?
&lt;/h2&gt;

&lt;p&gt;A factory that stops when one machine does isn't much of a factory. Here's what this one&lt;br&gt;
survives, and how I know:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What goes wrong&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;How I know&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A Postgres instance, or the node under it, is lost&lt;/td&gt;
&lt;td&gt;Another instance is promoted and the apps reconnect through the same service name&lt;/td&gt;
&lt;td&gt;After a cold boot the cluster promoted a different instance and kept serving; all six databases stayed applied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A database is damaged or dropped&lt;/td&gt;
&lt;td&gt;It can be restored to a point in time from object storage&lt;/td&gt;
&lt;td&gt;A nightly base backup plus continuous WAL archiving, both reporting healthy, with seven days kept&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A catalog pod dies&lt;/td&gt;
&lt;td&gt;The other replica answers; the ledger itself is in Postgres&lt;/td&gt;
&lt;td&gt;Nessie runs 2 replicas and keeps no state of its own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A search node dies&lt;/td&gt;
&lt;td&gt;The other two carry on, and the index can be rebuilt from Postgres anyway&lt;/td&gt;
&lt;td&gt;OpenSearch runs 3 nodes, one in each datacenter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A dbt build fails its tests&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;main&lt;/code&gt; is untouched and the failed batch stays on its branch for inspection&lt;/td&gt;
&lt;td&gt;One "Merged dbt-audit" commit per passing build, and nothing on a failing one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The lab is powered off overnight&lt;/td&gt;
&lt;td&gt;Jobs are scheduled in the day, and a missed slot is skipped, not replayed at boot&lt;/td&gt;
&lt;td&gt;Each job has a ten-minute start deadline; the build, crawl and lineage jobs all ran on schedule after the last cold boot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A new model is worse than the one serving&lt;/td&gt;
&lt;td&gt;It is logged but not promoted; the current model keeps serving&lt;/td&gt;
&lt;td&gt;Every run records &lt;code&gt;deploy_approved&lt;/code&gt; in MLflow, and the served revision only changes on a pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A new model passes the gate but misbehaves once served (Canary deployment strategy with Kserve)&lt;/td&gt;
&lt;td&gt;It is tested at 0% of traffic, then at 10% and 50%, and traffic returns to the old model if it does worse&lt;/td&gt;
&lt;td&gt;A rollout forced to fail put all traffic back on the old model and failed the run&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the honest limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Four stations are single replicas.&lt;/strong&gt; Trino, Superset, OpenMetadata and MLflow each
run one pod. If its node dies the pod is rescheduled and that station is down for a minute or
two. Nothing is lost, because their state lives in Postgres and object storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A demoted Postgres primary doesn't always come back by itself.&lt;/strong&gt; After that same cold
boot, the old primary couldn't rejoin: its history had diverged from the new primary's.
The database ran on two instances until I deleted the stale one's volume and pod.
The operator then cloned a fresh replica in the same datacenter and had it streaming
within a few minutes. Failover is automatic; that repair was not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each Postgres volume is one Ceph copy.&lt;/strong&gt; That was a deliberate trade for write
speed, covered in the databases section. Losing a drive loses an instance, not data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshot cleanup is still deferred.&lt;/strong&gt; The tag clean-up deletes for real now, but the
catalog's garbage collector only records which files it would remove and deletes
nothing yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The daily training run can lose a race with the morning boot.&lt;/strong&gt; If it starts before
the cluster has settled, it fails and waits for the next day. About half of the recent
scheduled runs went that way, which is next on my list to fix. A rerun by hand takes
about six minutes.&lt;/li&gt;
&lt;/ul&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%2Fn9ix7dmoc33tm36lh0bx.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%2Fn9ix7dmoc33tm36lh0bx.png" alt="lk" width="800" height="106"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kubectl get cluster.postgresql.cnpg.io -n mlops&lt;/code&gt;: &lt;code&gt;postgres-mlops-cnpg-cluster&lt;/code&gt; with 3 instances, 3 ready, and its current primary.&lt;/p&gt;
&lt;h2&gt;
  
  
  The traps I hit
&lt;/h2&gt;

&lt;p&gt;Everything above was &lt;em&gt;designed&lt;/em&gt; before it ran. Once it ran, it met a cluster that powers&lt;br&gt;
off at night and defaults that fail without an error. Three traps shaped the platform&lt;br&gt;
enough to belong here. The rest, from an operator chart pointing at an image that was&lt;br&gt;
never published to a catalog that would hand storage credentials to any pod that asked,&lt;br&gt;
have their own article in this series.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trino could query, but never create a table.&lt;/strong&gt; Every &lt;code&gt;CREATE TABLE&lt;/code&gt; failed:&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="s"&gt;Failed checking new table's location&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;s3://mlops-data-lake/warehouse/raw/...&lt;/span&gt;
  &lt;span class="s"&gt;-&amp;gt; Failed to list location&lt;/span&gt;
  &lt;span class="s"&gt;-&amp;gt; SdkClientException&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Unable to load credentials from any of the providers in the chain&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trino checks a new table's location with its own credentials before Nessie gets&lt;br&gt;
involved, and it had none. That gap alone is why dbt had never built anything. The fix&lt;br&gt;
gives Trino the same bucket key Nessie already vends (so no new access).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cron that never fired.&lt;/strong&gt; &lt;code&gt;dbt-build&lt;/code&gt; had been scheduled for 03:00 UTC for eleven&lt;br&gt;
days and never ran once, with no error, event or Job. Thanos had the answer: the lab&lt;br&gt;
&lt;strong&gt;powers off overnight&lt;/strong&gt;, and there isn't a single metric sample at 03:00 in the retention&lt;br&gt;
window. Every CronJob moved into daytime (09:30 dbt → 10:00 Trino crawl → 10:15 dbt&lt;br&gt;
lineage), with &lt;code&gt;startingDeadlineSeconds&lt;/code&gt; so a missed slot is &lt;em&gt;skipped&lt;/em&gt;, not replayed at boot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A quota on limits can block a model rollout.&lt;/strong&gt; Every Kubeflow profile namespace gets a&lt;br&gt;
ResourceQuota, generated by a Kyverno policy, and mine capped CPU &lt;em&gt;limits&lt;/em&gt; at 8. A KServe&lt;br&gt;
rollout runs the old and the new model server side by side until the new one is ready.&lt;br&gt;
Each asks for 2.5 CPU of limit, and one of those is the Istio sidecar's. Add one running&lt;br&gt;
notebook and the pipeline step itself and the total is 8.2. So the new revision was&lt;br&gt;
refused, the deploy step waited fifteen minutes and timed out, and the run failed with a&lt;br&gt;
perfectly good model sitting in the bucket. The symptom is a revision stuck on&lt;br&gt;
&lt;code&gt;FailedCreate&lt;/code&gt; with &lt;code&gt;exceeded quota&lt;/code&gt; in its events and no pod to look at. I raised the&lt;br&gt;
limit quota to 16 and left the &lt;em&gt;request&lt;/em&gt; quota at 4, because requests are what reserve&lt;br&gt;
real capacity. Then the change hit a second trap: ArgoCD reported the policy synced while&lt;br&gt;
the live object was still on its first generation with the old value. Check&lt;br&gt;
&lt;code&gt;metadata.generation&lt;/code&gt; on a policy before believing a sync.&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%2F95qd4c9lg5nt31x00mxo.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%2F95qd4c9lg5nt31x00mxo.png" alt="lk" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;One day on the factory floor: every schedule in the lakehouse in the order it fires, from the morning power-on to the afternoon backups&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  End-to-end workflow
&lt;/h2&gt;

&lt;p&gt;From a data scientist's seat, the platform looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ingest (on a branch).&lt;/strong&gt; A job writes new raw data as an Iceberg table on its own
Nessie branch, checks it, and merges the branch into &lt;code&gt;main&lt;/code&gt; in one commit, so nobody
ever reads a half-loaded table. The football pipeline below ingests exactly this way.
The other raw table, &lt;code&gt;raw.events&lt;/code&gt;, is a seeded demo table with no scheduled ingest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transform and test.&lt;/strong&gt; The 09:30 &lt;code&gt;dbt-build&lt;/code&gt; CronJob runs &lt;code&gt;raw.events&lt;/code&gt; →
&lt;code&gt;marts_staging.stg_events&lt;/code&gt; (view) → &lt;code&gt;marts_marts.daily_active_users&lt;/code&gt; (Iceberg) on the
&lt;code&gt;dbt-audit&lt;/code&gt; branch, so a failed test never reaches &lt;code&gt;main&lt;/code&gt;. The doubled-up schema names
are dbt's default (custom schema appended to the target schema); overriding
&lt;code&gt;generate_schema_name&lt;/code&gt; would move the live tables and orphan their OpenMetadata
history, so they stay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query.&lt;/strong&gt; Anyone with Trino access runs ad-hoc SQL over the marts
(&lt;code&gt;SELECT * FROM iceberg.marts_marts.daily_active_users&lt;/code&gt;), something v1 couldn't do without
Spark in the loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Train.&lt;/strong&gt; A Kubeflow notebook reads the curated Iceberg table (via Trino or the
Spark Nessie catalog), trains a model and logs to MLflow:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;   &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_tracking_uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://mlflow.mlops.svc.cluster.local:5000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="n"&gt;mlflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sklearn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;registered_model_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event-clf&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;Because the data lives on a Nessie tag, the run is &lt;strong&gt;reproducible&lt;/strong&gt;: months later you&lt;br&gt;
   can point back at the exact catalog state. That works through Nessie (pyiceberg reads&lt;br&gt;
   the table at the tag). Trino's catalogs are each pinned to one branch, so a SQL query&lt;br&gt;
   sees the current version, not last month's.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Register and govern.&lt;/strong&gt; The best run is promoted in MLflow's Model Registry, and
OpenMetadata's 10:00 and 10:15 CronJobs stitch the lineage together. Humans browse it
today; the Hermes agents will query it over MCP once the copilot lands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serve.&lt;/strong&gt; A KServe &lt;code&gt;InferenceService&lt;/code&gt; serves the promoted model (the football
match-outcome predictor is the first one live).&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The worked example: a football model trained on a pinned snapshot
&lt;/h3&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%2Fdeuvnt6sky8h4jdmtur6.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%2Fdeuvnt6sky8h4jdmtur6.png" alt="lk" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;From match results to a served model: football-data.org to landing Parquet, published to an Iceberg table through a Nessie branch, then Katib, the promotion gate, KServe, MLflow and the predictions site&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A KFP RecurringRun fires at 10:00 London time every day. A Job in &lt;code&gt;mlops&lt;/code&gt;, the only&lt;br&gt;
namespace with internet egress, pulls Premier League results into &lt;code&gt;s3://mlops-data-lake&lt;/code&gt;&lt;br&gt;
as Parquet. The lakehouse part is the publish step:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It writes &lt;code&gt;iceberg.raw_football.matches&lt;/code&gt; on a fresh Nessie branch.&lt;/li&gt;
&lt;li&gt;It audits that branch before anything else sees it: match ids are unique, finished
matches have scores, every season is present, and the finished-match count never
shrinks.&lt;/li&gt;
&lt;li&gt;It merges into &lt;code&gt;main&lt;/code&gt; in one commit and tags the commit &lt;code&gt;train-football-&amp;lt;UTC&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Katib and the training step read exactly that snapshot, and MLflow and the Kubeflow Model&lt;br&gt;
Registry record the tag, the commit and the Iceberg snapshot id beside the model. dbt's&lt;br&gt;
&lt;code&gt;football_matches&lt;/code&gt; model reads the same table.&lt;/p&gt;

&lt;p&gt;The model is modest, and I'd distrust anything better: a walk-forward log-loss of 1.050&lt;br&gt;
against 1.082 for always guessing the usual split, over 587 matches, and the right result&lt;br&gt;
47% of the time. A new model has to pass a promotion gate, a smoke test and a canary at&lt;br&gt;
10% then 50% of traffic before it serves. The full story, from the features to the rollout,&lt;br&gt;
is its own article in this series.&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%2Fr1pj742jgj87n3x32de4.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%2Fr1pj742jgj87n3x32de4.png" alt="lk" width="800" height="373"&gt;&lt;/a&gt;&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%2F2a86d1w7u2lzeuxhbtlh.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%2F2a86d1w7u2lzeuxhbtlh.png" alt="lk" width="800" height="373"&gt;&lt;/a&gt;&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%2Ft6fmjlo66ts4d0bw882u.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%2Ft6fmjlo66ts4d0bw882u.png" alt="lk" width="800" height="373"&gt;&lt;/a&gt;&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%2F2eoicmybunl72zygywf9.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%2F2eoicmybunl72zygywf9.png" alt="lk" width="800" height="373"&gt;&lt;/a&gt;&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%2Fkcodq7kbp1q61rmstfd5.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%2Fkcodq7kbp1q61rmstfd5.png" alt="lk" width="800" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;epl-predict.georgehomelab.com&lt;/code&gt;: the matchday predictions with the scorecard above them, and the goals tables with their "does not beat that base rate" line.&lt;/p&gt;

&lt;p&gt;This workflow is how I know the integration points actually work: S3 endpoints resolve,&lt;br&gt;
Nessie answers Iceberg REST, dbt tests gate the marts, MLflow logs to Postgres and S3, and&lt;br&gt;
a notebook in a &lt;em&gt;different&lt;/em&gt; namespace reaches everything over in-cluster DNS.&lt;/p&gt;

&lt;h3&gt;
  
  
  Superset's SQL Lab is a Trino SQL client
&lt;/h3&gt;

&lt;p&gt;Apache Superset is best known for charts and dashboards, but it also ships SQL Lab: a SQL editor in the browser. Point it at Trino and it becomes a SQL client for the whole lakehouse, with no desktop tool to install and no password to hand out.&lt;/p&gt;

&lt;p&gt;Here it signs you in through Keycloak, then sends each query to Trino as you, not as a shared Superset account. Trino applies the same per-person rules it applies everywhere else, and the query lands in the audit table under your email.&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%2F7fstl34yurvatpjap0im.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%2F7fstl34yurvatpjap0im.png" alt="lk" width="800" height="373"&gt;&lt;/a&gt;&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%2Fzydaey31pze579njufer.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%2Fzydaey31pze579njufer.png" alt="lk" width="800" height="373"&gt;&lt;/a&gt;&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%2F7mm2gyfdmrzsjh8emj6w.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%2F7mm2gyfdmrzsjh8emj6w.png" alt="lk" width="800" height="373"&gt;&lt;/a&gt;&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%2F3zqwlxx25a15zr1yv3dy.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%2F3zqwlxx25a15zr1yv3dy.png" alt="lk" width="800" height="373"&gt;&lt;/a&gt;&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%2Frh5wzwdfysvu7e1g1z7g.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%2Frh5wzwdfysvu7e1g1z7g.png" alt="lk" width="800" height="373"&gt;&lt;/a&gt;&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%2F2eb9qm3i3duw1r30u2ks.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%2F2eb9qm3i3duw1r30u2ks.png" alt="lk" width="800" height="373"&gt;&lt;/a&gt;&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%2Fpj087zw79hs0mue3nlm8.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%2Fpj087zw79hs0mue3nlm8.png" alt="lk" width="800" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Apache Superset&lt;/strong&gt; client  connected to Trino, running &lt;code&gt;SELECT * FROM iceberg.marts_marts.daily_active_users&lt;/code&gt; and returning rows: the query v1 couldn't run without Spark.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I deferred
&lt;/h2&gt;

&lt;p&gt;A list, with reasons. Most of these deserve an article of their own.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Hermes data copilot.&lt;/strong&gt; The AI-native part, an agent that reads the catalog,
lineage and data over OpenMetadata's MCP and flags tables it can't trust, is waiting on
the DGX Spark for local model inference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generic MLflow → KServe promotion.&lt;/strong&gt; One model is served today through its own
pipeline. The general controller ("watch the registry for a promoted version, materialize an
&lt;code&gt;InferenceService&lt;/code&gt;") is still to write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trino workers.&lt;/strong&gt; Trino runs coordinator-only, which is plenty for tables this
size. Workers come back with more hardware.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Soda/Great Expectations.&lt;/strong&gt; dbt tests cover data quality today; these stay deferred
until they aren't enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feast feature store.&lt;/strong&gt; It waits until a model needs online inference with sub-100ms
feature retrieval.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming, and Spark with it.&lt;/strong&gt; No broker runs today: Strimzi and Redpanda are both
parked, and Spark has no job. They come back together in their own article on
real-time fraud detection with Kafka and Spark, once the hardware is there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-tenant MLflow and a Spark History Server.&lt;/strong&gt; Not worth it for 1.x users;
&lt;code&gt;kubectl logs&lt;/code&gt; on the driver pod covers most debugging.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;This build cost about three weekends of focused work, and most of the hours went on the&lt;br&gt;
traps, not the happy paths.&lt;/p&gt;

&lt;p&gt;What I got back is close enough to production that the lessons carry over: real OIDC with&lt;br&gt;
per-app cookies, RBAC enforced in the data plane, continuous WAL archiving and&lt;br&gt;
three-instance Postgres, Vault behind every secret, GitOps behind every change, and metrics&lt;br&gt;
from day one.&lt;/p&gt;

&lt;p&gt;What I'd tell anyone trying this on their own metal:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Say no to bundled defaults.&lt;/strong&gt; Every chart wants to ship its own database, storage and
auth, and every "yes" is technical debt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One source of truth per concern.&lt;/strong&gt; Vault for secrets, CNPG for relational state,
Rook-Ceph for storage, Keycloak for identity. Two of any of these is an integration
problem, not a feature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The hard part is the seams, not the install.&lt;/strong&gt; Nessie → Postgres → Vault, MLflow →
RGW, oauth2-proxy → Keycloak → Google. That's where the documentation is thinnest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Defer aggressively, then ship when the workload arrives.&lt;/strong&gt; Trino was deferred until I
needed ad-hoc SQL, and then it pulled Nessie, dbt and OpenMetadata in with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't be afraid to rip out something everything depends on.&lt;/strong&gt; GitOps turned the
Hive + Airflow swap into a series of reviewable commits instead of a rebuild.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The part I lean on most is Git-for-data. dbt publishes each build to &lt;code&gt;main&lt;/code&gt; as one tested&lt;br&gt;
commit, and the football model trains on a tagged snapshot that MLflow and the Model&lt;br&gt;
Registry both remember. It stopped being a slide.&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%2Fdme7inpu28ddapsixp7s.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%2Fdme7inpu28ddapsixp7s.png" alt="lk" width="800" height="373"&gt;&lt;/a&gt;&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%2Fh4wl5kct6o49etho0w4x.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%2Fh4wl5kct6o49etho0w4x.png" alt="lk" width="800" height="373"&gt;&lt;/a&gt;&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%2Fsiejch5tzrznt95vuk7r.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%2Fsiejch5tzrznt95vuk7r.png" alt="lk" width="800" height="373"&gt;&lt;/a&gt;&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%2F69808650mf00i4wiqq7z.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%2F69808650mf00i4wiqq7z.png" alt="lk" width="800" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The ArgoCD UI filtered to &lt;code&gt;mlops-*&lt;/code&gt; &amp;amp; &lt;code&gt;kubeflow-*&lt;/code&gt;: the whole lakehouse (Trino, Nessie, dbt, OpenMetadata, MLflow, CNPG) Synced and Healthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;The next milestones, and probably the next few articles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Hermes copilot&lt;/strong&gt;, once the DGX Spark lands: local inference, read-only
verification first, proposed writes later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming with Kafka and Spark&lt;/strong&gt;: real-time fraud detection, with card transactions
in and a model trained in Kubeflow out. This is the job that puts Spark on the line,
and it waits for more hardware.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generic MLflow → KServe promotion&lt;/strong&gt;, so any registered model can be served without its
own bespoke pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turning the garbage collector on for real&lt;/strong&gt;: Nessie GC without &lt;code&gt;--defer-deletes&lt;/code&gt;,
after a few weeks of clean logs. Tag retention already deletes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unstructured data&lt;/strong&gt;, probably arriving with the DGX Spark. That's the point where I
revisit lakeFS, as a replacement for Nessie and not as a second layer on top of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see how those go, including the parts that break, follow along.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.linkedin.com/in/george-ezejiofor-89615a8a/" rel="noopener noreferrer"&gt;Follow me on LinkedIn&lt;/a&gt;&lt;/strong&gt; for the next posts in this series.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Author: George Ezejiofor&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>mlops</category>
      <category>homelab</category>
      <category>devops</category>
    </item>
    <item>
      <title>Cilium Hubble on RKE2: Microservice Traffic Observability (Seeing the Invisible)</title>
      <dc:creator>George Ezejiofor</dc:creator>
      <pubDate>Mon, 12 Oct 2026 20:40:33 +0000</pubDate>
      <link>https://dev.to/ezejioforog/cilium-hubble-on-rke2-microservice-traffic-observability-seeing-the-invisible-5glb</link>
      <guid>https://dev.to/ezejioforog/cilium-hubble-on-rke2-microservice-traffic-observability-seeing-the-invisible-5glb</guid>
      <description>&lt;p&gt;&lt;em&gt;Part of the homelab build series. Every packet in my cluster already passes through Cilium's eBPF datapath on all 29 nodes. Hubble turns that into a live map of who talks to whom, with no sidecars and no code changes. This is how I switched it on for RKE2, the two traps that crash-looped it, and the uncomfortable thing I found when I went looking for the HTTP metrics.&lt;/em&gt;&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%2Fspht4wj9m19vh36rxj9r.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%2Fspht4wj9m19vh36rxj9r.png" alt="cilium" width="799" height="374"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Hubble UI service map for one namespace, drawn live from real traffic.&lt;/p&gt;




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

&lt;p&gt;&lt;em&gt;The map you already had and couldn't see&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Ask anyone to draw their cluster's network and you'll get a confident whiteboard sketch that's quietly wrong.&lt;/p&gt;

&lt;p&gt;The frontend talks to the API, the API talks to Postgres, everything talks to DNS. Sure. But which pod is really hammering that service? Was that "connection refused" a crashed pod, a missing endpoint, or a NetworkPolicy nobody remembers writing? The real traffic graph, the one packets actually follow, is invisible. So we debug it the old way: &lt;code&gt;kubectl exec&lt;/code&gt; into a pod, run &lt;code&gt;tcpdump&lt;/code&gt;, squint, and guess.&lt;/p&gt;

&lt;p&gt;Here's the thing I'd missed for months. My CNI is Cilium. It's eBPF, it &lt;em&gt;is&lt;/em&gt; the datapath, and it was already seeing every single packet on every node. I just wasn't looking.&lt;/p&gt;

&lt;p&gt;Hubble is how you look. It turns Cilium's datapath into a stream of flows that say "&lt;code&gt;kubeflow/notebook&lt;/code&gt; → &lt;code&gt;kube-system/coredns:53&lt;/code&gt;, FORWARDED", not "10.42.3.7 → 10.42.1.9". No sidecar, no code change, and no app knows it's being watched.&lt;/p&gt;

&lt;p&gt;So I switched it on. Within an afternoon I had a live service map behind Keycloak, &lt;code&gt;hubble observe&lt;/code&gt; streaming every flow on the cluster, and network-policy decisions I could read instead of guess. It also cost me a week of debugging, and it taught me exactly where its vision stops.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Cilium and Hubble actually are
&lt;/h2&gt;

&lt;p&gt;Two layers, one on top of the other:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cilium&lt;/strong&gt; is the CNI. It wires pod networking and enforces NetworkPolicy with eBPF programs in the kernel rather than iptables rules. Every workload gets a stable &lt;strong&gt;identity&lt;/strong&gt; derived from its labels, not its IP, which is why its telemetry reads like Kubernetes instead of a wall of addresses. Here it's version 1.19.1, bundled with RKE2, using a VXLAN overlay alongside kube-proxy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hubble&lt;/strong&gt; is the observability built on top. Each &lt;code&gt;cilium-agent&lt;/code&gt; exposes its flows on a socket. &lt;strong&gt;Hubble Relay&lt;/strong&gt; gathers every node's stream into one cluster-wide feed. &lt;strong&gt;Hubble UI&lt;/strong&gt; draws it as a live map.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You get three ways in: the &lt;strong&gt;CLI&lt;/strong&gt; (&lt;code&gt;hubble observe&lt;/code&gt;, grep for the network), the &lt;strong&gt;UI&lt;/strong&gt; (the live service map), and &lt;strong&gt;Prometheus metrics&lt;/strong&gt; (flow rates, drops, DNS).&lt;/p&gt;

&lt;p&gt;The mental model I use: &lt;strong&gt;Hubble is &lt;code&gt;tcpdump&lt;/code&gt; with Kubernetes identity, across the whole cluster, at almost no cost.&lt;/strong&gt; It sees layers 3 and 4 for everything. Layer 7 is a different story, and I'll get to 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%2F3z6qqx3za12lgl1flikn.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%2F3z6qqx3za12lgl1flikn.png" alt="cilium" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;How Hubble sees the cluster: &lt;code&gt;cilium-agent&lt;/code&gt; on every node, Relay in the middle, the UI behind oauth2-proxy and Keycloak.&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%2Fhtlgwfiu91914i4cpe2l.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%2Fhtlgwfiu91914i4cpe2l.png" alt="cilium" width="800" height="254"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How eBPF sees a packet
&lt;/h2&gt;

&lt;p&gt;"It's eBPF" gets said a lot and explained rarely, so here is what happens to one packet.&lt;/p&gt;

&lt;p&gt;First, what it is. eBPF is a feature of the Linux kernel, not a product. Cilium is one of many tools built on it.&lt;/p&gt;

&lt;p&gt;eBPF lets you load a small program into the running Linux kernel and attach it to a hook. The kernel checks the program before it runs, so it can't crash the node or loop forever. Cilium attaches its programs to the network hook on each pod's virtual interface, the &lt;code&gt;veth&lt;/code&gt; pair that joins the pod to the node. Nothing can enter or leave a pod without passing one.&lt;/p&gt;

&lt;p&gt;When a packet arrives, the program does three things, all inside the kernel:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It works out who is talking.&lt;/strong&gt; It looks up the source and destination addresses in an eBPF map, a hash table shared between the kernel and the agent, and gets back two identities. An identity is a number that stands for a set of labels, so &lt;code&gt;app=orders&lt;/code&gt; in one namespace is one identity however many pods carry it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It decides.&lt;/strong&gt; A second map holds the policy for that pod: which identities may reach which ports. One lookup gives the verdict, forward or drop. That is the difference from iptables, which walks a list of rules until one matches and so gets slower as policies grow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It reports.&lt;/strong&gt; The program writes a short event into a buffer the agent reads: the two identities, the ports, and the verdict. The packet itself is never copied out of the kernel.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Hubble is what reads those events. The agent turns the identities back into pod names and labels, and keeps the result as a flow. So the traffic flow you see in &lt;code&gt;hubble observe&lt;/code&gt; is the same decision the kernel made, written down as it was made, not a second system guessing from the outside.&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%2Flmnzwzn244uxed6ly713.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%2Flmnzwzn244uxed6ly713.png" alt="cilium" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is also why the service map draws itself. Cilium sees every connection, and it already knows which pod owns which address because it handed the addresses out. Nothing has to be registered or annotated: an edge appears on the map because traffic passed. The other side of that is that a service which hasn't talked recently has no edge.&lt;/p&gt;

&lt;p&gt;Two details explain the cost. Cilium doesn't report every packet. By default it reports when a connection opens, changes state or closes, which is why a node pushing a lot of traffic still produces only a few hundred flows a second. And nothing is added to the pod: no sidecar, no extra network hop, no restart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The same word, different tools.&lt;/strong&gt; If you've used OpenTelemetry's eBPF instrumentation, this is not that. Both load programs into the kernel, but they attach in different places. Cilium hooks the pod's network interface and sees packets. OpenTelemetry's instrumentation hooks the application process and sees requests: method, path, status and duration, read before the application encrypts them. So one can block traffic and is blind inside TLS, and the other only observes but can see inside a service mesh. "It uses eBPF" tells you how a tool collects its data, not what data it collects.&lt;/p&gt;

&lt;p&gt;That design also sets the limit. The kernel program reads addresses, ports and TCP flags. It does not read what is inside the connection. An HTTP path or a status code is layer 7, and for that Cilium has to hand the connection to a proxy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switching it on in RKE2
&lt;/h2&gt;

&lt;p&gt;RKE2 ships Cilium as a bundled chart, so you don't install Cilium yourself. You layer values onto it with a &lt;code&gt;HelmChartConfig&lt;/code&gt; called &lt;code&gt;rke2-cilium&lt;/code&gt; in &lt;code&gt;kube-system&lt;/code&gt;, and RKE2's helm controller merges and re-applies it within about 30 seconds. My whole Hubble setup is one file. Here's the heart of 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;helm.cattle.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;HelmChartConfig&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;rke2-cilium&lt;/span&gt;          &lt;span class="c1"&gt;# must match the bundled release&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;kube-system&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;valuesContent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|-&lt;/span&gt;
    &lt;span class="s"&gt;hubble:&lt;/span&gt;
      &lt;span class="s"&gt;enabled: true&lt;/span&gt;
      &lt;span class="s"&gt;relay: { enabled: true, replicas: 1 }&lt;/span&gt;
      &lt;span class="s"&gt;ui:    { enabled: true, replicas: 1, ingress: { enabled: false } }&lt;/span&gt;
      &lt;span class="s"&gt;metrics:&lt;/span&gt;
        &lt;span class="s"&gt;enabled:&lt;/span&gt;
          &lt;span class="s"&gt;- dns&lt;/span&gt;
          &lt;span class="s"&gt;- drop&lt;/span&gt;
          &lt;span class="s"&gt;- tcp&lt;/span&gt;
          &lt;span class="s"&gt;- flow&lt;/span&gt;
          &lt;span class="s"&gt;- port-distribution&lt;/span&gt;
          &lt;span class="s"&gt;- icmp&lt;/span&gt;
          &lt;span class="s"&gt;- "httpV2:exemplars=true;labelsContext=source_namespace,source_workload,destination_namespace,destination_workload,traffic_direction"&lt;/span&gt;
      &lt;span class="s"&gt;tls:&lt;/span&gt;
        &lt;span class="s"&gt;auto: { enabled: true, method: cronJob }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few choices worth borrowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The UI has no ingress, on purpose.&lt;/strong&gt; Hubble UI shows &lt;em&gt;all&lt;/em&gt; your cluster's traffic, so you never expose it raw. An oauth2-proxy in front of Keycloak serves it at &lt;code&gt;hubble.georgehomelab.com&lt;/code&gt;, the same single sign-on pattern every other UI here uses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS between the pieces uses Cilium's own CronJob&lt;/strong&gt;, so &lt;code&gt;kube-system&lt;/code&gt; doesn't depend on cert-manager.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's applied by hand, not by ArgoCD.&lt;/strong&gt; &lt;code&gt;kube-system&lt;/code&gt; is too foundational for a reconcile loop: one bad sync here can cut a node off the network. So it's a reviewed &lt;code&gt;kubectl apply&lt;/code&gt;, and you watch the agents roll.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The three things it's actually good for
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The live service map.&lt;/strong&gt; Open the UI, pick a namespace, and the graph draws itself from real traffic. It's not a diagram someone drew; it's the actual edges, appearing as packets flow.&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%2F3489n4bd5kwxibg9cbgz.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%2F3489n4bd5kwxibg9cbgz.png" alt="cilium" width="799" height="374"&gt;&lt;/a&gt;&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%2Fcfg22ckwotj7u35yxbw7.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%2Fcfg22ckwotj7u35yxbw7.png" alt="cilium" width="799" height="374"&gt;&lt;/a&gt;&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%2Fyj3kvg8qecys44he7k1z.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%2Fyj3kvg8qecys44he7k1z.png" alt="cilium" width="799" height="374"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Hubble UI filtered to one namespace (kubeflow), with per-edge protocols and flow counts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reading the network's decisions instead of guessing them.&lt;/strong&gt; When a connection fails, the question is always "is this supposed to work, and if not, what blocked it?" Hubble answers directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;hubble observe &lt;span class="nt"&gt;--namespace&lt;/span&gt; kubeflow &lt;span class="nt"&gt;--verdict&lt;/span&gt; DROPPED &lt;span class="nt"&gt;--last&lt;/span&gt; 50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kernel already made the decision, and Hubble reports it as it happened: FORWARDED or DROPPED, and why. No more simulating NetworkPolicy rules in your head. That matters here: there are 48 Kubernetes NetworkPolicies and 6 Cilium policies on this cluster, and I can't hold all of them in my head.&lt;/p&gt;

&lt;p&gt;For what it's worth, the cluster is quiet. When I swept all 20 nodes for drops, I found 24, all &lt;code&gt;UNSUPPORTED_L3_PROTOCOL&lt;/code&gt;: non-IP frames Cilium simply doesn't route. Not one was a policy denial. That's a nice thing to be able to prove rather than assume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DNS, where the policy asks for it.&lt;/strong&gt; Cilium can see DNS queries and answers, but only for traffic passing through its DNS proxy. Here that means the Kubeflow profile namespaces, where a Cilium policy lets notebooks look up the package index. In those namespaces Hubble shows every lookup and its answer, which is exactly what you want when &lt;code&gt;pip install&lt;/code&gt; hangs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I found when I went looking for HTTP
&lt;/h2&gt;

&lt;p&gt;The draft of this article promised "which HTTP call 500'd". Then I went and checked.&lt;/p&gt;

&lt;p&gt;Across all 20 nodes, Hubble had seen &lt;strong&gt;zero HTTP flows&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;There are two reasons, and both are worth knowing before you enable the same &lt;code&gt;httpV2&lt;/code&gt; metric I did:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cilium only parses HTTP when traffic goes through its layer-7 proxy&lt;/strong&gt;, and it only does that for flows a policy tells it to inspect. None of my policies have HTTP rules, so Cilium never looks inside.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Most of my traffic is inside an Istio mesh.&lt;/strong&gt; The sidecars encrypt pod-to-pod traffic with mTLS before it reaches Cilium's datapath, so even with an L7 rule, all Cilium would see is TLS.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So for the meshed workloads, which is most of this cluster, Hubble is a layer 3, layer 4 and DNS tool, and it's a very good one. HTTP behaviour comes from the mesh instead: Kiali and Istio's telemetry see every request, status code and retry, because the sidecars &lt;em&gt;are&lt;/em&gt; the HTTP endpoints. That's not a failure of Hubble; it's the two tools working at different layers. But I'd have liked to know before I put HTTP in the article's subtitle.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making it show HTTP
&lt;/h3&gt;

&lt;p&gt;Zero flows is a fact about my policies, not about Hubble. To prove it, I gave Cilium one flow it was allowed to read.&lt;/p&gt;

&lt;p&gt;The demo is two small workloads in a namespace that is deliberately outside the mesh: &lt;code&gt;storefront&lt;/code&gt; calls &lt;code&gt;orders&lt;/code&gt; every two seconds. Both are plain busybox: &lt;code&gt;orders&lt;/code&gt; runs &lt;code&gt;httpd&lt;/code&gt; on port 8080 with two small files to serve, and &lt;code&gt;storefront&lt;/code&gt; loops over &lt;code&gt;wget&lt;/code&gt;. The part that matters is the policy on &lt;code&gt;orders&lt;/code&gt;:&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;cilium.io/v2&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;CiliumNetworkPolicy&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;orders-http&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;hubble-l7-demo&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;endpointSelector&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;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders&lt;/span&gt;
  &lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;fromEndpoints&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;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;storefront&lt;/span&gt;
      &lt;span class="na"&gt;toPorts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080"&lt;/span&gt;
              &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
          &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GET&lt;/span&gt;
                &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GET&lt;/span&gt;
                &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/api/.*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take away the &lt;code&gt;rules.http&lt;/code&gt; block and this is an ordinary layer 4 rule: &lt;code&gt;storefront&lt;/code&gt; may reach port 8080. With it, Cilium redirects that port through the Envoy proxy built into each agent, and the proxy reads every request. The policy now allows &lt;code&gt;GET /&lt;/code&gt; and &lt;code&gt;GET&lt;/code&gt; on anything under &lt;code&gt;/api/&lt;/code&gt;, and nothing else.&lt;/p&gt;

&lt;p&gt;The client sends four requests each round: two that should work, one for a file that doesn't exist, and a &lt;code&gt;POST&lt;/code&gt; the policy doesn't allow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;hubble observe &lt;span class="nt"&gt;--namespace&lt;/span&gt; hubble-l7-demo &lt;span class="nt"&gt;--protocol&lt;/span&gt; http
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;storefront-556fcd89c7-gs8k9 -&amp;gt; orders-5b87b7c4bc-5bcq4:8080 http-request  FORWARDED &lt;span class="o"&gt;(&lt;/span&gt;HTTP/1.1 GET http://orders:8080/api/orders.json&lt;span class="o"&gt;)&lt;/span&gt;
storefront-556fcd89c7-gs8k9 &amp;lt;- orders-5b87b7c4bc-5bcq4:8080 http-response FORWARDED &lt;span class="o"&gt;(&lt;/span&gt;HTTP/1.1 200 2ms &lt;span class="o"&gt;(&lt;/span&gt;GET http://orders:8080/api/orders.json&lt;span class="o"&gt;))&lt;/span&gt;
storefront-556fcd89c7-gs8k9 -&amp;gt; orders-5b87b7c4bc-5bcq4:8080 http-request  FORWARDED &lt;span class="o"&gt;(&lt;/span&gt;HTTP/1.1 GET http://orders:8080/api/missing&lt;span class="o"&gt;)&lt;/span&gt;
storefront-556fcd89c7-gs8k9 &amp;lt;- orders-5b87b7c4bc-5bcq4:8080 http-response FORWARDED &lt;span class="o"&gt;(&lt;/span&gt;HTTP/1.1 404 4ms &lt;span class="o"&gt;(&lt;/span&gt;GET http://orders:8080/api/missing&lt;span class="o"&gt;))&lt;/span&gt;
storefront-556fcd89c7-gs8k9 -&amp;gt; orders-5b87b7c4bc-5bcq4:8080 http-request  DROPPED   &lt;span class="o"&gt;(&lt;/span&gt;HTTP/1.1 POST http://orders:8080/api/orders.json&lt;span class="o"&gt;)&lt;/span&gt;
storefront-556fcd89c7-gs8k9 &amp;lt;- orders-5b87b7c4bc-5bcq4:8080 http-response FORWARDED &lt;span class="o"&gt;(&lt;/span&gt;HTTP/1.1 403 0ms &lt;span class="o"&gt;(&lt;/span&gt;POST http://orders:8080/api/orders.json&lt;span class="o"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the real output, with the timestamps, namespace prefix and identity numbers trimmed so it fits. Three different stories in six lines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;200 in 2 ms.&lt;/strong&gt; Allowed, served, and timed. The latency is measured by the proxy, per request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;404.&lt;/strong&gt; The policy allowed it and the application said no. The network was fine; the file isn't there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;403, and &lt;code&gt;DROPPED&lt;/code&gt; on the request.&lt;/strong&gt; The policy refused it. Cilium's proxy answered with the 403 itself, in under a millisecond, and the request never reached &lt;code&gt;orders&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last pair is the one worth having. A 403 from your application and a 403 from the network look the same to the caller. Hubble shows which it was.&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%2F524rvbmhciam5jci0sxd.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%2F524rvbmhciam5jci0sxd.png" alt="cilium" width="800" height="377"&gt;&lt;/a&gt;&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%2Fzcnmgq5agazbusu5lozb.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%2Fzcnmgq5agazbusu5lozb.png" alt="cilium" width="800" height="377"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Hubble UI on the &lt;code&gt;hubble-l7-demo&lt;/code&gt; namespace: the &lt;code&gt;storefront&lt;/code&gt; → &lt;code&gt;orders&lt;/code&gt; edge, and the flows table with the HTTP method, path and the 200, 404 and 403 status codes.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;httpV2&lt;/code&gt; metric I'd switched on months earlier finally had something to count, too. Straight from one agent's metrics port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;hubble_http_requests_total&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;destination_workload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"orders"&lt;/span&gt;,method&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;,source_workload&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"storefront"&lt;/span&gt;,status&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"200"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 15
hubble_http_requests_total&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;destination_workload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"orders"&lt;/span&gt;,method&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;,source_workload&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"storefront"&lt;/span&gt;,status&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"404"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 9
hubble_http_requests_total&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;destination_workload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"orders"&lt;/span&gt;,method&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"POST"&lt;/span&gt;,source_workload&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"storefront"&lt;/span&gt;,status&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"403"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Request rate, error rate and a latency histogram per workload pair, with no sidecar and no change to the application. I read those from the agent directly, because nothing scrapes them yet (see the traps).&lt;/p&gt;

&lt;p&gt;Before you copy this onto a real service, three things it changes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The policy is also a firewall.&lt;/strong&gt; The moment a pod is selected by an ingress policy, everything that policy doesn't allow is refused. &lt;code&gt;orders&lt;/code&gt; now accepts &lt;code&gt;storefront&lt;/code&gt; on port 8080 and nothing else. Write an HTTP rule to get visibility and forget another caller, and you've caused an outage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The traffic takes a detour.&lt;/strong&gt; Requests to that port now pass through a proxy on the node, so they pick up a little latency and depend on that proxy. It's one proxy per node, not one per pod, which is still far lighter than a sidecar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flows now carry request details.&lt;/strong&gt; The URL and headers are in the flow record, visible to anyone who can open the Hubble UI. Cilium can redact them, and I'd turn that on before pointing this at anything with tokens in a query string.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And it doesn't change the answer for the mesh. An HTTP rule on a meshed pod would still show Cilium a TLS stream. On this cluster, layer 7 through Hubble is for the workloads Istio doesn't cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  How reliable is it?
&lt;/h2&gt;

&lt;p&gt;Here's what the networking layer survives, and the evidence for each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What goes wrong&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;How I know&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A node reboots or the lab loses power&lt;/td&gt;
&lt;td&gt;Each node's cilium-agent comes back and rejoins&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cilium status&lt;/code&gt;: 20 of 20 nodes reachable, and 34 of 34 controllers healthy on the agent I asked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One node's agent dies&lt;/td&gt;
&lt;td&gt;Only that node's pods are affected; every other node keeps its own datapath&lt;/td&gt;
&lt;td&gt;One agent per node, as a DaemonSet: 20 desired, 20 ready&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KubeArmor tries to confine the agent&lt;/td&gt;
&lt;td&gt;It can't: the agent runs unconfined&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;cilium-agent: unconfined&lt;/code&gt; annotation is on the live DaemonSet (see the traps)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The Relay lands on a bad node&lt;/td&gt;
&lt;td&gt;It can't: it's kept off the two nodes where it failed&lt;/td&gt;
&lt;td&gt;A node anti-affinity in the values file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A node runs short of memory&lt;/td&gt;
&lt;td&gt;The scheduler knows what the agent really uses&lt;/td&gt;
&lt;td&gt;512Mi memory request on every agent (see the traps)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the honest limits. The Relay and the UI are single replicas, so if the Relay is down you lose the cluster-wide view, though every node's agent keeps working. Each node keeps 4,095 flows in memory. A quiet node sees about 10 flows a second and a busy one close to 300, so that's anywhere from six minutes of history down to fifteen seconds, and about 45 seconds on a typical node. And nothing scrapes the metrics yet. All three are on the list at the end.&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%2Fbc13jrnxmrhnz52fe2z1.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%2Fbc13jrnxmrhnz52fe2z1.png" alt="cilium" width="800" height="230"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cilium status&lt;/code&gt; from one agent: &lt;code&gt;Cluster health: 20/20 reachable&lt;/code&gt;, &lt;code&gt;Hubble: Ok&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The traps that taught me
&lt;/h2&gt;

&lt;h3&gt;
  
  
  KubeArmor crash-looped my CNI
&lt;/h3&gt;

&lt;p&gt;After I enabled Hubble, &lt;code&gt;cilium-agent&lt;/code&gt; on two nodes went into &lt;code&gt;CrashLoopBackOff&lt;/code&gt; with &lt;code&gt;permission denied&lt;/code&gt; trying to create &lt;code&gt;/sys/fs/bpf/tc&lt;/code&gt;. It couldn't pin its own eBPF maps. Nothing in the Cilium config was wrong.&lt;/p&gt;

&lt;p&gt;The culprit was KubeArmor, the runtime security layer from the security episode. It generates a default-deny AppArmor profile for every pod, and that profile blocked the exact BPF calls Cilium needs. My CNI was being confined by my security tool: two layers of the stack fighting in the kernel.&lt;/p&gt;

&lt;p&gt;The fix is one annotation, and the hard part is &lt;em&gt;where&lt;/em&gt; it goes:&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;podAnnotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;container.apparmor.security.beta.kubernetes.io/cilium-agent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unconfined&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It has to be &lt;strong&gt;top-level &lt;code&gt;podAnnotations&lt;/code&gt;&lt;/strong&gt;. &lt;code&gt;daemon.podAnnotations&lt;/code&gt; looks right and isn't a real Cilium key, so it's silently ignored, and the agent gets confined again on the next roll. It also has to be this &lt;strong&gt;per-container&lt;/strong&gt; annotation; a pod-level &lt;code&gt;appArmorProfile: Unconfined&lt;/code&gt; isn't enough, because KubeArmor keys off the container one. Without it, the two nodes wouldn't recover even after a reboot.&lt;/p&gt;

&lt;p&gt;That fix got undone more than once. An older copy of this manifest sitting on the control-plane disks kept putting the broken version back. &lt;/p&gt;

&lt;h3&gt;
  
  
  The Relay that crash-looped on two nodes
&lt;/h3&gt;

&lt;p&gt;The Relay kept crash-looping, 105 restarts, with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dial tcp &amp;lt;hubble-peer ClusterIP&amp;gt;:443: connect: connection refused
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It only happened when the Relay landed on two particular nodes. On those two, Cilium's datapath answers the &lt;code&gt;hubble-peer&lt;/code&gt; service with ICMP port-unreachable. The service is &lt;code&gt;internalTrafficPolicy: Local&lt;/code&gt;, hard-coded in the chart, so the Relay can only reach the peer service through its own node, and on those two nodes that fails. On every other node it works.&lt;/p&gt;

&lt;p&gt;I still don't know the root cause. I ruled out kube-proxy, socket load-balancing, Cilium's state database and the kernel. So the fix is a placement workaround, and I'm saying so plainly:&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;relay&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;affinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;nodeAffinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;requiredDuringSchedulingIgnoredDuringExecution&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;nodeSelectorTerms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matchExpressions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes.io/hostname&lt;/span&gt;
                &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NotIn&lt;/span&gt;
                &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;rke2-worker-dc3-app-a&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;rke2-worker-dc3-db-a&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting &lt;code&gt;affinity&lt;/code&gt; replaces the chart's default, which includes a pod affinity that keeps the Relay next to a Cilium agent. Restate that too, or you'll break it differently. Since the workaround, the Relay has stayed on healthy nodes.&lt;/p&gt;

&lt;h3&gt;
  
  
  8 GiB of memory the scheduler couldn't see
&lt;/h3&gt;

&lt;p&gt;The cilium-agent ran with no memory request at all, while actually using 217 to 460 MiB per node. Across 20 nodes that's about &lt;strong&gt;8.3 GiB of real memory the scheduler didn't know existed&lt;/strong&gt;. That's how nodes hit real memory pressure while still reporting headroom.&lt;/p&gt;

&lt;p&gt;The fix is a request, and deliberately no limit:&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;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;100m&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512Mi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;512Mi sits just above the highest use I measured. There's no limit on purpose: a memory limit on the CNI agent is an off switch for the node's network. It made every node look about 512Mi fuller to the scheduler, and that's the point. The memory was already in use; now the scheduler knows.&lt;/p&gt;

&lt;h3&gt;
  
  
  The metrics nobody reads
&lt;/h3&gt;

&lt;p&gt;All those metrics are switched on, and nothing scrapes them. There's no ServiceMonitor for Cilium or Hubble yet, so the drop and DNS rates exist and vanish. It costs almost nothing, but it's the first thing on the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hubble or something else?
&lt;/h2&gt;

&lt;p&gt;"Isn't this just what &lt;strong&gt;Kiali&lt;/strong&gt; does?" is the fair question. The answer is no, and I run both. They work at different layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;eBPF network observability, no sidecar:&lt;/strong&gt; Cilium Hubble, Microsoft Retina, Inspektor Gadget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service-mesh observability, sidecar-based:&lt;/strong&gt; Kiali with Istio, Linkerd Viz.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Packet capture:&lt;/strong&gt; &lt;code&gt;tcpdump&lt;/code&gt;, Wireshark, &lt;code&gt;ksniff&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commercial network monitoring:&lt;/strong&gt; Datadog Network Monitoring, Kentik.&lt;/li&gt;
&lt;/ol&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;Layer&lt;/th&gt;
&lt;th&gt;Sidecar?&lt;/th&gt;
&lt;th&gt;HTTP depth&lt;/th&gt;
&lt;th&gt;Knows Kubernetes identity&lt;/th&gt;
&lt;th&gt;Best at&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hubble&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;eBPF datapath&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;⚠️ only where a policy has HTTP rules, and not through mTLS&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;flow map and policy decisions at almost no cost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kiali / Istio&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the mesh&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅ every request&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;HTTP behaviour of meshed services&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;tcpdump / Wireshark&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;raw packets&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;✅ if you parse it&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;one flow in full detail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Retina&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;eBPF, any CNI&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;flow visibility on a non-Cilium cluster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Datadog NPM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;agent&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;managed and correlated, if you'll pay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kiali and Istio&lt;/strong&gt; win for per-request behaviour: status codes, retries, latency, mTLS. On this cluster that's where HTTP visibility comes from, because the sidecars are the HTTP endpoints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;tcpdump&lt;/code&gt;&lt;/strong&gt; is still the tool for "is this TLS handshake actually broken". It just doesn't scale past one flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retina&lt;/strong&gt; is the pick if you're not on Cilium.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Datadog&lt;/strong&gt; wins on zero operations, if you'll send your flows to their cloud and pay per host.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why Hubble here: it's eBPF, it knows Kubernetes identity, it's free, and Cilium was already my CNI, so it cost a few Helm values. For "what's talking to what, and did the network allow it", nothing else comes close for the price.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;The invisible is now a live map behind a Keycloak login, and the network's decisions are one command away. What I'm doing next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scrape the metrics:&lt;/strong&gt; a ServiceMonitor for Cilium and Hubble, and a dashboard for drops and DNS errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep more than a minute:&lt;/strong&gt; export flows to OpenObserve, so "what dropped at 3 a.m." is a query, not luck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run two Relays&lt;/strong&gt;, so the cluster-wide view survives losing one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find the real cause&lt;/strong&gt; behind the two nodes that refuse the Relay, and drop the workaround.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the SRE agents use it:&lt;/strong&gt; an agent that reads &lt;code&gt;DROPPED&lt;/code&gt; flows and proposes the NetworkPolicy fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're on Cilium and you've never switched Hubble on, you're sitting on an X-ray machine you haven't plugged in. Just check what it can see through before you promise anyone HTTP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.linkedin.com/in/george-ezejiofor-89615a8a/" rel="noopener noreferrer"&gt;Follow me on LinkedIn&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Author: George Ezejiofor&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>cilium</category>
      <category>ebpf</category>
      <category>networking</category>
    </item>
    <item>
      <title>NVIDIA GPU Operator on RKE2: Passthrough to Kubeflow (The Sports Car in the Garage)</title>
      <dc:creator>George Ezejiofor</dc:creator>
      <pubDate>Mon, 12 Oct 2026 16:31:01 +0000</pubDate>
      <link>https://dev.to/ezejioforog/nvidia-gpu-operator-on-rke2-passthrough-to-kubeflow-the-sports-car-in-the-garage-1f5a</link>
      <guid>https://dev.to/ezejioforog/nvidia-gpu-operator-on-rke2-passthrough-to-kubeflow-the-sports-car-in-the-garage-1f5a</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%2Fjjer1f24n8s0ydl66n9l.gif" 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%2Fjjer1f24n8s0ydl66n9l.gif" alt="gpu" width="500" height="281"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Episode 7 of the homelab build series. The foundation is poured, the walls are up, the alarms are wired, the storage floor is down and the safe is locked. Now for the fun room: three NVIDIA GPUs, one in each machine, so GPU capacity stays highly available when any one of them is down, managed by the NVIDIA GPU Operator and wired into Kubeflow.&lt;/em&gt;&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%2Fp81aye4awx65ue8cigh7.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%2Fp81aye4awx65ue8cigh7.png" alt="gpu" width="799" height="309"&gt;&lt;/a&gt;&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%2Fukay2i536nllyc9k1qzn.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%2Fukay2i536nllyc9k1qzn.png" alt="gpu" width="798" height="183"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;nvidia-smi&lt;/code&gt; side by side on the three GPU nodes, one per datacenter.&lt;/p&gt;




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

&lt;p&gt;&lt;em&gt;Three GPUs. Three datacenters. Zero schedulable.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I had three NVIDIA cards in three machines, and Kubernetes swore I had none. I asked for one GPU in a test pod and got this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0/21 nodes are available: 21 Insufficient nvidia.com/gpu.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Proxmox hosts could see them. &lt;code&gt;lspci&lt;/code&gt; could see them. &lt;code&gt;nvidia-smi&lt;/code&gt; on the host lit up. But to the cluster, three cards' worth of Ampere silicon were parked, idle and invisible. That's the most expensive kind of idle there is.&lt;/p&gt;

&lt;p&gt;And the whole MLOps platform from the earlier episodes (Kubeflow, Katib, KServe, the training pipelines) was built to train and serve real models. You can't schedule what the scheduler doesn't know exists.&lt;/p&gt;

&lt;p&gt;Fitting the cards took a Saturday. Getting the kernel module, the container runtime, the device plugin and the scheduler to agree took a lot longer, and taught me thirteen different ways a GPU can hide from Kubernetes.&lt;/p&gt;

&lt;p&gt;Here's where it ended up. Three RTX A1000s, one in each "datacenter", which here means one in each of three mini PCs on a shelf. Lose a whole machine and two cards are still there. The NVIDIA GPU Operator builds the driver, wires up the runtime and advertises the cards on its own. And the part I'm proud of: when the whole lab loses power, all three cards come back and pass their CUDA self-test without me touching anything.&lt;/p&gt;

&lt;p&gt;This is how I got there, every trap along the way, and the parts that still aren't done.&lt;/p&gt;

&lt;p&gt;And at the end there's a real job on a real card: a 7-billion-parameter language model answering at about 30 tokens a second, against 4 on the CPU beside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this sits in the build
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Foundation:&lt;/strong&gt; Terraform on Proxmox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Framing:&lt;/strong&gt; Ansible building the five-master HA RKE2 cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security:&lt;/strong&gt; Kyverno, KubeArmor and Trivy, with Falco parked for now to save CPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workshop:&lt;/strong&gt; Tekton, Harbor, SonarQube and Chains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage floor:&lt;/strong&gt; Rook-Ceph and CloudNativePG.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The safe:&lt;/strong&gt; HA HashiCorp Vault.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The sports car:&lt;/strong&gt; this one.&lt;/li&gt;
&lt;/ul&gt;

&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%2Fik2fsmc7c6b57fn9uive.gif" 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%2Fik2fsmc7c6b57fn9uive.gif" alt=" " width="374" height="203"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is where the cluster stops being general-purpose Kubernetes and gains an accelerator pool. Before the operator went in, the three GPU workers were scheduled like any other node, by CPU and memory, with no idea a GPU was attached.&lt;/p&gt;

&lt;p&gt;The repo side is small: one namespace, one chart, one values file, and one &lt;code&gt;ClusterPolicy&lt;/code&gt; the operator builds everything from.&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="s"&gt;kubernetes-addons/0-boostrap/gpu-operator/&lt;/span&gt;
  &lt;span class="s"&gt;namespace.yaml&lt;/span&gt;             &lt;span class="c1"&gt;# gpu-operator namespace, privileged Pod Security&lt;/span&gt;
  &lt;span class="s"&gt;gpu-operator-values.yaml&lt;/span&gt;   &lt;span class="c1"&gt;# annotated Helm values; every trap fix lives here&lt;/span&gt;
  &lt;span class="s"&gt;dcgm-custom-metrics.yaml&lt;/span&gt;   &lt;span class="c1"&gt;# extra DCGM fields for the dashboard&lt;/span&gt;
  &lt;span class="s"&gt;nvidia-dcgm-dashboard.yaml&lt;/span&gt; &lt;span class="c1"&gt;# Grafana dashboard&lt;/span&gt;
  &lt;span class="s"&gt;examples/&lt;/span&gt;                  &lt;span class="c1"&gt;# multi-GPU best-practice manifests&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The hardware in the garage
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The cards: three NVIDIA RTX A1000s.&lt;/strong&gt; Ampere &lt;code&gt;GA107GL&lt;/code&gt;, 8 GB of GDDR6, 2,304 CUDA cores, about 50 W. It draws all its power from the PCIe slot, with no extra connector, which is exactly why it fits where a bigger card wouldn't. The node label says it plainly:&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="nv"&gt;$ &lt;/span&gt;kubectl get node rke2-worker-dc1-gpu-a &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;'{.metadata.labels.nvidia\.com/gpu\.product} {.metadata.labels.nvidia\.com/gpu\.memory}MiB'&lt;/span&gt;
NVIDIA-RTX-A1000 8188MiB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The hosts: three Minisforum MS-01s.&lt;/strong&gt; Intel i9-13900H, 96 GB of DDR5, and, rarely for a mini PC, one real half-height PCIe slot wired to the CPU: x16 in length, x8 electrically, which is plenty for this card. That slot is the whole reason this build exists.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Datacenter&lt;/th&gt;
&lt;th&gt;Proxmox host&lt;/th&gt;
&lt;th&gt;GPU worker&lt;/th&gt;
&lt;th&gt;GPU&lt;/th&gt;
&lt;th&gt;VRAM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;dc1&lt;/td&gt;
&lt;td&gt;proxmox-dc-1&lt;/td&gt;
&lt;td&gt;rke2-worker-dc1-gpu-a&lt;/td&gt;
&lt;td&gt;RTX A1000&lt;/td&gt;
&lt;td&gt;8 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dc2&lt;/td&gt;
&lt;td&gt;proxmox-dc-2&lt;/td&gt;
&lt;td&gt;rke2-worker-dc2-gpu-a&lt;/td&gt;
&lt;td&gt;RTX A1000&lt;/td&gt;
&lt;td&gt;8 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dc3&lt;/td&gt;
&lt;td&gt;proxmox-dc-3&lt;/td&gt;
&lt;td&gt;rke2-worker-dc3-gpu-a&lt;/td&gt;
&lt;td&gt;RTX A1000&lt;/td&gt;
&lt;td&gt;8 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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%2F8wqi7a20anirrhejc4dx.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%2F8wqi7a20anirrhejc4dx.png" alt="gpu" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three cards, three machines: PCIe passthrough from Proxmox host to RKE2 worker.&lt;/p&gt;

&lt;p&gt;That's 24 GB of GPU memory across the fleet. One 8 GB card runs a 4-bit 7B model with room to spare; I measure that further down. It's great for inference, fine for small fine-tunes, and marginal for training from scratch, which is a rented-A100 job anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why one card per datacenter instead of three in one box?&lt;/strong&gt; The same answer as storage: failure domains. If a machine goes down, GPU work still has two healthy places to land. In a homelab, rack density is worth nothing and HA is worth a lot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why bother with Kubernetes when the GPU is right there on the host?&lt;/strong&gt; Three reasons. Each project gets its CUDA version from an image tag instead of eight CUDA installs fighting each other. The runtime becomes a pinned image, so results are reproducible. And the scheduler is the only sane way to say "this job gets one card, that one gets another".&lt;/p&gt;

&lt;h2&gt;
  
  
  PCIe passthrough: from Proxmox to the VM
&lt;/h2&gt;

&lt;p&gt;Each card is bound to &lt;code&gt;vfio-pci&lt;/code&gt; on its Proxmox host and passed into exactly one VM. An Ansible playbook handles the nouveau blacklist, the vfio binding, IOMMU and the reboot. All three cards sit at the same address, &lt;code&gt;0000:01:00&lt;/code&gt;, which keeps the Terraform pleasingly uniform.&lt;/p&gt;

&lt;p&gt;The passthrough itself is Terraform with the bpg provider, through a &lt;code&gt;gpu_pci_hosts&lt;/code&gt; map per datacenter. One gotcha deserves its own sentence: &lt;strong&gt;raw &lt;code&gt;hostpci&lt;/code&gt; needs the &lt;code&gt;root@pam&lt;/code&gt; Proxmox user.&lt;/strong&gt; A normal API token can't assign raw PCI devices, and the error doesn't tell you that.&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="nb"&gt;cd &lt;/span&gt;terraform/environment/prod-rke2/bpg
terraform plan  &lt;span class="nt"&gt;-target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;module.rke2_worker_dc2_gpu &lt;span class="nt"&gt;-target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;module.rke2_worker_dc3_gpu
&lt;span class="c"&gt;# 0 to add, 2 to change, 0 to destroy   (in place: +machine=q35, +hostpci0)&lt;/span&gt;
terraform apply &lt;span class="nt"&gt;-target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;module.rke2_worker_dc2_gpu &lt;span class="nt"&gt;-target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;module.rke2_worker_dc3_gpu
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;machine=q35&lt;/code&gt; and &lt;code&gt;hostpci0&lt;/code&gt; only take effect after a full power cycle of the VM, not a soft reboot.&lt;/p&gt;

&lt;p&gt;The best moment of the whole build came here. Once dc1 worked, I passed the cards through on dc2 and dc3 and rebooted them. The card appeared, the node got labelled, and the operator rolled the entire stack onto both nodes by itself. The first node took me twelve hours. The other two took about ninety seconds each, and I didn't touch them.&lt;/p&gt;

&lt;p&gt;Three hardware notes that earned their place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Driver version is pinned&lt;/strong&gt; to &lt;code&gt;570.133.20&lt;/code&gt;. The version stays put. What has to follow the kernel is the module, which the driver container rebuilds against whatever kernel the VM is running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No vGPU, no MIG.&lt;/strong&gt; The A1000 supports neither.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secure Boot never gets a say.&lt;/strong&gt; The GPU VMs boot with SeaBIOS, not UEFI, so the unsigned NVIDIA module loads without any key enrolment. The Proxmox hosts themselves do have Secure Boot on; it doesn't matter, because the module loads inside the VM. If your VMs use UEFI with Secure Boot, you'll have to sign the module or switch it off there.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the GPU Operator actually does
&lt;/h2&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%2Fv0jeawysjofpf4gprojb.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%2Fv0jeawysjofpf4gprojb.png" alt="gpu" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What the GPU Operator runs: a tower where every floor waits for the one below.&lt;/p&gt;

&lt;p&gt;One Helm release of &lt;code&gt;nvidia/gpu-operator&lt;/code&gt; (chart v26.3.2) runs a set of DaemonSets from a single &lt;code&gt;ClusterPolicy&lt;/code&gt;. Sticking with the garage:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;In plain terms&lt;/th&gt;
&lt;th&gt;What it really does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Operator&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the garage manager&lt;/td&gt;
&lt;td&gt;watches the &lt;code&gt;ClusterPolicy&lt;/code&gt; and keeps everything below in shape&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NFD (Node Feature Discovery)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the inspector&lt;/td&gt;
&lt;td&gt;scans hardware and labels nodes that have an NVIDIA PCI device&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GFD (GPU Feature Discovery)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the spec sheet&lt;/td&gt;
&lt;td&gt;adds product, memory, count, driver and CUDA labels&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Driver&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the mechanic&lt;/td&gt;
&lt;td&gt;compiles and loads &lt;code&gt;nvidia.ko&lt;/code&gt; against the running kernel, inside a container&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Container Toolkit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the licence plate&lt;/td&gt;
&lt;td&gt;registers the &lt;code&gt;nvidia&lt;/code&gt; runtime with containerd&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Device Plugin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the parking attendant&lt;/td&gt;
&lt;td&gt;advertises &lt;code&gt;nvidia.com/gpu&lt;/code&gt; to the kubelet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DCGM Exporter&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the dashboard&lt;/td&gt;
&lt;td&gt;per-GPU metrics for Prometheus&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Validator&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the MOT&lt;/td&gt;
&lt;td&gt;runs a CUDA test on every card and reports the result&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The dependency chain is the thing to remember.&lt;/strong&gt; NFD labels the nodes that have a GPU. Everything else only runs where that label is. So the driver, toolkit and device plugin land on the three GPU workers and nowhere else. No NFD label, no driver. No driver, no GPU.&lt;/p&gt;

&lt;p&gt;And every other piece waits for the driver. When the bottom of that tower is broken, everything above it sits in &lt;code&gt;Init&lt;/code&gt;. That gave me the rule that saved the most time: &lt;strong&gt;debug from the driver up.&lt;/strong&gt; There's no point staring at the device plugin while the driver is still crash-looping.&lt;/p&gt;

&lt;p&gt;What I deliberately don't run: the MIG manager (the A1000 has no MIG), the vGPU manager (that's for licensed shared vGPU), the sandbox and Kata pieces (the card is passed through once, at Proxmox), GPUDirect Storage (no hardware for it), and a standalone DCGM (the exporter brings its own, and two would fight over one socket).&lt;/p&gt;

&lt;p&gt;The stack lives in the &lt;code&gt;gpu-operator&lt;/code&gt; namespace with &lt;strong&gt;privileged&lt;/strong&gt; Pod Security. The driver, toolkit and device plugin load kernel modules and touch host paths, and a restricted namespace blocks every one of them.&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%2F7997m8qadm377eqo05f2.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%2F7997m8qadm377eqo05f2.png" alt="gpu" width="800" height="316"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kubectl get pods -n gpu-operator&lt;/code&gt; mid-cascade: a wall of &lt;code&gt;Init:0/1&lt;/code&gt; and one very sad &lt;code&gt;nvidia-driver-daemonset&lt;/code&gt;. The "before" shot.&lt;/p&gt;

&lt;h2&gt;
  
  
  From PCIe slot to scheduled pod
&lt;/h2&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%2Fuj5afsm0lposx4wa56f8.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%2Fuj5afsm0lposx4wa56f8.png" alt="gpu" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;From PCIe slot to scheduled pod: once per node, then once per pod.&lt;/p&gt;

&lt;p&gt;Once per node:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The worker boots with the GPU present as a PCI device.&lt;/li&gt;
&lt;li&gt;NFD labels the node.&lt;/li&gt;
&lt;li&gt;The driver pod compiles &lt;code&gt;nvidia.ko&lt;/code&gt; for the running kernel and loads it.&lt;/li&gt;
&lt;li&gt;The toolkit registers the &lt;code&gt;nvidia&lt;/code&gt; runtime with containerd.&lt;/li&gt;
&lt;li&gt;The device plugin tells the kubelet the node has &lt;code&gt;nvidia.com/gpu: 1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;GFD adds &lt;code&gt;nvidia.com/gpu.product=NVIDIA-RTX-A1000&lt;/code&gt; and friends.&lt;/li&gt;
&lt;li&gt;The DCGM exporter starts and metrics flow.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then once per pod:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A workload asks for &lt;code&gt;nvidia.com/gpu: 1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Kyverno adds the taint toleration and the GPU node rule (more on that in the next section).&lt;/li&gt;
&lt;li&gt;The scheduler finds a GPU node with a free card and binds the pod.&lt;/li&gt;
&lt;li&gt;The NVIDIA runtime injects the device and driver libraries.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nvidia-smi&lt;/code&gt; inside the container sees its card.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All of it is automatic once the operator is in. That's the whole argument against a hand-rolled Ansible role that builds kernel modules per node and breaks on every kernel upgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping GPU pods on GPU nodes
&lt;/h2&gt;

&lt;p&gt;Two bits of metadata mark these nodes, and you want both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A label&lt;/strong&gt; so workloads can find them: &lt;code&gt;nodepool=gpu&lt;/code&gt;, plus everything NFD and GFD add.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A taint&lt;/strong&gt; so ordinary workloads can't drift onto them and starve a GPU job: &lt;code&gt;nvidia.com/gpu=true:NoSchedule&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl get node rke2-worker-dc1-gpu-a &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;'{.spec.taints}'&lt;/span&gt;
&lt;span class="o"&gt;[{&lt;/span&gt;&lt;span class="s2"&gt;"effect"&lt;/span&gt;:&lt;span class="s2"&gt;"NoSchedule"&lt;/span&gt;,&lt;span class="s2"&gt;"key"&lt;/span&gt;:&lt;span class="s2"&gt;"nvidia.com/gpu"&lt;/span&gt;,&lt;span class="s2"&gt;"value"&lt;/span&gt;:&lt;span class="s2"&gt;"true"&lt;/span&gt;&lt;span class="o"&gt;}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Normally that means every GPU pod needs a toleration, a node selector and a resource limit, and forgetting any one gives you a confusing failure. On this cluster a Kyverno policy, &lt;code&gt;gpu-pods-to-gpu-nodes&lt;/code&gt;, does the first two for you. Any pod that asks for &lt;code&gt;nvidia.com/gpu&lt;/code&gt; is mutated to tolerate the taint and require a &lt;code&gt;nodepool=gpu&lt;/code&gt; node. So a GPU pod here needs exactly one line:&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;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;nvidia.com/gpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The operator's own DaemonSets carry the toleration in the values file, and NFD's master also needs a control-plane toleration:&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;daemonsets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;priorityClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;system-node-critical&lt;/span&gt;
  &lt;span class="na"&gt;tolerations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia.com/gpu&lt;/span&gt;
      &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Exists&lt;/span&gt;
      &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NoSchedule&lt;/span&gt;

&lt;span class="na"&gt;nfd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;master&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tolerations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node-role.kubernetes.io/control-plane&lt;/span&gt;
        &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Equal&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
        &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NoSchedule&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Prove it works
&lt;/h3&gt;

&lt;p&gt;The quickest proof is NVIDIA's own CUDA sample. It adds two vectors on the card and prints one line if the whole chain held: driver, runtime, device plugin, scheduler.&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;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;Pod&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;cuda-vectoradd&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;default&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;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OnFailure&lt;/span&gt;
  &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runAsNonRoot&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;runAsUser&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1000&lt;/span&gt;
    &lt;span class="na"&gt;seccompProfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RuntimeDefault&lt;/span&gt;
  &lt;span class="na"&gt;containers&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;cuda-vectoradd&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvcr.io/nvidia/k8s/cuda-sample:vectoradd-cuda12.5.0-ubuntu22.04&lt;/span&gt;
      &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;allowPrivilegeEscalation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ALL&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;nvidia.com/gpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at what isn't there: no toleration, no node selector, no &lt;code&gt;runtimeClassName&lt;/code&gt;. The only GPU line is the limit. The &lt;code&gt;securityContext&lt;/code&gt; blocks have nothing to do with the GPU; my &lt;code&gt;default&lt;/code&gt; namespace enforces the restricted Pod Security Standard, so every pod there needs them.&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="nv"&gt;$ &lt;/span&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; cuda-vectoradd.yaml
&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl get pod cuda-vectoradd &lt;span class="nt"&gt;-o&lt;/span&gt; wide
NAME             READY   STATUS      RESTARTS   AGE   NODE
cuda-vectoradd   0/1     Completed   0          16s   rke2-worker-dc1-gpu-a

&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl logs cuda-vectoradd
&lt;span class="o"&gt;[&lt;/span&gt;Vector addition of 50000 elements]
Copy input data from the host memory to the CUDA device
CUDA kernel launch with 196 blocks of 256 threads
Copy output data from the CUDA device to the host memory
Test PASSED
Done
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sixteen seconds from apply to &lt;code&gt;Completed&lt;/code&gt;, image pull included. The pod landed on a GPU worker without being told to, and you can see what Kyverno wrote into it on the way in:&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="nv"&gt;$ &lt;/span&gt;kubectl get pod cuda-vectoradd &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;'{.spec.tolerations}'&lt;/span&gt;
&lt;span class="o"&gt;[{&lt;/span&gt;&lt;span class="s2"&gt;"effect"&lt;/span&gt;:&lt;span class="s2"&gt;"NoSchedule"&lt;/span&gt;,&lt;span class="s2"&gt;"key"&lt;/span&gt;:&lt;span class="s2"&gt;"nvidia.com/gpu"&lt;/span&gt;,&lt;span class="s2"&gt;"operator"&lt;/span&gt;:&lt;span class="s2"&gt;"Equal"&lt;/span&gt;,&lt;span class="s2"&gt;"value"&lt;/span&gt;:&lt;span class="s2"&gt;"true"&lt;/span&gt;&lt;span class="o"&gt;}]&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl get pod cuda-vectoradd &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;'{.spec.affinity.nodeAffinity}'&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"requiredDuringSchedulingIgnoredDuringExecution"&lt;/span&gt;:&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"nodeSelectorTerms"&lt;/span&gt;:[&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"matchExpressions"&lt;/span&gt;:[&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"key"&lt;/span&gt;:&lt;span class="s2"&gt;"nodepool"&lt;/span&gt;,&lt;span class="s2"&gt;"operator"&lt;/span&gt;:&lt;span class="s2"&gt;"In"&lt;/span&gt;,&lt;span class="s2"&gt;"values"&lt;/span&gt;:[&lt;span class="s2"&gt;"gpu"&lt;/span&gt;&lt;span class="o"&gt;]}]}]}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Delete the pod when you're done. It has finished, so it isn't holding the card, but it's clutter.&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%2Ftgo0u4ihqmeiy2jo2fhx.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%2Ftgo0u4ihqmeiy2jo2fhx.png" alt="gpu" width="800" height="164"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kubectl logs cuda-vectoradd&lt;/code&gt; ending in &lt;code&gt;Test PASSED&lt;/code&gt;. The hero shot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The runtime: getting containerd to know about GPUs
&lt;/h2&gt;

&lt;p&gt;containerd doesn't know what a GPU is. Something has to register an &lt;code&gt;nvidia&lt;/code&gt; runtime that injects the device and driver libraries into containers. The classic way is to patch containerd's &lt;code&gt;config.toml&lt;/code&gt;, and &lt;strong&gt;RKE2 regenerates that file from a template&lt;/strong&gt;, throwing your patch away on every restart.&lt;/p&gt;

&lt;p&gt;Two things make it work here. CDI is on, so the toolkit writes a device spec at &lt;code&gt;/etc/cdi/nvidia.yaml&lt;/code&gt; that containerd reads at container start:&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;cdi&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&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;nriPluginEnabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the toolkit is pointed at RKE2's own containerd rather than the default one:&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;toolkit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;env&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;CONTAINERD_CONFIG&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/lib/rancher/rke2/agent/etc/containerd/config.toml&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;CONTAINERD_SOCKET&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/run/k3s/containerd/containerd.sock&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;CONTAINERD_RUNTIME_CLASS&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia&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;CONTAINERD_SET_AS_DEFAULT&lt;/span&gt;
      &lt;span class="na"&gt;value&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subtle bit is that &lt;code&gt;CONTAINERD_CONFIG&lt;/code&gt; points at the &lt;strong&gt;generated&lt;/strong&gt; &lt;code&gt;config.toml&lt;/code&gt;, not the &lt;code&gt;.tmpl&lt;/code&gt;. The toolkit re-applies its setup every time it starts, so when a reboot regenerates the config without nvidia, the toolkit simply puts it back. &lt;strong&gt;That's what makes it heal itself across reboots.&lt;/strong&gt; Point it at the template and the live config sits there without a GPU runtime until someone restarts &lt;code&gt;rke2-agent&lt;/code&gt; by hand.&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;nvidia&lt;/code&gt; is the default runtime, workloads here don't need &lt;code&gt;runtimeClassName: nvidia&lt;/code&gt;. On a cluster without that setting they do, and that one line is what confuses people copying manifests between clusters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharing the cards
&lt;/h2&gt;

&lt;p&gt;Each A1000 advertises exactly one &lt;code&gt;nvidia.com/gpu&lt;/code&gt;, so by default one pod owns the whole card.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Isolation&lt;/th&gt;
&lt;th&gt;Here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Exclusive&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;one pod owns the card&lt;/td&gt;
&lt;td&gt;full&lt;/td&gt;
&lt;td&gt;✅ what runs today&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Time-slicing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;several pods take turns on the card&lt;/td&gt;
&lt;td&gt;compute only, &lt;strong&gt;memory is shared&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;available, not switched on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MIG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;hardware partitions with their own memory&lt;/td&gt;
&lt;td&gt;full&lt;/td&gt;
&lt;td&gt;❌ the A1000 has no MIG&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MPS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;processes share one GPU context&lt;/td&gt;
&lt;td&gt;partial&lt;/td&gt;
&lt;td&gt;available, not used&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The node labels confirm it: &lt;code&gt;nvidia.com/gpu.replicas=1&lt;/code&gt; and &lt;code&gt;sharing-strategy=none&lt;/code&gt; on all three.&lt;/p&gt;

&lt;p&gt;Time-slicing is a ConfigMap plus a patch to the &lt;code&gt;ClusterPolicy&lt;/code&gt;:&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;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;ConfigMap&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;time-slicing-config&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;gpu-operator&lt;/span&gt;
&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;a1000&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|-&lt;/span&gt;
    &lt;span class="s"&gt;version: v1&lt;/span&gt;
    &lt;span class="s"&gt;sharing:&lt;/span&gt;
      &lt;span class="s"&gt;timeSlicing:&lt;/span&gt;
        &lt;span class="s"&gt;resources:&lt;/span&gt;
          &lt;span class="s"&gt;- name: nvidia.com/gpu&lt;/span&gt;
            &lt;span class="s"&gt;replicas: 4        # one A1000 looks like four schedulable GPUs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl patch clusterpolicies.nvidia.com cluster-policy &lt;span class="nt"&gt;--type&lt;/span&gt; merge &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"spec":{"devicePlugin":{"config":{"name":"time-slicing-config","default":"a1000"}}}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Create the ConfigMap first, then patch.&lt;/strong&gt; I ran the patch on its own once, and the device plugin's replacement pod sat in &lt;code&gt;Init&lt;/code&gt; with &lt;code&gt;configmap "time-slicing-config" not found&lt;/code&gt;, which took that node's card from one schedulable GPU to zero until I cleared the reference again. The DaemonSet replaces one pod at a time, so the rollout stalled there and the other two cards kept working.&lt;/p&gt;

&lt;p&gt;Before you turn it on, remember it's oversubscription, not partitioning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No memory isolation.&lt;/strong&gt; Four pods share 8 GB. Four 2 GB models fit; a fourth 3 GB model dies with CUDA out-of-memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No fault isolation.&lt;/strong&gt; One pod that wedges the card affects its neighbours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sharing, not guarantees.&lt;/strong&gt; Right for notebooks and bursty inference, wrong for anything with a latency target.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My plan is to time-slice dc1 and dc2 and leave dc3 whole for jobs that need all 8 GB.&lt;/p&gt;

&lt;h2&gt;
  
  
  How reliable is it?
&lt;/h2&gt;

&lt;p&gt;Here's what the GPU tier survives, and the evidence for each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What goes wrong&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;How I know&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A whole machine goes down&lt;/td&gt;
&lt;td&gt;Its card is gone, and so is any job that was running on it; new GPU pods land on the other two&lt;/td&gt;
&lt;td&gt;One card per machine (dc1, dc2, dc3), each advertising &lt;code&gt;nvidia.com/gpu: 1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The whole lab loses power&lt;/td&gt;
&lt;td&gt;Every node rebuilds its GPU stack and re-tests its card, hands-off&lt;/td&gt;
&lt;td&gt;After the last power cut, the toolkit, device plugin, DCGM exporter and validators came back on all three nodes by themselves, and the CUDA validator &lt;strong&gt;Succeeded&lt;/strong&gt; on every card&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RKE2 regenerates containerd's config on reboot&lt;/td&gt;
&lt;td&gt;The toolkit re-applies the &lt;code&gt;nvidia&lt;/code&gt; runtime&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CONTAINERD_CONFIG&lt;/code&gt; points at the generated file, not the template&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Someone forgets the GPU toleration&lt;/td&gt;
&lt;td&gt;Kyverno adds it, plus the GPU node affinity&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gpu-pods-to-gpu-nodes&lt;/code&gt; mutation policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A notebook is abandoned with a card&lt;/td&gt;
&lt;td&gt;It should be stopped after 24 hours idle and the card freed&lt;/td&gt;
&lt;td&gt;Config only: &lt;code&gt;ENABLE_CULLING=true&lt;/code&gt;, &lt;code&gt;CULL_IDLE_TIME=1440&lt;/code&gt;. I have not seen it fire. The lab is powered off overnight, and two notebooks sat on cards until I stopped them by hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A driver or runtime problem&lt;/td&gt;
&lt;td&gt;The operator stops and reports it&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ClusterPolicy&lt;/code&gt; state &lt;code&gt;ready&lt;/code&gt;; validators gate everything above the driver&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the honest limits. "Highly available" here means GPU &lt;em&gt;capacity&lt;/em&gt;: a job running on a card dies with its machine and has to start again elsewhere. The operator itself is a single replica; if it's down, running GPU pods carry on and nothing new gets configured. A kernel upgrade on a GPU node still needs a driver rebuild (see the traps), there are no GPU alerts wired yet, and nothing caps how many cards one namespace can take. Those last three are on the list at the end.&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%2Fvqkd8p81b61rst1a2she.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%2Fvqkd8p81b61rst1a2she.png" alt="gpu" width="800" height="68"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kubectl get pods -n gpu-operator -l app=nvidia-cuda-validator&lt;/code&gt;: three validators, one per datacenter, all &lt;code&gt;Completed&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The traps, from the driver up
&lt;/h2&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%2Fomnsh78x0wy7ijgfjgyi.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%2Fomnsh78x0wy7ijgfjgyi.png" alt="gpu" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Thirteen ways a GPU hides from Kubernetes, by layer, from the driver up.&lt;/p&gt;

&lt;p&gt;Thirteen of them, grouped by layer, in the order you should debug them. Most of them bit me. A few I only know from other people's write-ups, and those say so. Every piece waits on the driver, so a broken foundation makes everything above it look broken too.&lt;/p&gt;

&lt;h3&gt;
  
  
  The driver
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The driver gets OOM-killed, exit 137.&lt;/strong&gt; The log dies right after an &lt;code&gt;nvidia-drm&lt;/code&gt; warning, then &lt;code&gt;nvidia-smi: not found&lt;/code&gt;, and it crash-loops. It looks like a driver bug. It isn't: &lt;strong&gt;the driver container compiles the kernel module&lt;/strong&gt;, running &lt;code&gt;gcc&lt;/code&gt; against the kernel headers, and a 512Mi limit kills it mid-compile.&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;driver&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="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;
      &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;4Gi&lt;/span&gt;     &lt;span class="c1"&gt;# 512Mi OOMs the kernel-module compile&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;failed to install nvidia kernel module: distro 'ubuntu24.04' not supported&lt;/code&gt;.&lt;/strong&gt; The driver image ships pre-built modules for a fixed list of distro and kernel pairs, and mine wasn't on it. Build from source instead:&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;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kernelModuleType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;auto&lt;/span&gt;      &lt;span class="c1"&gt;# open modules first, proprietary as fallback&lt;/span&gt;
  &lt;span class="na"&gt;usePrecompiled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;       &lt;span class="c1"&gt;# build from source, about 3 minutes on first boot&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;nvidia: Unknown symbol __register_chrdev&lt;/code&gt; after a host kernel upgrade.&lt;/strong&gt; The cached module was built for the old kernel. Force a rebuild:&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;-n&lt;/span&gt; gpu-operator rollout restart daemonset/nvidia-driver-daemonset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real lesson is a runbook entry, not a config change: &lt;strong&gt;a GPU node's kernel upgrade is a special operation&lt;/strong&gt;, with a driver restart on the checklist. A plain power cut doesn't need it, because the kernel hasn't changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing kernel headers.&lt;/strong&gt; The driver builds against the running kernel, so a kernel upgraded without &lt;code&gt;linux-headers-$(uname -r)&lt;/code&gt; fails the build. The Ansible role for the GPU pool installs them, and the error is plain in the driver pod log.&lt;/p&gt;

&lt;h3&gt;
  
  
  The validator
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;nvidia-validator: command not found&lt;/code&gt;, exit 127.&lt;/strong&gt; Version skew, and the error tells you nothing. Older charts shipped the validator as a separate image; the 26.x chart puts the binary inside the main operator image. Values written for one layout against a chart of the other produce a missing binary. Pin the chart (&lt;code&gt;v26.3.2&lt;/code&gt; here), deploy it from the NVIDIA repo rather than a stale vendored copy, and check what's actually running with &lt;code&gt;helm list -n gpu-operator&lt;/code&gt;, not what your values file assumes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The toolkit and runtime
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;no runtime for "nvidia" is configured&lt;/code&gt;.&lt;/strong&gt; The RKE2 trap the generic docs miss. The toolkit looks for &lt;code&gt;/run/containerd/containerd.sock&lt;/code&gt;; RKE2 runs its own containerd at &lt;code&gt;/run/k3s/containerd/containerd.sock&lt;/code&gt;. The fix is the &lt;code&gt;toolkit.env&lt;/code&gt; block above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Failed to initialize NVML: Driver/library version mismatch&lt;/code&gt;&lt;/strong&gt; inside a pod, while the host shows the same version. The toolkit had patched &lt;code&gt;config.toml&lt;/code&gt; directly and RKE2 regenerated it. CDI or the generated-config setting fixes it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;KubeArmor's AppArmor profile blocks the toolkit.&lt;/strong&gt; The same family of trap that once bit Cilium here: the toolkit can't write what it needs, containerd is silently never configured, and pods report the &lt;code&gt;nvidia&lt;/code&gt; RuntimeClass as missing. If you hit it, run the toolkit container unconfined. Today it runs fine under its own KubeArmor-generated profile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The toolkit writes the CDI spec and containerd never reads it.&lt;/strong&gt; Pods schedule, but &lt;code&gt;nvidia-smi&lt;/code&gt; hangs. CDI needs containerd 1.7 or newer, so check what your RKE2 release ships before you switch it on. I didn't hit this one: my containerd is 2.x.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing &lt;code&gt;runtimeClassName: nvidia&lt;/code&gt;&lt;/strong&gt; on a cluster where nvidia isn't the default runtime. The pod schedules, runs with no GPU injected, and looks exactly like broken hardware. Not an issue here because of &lt;code&gt;CONTAINERD_SET_AS_DEFAULT&lt;/code&gt;, which is why it's worth knowing: the requirement depends on the cluster.&lt;/p&gt;

&lt;h3&gt;
  
  
  The device plugin and scheduling
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Insufficient nvidia.com/gpu&lt;/code&gt; while three healthy GPUs sit idle.&lt;/strong&gt; The driver and toolkit were running, but the device plugin was &lt;code&gt;Pending&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0/21 nodes are available: 21 node&lt;span class="o"&gt;(&lt;/span&gt;s&lt;span class="o"&gt;)&lt;/span&gt; had untolerated taint
&lt;span class="o"&gt;{&lt;/span&gt;nvidia.com/gpu: &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;, that the pod didn&lt;span class="s1"&gt;'t tolerate.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No device-plugin pod means nothing tells the kubelet about the card. The upstream chart ships the right toleration, but a trimmed values file copied from a blog about an untainted cluster quietly drops it. Same trap one layer down: NFD's master without its control-plane toleration never labels anything, so nothing else schedules either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NFD takes a minute on first install.&lt;/strong&gt; The operator comes up before NFD's first scan, so for about a minute it looks like the operator is running and nothing is happening. It isn't broken. Don't start changing things.&lt;/p&gt;

&lt;h3&gt;
  
  
  Telemetry
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The DCGM exporter gets OOM-killed&lt;/strong&gt; right after logging &lt;code&gt;DCGM successfully initialized!&lt;/code&gt;. It loads its whole field set into memory and 256Mi isn't enough:&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;dcgmExporter&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="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Gi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;All of these fixes live in &lt;code&gt;gpu-operator-values.yaml&lt;/code&gt;,&lt;/strong&gt; .&lt;/p&gt;

&lt;h2&gt;
  
  
  Who holds a card, and how to get it back
&lt;/h2&gt;

&lt;p&gt;This is the part I'd most have wanted to read, because it's invisible until a GPU job won't schedule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;nvidia.com/gpu&lt;/code&gt; can't be oversubscribed.&lt;/strong&gt; Without time-slicing, a pod with &lt;code&gt;nvidia.com/gpu: 1&lt;/code&gt; owns that physical card for its whole life. Three cards means three GPU pods at once, full stop.&lt;/p&gt;

&lt;p&gt;That makes long-lived notebooks the quiet problem. At one point two Kubeflow notebooks were each holding a whole A1000, one of them for three and a half months, while asking for 0.6 and 1.2 CPUs. A notebook that asks for less than one CPU and holds an entire GPU is the shape of the problem. It also quietly capped a Katib sweep at one GPU trial at a time. And they cost more than cards: together they used over half of their namespace's CPU quota, enough to make a daily training pipeline time out because its last job could never be scheduled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The claim is on the pod&lt;/strong&gt;, not the Notebook, the StatefulSet or the volume. So stopping a notebook frees the card. Kubeflow's "stop" sets an annotation, the controller scales to zero, and the pod goes away:&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;-n&lt;/span&gt; &amp;lt;profile&amp;gt; annotate notebook &amp;lt;name&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  kubeflow-resource-stopped&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y-%m-%dT%H:%M:%SZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--overwrite&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I sampled the free-GPU count every ten seconds while doing it:&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="o"&gt;[&lt;/span&gt;01] sts &lt;span class="nv"&gt;replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0   &lt;span class="nv"&gt;pod&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Terminating   FREE &lt;span class="nv"&gt;GPUs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="o"&gt;[&lt;/span&gt;02] sts &lt;span class="nv"&gt;replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0   &lt;span class="nv"&gt;pod&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gone          FREE &lt;span class="nv"&gt;GPUs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the first line. The notebook was "stopped" by every API you'd think to check, and the card was &lt;strong&gt;still claimed&lt;/strong&gt;, because the pod hadn't finished terminating. &lt;code&gt;replicas=0&lt;/code&gt; isn't proof a card came back. On a cluster where KubeArmor can wedge pods in &lt;code&gt;Terminating&lt;/code&gt;, that matters. The reliable check counts from the pods and cross-checks the scheduler:&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;# what pods claim&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="s1"&gt;'[.items[] | select(.status.phase=="Running" or .status.phase=="Pending")
  | .spec.containers[].resources.limits["nvidia.com/gpu"] // "0" | tonumber] | add'&lt;/span&gt;

&lt;span class="c"&gt;# what the scheduler has allocated on a node&lt;/span&gt;
kubectl describe node &amp;lt;gpu-node&amp;gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'/Allocated resources/,/Events/'&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;nvidia.com/gpu
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As I write this, both GPU notebooks are stopped and all three cards are free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Culling is on as a safety net.&lt;/strong&gt; The notebook controller stops notebooks that have been idle for 24 hours. It stops, it never deletes: the volume and every file survive, and restarting is one click. What's lost is in-memory kernel state. A running cell counts as busy, so a long training run won't be culled. And the setting is global and not GPU-aware, so it catches abandoned notebooks; it doesn't manage contention. The better lever is not giving notebooks a GPU by default.&lt;/p&gt;

&lt;p&gt;⚠️ One more surprise: &lt;code&gt;nvidia.com/gpu&lt;/code&gt; isn't in the profile namespaces' ResourceQuota, and a quota only limits what it names. So GPU use per namespace is unbounded even though CPU and memory are capped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting the GPUs to work
&lt;/h2&gt;

&lt;p&gt;Making &lt;code&gt;nvidia.com/gpu&lt;/code&gt; schedulable was the prerequisite, not the goal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Who on the platform can ask for a card
&lt;/h3&gt;

&lt;p&gt;Nothing on this platform &lt;em&gt;needs&lt;/em&gt; a GPU to run. Every Kubeflow component is a CPU service; what changes is the work users launch through them. Here's each door to a card, and whether anything has walked through it yet:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform piece&lt;/th&gt;
&lt;th&gt;How it asks for a card&lt;/th&gt;
&lt;th&gt;Has it used one here?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kubeflow Notebooks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;a GPU dropdown in the spawner form&lt;/td&gt;
&lt;td&gt;Yes, and too well: two notebooks held cards while idle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Katib&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;nvidia.com/gpu&lt;/code&gt; in the trial template&lt;/td&gt;
&lt;td&gt;No. Both sweeps ran on CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kubeflow Trainer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;resourcesPerNode&lt;/code&gt; on a &lt;code&gt;TrainJob&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No. The runtimes are installed (&lt;code&gt;torch-distributed&lt;/code&gt;, three torchtune LLM fine-tunes); no job has run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;KServe&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;a GPU limit on the predictor&lt;/td&gt;
&lt;td&gt;No. The one live model is XGBoost on CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kubeflow Pipelines&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;a GPU limit on a single step&lt;/td&gt;
&lt;td&gt;No. The daily pipeline is all CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Spark&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;executor GPU settings plus the RAPIDS plugin&lt;/td&gt;
&lt;td&gt;No, and not planned: these jobs are small&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A plain pod&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;one line, &lt;code&gt;nvidia.com/gpu: 1&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes: the LLM benchmark below&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything else in the data platform (Kafka, Airflow, MLflow, the databases) never touches a card and shouldn't.&lt;/p&gt;

&lt;p&gt;So the honest summary is two real users so far: notebooks, which took cards and did nothing with them, and one benchmark pod, which took a card and drove it at 98%.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hyperparameter search with Katib
&lt;/h3&gt;

&lt;p&gt;Katib runs an &lt;code&gt;Experiment&lt;/code&gt; that launches trials as pods, each reporting a metric back through an injected sidecar. Getting it to run at all taught me more than the GPUs did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It couldn't run in any Kubeflow profile namespace.&lt;/strong&gt; An Experiment sat at &lt;code&gt;Created&lt;/code&gt; with &lt;strong&gt;zero&lt;/strong&gt; trials, and the UI only said "Couldn't find any successful Trial." The real error was two levels down, on a ReplicaSet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pods &lt;span class="s2"&gt;"&amp;lt;exp&amp;gt;-tpe-..."&lt;/span&gt; is forbidden:
  minimum memory usage per Container is 16Mi, but request is 10Mi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Katib asks for 10Mi of memory for its suggestion pods, and the Kyverno-generated LimitRange in every profile namespace has a 16Mi floor. Eleven of twelve algorithms had no explicit resources, so eleven of twelve couldn't schedule. Fixing those exposed the same problem on the trial pods, because the metrics sidecar is injected by a webhook and its default never shows up in the template. The fix is to raise Katib to meet the floor, not lower the floor:&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;runtime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;suggestions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;algorithmName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tpe&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/kubeflow/katib/suggestion-hyperopt:v0.19.0&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;50m&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;128Mi&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;500m&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512Mi&lt;/span&gt;
  &lt;span class="na"&gt;metricsCollectors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&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;StdOut&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/kubeflow/katib/file-metrics-collector:v0.19.0&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;50m&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;32Mi&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;200m&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;128Mi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three more gotchas from getting a sweep to finish:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;katib-config&lt;/code&gt; is mounted with &lt;code&gt;subPath&lt;/code&gt;&lt;/strong&gt;, which never receives updates. Restart the Katib controller after every config change; waiting does nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never put a multi-line &lt;code&gt;python3 -c&lt;/code&gt; script in a trial template.&lt;/strong&gt; The stdout collector rewrites the command into a shell string, and the inline script doesn't survive. Pass a file path and flags instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A sub-second trial can finish before the collector attaches&lt;/strong&gt;, which shows up as random failures with an empty observation. Pace the objective and set &lt;code&gt;backoffLimit: 1&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With all of that fixed, a 12-trial TPE sweep finished 12 out of 12 and found the known optimum, on CPU, because for a demonstration sweep a GPU changes nothing (more on that below). And with no time-slicing, the real ceiling for GPU trials is three at a time, one per card. A &lt;code&gt;parallelTrialCount&lt;/code&gt; higher than that just leaves trials &lt;code&gt;Pending&lt;/code&gt;.&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%2Fo425rfa6zarmpygtslmi.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%2Fo425rfa6zarmpygtslmi.png" alt="gpu" width="799" height="401"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Katib trial leaderboard with the best trial highlighted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Training and serving
&lt;/h3&gt;

&lt;p&gt;A GPU training job in Kubeflow Trainer V2 looks like this. I haven't run this one yet, so treat it as the shape, not a tested manifest. With one card per machine, two workers have to land on two different machines:&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;trainer.kubeflow.org/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;TrainJob&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;resnet-finetune&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;terranetes&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;runtimeRef&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;torch-distributed&lt;/span&gt;
  &lt;span class="na"&gt;trainer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;numNodes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
    &lt;span class="na"&gt;resourcesPerNode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;nvidia.com/gpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For serving, a KServe &lt;code&gt;InferenceService&lt;/code&gt; with &lt;code&gt;minReplicas: 0&lt;/code&gt; gives the card back when the model is idle and cold-starts it on demand. That works &lt;em&gt;because&lt;/em&gt; the GPU claim is per pod: scale to zero, the pod goes, the card is free. With three cards, scale-to-zero is the difference between one always-on model and a fleet of models sharing three GPUs.&lt;/p&gt;

&lt;p&gt;My live models today don't need a GPU at all. The football match predictor is an XGBoost model on tabular data, and it trains and serves on CPU in seconds. The first real GPU TrainJob and a properly served LLM are both on the list at the end.&lt;/p&gt;

&lt;h3&gt;
  
  
  The first real job: a 7B model on one card
&lt;/h3&gt;

&lt;p&gt;For three months these cards passed their self-test and did nothing else. Prometheus has the receipts: 0% utilisation and 0 MiB of GPU memory on all three, the whole time. So before writing another word about what an A1000 can do, I made one do it.&lt;/p&gt;

&lt;p&gt;The job is deliberately plain. One pod, one card, Ollama running Mistral 7B in 4-bit (&lt;code&gt;Q4_K_M&lt;/code&gt;, a 4.1 GB download), the same prompt every time with a fixed seed. The whole request for hardware is one line, &lt;code&gt;nvidia.com/gpu: 1&lt;/code&gt;. Kyverno added the toleration and the node rule, the scheduler put it on dc2, and &lt;code&gt;nvidia-smi&lt;/code&gt; inside the container saw the card.&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="s"&gt;NAME          SIZE      PROCESSOR    CONTEXT&lt;/span&gt;
&lt;span class="s"&gt;mistral:7b    5.8 GB    100% GPU     &lt;/span&gt;&lt;span class="m"&gt;4096&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the same model on the same node with the GPU switched off, four threads against the pod's four-core limit:&lt;/p&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;Tokens per second&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RTX A1000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;about 30&lt;/strong&gt; (four runs: 29.9 to 31.6)&lt;/td&gt;
&lt;td&gt;5,031 MiB of the 8,188 MiB used, 98-99% utilisation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CPU, 4 threads (i9-13900H)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;about 4.2&lt;/strong&gt; (two runs: 4.1 and 4.3)&lt;/td&gt;
&lt;td&gt;same model, same prompt, same seed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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%2Fszgkfqxtdxg1irr5je6i.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%2Fszgkfqxtdxg1irr5je6i.png" alt="gpu" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Mistral 7B on one RTX A1000 against the CPU beside it: about 30 tokens a second against about 4.2.&lt;/p&gt;

&lt;p&gt;Seven times faster, on a 50-watt card that fits in a mini PC. A 400-token answer took 13 seconds on the GPU. At the CPU's rate it would take over a minute and a half. That is the difference between a tool you'd use and one you wouldn't.&lt;/p&gt;

&lt;p&gt;Three things the run taught me that the spec sheet didn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It runs hot.&lt;/strong&gt; The card idles at about 60 °C in the MS-01 and sat at 88 to 90 °C within a minute of load, a few degrees over its own 86 °C target, with the driver reporting a software thermal slowdown. Speed drifted from 31.6 to 29.9 tokens a second across the runs. It never reached the 96 °C hard slowdown, but a small case and a single-slot cooler have a ceiling, and sustained training will live at it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It can't tell you its power draw.&lt;/strong&gt; &lt;code&gt;nvidia-smi&lt;/code&gt; reports &lt;code&gt;power.draw&lt;/code&gt; as &lt;code&gt;[N/A]&lt;/code&gt; on this card under passthrough. So the missing power metric isn't an exporter setting I forgot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Threads matter more than cores.&lt;/strong&gt; My first CPU run was left to pick its own thread count. It saw 20 cores, started 20 threads inside a four-core limit, and was still going ten minutes later. Pinned to four threads it produced 60 tokens in about fifteen seconds. If you benchmark CPU inside a container, set the thread count to the limit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The manifest and the exact commands are in &lt;code&gt;examples/llm-benchmark-pod.yaml&lt;/code&gt;. It's a benchmark, not a service: the pod holds a whole card while it exists, so I deleted it when the numbers were in, which is this article's own advice.&lt;/p&gt;

&lt;p&gt;📸 &lt;strong&gt;SCREENSHOT:&lt;/strong&gt; the Grafana DCGM dashboard for dc2 across the run: utilisation jumping from 0 to 98%, memory to 5 GiB, temperature from 60 to the high 80s. The first non-zero lines those panels have ever drawn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-GPU, and does a GPU change the result?
&lt;/h2&gt;

&lt;p&gt;With more than one card, the internet will tell you to reach for tensor parallelism and model sharding. On three 8 GB cards over PCIe with no NVLink, most of that advice is wrong.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;Splits&lt;/th&gt;
&lt;th&gt;Use when&lt;/th&gt;
&lt;th&gt;On these cards&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Data parallel (DDP)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the batch, full model per GPU&lt;/td&gt;
&lt;td&gt;the model fits in 8 GB&lt;/td&gt;
&lt;td&gt;✅ the default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sharding (FSDP, ZeRO)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;parameters and optimiser state&lt;/td&gt;
&lt;td&gt;the model won't fit one GPU&lt;/td&gt;
&lt;td&gt;⚠️ prefer offloading to CPU RAM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tensor parallel&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;individual layers&lt;/td&gt;
&lt;td&gt;needs NVLink&lt;/td&gt;
&lt;td&gt;❌ slower over PCIe than not doing it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pipeline parallel&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;layer stages&lt;/td&gt;
&lt;td&gt;very deep models, many GPUs&lt;/td&gt;
&lt;td&gt;⚠️ rarely worth it here&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use single-GPU tricks first (bf16, gradient checkpointing, smaller batches), then DDP for throughput, and only then sharding. For inference, don't shard: quantise. A 7B model in int4 fits on one card, and splitting it across two PCIe cards is strictly worse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does a GPU change the result, or just the speed?&lt;/strong&gt; It changes how fast you get a result, not what the result is. With a fixed number of trials, a GPU gets you the same answer sooner. With a fixed amount of time, faster trials mean more trials, and more trials is how a search finds a better optimum. That gain comes from trial count, not from the GPU. And it can make results slightly worse: GPU reductions aren't always deterministic, so training often isn't bit-for-bit reproducible.&lt;/p&gt;

&lt;p&gt;So putting a CPU-bound job on a GPU is theatre, and it costs a card something else needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring
&lt;/h2&gt;

&lt;p&gt;The DCGM exporter publishes per-GPU metrics, and a ServiceMonitor puts them in Prometheus, with NVIDIA's dashboard in Grafana. The ones worth watching:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;DCGM_FI_DEV_GPU_UTIL&lt;/code&gt;: utilisation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DCGM_FI_DEV_FB_USED&lt;/code&gt; and &lt;code&gt;DCGM_FI_DEV_FB_FREE&lt;/code&gt;: memory used and free&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DCGM_FI_DEV_GPU_TEMP&lt;/code&gt;: temperature&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DCGM_FI_DEV_PCIE_REPLAY_COUNTER&lt;/code&gt;: link errors on the PCIe slot&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two I want and don't have. &lt;code&gt;DCGM_FI_DEV_XID_ERRORS&lt;/code&gt; isn't in my exporter's field list yet. &lt;code&gt;DCGM_FI_DEV_POWER_USAGE&lt;/code&gt; isn't either, and adding it won't help: this card doesn't report power draw under passthrough at all.&lt;/p&gt;

&lt;p&gt;The most useful panel is utilisation by pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum(DCGM_FI_DEV_GPU_UTIL{Hostname="rke2-worker-dc2-gpu-a"}) by (exported_namespace, exported_pod)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mind the label. On my setup &lt;code&gt;pod&lt;/code&gt; is the exporter's own pod, and the workload is in &lt;code&gt;exported_pod&lt;/code&gt;. Group by &lt;code&gt;pod&lt;/code&gt; and every card looks like it's being used by the DCGM exporter.&lt;/p&gt;

&lt;p&gt;What's missing is any alerting. The metrics are there, but nothing fires on a hot card, full memory or an XID error yet. And the metric I want most isn't on any dashboard: &lt;strong&gt;allocated versus used&lt;/strong&gt;. A card that's 100% allocated and 0% used is the notebook problem, and utilisation alone makes it look exactly like a free card.&lt;/p&gt;

&lt;p&gt;NVIDIA has since open-sourced something aimed at exactly that gap: the &lt;a href="https://github.com/NVIDIA/gpu-usage-monitor" rel="noopener noreferrer"&gt;GPU Usage Monitor&lt;/a&gt;, an Apache 2.0 Helm chart that pairs the DCGM exporter with kube-state-metrics, Prometheus and Grafana, and ships dashboards for allocated against used, by namespace and by pod, plus GPU pods stuck &lt;code&gt;Pending&lt;/code&gt;. I haven't installed it. I already run every one of those four pieces, so what I'd want from it is the dashboards, not a second Prometheus. And its platform filter is named for datacenter cards (Hopper, Blackwell), so I don't yet know how it treats an A1000. It's on the list.&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%2F30fnxeozek2qd55xadqh.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%2F30fnxeozek2qd55xadqh.png" alt="gpu" width="799" height="419"&gt;&lt;/a&gt;&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%2Fz5rzbob8ii0eonibdid6.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%2Fz5rzbob8ii0eonibdid6.png" alt="gpu" width="799" height="419"&gt;&lt;/a&gt;&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%2F5g4s7i62kx4pz286jvtl.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%2F5g4s7i62kx4pz286jvtl.png" alt="gpu" width="799" height="419"&gt;&lt;/a&gt;&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%2Fgp4medpb3ygd6qn50hq2.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%2Fgp4medpb3ygd6qn50hq2.png" alt="gpu" width="799" height="419"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Grafana DCGM dashboard showing all three A1000s.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Three used RTX A1000s for about £600, in slots the MS-01s already had. No extra power. The card is rated at 50 W; I can't give you a measured figure, because it doesn't report one. Cooling is the honest cost: nothing extra was fitted, and under load the card sits at its thermal limit.&lt;/p&gt;

&lt;p&gt;The operational cost is mostly kernel upgrades on GPU nodes, which need the driver restart. Adding a GPU node is now Terraform the passthrough, label and taint it, and let the operator do the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;The sports car runs, survives a power cut and comes back on its own. What's left, roughly in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Try NVIDIA's GPU Usage Monitor dashboards&lt;/strong&gt; against the Prometheus I already have, to get allocated versus used on a screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export XID errors&lt;/strong&gt;, then &lt;strong&gt;wire up GPU alerts:&lt;/strong&gt; temperature (now that I know it reaches 90 °C), memory nearly full, XID errors, and a card allocated but unused for hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn on time-slicing&lt;/strong&gt; on dc1 and dc2, and keep dc3 whole.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put &lt;code&gt;nvidia.com/gpu&lt;/code&gt; in the profile ResourceQuota&lt;/strong&gt;, so one namespace can't take every card.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run a real GPU TrainJob&lt;/strong&gt; across two machines, and &lt;strong&gt;serve that LLM properly&lt;/strong&gt; behind KServe with scale-to-zero, not from a benchmark pod.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automate the driver restart&lt;/strong&gt; after a GPU node's kernel upgrade, so it stops being a checklist item.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've got a GPU sitting in a box that Kubernetes can't see, I hope this saves you the thirteen traps on this list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.linkedin.com/in/george-ezejiofor-89615a8a/" rel="noopener noreferrer"&gt;Follow me on LinkedIn&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Author: George Ezejiofor&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>nvidia</category>
      <category>mlops</category>
      <category>homelab</category>
    </item>
    <item>
      <title>HA HashiCorp Vault: Single Source of Secrets on RKE2 Homelab (The secured Safe)</title>
      <dc:creator>George Ezejiofor</dc:creator>
      <pubDate>Sat, 10 Oct 2026 18:22:09 +0000</pubDate>
      <link>https://dev.to/ezejioforog/ha-hashicorp-vault-single-source-of-secrets-on-rke2-homelab-the-secured-safe-4k5i</link>
      <guid>https://dev.to/ezejioforog/ha-hashicorp-vault-single-source-of-secrets-on-rke2-homelab-the-secured-safe-4k5i</guid>
      <description>&lt;p&gt;&lt;em&gt;Episode 6 of the homelab build series. The storage floor is down, the filing cabinets are full, and every one of them has a key. Those keys need somewhere secure to live, and this episode is that secured safe: a highly available, three-node HashiCorp Vault cluster, built and configured with Terraform, delivering secrets to pods through the Vault Secrets Operator with VaultAuth and VaultStaticSecret, and VaultDynamicSecret ready for short-lived credentials.&lt;/em&gt;&lt;/p&gt;

&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%2Fvtgmodj6jxoh6enyjh7g.gif" 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%2Fvtgmodj6jxoh6enyjh7g.gif" alt="vault" width="460" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Vault UI at &lt;code&gt;vault.georgehomelab.com&lt;/code&gt;, signed in through Keycloak, on the Raft status page: one active node, two standbys, &lt;code&gt;Seal Type: transit&lt;/code&gt;. &lt;br&gt;
SSO Login with keycloak and Oauth2-proxy using google and Github social login.&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%2Futprccqwg6kxlw4zvy1z.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%2Futprccqwg6kxlw4zvy1z.png" alt="vault" width="800" height="465"&gt;&lt;/a&gt;&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%2Fbco1zi20rksg39yunm3t.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%2Fbco1zi20rksg39yunm3t.png" alt="vault" width="800" height="465"&gt;&lt;/a&gt;&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%2Fgz6ghjnzbsvnmib3pd1w.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%2Fgz6ghjnzbsvnmib3pd1w.png" alt="vault" width="800" height="465"&gt;&lt;/a&gt;&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%2Fvqzpbhhus5qdodxoe6d0.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%2Fvqzpbhhus5qdodxoe6d0.png" alt="vault" width="800" height="465"&gt;&lt;/a&gt;&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%2Fmb6d7580qx34jc0we707.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%2Fmb6d7580qx34jc0we707.png" alt="vault" width="800" height="455"&gt;&lt;/a&gt;&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%2Fzd370kp6qg5myfx5nu3b.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%2Fzd370kp6qg5myfx5nu3b.png" alt="vault" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;


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

&lt;p&gt;&lt;em&gt;The cluster can survive a node failure. Could my secrets?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Everything in this homelab is built to survive. Five control-plane nodes. Three datacenters. Ceph keeping three copies of every byte. Postgres clusters that fail over on their own. Lose a node, a disk, even a whole datacenter, and the platform keeps running.&lt;/p&gt;

&lt;p&gt;But every one of those systems is holding a key. Tekton Chains holds the private key that signs every image I build. Kyverno checks every image against its public half. Harbor hands out robot credentials. Every database has a password. So underneath the whole series sat a question I kept stepping around: where do the keys to the kingdom actually live, and what happens when that place goes down?&lt;/p&gt;

&lt;p&gt;For a while, the honest answer was gitignored files and &lt;code&gt;kubectl create secret&lt;/code&gt; run from my laptop. A pile of credentials wearing a security costume.&lt;/p&gt;

&lt;p&gt;So I built a safe. A highly available HashiCorp Vault: three nodes on Raft, one active and two standbys, so any one of them can die and nobody notices. Terraform writes every secret. The Vault Secrets Operator delivers them to pods. No passwords in git, ever. I was proud of it.&lt;/p&gt;

&lt;p&gt;Then I found out it had been dead for two months.&lt;/p&gt;

&lt;p&gt;One token, the one the main Vault uses to fetch its unseal key from a second, tiny Vault, had quietly run out. A comment in my own playbook promised it renewed itself. It didn't. The main Vault couldn't unlock itself, and every pod went round in circles. Nothing visibly broke, because the secrets already handed out kept working. That's the scary part: a dead safe can look exactly like a healthy one.&lt;/p&gt;

&lt;p&gt;So this episode isn't about installing Vault and declaring victory. It's about making the safe as reliable as everything it protects. Now, when the whole lab loses power, it opens itself in about two minutes, all three nodes healthy, with no human involved. If it ever stays shut, an alert tells me.&lt;/p&gt;

&lt;p&gt;Because a secure platform isn't one where secrets are merely hidden. It's one where every app gets exactly the secrets it needs, without ever having to own them.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why a safe at all
&lt;/h2&gt;

&lt;p&gt;There are two kinds of homelab: the ones that have leaked a secret into a git repo, and the ones that are about to. I've been both.&lt;/p&gt;

&lt;p&gt;Every credential starts life somewhere careless. A password in a &lt;code&gt;values.yaml&lt;/code&gt;. An API key in a &lt;code&gt;.env&lt;/code&gt; that's "definitely gitignored". A &lt;code&gt;kubectl create secret&lt;/code&gt; run from a laptop at 1 a.m. that nobody can trace any more. It all works right up until you need to rotate something, rebuild the cluster, or work out how many copies of a password exist.&lt;/p&gt;

&lt;p&gt;What I wanted fits in one sentence: &lt;strong&gt;every credential lives in one place, every workload fetches it the same way, and rotating one is a &lt;code&gt;terraform apply&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's what that replaced:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Secrets in git&lt;/strong&gt;, even encrypted ones, which always end up in someone's terminal history or a screenshot eventually.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Passwords in Helm values&lt;/strong&gt;, which end up in the rendered manifests and the release history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;kubectl create secret&lt;/code&gt; from a laptop&lt;/strong&gt;, which is fine until anyone else has to rebuild what you did.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Vault, or something lighter?
&lt;/h2&gt;

&lt;p&gt;"Just use SOPS" is a fair question, so here's my fair answer. These tools don't all solve the same problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Encrypt and commit&lt;/strong&gt; (Sealed Secrets, SOPS): the secret lives in git, encrypted. They answer "how do I put a secret in git safely?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sync from somewhere else&lt;/strong&gt; (External Secrets Operator, Vault Secrets Operator): delivery trucks, not warehouses. They copy secrets from a store into Kubernetes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A secrets engine&lt;/strong&gt; (Vault, cloud secret managers): something that can store, issue, rotate, revoke and audit credentials, and do encryption as a service.&lt;/li&gt;
&lt;/ul&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;Secret lives in&lt;/th&gt;
&lt;th&gt;Short-lived creds&lt;/th&gt;
&lt;th&gt;Rotation&lt;/th&gt;
&lt;th&gt;Audit log&lt;/th&gt;
&lt;th&gt;Encryption as a service&lt;/th&gt;
&lt;th&gt;Runtime dependency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vault&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Vault&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;terraform apply&lt;/code&gt;, then re-sync&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Must be up and unsealed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sealed Secrets&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;git, encrypted&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;re-encrypt and commit&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;its controller&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SOPS + age&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;git, encrypted&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;re-encrypt and commit&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;External Secrets&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;whatever backend&lt;/td&gt;
&lt;td&gt;backend's&lt;/td&gt;
&lt;td&gt;backend's&lt;/td&gt;
&lt;td&gt;backend's&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;backend plus operator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cloud secret manager&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the cloud&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;provider API&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;the cloud&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you've got a handful of static secrets and one GitOps loop, use Sealed Secrets or SOPS and stop there. If you live in one cloud, use its secret manager. I picked Vault for two reasons the git-based tools can't touch. Its transit engine is what lets one Vault unseal another, and the same Vault answers to pods, to Terraform and to me, with every read logged.&lt;/p&gt;

&lt;p&gt;The cost is the one the rest of this article keeps coming back to. &lt;strong&gt;Vault is a live, stateful dependency.&lt;/strong&gt; If it's sealed or down, nothing new gets a secret. SOPS can't fail that way, and I learned exactly how much that matters.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two Vaults: the safe and the key-holder
&lt;/h2&gt;

&lt;p&gt;Vault is sealed at rest. When a Vault pod starts, it can read its data from disk but can't decrypt any of it until something supplies the root key. In a cloud you'd hand that job to the cloud's key service. I don't have one, so I built a small 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%2F7eefrpxtxac88crhek10.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%2F7eefrpxtxac88crhek10.png" alt="vault" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The two-Vault architecture: a three-pod main Vault that cannot unseal itself, and the one-pod transit Vault that holds the key. Links to the interactive diagram.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Click the image for the interactive version. Direct link: &lt;a href="https://george-hashicorp-vault.terranetes.workers.dev/" rel="noopener noreferrer"&gt;INTERACTIVE-DIAGRAM-URL&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The main Vault&lt;/strong&gt; runs three pods in the &lt;code&gt;vault&lt;/code&gt; namespace (Vault 1.21.2, integrated Raft storage). Its config has a &lt;code&gt;seal "transit"&lt;/code&gt; block. On start it sends its encrypted root key to the transit Vault, gets it back decrypted, and unseals itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The transit Vault&lt;/strong&gt; is one pod in &lt;code&gt;vault-transit&lt;/code&gt;. It holds a single transit key and nothing else, and it's sealed the old-fashioned way, with one Shamir share.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The transit Vault is the single point of failure for the whole secret tier, and I chose that on purpose. The alternative is Shamir on the main Vault, which means unsealing three pods by hand after every restart. One small Vault is easier to look after, as long as something actually looks after it. That turned out to be the hard part.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kubectl get pods -n vault&lt;/code&gt; next to &lt;code&gt;kubectl get pods -n vault-transit&lt;/code&gt;: three main pods, the transit pod, and its unsealer.&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%2Fc6cjhz7qnjpb3lnfvijp.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%2Fc6cjhz7qnjpb3lnfvijp.png" alt="vault" width="800" height="465"&gt;&lt;/a&gt;&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%2Fzpw14qzvl3m802515xtd.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%2Fzpw14qzvl3m802515xtd.png" alt="vault" width="800" height="465"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The main Vault
&lt;/h2&gt;

&lt;p&gt;Each of the three main pods has a 10Gi Raft data volume and a 5Gi audit volume, both on &lt;code&gt;ceph-block&lt;/code&gt;. One pod is active and the other two are standbys that forward writes to it. If the active pod dies, Raft elects a new leader from the other two.&lt;/p&gt;

&lt;p&gt;Today they're spread one per datacenter: &lt;code&gt;dc1-app-d&lt;/code&gt;, &lt;code&gt;dc2-app-c&lt;/code&gt; and &lt;code&gt;dc3-app-d&lt;/code&gt;. Mind you, that's luck, not policy, and I'll come back to it at the end.&lt;/p&gt;

&lt;p&gt;That's what HA buys you here. Kill the active pod and one of the standbys takes over the leadership, while the Service keeps sending requests to whichever pods are up. Reboot a node and its Vault pod comes back, unseals itself against transit, and rejoins the cluster from Raft. At no point does a human type a key.&lt;/p&gt;

&lt;p&gt;The UI is at &lt;code&gt;vault.georgehomelab.com&lt;/code&gt; behind Istio, and you log in through Keycloak. Yes, Vault is gated by Keycloak while Keycloak's database password lives in Vault. The first deploy breaks that circle with the root token, and only after Keycloak is up does the OIDC login get configured.&lt;/p&gt;

&lt;p&gt;You can see the login page above. The Vault UI on the Raft page: one active node, two standbys, &lt;code&gt;Seal Type: transit&lt;/code&gt;, &lt;code&gt;Recovery Seal Type: shamir&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  AUTOMATING HASHICORP VAULT CONFIGURATION WITH TERRAFORM
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Vault Config — prod Environment&lt;/code&gt;&lt;br&gt;
Calls the vault-config module against the homelab Vault. One module call, one terraform apply per environment.&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="s"&gt;terraform/vault-config/&lt;/span&gt;
&lt;span class="s"&gt;├── modules/&lt;/span&gt;
&lt;span class="s"&gt;│   └── vault-config/&lt;/span&gt;         &lt;span class="c1"&gt;# all resources (KV, PKI, policies, k8s auth, fleet, LLM)&lt;/span&gt;
&lt;span class="s"&gt;│       ├── main.tf&lt;/span&gt;           &lt;span class="c1"&gt;# core: audit, kv mount, PKI, policies, k8s auth backend&lt;/span&gt;
&lt;span class="s"&gt;│       ├── ai-agents-secrets.tf&lt;/span&gt;
&lt;span class="s"&gt;│       ├── llm-secrets.tf&lt;/span&gt;
&lt;span class="s"&gt;│       ├── gitops-secrets.tf&lt;/span&gt;
&lt;span class="s"&gt;│       ├── keycloak-secrets.tf&lt;/span&gt;
&lt;span class="s"&gt;│       ├── database-secrets.tf&lt;/span&gt;
&lt;span class="s"&gt;│       ├── monitoring-secrets.tf&lt;/span&gt;
&lt;span class="s"&gt;│       ├── storage-secrets.tf&lt;/span&gt;
&lt;span class="s"&gt;│       ├── git-secrets.tf&lt;/span&gt;
&lt;span class="s"&gt;│       ├── dns-secrets.tf&lt;/span&gt;
&lt;span class="s"&gt;│       ├── vault-internal-secrets.tf&lt;/span&gt;
&lt;span class="s"&gt;│       ├── variables.tf&lt;/span&gt;
&lt;span class="s"&gt;│       ├── outputs.tf&lt;/span&gt;
&lt;span class="s"&gt;│       └── versions.tf&lt;/span&gt;
&lt;span class="s"&gt;└── environment/&lt;/span&gt;
    &lt;span class="s"&gt;└── prod/                 ← you are here&lt;/span&gt;
        &lt;span class="s"&gt;├── main.tf&lt;/span&gt;           &lt;span class="c1"&gt;# module "vault_config" { source = "../../modules/vault-config" ... }&lt;/span&gt;
        &lt;span class="s"&gt;├── providers.tf&lt;/span&gt;      &lt;span class="c1"&gt;# provider "vault" { … }  (JWT auth via Keycloak)&lt;/span&gt;
        &lt;span class="s"&gt;├── variables.tf&lt;/span&gt;      &lt;span class="c1"&gt;# provider-level + module-passthrough variables&lt;/span&gt;
        &lt;span class="s"&gt;├── variables.secrets.tf&lt;/span&gt;
        &lt;span class="s"&gt;├── locals.secrets.tf&lt;/span&gt;
        &lt;span class="s"&gt;├── outputs.tf&lt;/span&gt;        &lt;span class="c1"&gt;# re-exports module outputs&lt;/span&gt;
        &lt;span class="s"&gt;├── moved.tf&lt;/span&gt;          &lt;span class="c1"&gt;# 62 state migrations (delete after first apply)&lt;/span&gt;
        &lt;span class="s"&gt;├── imports.tf&lt;/span&gt;        &lt;span class="c1"&gt;# HCL imports for Vault-auto-created mounts&lt;/span&gt;
        &lt;span class="s"&gt;├── terraform.auto.tfvars&lt;/span&gt;
        &lt;span class="s"&gt;├── .secrets.auto.tfvars.example&lt;/span&gt;
        &lt;span class="s"&gt;├── scripts/refresh-jwt.sh&lt;/span&gt;
        &lt;span class="s"&gt;├── vault-config-terraform-deploy.sh&lt;/span&gt;
        &lt;span class="s"&gt;├── terraform.tfstate&lt;/span&gt;
        &lt;span class="s"&gt;└── README.md&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Provider auth
&lt;/h2&gt;

&lt;p&gt;JWT via Keycloak. &lt;code&gt;scripts/refresh-jwt.sh&lt;/code&gt; fetches a fresh access token&lt;br&gt;
from realm &lt;code&gt;vault&lt;/code&gt;, client &lt;code&gt;terraform-cicd&lt;/code&gt;, writes&lt;br&gt;
&lt;code&gt;.secrets.auto.tfvars&lt;/code&gt;. The provider's &lt;code&gt;auth_login&lt;/code&gt; block exchanges that&lt;br&gt;
JWT for a Vault token bound to policy &lt;code&gt;terraform-cicd&lt;/code&gt; (TTL 1h, max 4h).&lt;br&gt;
Every action attributable in the audit log to&lt;br&gt;
&lt;code&gt;service-account-terraform-cicd&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why modules + environment
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Re-use&lt;/strong&gt;: drop a sister &lt;code&gt;environment/staging/&lt;/code&gt; to point the same module
at a different Vault. The module is the source of truth; environment are
just thin callers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-environment overrides&lt;/strong&gt;: each env has its own &lt;code&gt;terraform.auto.tfvars&lt;/code&gt;,
&lt;code&gt;.secrets.auto.tfvars&lt;/code&gt;, &lt;code&gt;terraform.tfstate&lt;/code&gt;. No cross-talk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maintenance&lt;/strong&gt;: &lt;code&gt;terraform plan&lt;/code&gt; shows you exactly what &lt;em&gt;this&lt;/em&gt; env diverges
from. The module change is one commit; rolling it across envs is one
&lt;code&gt;terraform apply&lt;/code&gt; per env.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Run flow
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;cd terraform/vault-config/environment/prod&lt;/span&gt;

&lt;span class="c1"&gt;# First time only&lt;/span&gt;
&lt;span class="s"&gt;terraform init&lt;/span&gt;

&lt;span class="c1"&gt;# Every apply&lt;/span&gt;
&lt;span class="s"&gt;./scripts/refresh-jwt.sh&lt;/span&gt;        &lt;span class="c1"&gt;# writes .secrets.auto.tfvars (mode 0600)&lt;/span&gt;
&lt;span class="s"&gt;terraform plan&lt;/span&gt;
&lt;span class="s"&gt;terraform apply&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Or the one-shot wrapper that does init → validate → refresh-jwt → plan → apply:&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="s"&gt;./vault-config-terraform-deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Getting secrets to pods: the Vault Secrets Operator
&lt;/h2&gt;

&lt;p&gt;The main Vault is the safe. &lt;strong&gt;The Vault Secrets Operator (VSO 1.3.0) is the person who fetches things out of it and delivers them to the right room.&lt;/strong&gt; It renders ordinary Kubernetes Secrets, so an unmodified Helm chart reads them with &lt;code&gt;envFrom&lt;/code&gt; and never learns Vault exists.&lt;/p&gt;

&lt;p&gt;VSO works with three resources. &lt;code&gt;VaultAuth&lt;/code&gt; says how a workload proves who it is. &lt;code&gt;VaultStaticSecret&lt;/code&gt; copies a KV secret into a Kubernetes Secret and keeps it fresh. &lt;code&gt;VaultDynamicSecret&lt;/code&gt; asks Vault to mint a short-lived credential on demand; it's ready to use, but nothing here uses it yet, and that's on the list at the end.&lt;/p&gt;

&lt;p&gt;Here's the real MLflow example, end to end. First, how MLflow's service account proves who it is:&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;secrets.hashicorp.com/v1beta1&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;VaultAuth&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;mlops-mlflow&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;mlops&lt;/span&gt; &lt;span class="pi"&gt;}&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;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes&lt;/span&gt;
  &lt;span class="na"&gt;mount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes&lt;/span&gt;
  &lt;span class="na"&gt;kubernetes&lt;/span&gt;&lt;span class="pi"&gt;:&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;mlops-mlflow&lt;/span&gt;
    &lt;span class="na"&gt;serviceAccount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mlops-mlflow&lt;/span&gt;
    &lt;span class="na"&gt;audiences&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;vault&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;tokenExpirationSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;600&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then what to fetch and how to shape it:&lt;/p&gt;

&lt;p&gt;&lt;br&gt;
{% raw %}&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;secrets.hashicorp.com/v1beta1&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;VaultStaticSecret&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;mlops-mlflow-db&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;mlops&lt;/span&gt; &lt;span class="pi"&gt;}&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;vaultAuthRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mlops-mlflow&lt;/span&gt;
  &lt;span class="na"&gt;mount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secret&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kv-v2&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;homelab/mlops/mlflow-db&lt;/span&gt;
  &lt;span class="na"&gt;refreshAfter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
  &lt;span class="na"&gt;destination&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;mlops-mlflow-db&lt;/span&gt;
    &lt;span class="na"&gt;create&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;overwrite&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;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes.io/basic-auth&lt;/span&gt;          &lt;span class="c1"&gt;# CloudNativePG insists on this type&lt;/span&gt;
    &lt;span class="na"&gt;transformation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;excludeRaw&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;templates&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;get&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;.Secrets&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"username"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;get&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;.Secrets&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"password"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;      &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;postgresql://{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;get&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;.Secrets&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"username"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}:{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;get&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;.Secrets&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"password"&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}@postgres-mlops-cnpg-cluster-rw.mlops.svc.cluster.local:5432/mlflow_backend_db'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;




&lt;p&gt;That &lt;code&gt;type: kubernetes.io/basic-auth&lt;/code&gt; line has a story of its own in the &lt;a href="https://georgeezejiofor.com/cloudnativepg-homelab-ha-postgres-rke2-ceph-pitr" rel="noopener noreferrer"&gt;CNPG companion article&lt;/a&gt;: without it, the database role gets created with no password at all, and nothing complains.&lt;/p&gt;

&lt;p&gt;The numbers are what turn "single source of truth" from a slogan into something you can count. Today there are &lt;strong&gt;48 VaultStaticSecrets across 19 namespaces&lt;/strong&gt;, 36 VaultAuths, and exactly one VaultConnection. Not one of those Secrets was made by hand with &lt;code&gt;kubectl create secret&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Why my setup looks different from the tutorials
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The tutorials teach the Agent Injector; I use VSO.&lt;/strong&gt; The injector puts a sidecar in every pod and writes secrets to a file the app has to read. It's installed here (two pods), but no pod in the cluster uses it today.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tutorials stop at Shamir.&lt;/strong&gt; Five key shares, three needed, and you type them in after every restart. That's fine for a tutorial and miserable at 3 a.m.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anti-affinity is &lt;code&gt;required&lt;/code&gt;.&lt;/strong&gt; I'd rather a Vault pod sit &lt;code&gt;Pending&lt;/code&gt; than put two Raft members on one node. The catch is in the "What's Next" list.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Terraform is the only writer
&lt;/h2&gt;

&lt;p&gt;No human writes to Vault by hand. Every secret starts in a gitignored &lt;code&gt;.secrets.auto.tfvars&lt;/code&gt; file on my machine, and Terraform writes it to Vault:&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="s"&gt;1. edit .secrets.auto.tfvars&lt;/span&gt;              &lt;span class="c1"&gt;# gitignored, never committed&lt;/span&gt;
&lt;span class="s"&gt;2. terraform apply&lt;/span&gt;                        &lt;span class="c1"&gt;# terraform/vault-config/environment/prod&lt;/span&gt;
&lt;span class="na"&gt;3. Vault KV v2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secret/homelab/mlops/mlflow-db&lt;/span&gt;
&lt;span class="s"&gt;4. VSO refreshes (hourly, or when nudged)&lt;/span&gt;
&lt;span class="s"&gt;5. Secret mlops/mlops-mlflow-db&lt;/span&gt;
&lt;span class="s"&gt;6. the pod's env, and CNPG's managed role password&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Terraform logs in to Vault with a short-lived Keycloak JWT rather than a stored token, so even the writer has to prove who it is each time.&lt;/p&gt;

&lt;p&gt;Today Terraform owns &lt;strong&gt;47 secret paths&lt;/strong&gt;, &lt;strong&gt;55 policies&lt;/strong&gt; and &lt;strong&gt;65 Kubernetes auth roles&lt;/strong&gt;. The paths are grouped by area under &lt;code&gt;secret/homelab/&lt;/code&gt;. The biggest groups are monitoring, the AI agents and MLOps, each with somewhere between 15 and 19 path references.&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%2F9ebn8yws1b7whi02vt4e.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%2F9ebn8yws1b7whi02vt4e.png" alt="vault" width="800" height="465"&gt;&lt;/a&gt;&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%2Fdt2qvhgsf0jotkwjdxxg.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%2Fdt2qvhgsf0jotkwjdxxg.png" alt="vault" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Vault UI's KV browser at &lt;code&gt;secret/homelab/&lt;/code&gt;: one folder per area. Shoot the folder list only, never an opened secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who can read what
&lt;/h2&gt;

&lt;p&gt;Three layers keep each app in its own lane:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Paths.&lt;/strong&gt; Each area owns its own folder under &lt;code&gt;secret/homelab/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policies.&lt;/strong&gt; Each role's policy grants read on exactly the paths it needs. For MLflow's database secret it boils down to this:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;path "secret/data/homelab/mlops/mlflow-db" {&lt;/span&gt;
  &lt;span class="s"&gt;capabilities = ["read"]&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Roles bound to service accounts.&lt;/strong&gt; The &lt;code&gt;mlops-mlflow&lt;/code&gt; role only accepts a token from the &lt;code&gt;mlops-mlflow&lt;/code&gt; service account in &lt;code&gt;mlops&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So if someone took over MLflow's pod, they'd get MLflow's database password and nothing else. They couldn't read Keycloak's secrets or list other paths, and they couldn't reach the audit log.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two months the safe stayed shut
&lt;/h2&gt;

&lt;p&gt;Here's what actually happened.&lt;/p&gt;

&lt;p&gt;The main Vault authenticates to the transit Vault with a token that Ansible created during the first install, with a 768-hour period. The playbook had a comment saying the token "auto-renews". It doesn't. A periodic token only resets its clock when something calls &lt;code&gt;renew-self&lt;/code&gt;, and nothing did. When it ran out, every main Vault pod got &lt;code&gt;permission denied&lt;/code&gt; from transit, couldn't unseal, and crash-looped.&lt;/p&gt;

&lt;p&gt;The fix is a tiny daily CronJob, &lt;code&gt;vault-transit-token-renewer&lt;/code&gt;, that renews the token. Daily against a 32-day period means it would have to fail about thirty times in a row before anything was at risk. That's deliberately generous, because the failure mode is the whole secret tier going dark.&lt;/p&gt;

&lt;p&gt;That wasn't the only way the safe stayed shut:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every reboot resealed the transit Vault.&lt;/strong&gt; One Shamir share means someone has to type it in, and for months that someone was me, running an Ansible playbook. Now a small Deployment, &lt;code&gt;vault-transit-unsealer&lt;/code&gt;, checks the transit Vault every 15 seconds and unseals it if it finds it sealed. It reads the key from a mounted Secret, so its service account needs no permission to read Secrets at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Power cuts left the transit pod stuck.&lt;/strong&gt; When my hosts lose power, pods die without releasing their Ceph block-device locks, and on boot the transit pod sits in &lt;code&gt;Unknown&lt;/code&gt; holding a stale one. Once, that took all of Vault down until I force-deleted pods by hand. A &lt;code&gt;stuck-pod-reaper&lt;/code&gt; CronJob now does what I used to do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nobody was watching.&lt;/strong&gt; There's now a PrometheusRule that alerts when the transit pod isn't ready. It uses pod readiness from kube-state-metrics rather than Vault's own metrics, because when Vault is sealed it's the worst possible thing to ask whether Vault is OK. The main Vault also has a ServiceMonitor scraping &lt;code&gt;/v1/sys/metrics&lt;/code&gt; now.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last power cut showed all of it working. The lab powered on, and the unsealer logged "cannot reach transit API" every 15 seconds while the transit pod came up. The moment transit answered, it logged &lt;code&gt;vault-transit was SEALED -&amp;gt; unsealed OK&lt;/code&gt;. About two minutes later the main Vault pods were up and unsealed. I was making coffee.&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%2Fuz6l7mqiw9ujfq1z6c2o.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%2Fuz6l7mqiw9ujfq1z6c2o.png" alt="vault" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kubectl -n vault-transit logs deploy/vault-transit-unsealer&lt;/code&gt;: the "cannot reach" lines, then &lt;code&gt;SEALED -&amp;gt; unsealed OK&lt;/code&gt;. It's the most reassuring log line in the cluster.&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%2Fc1trpzf23nzkg3qylo0k.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%2Fc1trpzf23nzkg3qylo0k.png" alt="vault" width="800" height="90"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How reliable is it now?
&lt;/h2&gt;

&lt;p&gt;Here's what the safe survives, and the evidence for each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What goes wrong&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;How I know&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One Vault pod dies&lt;/td&gt;
&lt;td&gt;The two standbys keep serving, and Raft elects a new leader if it was the active one&lt;/td&gt;
&lt;td&gt;Three-node Raft, &lt;code&gt;HA Mode: active&lt;/code&gt; plus two standbys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A node reboots&lt;/td&gt;
&lt;td&gt;The pod comes back, auto-unseals against transit and rejoins Raft&lt;/td&gt;
&lt;td&gt;After the last power cut, vault-1 and vault-2 came back and rejoined on their own, with no crash restarts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The whole lab loses power&lt;/td&gt;
&lt;td&gt;Transit is unsealed within about 15 seconds of coming up, and the main Vault follows&lt;/td&gt;
&lt;td&gt;Last power cut: transit unsealed the moment it answered, main Vault up about two minutes later, no human involved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The auto-unseal token ages&lt;/td&gt;
&lt;td&gt;Renewed daily against a 32-day period&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;vault-transit-token-renewer&lt;/code&gt; runs every day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A pod is stuck after a power cut&lt;/td&gt;
&lt;td&gt;Reaped within three minutes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;stuck-pod-reaper&lt;/code&gt; runs every 3 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The transit Vault isn't ready&lt;/td&gt;
&lt;td&gt;An alert fires&lt;/td&gt;
&lt;td&gt;PrometheusRule on transit readiness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vault is down for a while&lt;/td&gt;
&lt;td&gt;Running apps keep their existing Secrets; only new syncs wait&lt;/td&gt;
&lt;td&gt;That's how the two-month outage went unnoticed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the honest limits, all on the list at the end: the transit Vault is a single pod, the main Vault spreads across nodes rather than strictly across datacenters, and there are no scheduled Raft snapshots yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The traps that taught me
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Istio makes a TLS listener look like plain HTTP
&lt;/h3&gt;

&lt;p&gt;The pods came up green, then &lt;code&gt;vault status&lt;/code&gt; said &lt;code&gt;http: server gave HTTP response to HTTPS client&lt;/code&gt;. The listener &lt;em&gt;was&lt;/em&gt; TLS. Istio's sidecar was grabbing the connection and forwarding plain text into it. Exclude Vault's ports in both directions:&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;server&lt;/span&gt;&lt;span class="pi"&gt;:&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;traffic.sidecar.istio.io/excludeInboundPorts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8200,8201"&lt;/span&gt;
    &lt;span class="na"&gt;traffic.sidecar.istio.io/excludeOutboundPorts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8200,8201"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everyone remembers inbound. Forgetting outbound breaks &lt;code&gt;retry_join&lt;/code&gt;, because vault-1 calling vault-0 goes through the sidecar too. And the chart key is &lt;code&gt;server.annotations&lt;/code&gt;, not &lt;code&gt;server.podAnnotations&lt;/code&gt;, which the chart silently ignores.&lt;/p&gt;

&lt;h3&gt;
  
  
  Raft redirects to an IP your certificate doesn't cover
&lt;/h3&gt;

&lt;p&gt;vault-1 and vault-2 joined, got redirected to the leader's pod IP, and died with &lt;code&gt;x509: cannot validate certificate for 10.42.x.y because it doesn't contain any IP SANs&lt;/code&gt;. Pod IPs change on every reschedule, so they don't belong in a certificate. Pin the name instead:&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="s"&gt;retry_join {&lt;/span&gt;
  &lt;span class="s"&gt;leader_api_addr       = "https://vault-0.vault-internal:8200"&lt;/span&gt;
  &lt;span class="s"&gt;leader_tls_servername = "vault.vault.svc.cluster.local"&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  One letter in the seal block
&lt;/h3&gt;

&lt;p&gt;Every pod crash-looped with &lt;code&gt;seal init failed&lt;/code&gt; and nothing else. The transit Vault runs without TLS inside the cluster, so its address has to be &lt;code&gt;http://&lt;/code&gt;. Every example online uses &lt;code&gt;https://&lt;/code&gt;. I stared at that line for an hour before I saw it.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;vault operator init&lt;/code&gt; fails one time in three
&lt;/h3&gt;

&lt;p&gt;A fresh vault-0 spends its first few seconds in a busy &lt;code&gt;retry_join&lt;/code&gt; loop, and init during that window returns a bare &lt;code&gt;500 internal error&lt;/code&gt;. Ansible's &lt;code&gt;no_log&lt;/code&gt; hid even that. The fix is to wait until &lt;code&gt;vault status&lt;/code&gt; settles, pause, then init with retries, and treat "already initialized" as success.&lt;/p&gt;

&lt;h3&gt;
  
  
  The unseal command that ignores your key
&lt;/h3&gt;

&lt;p&gt;Older notes of mine, and the first version of this article, said to pipe the key in with &lt;code&gt;vault operator unseal -&lt;/code&gt;. On Vault 1.21.2 that doesn't read stdin. It treats the &lt;code&gt;-&lt;/code&gt; as the key itself, and the unseal quietly fails. The unsealer passes the key as an argument instead. If you're doing it by hand, read it into a variable first so it never lands in your shell history:&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="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-rs&lt;/span&gt; KEY
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; vault-transit &lt;span class="nb"&gt;exec &lt;/span&gt;vault-transit-0 &lt;span class="nt"&gt;--&lt;/span&gt; vault operator unseal &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;unset &lt;/span&gt;KEY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The operator says 38 secrets are broken. They aren't.
&lt;/h3&gt;

&lt;p&gt;After the last power-on, &lt;code&gt;kubectl get vaultstaticsecrets -A&lt;/code&gt; showed 38 of 48 with &lt;code&gt;SecretSynced=False&lt;/code&gt;. The message was a &lt;code&gt;no route to host&lt;/code&gt; to an old pod IP, logged while Vault was still coming up. Every one of those 38 Secrets exists and has its keys. The condition just hadn't been updated since. Check the Secret itself, not the condition.&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%2F61x62gfp65r1tlzo1bl3.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%2F61x62gfp65r1tlzo1bl3.png" alt="vault" width="800" height="431"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What I thought Vault was doing for my disks
&lt;/h3&gt;

&lt;p&gt;An earlier draft of this article said Vault holds the encryption keys for my Ceph disks. It doesn't. The Ceph config points at Vault, a &lt;code&gt;rook&lt;/code&gt; mount and a policy exist, and the disks really are encrypted. But the keys are in Ceph's own monitor store. Vault's &lt;code&gt;rook&lt;/code&gt; mount is empty.&lt;/p&gt;

&lt;p&gt;The cause is the Istio pattern again. A DestinationRule makes the sidecar wrap traffic to Vault in TLS, so meshed clients have to speak &lt;code&gt;http://&lt;/code&gt;. Rook was set to &lt;code&gt;https://&lt;/code&gt;, which is TLS inside TLS, and it never once connected:&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="s"&gt;TLSv1.3 (OUT), TLS alert, record overflow (534)&lt;/span&gt;
&lt;span class="s"&gt;error:0A0000C6:SSL routines::packet length too long&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things came out of finding that. The &lt;code&gt;VAULT_SKIP_VERIFY&lt;/code&gt; setting I'd been apologising for never mattered. The thing actually skipping certificate checks is &lt;code&gt;insecureSkipVerify: true&lt;/code&gt; in that DestinationRule, and it applies to every meshed client of Vault. And my storage doesn't depend on Vault at all, which is a happier failure mode than the one I thought I had. I wrote that Vault held the keys because the config said so. Config isn't evidence; &lt;code&gt;ceph config-key ls&lt;/code&gt; is.&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%2Fcbov4goq7c5oafjdaz64.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%2Fcbov4goq7c5oafjdaz64.png" alt="Vault" width="800" height="185"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;CephCluster&lt;/code&gt; spec showing &lt;code&gt;KMS_PROVIDER: vault&lt;/code&gt;, next to &lt;code&gt;ceph config-key ls | grep dm-crypt&lt;/code&gt; listing six &lt;code&gt;dm-crypt/osd/&amp;lt;fsid&amp;gt;/luks&lt;/code&gt; entries. Shoot the spec and the key &lt;em&gt;names&lt;/em&gt; only, never a key, and never the &lt;code&gt;rook/&lt;/code&gt; KV path.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pods:&lt;/strong&gt; 3 main Vault, 1 transit, 1 unsealer, 2 Agent Injector, the VSO controller, and a CSI provider on every node (29 on my cluster).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage:&lt;/strong&gt; 3 × 10Gi Raft plus 3 × 5Gi audit on &lt;code&gt;ceph-block&lt;/code&gt;, and 1Gi for transit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upkeep:&lt;/strong&gt; the daily renewer, the reaper, and an alert. All three exist because of something that went wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it buys: one answer to "where does this password come from?", an audit log of every read, rotation by &lt;code&gt;terraform apply&lt;/code&gt;, and per-app isolation enforced by Vault rather than by everyone being careful.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;The safe now opens itself after a power cut, and I'll know if it doesn't. What's still on my list, roughly in order of how bad the failure would be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Spread Vault by datacenter, not just by node.&lt;/strong&gt; The anti-affinity uses &lt;code&gt;kubernetes.io/hostname&lt;/code&gt;. Today the pods happen to sit one per datacenter, but nothing stops two landing in the same one. If that datacenter goes, Raft loses quorum and the safe stays shut. The CNPG clusters already do this properly with &lt;code&gt;topology.kubernetes.io/zone&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled Raft snapshots.&lt;/strong&gt; Ceph's three copies aren't a backup. &lt;code&gt;vault operator raft snapshot save&lt;/code&gt; on a CronJob is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A NetworkPolicy for the &lt;code&gt;vault&lt;/code&gt; namespace.&lt;/strong&gt; The transit Vault has one. The main Vault doesn't, so anything in the cluster can reach port 8200 and try its luck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Close &lt;code&gt;insecureSkipVerify&lt;/code&gt;&lt;/strong&gt; by getting Vault's CA to the sidecars, then point Rook at &lt;code&gt;http://&lt;/code&gt; so disk keys can finally reach Vault.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic database credentials,&lt;/strong&gt; so apps get short-lived Postgres logins instead of static passwords.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next episode, the secrets get put to work. The NVIDIA GPU Operator brings the cluster's GPUs online, and everything that trains a model starts asking the safe for its keys.&lt;/p&gt;

&lt;p&gt;If you're still keeping secrets in a &lt;code&gt;.env&lt;/code&gt; you swear is gitignored, this is the episode to borrow from. Just set an alarm for your unseal token.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.linkedin.com/in/george-ezejiofor-89615a8a/" rel="noopener noreferrer"&gt;Follow me on LinkedIn&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Author: George Ezejiofor&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>vault</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>RKE2 Homelab Storage: Rook-Ceph &amp; CloudNativePG (The Garage &amp; Cabinets )</title>
      <dc:creator>George Ezejiofor</dc:creator>
      <pubDate>Wed, 07 Oct 2026 09:31:29 +0000</pubDate>
      <link>https://dev.to/ezejioforog/rke2-homelab-storage-rook-ceph-cloudnativepg-the-garage-cabinets--33h7</link>
      <guid>https://dev.to/ezejioforog/rke2-homelab-storage-rook-ceph-cloudnativepg-the-garage-cabinets--33h7</guid>
      <description>&lt;p&gt;&lt;em&gt;Episode 5 of the homelab build series. Terraform poured the foundation, Ansible and RKE2 framed the walls, Kyverno and friends wired the alarms, and Tekton and Harbor built the workshop out back. This one is about the floor everything else stands on: Rook-Ceph as the garage that holds whatever you throw at it, and CloudNativePG as the filing cabinets for the stuff that needs a proper label.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The Ceph dashboard landing page: &lt;code&gt;HEALTH_OK&lt;/code&gt;, 22 TiB raw, 6 OSDs up and in across three zones. SSO Login with keycloak and Oauth2-proxy using google and Github social login.&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%2Fsdn2j0zeofe1s3ewj10i.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%2Fsdn2j0zeofe1s3ewj10i.png" alt="ceph11" width="799" height="463"&gt;&lt;/a&gt;&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%2Fgoe2g58wmwrkt0274fdw.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%2Fgoe2g58wmwrkt0274fdw.png" alt="ceph" width="800" height="533"&gt;&lt;/a&gt;&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%2Fd2hg0ueurirx76lo7kv1.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%2Fd2hg0ueurirx76lo7kv1.png" alt="ceph11" width="799" height="463"&gt;&lt;/a&gt;&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%2F2aps4aqpl20bl1o2uf79.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%2F2aps4aqpl20bl1o2uf79.png" alt="ceph11" width="799" height="463"&gt;&lt;/a&gt;&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%2Ff9w2nqgv3eqf4lpu050t.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%2Ff9w2nqgv3eqf4lpu050t.png" alt="ceph11" width="799" height="463"&gt;&lt;/a&gt;&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%2Ft3wqyb2r0hleac5omcf6.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%2Ft3wqyb2r0hleac5omcf6.png" alt="ceph11" width="799" height="463"&gt;&lt;/a&gt;&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%2F1x6hudvj2wldtvdo8ojo.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%2F1x6hudvj2wldtvdo8ojo.png" alt="ceph11" width="799" height="463"&gt;&lt;/a&gt;&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%2Fsjyijw5cy9a6o9je9q68.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%2Fsjyijw5cy9a6o9je9q68.png" alt="ceph11" width="799" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Grafana Dashboard for CEPH &lt;code&gt;signed in with google SSO and keycloak&lt;/code&gt;
&lt;/h2&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%2Fsqy9tiv5hva91l7juotv.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%2Fsqy9tiv5hva91l7juotv.png" alt="ceph" width="800" height="416"&gt;&lt;/a&gt;&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%2Fnp8qkxwtmy64ml4r8pnj.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%2Fnp8qkxwtmy64ml4r8pnj.png" alt="ceph" width="800" height="416"&gt;&lt;/a&gt;&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%2Fvnut7nesx1rr86508wgj.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%2Fvnut7nesx1rr86508wgj.png" alt="ceph" width="800" height="416"&gt;&lt;/a&gt;&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%2F7gkfs70f077sp1gvjiqx.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%2F7gkfs70f077sp1gvjiqx.png" alt="ceph" width="800" height="416"&gt;&lt;/a&gt;&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%2F7tj8cle9j2bja4719qed.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%2F7tj8cle9j2bja4719qed.png" alt="ceph" width="800" height="416"&gt;&lt;/a&gt;&lt;/p&gt;




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

&lt;p&gt;&lt;em&gt;I was proud of a cluster that was lying to me&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For a few months I told anyone who'd listen that my data was safe. Six Ceph OSDs, three datacenters, every object stored three times. It sounded great when I said it out loud.&lt;/p&gt;

&lt;p&gt;Then I actually looked at where those OSDs lived. Each one was a 100 GiB slice of the same local disk that held the VM boot volumes on that host, etcd's included. I'd built three copies of my data on three copies of the same weak spot, and I'd been bragging about it.&lt;/p&gt;

&lt;p&gt;Ceph kept filling up, and I kept blaming Ceph. It wasn't Ceph. I'd put the garage, the filing cabinets and the house wiring on one shelf and hoped nobody leaned on it.&lt;/p&gt;

&lt;p&gt;So this episode is the fix, and the story of what the fix taught me. It's the least exciting room in the house. Nobody comes round to admire your PVCs. But every episode after this one (the MLOps stack, the lakehouse, Kubeflow) is a tenant that needs somewhere to put things, and tenants don't move into a building without floors.&lt;/p&gt;

&lt;p&gt;Two floors, in the end. &lt;strong&gt;Rook-Ceph&lt;/strong&gt; at the bottom gives the cluster block, file and object storage from one system. &lt;strong&gt;CloudNativePG&lt;/strong&gt; sits on top of it for the one kind of data that deserves its own filing system: relational state. Here's how both are built, what moving onto real NVMe took, and the traps that taught me the difference between "replicated" and "safe".&lt;/p&gt;

&lt;h2&gt;
  
  
  Why storage had to come now
&lt;/h2&gt;

&lt;p&gt;If you've been following along, the cluster so far:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Foundation.&lt;/strong&gt; Terraform stood the VMs up on Proxmox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Framing.&lt;/strong&gt; Ansible turned them into a five-master HA RKE2 cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security.&lt;/strong&gt; Kyverno at the door, KubeArmor on the inside locks, Falco on the cameras, Trivy doing the rounds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workshop.&lt;/strong&gt; Tekton, SonarQube, Harbor and Chains: the supply chain that builds and signs everything that lands here.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Storage had to come next because everything after this point wants somewhere to keep things. The MLOps stack alone wanted Postgres for Hive Metastore, MLflow and Airflow plus buckets for artifacts. Harbor wanted a database, a Redis and a blob store. Keycloak, SonarQube and Vault each wanted their own.&lt;/p&gt;

&lt;p&gt;Without a real storage layer, each of those apps either drags in its own bundled database (another StatefulSet I'd have to babysit) or writes to hostPath and dies with whatever node it happened to land on. I've run the hostPath version before. Never again.&lt;/p&gt;

&lt;p&gt;The repo side is two folders that do very different jobs but behave like one layer:&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="s"&gt;0-boostrap/&lt;/span&gt;
  &lt;span class="s"&gt;rook-ceph/&lt;/span&gt;                    &lt;span class="c1"&gt;# the garage: block, file and object&lt;/span&gt;
    &lt;span class="s"&gt;values-operator.yaml&lt;/span&gt;
    &lt;span class="s"&gt;values-cluster.yaml&lt;/span&gt;         &lt;span class="c1"&gt;# OSD devices, pools, storage classes&lt;/span&gt;
    &lt;span class="s"&gt;rgw-tls.yaml&lt;/span&gt;                &lt;span class="c1"&gt;# RGW is TLS-only&lt;/span&gt;
    &lt;span class="s"&gt;rgw-s3-tls-origination.yaml&lt;/span&gt; &lt;span class="c1"&gt;# how Istio talks HTTPS to it&lt;/span&gt;
    &lt;span class="s"&gt;rgw-keycloak-oidc/&lt;/span&gt;          &lt;span class="c1"&gt;# S3 access via Keycloak tokens&lt;/span&gt;
    &lt;span class="s"&gt;local-ceph-pvs.yaml&lt;/span&gt;         &lt;span class="c1"&gt;# LEGACY: the old 100 GiB slices, unused&lt;/span&gt;
  &lt;span class="s"&gt;cnpg/&lt;/span&gt;                         &lt;span class="c1"&gt;# the filing cabinets&lt;/span&gt;
    &lt;span class="s"&gt;cnpg-operator/&lt;/span&gt;
    &lt;span class="s"&gt;cnpg-plugin-barman-cloud/&lt;/span&gt;
    &lt;span class="s"&gt;cnpg-clusters/&lt;/span&gt;              &lt;span class="c1"&gt;# Cluster + Database CRs per app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Ftotyakjplz810orubitq.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%2Ftotyakjplz810orubitq.png" alt="ceph10" width="799" height="463"&gt;&lt;/a&gt;&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%2Fkovvkwon00f93stikyzx.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%2Fkovvkwon00f93stikyzx.png" alt="ceph9" width="799" height="463"&gt;&lt;/a&gt;&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%2F0v1lyzl65bvhkaod1psn.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%2F0v1lyzl65bvhkaod1psn.png" alt="ceph8" width="799" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The two floors
&lt;/h2&gt;

&lt;p&gt;The mental model I settled on is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rook-Ceph is the garage.&lt;/strong&gt; It doesn't care what you put in it. It hands Kubernetes three storage classes and sits underneath everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudNativePG is the filing cabinets inside the garage.&lt;/strong&gt; It's for one specific thing, relational data, and it lives &lt;em&gt;on&lt;/em&gt; Ceph. Every Postgres data directory is itself a &lt;code&gt;ceph-block&lt;/code&gt; volume.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That second point matters more than it sounds. CNPG isn't a second storage system. It's a very opinionated tenant of the first 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%2Fyyz5l4py1znykyitpd58.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%2Fyyz5l4py1znykyitpd58.png" alt="ceph" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The two-tier storage stack: applications on top, CloudNativePG in the middle, Rook-Ceph and six NVMe drives underneath. Links to the interactive diagram.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Click the image for the interactive version. You can pan, zoom and step through five guided views. Direct link: &lt;a href="https://george-storage-garage.terranetes.workers.dev/" rel="noopener noreferrer"&gt;INTERACTIVE-DIAGRAM-URL&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://george-storage-garage.terranetes.workers.dev/" rel="noopener noreferrer"&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%2Fkl272rekp8szs68rrbf4.png" alt="ceph" width="799" height="296"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The garage: Rook-Ceph
&lt;/h2&gt;

&lt;p&gt;Rook is the operator that runs Ceph on Kubernetes. It turns MONs, MGRs, OSDs, metadata servers and S3 gateways into ordinary Kubernetes objects and keeps them alive. Underneath, it's plain Ceph: version 19.2.4, "Squid", deployed with the rook-ceph and rook-ceph-cluster charts at v1.16.2.&lt;/p&gt;

&lt;p&gt;What's actually running:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;3 MONs&lt;/strong&gt; keep the cluster map and agree on it by quorum.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 MGRs&lt;/strong&gt;, one active and one standby, run the dashboard, metrics and balancer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;6 OSDs&lt;/strong&gt;, one per NVMe drive, two in each datacenter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 MDS daemons&lt;/strong&gt; for CephFS, one active and one hot standby.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 RGW gateways&lt;/strong&gt; speaking S3, spread across zones.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The OSDs are the part that changed. Each box has two Samsung 990 PRO 4TB drives, both handed to that box's single storage VM by PCI passthrough, and Rook claims it as a raw device by its stable path:&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;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;useAllNodes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;useAllDevices&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;nodes&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;rke2-worker-dc1-db-b&lt;/span&gt;
      &lt;span class="na"&gt;devices&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;/dev/disk/by-id/nvme-Samsung_SSD_990_PRO_4TB_&amp;lt;drive-serial&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;by-id&lt;/code&gt; path isn't me being fussy. &lt;code&gt;/dev/sdc&lt;/code&gt; is just whatever the kernel found third this boot. After a reboot it can be a different disk, and that's how you hand a live OSD to the wrong drive. I learned that one properly, and it's in the traps section below.&lt;/p&gt;

&lt;p&gt;The last time I checked, the whole lab had just come back up from a power cut. The monitors had been running for twelve minutes, and Ceph had already sorted itself out:&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="s"&gt;$ ceph -s&lt;/span&gt;
&lt;span class="na"&gt;health&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HEALTH_OK&lt;/span&gt;
&lt;span class="na"&gt;mon&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3 daemons, quorum a,c,d (age 12m)&lt;/span&gt;
&lt;span class="na"&gt;mgr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;a(active, since 12m), standbys&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;b&lt;/span&gt;
&lt;span class="na"&gt;mds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1/1 daemons up, 1 hot standby&lt;/span&gt;
&lt;span class="na"&gt;osd: 6 osds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;6 up (since 11m), 6 in (since 2w)&lt;/span&gt;
&lt;span class="na"&gt;rgw&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2 daemons active (2 hosts, 1 zones)&lt;/span&gt;
&lt;span class="na"&gt;pgs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;169 active+clean&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the part I still enjoy. I didn't touch anything. The drives came back, the placement groups peered, and it went green on its own.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ceph osd tree&lt;/code&gt;: six OSDs, two per datacenter, each a whole 3.64 TiB drive.&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%2Fe0bewe4hv4m1cy6rexmb.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%2Fe0bewe4hv4m1cy6rexmb.png" alt="ceph7" width="799" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Ceph and not something lighter
&lt;/h3&gt;

&lt;p&gt;People ask me this a lot, usually with MinIO or Longhorn in mind. The thing that took me a while to see is that most of Ceph's "competitors" are only competing with a third of it. Storage on Kubernetes comes in three shapes, and they aren't interchangeable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Block (RWO).&lt;/strong&gt; One writer, low latency. What a database's data directory wants.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File (RWX).&lt;/strong&gt; Lots of pods mounting the same filesystem at once. Shared datasets, shared scratch space.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Object (S3).&lt;/strong&gt; HTTP put and get of whole blobs. Backups, artifacts, data lakes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;MinIO&lt;/strong&gt; does object. &lt;strong&gt;Longhorn&lt;/strong&gt; does block. &lt;strong&gt;SeaweedFS&lt;/strong&gt; does object and file. &lt;strong&gt;HDFS&lt;/strong&gt; is a batch analytics filesystem from another era. &lt;strong&gt;Ceph&lt;/strong&gt; does all three from one cluster, and that's the whole argument. When you compare Ceph to MinIO, the real question is "do I want to run one storage system or three?"&lt;/p&gt;

&lt;p&gt;I didn't arrive here from theory. I ran Longhorn first and liked it, right up until I started pulling nodes out on purpose. A node crash kicked off a rebuild that ate enough CPU and memory to get unrelated apps OOM-killed. RWX meant bolting NFS on top, which is its own single point of failure. And I never felt confident about point-in-time backups with it.&lt;/p&gt;

&lt;p&gt;Here's how I'd sum the options up for a friend:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;System&lt;/th&gt;
&lt;th&gt;Block&lt;/th&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Object&lt;/th&gt;
&lt;th&gt;Runs as an operator&lt;/th&gt;
&lt;th&gt;Best at&lt;/th&gt;
&lt;th&gt;Watch out for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rook-Ceph&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ RBD&lt;/td&gt;
&lt;td&gt;✅ CephFS&lt;/td&gt;
&lt;td&gt;✅ RGW&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;All three from one self-healing cluster&lt;/td&gt;
&lt;td&gt;Heavy, and a real learning curve&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MinIO&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;⚠️ Helm&lt;/td&gt;
&lt;td&gt;Fast, simple S3&lt;/td&gt;
&lt;td&gt;No volumes for your databases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SeaweedFS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;⚠️ CSI&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;Huge numbers of small files&lt;/td&gt;
&lt;td&gt;Smaller community, block isn't its thing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Longhorn&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;⚠️ via NFS&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Easiest block on a small cluster&lt;/td&gt;
&lt;td&gt;Rebuild storms, no object&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OpenEBS Mayastor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ NVMe-oF&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Lowest-latency block&lt;/td&gt;
&lt;td&gt;Block only, younger&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HDFS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;⚠️ not POSIX&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;Old-school batch analytics&lt;/td&gt;
&lt;td&gt;Wrong tool for pod volumes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you only need one of those things, use the specialist. Pure S3 is MinIO. Simple block on a small cluster is Longhorn. Mind you, MinIO's catch shows up the day you also need a block volume for Postgres, because now you're running two systems. That's exactly why my KFP and MLflow artifact stores point at Ceph's S3 gateway instead of the MinIO their manifests ship with.&lt;/p&gt;

&lt;p&gt;HDFS isn't really in this race. Object storage plus open table formats like Iceberg replaced that whole way of working, and in my lakehouse that's literally what happened: Trino and Iceberg on Ceph's S3 retired the Hive and HDFS setup. Ceph didn't beat HDFS. Object storage did, and Ceph is how I serve it.&lt;/p&gt;

&lt;p&gt;The cost is real, though, so let me be straight about it. The &lt;code&gt;rook-ceph&lt;/code&gt; namespace runs 115 pods. Only 17 of those are Ceph itself; the rest are per-node &lt;strong&gt;CSI drivers&lt;/strong&gt;, &lt;strong&gt;metrics exporters&lt;/strong&gt; and &lt;strong&gt;crash collectors&lt;/strong&gt;. And on the night something breaks, you'll be reading about placement groups and CRUSH rules at an hour you'd rather be asleep. With one machine and one disk, don't bother: use Longhorn or hostPath. Ceph starts paying off around three machines, more than one failure domain, and needing more than one kind of storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three doors into the garage
&lt;/h2&gt;

&lt;p&gt;Ceph gives Kubernetes three storage classes, and each one behaves very differently.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;ceph-block&lt;/code&gt;: the locked cabinet
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Provisioner:&lt;/strong&gt; &lt;code&gt;rook-ceph.rbd.csi.ceph.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access:&lt;/strong&gt; ReadWriteOnce, and it's the cluster default&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For:&lt;/strong&gt; database data directories, Redis, anything one pod owns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One pod gets a block device, formats it and writes to it like a normal disk. Postgres can &lt;code&gt;fsync&lt;/code&gt; straight to it. Every database in the cluster lives behind this door: the CNPG clusters, Harbor's database and Redis, SonarQube's data, Vault's storage.&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%2F7yow6j32utdf56jy6wfg.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%2F7yow6j32utdf56jy6wfg.png" alt="ceep6" width="799" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;ceph-filesystem&lt;/code&gt;: the shared shed
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Provisioner:&lt;/strong&gt; &lt;code&gt;rook-ceph.cephfs.csi.ceph.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access:&lt;/strong&gt; ReadWriteMany&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For:&lt;/strong&gt; anything several pods need to read and write together&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My rule is to avoid it unless a workload genuinely needs it, and the numbers back that up. There are &lt;strong&gt;52 volumes on &lt;code&gt;ceph-block&lt;/code&gt; and 3 on &lt;code&gt;ceph-filesystem&lt;/code&gt;&lt;/strong&gt;. The three are dbt's shared artifacts directory in &lt;code&gt;mlops&lt;/code&gt;, the technical-writer agents' shared workspace, and a test volume. Everything else is one-writer storage.&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%2Fjgvkwzr94h36v0ev9gdb.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%2Fjgvkwzr94h36v0ev9gdb.png" alt="ceph5" width="799" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;ceph-bucket&lt;/code&gt;: the loading bay
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Provisioner:&lt;/strong&gt; &lt;code&gt;rook-ceph.ceph.rook.io/bucket&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access:&lt;/strong&gt; S3 over HTTPS, not a mounted volume&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For:&lt;/strong&gt; ML artifacts, pipeline outputs, the data lake, backups&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This door has changed since I first built it. The S3 gateway used to answer on plain HTTP port 80 inside the cluster. Now it's TLS-only: the in-cluster Service is &lt;code&gt;rook-ceph-rgw-ceph-objectstore.rook-ceph.svc:443&lt;/code&gt;, and everything outside the gateway uses &lt;code&gt;https://s3.georgehomelab.com&lt;/code&gt;. Getting Istio to talk HTTPS to it properly was its own little adventure, which I've put in the traps below.&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%2Fqxis13sf5q8hx3wxrs9t.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%2Fqxis13sf5q8hx3wxrs9t.png" alt="ceph4" width="799" height="463"&gt;&lt;/a&gt;&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%2Fehosxpaifxidah328azr.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%2Fehosxpaifxidah328azr.png" alt="ceph3" width="799" height="463"&gt;&lt;/a&gt;&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%2F3d0a446jwm8n8olom55g.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%2F3d0a446jwm8n8olom55g.png" alt="ceph2" width="799" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kubectl get sc&lt;/code&gt;: the three doors with &lt;code&gt;ceph-block (default)&lt;/code&gt;. You'll also see &lt;code&gt;otelcol-premium-retain&lt;/code&gt;, which is the same RBD provisioner with &lt;code&gt;Retain&lt;/code&gt; instead of &lt;code&gt;Delete&lt;/code&gt; for telemetry that must outlive its PVC. Same door, different lock.&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%2F6g5vxx9272ipfj3s1rgl.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%2F6g5vxx9272ipfj3s1rgl.png" alt="ceph1" width="800" height="124"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Buckets with one &lt;code&gt;kubectl apply&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is the bit of Rook that surprised me most. A new bucket is just a claim:&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;objectbucket.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;ObjectBucketClaim&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;mlflow-artifacts&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;mlops&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;bucketName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mlflow-artifacts&lt;/span&gt;
  &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ceph-bucket&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply that and Rook creates an S3 user and the bucket, then drops a ConfigMap with the bucket name and endpoint, plus a Secret with that user's keys, into the same namespace. The app reads both with &lt;code&gt;envFrom&lt;/code&gt; and gets on with its life. No console, no clicking around, and each bucket has its own keys instead of everyone sharing a root key.&lt;/p&gt;

&lt;p&gt;Four buckets come from claims:&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;kfp-artifacts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;        &lt;span class="s"&gt;kubeflow&lt;/span&gt;   &lt;span class="c1"&gt;# pipeline artifacts, instead of bundled MinIO&lt;/span&gt;
&lt;span class="na"&gt;kubeflow-db-backups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s"&gt;kubeflow&lt;/span&gt;
&lt;span class="na"&gt;mlflow-artifacts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;     &lt;span class="s"&gt;mlops&lt;/span&gt;      &lt;span class="c1"&gt;# model files&lt;/span&gt;
&lt;span class="na"&gt;mlops-data-lake&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;      &lt;span class="s"&gt;mlops&lt;/span&gt;      &lt;span class="c1"&gt;# raw and curated data, the Iceberg warehouse&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a fifth bucket, &lt;code&gt;cnpg-backups&lt;/code&gt;, that deliberately isn't a claim. It belongs to a dedicated &lt;code&gt;cnpg-backup&lt;/code&gt; S3 user whose keys live in Vault, so the database backups don't depend on Rook's claim machinery. That choice has a catch, which you'll find in the traps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The filing cabinets: CloudNativePG
&lt;/h2&gt;

&lt;p&gt;CNPG is a Postgres operator. You write one &lt;code&gt;Cluster&lt;/code&gt; resource saying "three instances, this much storage, back up here", and it does the rest: creates the pods, sets up replication, picks a primary, fails over when a pod dies, ships WAL to S3, and restores to a point in time when you ask.&lt;/p&gt;

&lt;p&gt;It runs in &lt;code&gt;cnpg-system&lt;/code&gt; at version 1.29.0. Here's the MLOps cluster, trimmed to the parts that matter:&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;postgresql.cnpg.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;Cluster&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;postgres-mlops-cnpg-cluster&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;mlops&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;instances&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;40Gi&lt;/span&gt;
    &lt;span class="na"&gt;storageClass&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ceph-block-pg-local&lt;/span&gt;        &lt;span class="c1"&gt;# the filing cabinet sits on the garage floor&lt;/span&gt;
  &lt;span class="na"&gt;affinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;topologyKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;topology.kubernetes.io/zone&lt;/span&gt;
    &lt;span class="na"&gt;podAntiAffinityType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;required&lt;/span&gt;   &lt;span class="c1"&gt;# one instance per datacenter, no exceptions&lt;/span&gt;
  &lt;span class="na"&gt;backup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;retentionPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;7d&lt;/span&gt;
    &lt;span class="na"&gt;barmanObjectStore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;destinationPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;s3://cnpg-backups/postgres-mlops-cnpg-cluster&lt;/span&gt;
      &lt;span class="na"&gt;endpointURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://rook-ceph-rgw-ceph-objectstore.rook-ceph.svc:443&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth pointing out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Three instances, one per datacenter.&lt;/strong&gt; The anti-affinity is &lt;code&gt;required&lt;/code&gt;, so the scheduler would rather leave a pod Pending than put two members in the same building.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replication is asynchronous&lt;/strong&gt; (&lt;code&gt;maxSyncReplicas: 0&lt;/code&gt;). That gives great availability, but a failover can lose the last few transactions. For Keycloak sessions and ML metadata that's fine. For anything involving money it wouldn't be.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups are continuous, plus a nightly base backup.&lt;/strong&gt; WAL streams to &lt;code&gt;s3://cnpg-backups/&lt;/code&gt; all the time, and a &lt;code&gt;ScheduledBackup&lt;/code&gt; takes a full base backup every day. That's why "restore MLflow's database to last Tuesday at 4pm" is a field in a resource, not a weekend.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Barman Cloud plugin is installed too (v0.12.0), but the clusters still use the built-in &lt;code&gt;barmanObjectStore&lt;/code&gt; method. Moving to the plugin is on the list, not done.&lt;/p&gt;

&lt;p&gt;One cluster at 3/3, naming its own primary:&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="s"&gt;kubectl get clusters.postgresql.cnpg.io -A&lt;/span&gt;

&lt;span class="s"&gt;kubectl -n keycloak get clusters.postgresql.cnpg.io postgres-keycloak-cnpg-cluster \&lt;/span&gt;
  &lt;span class="s"&gt;-o custom-columns='NAME:.metadata.name,INSTANCES:.spec.instances,READY:.status.readyInstances,PRIMARY:.status.currentPrimary'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2F2wzaq1es6qu0yubyn198.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%2F2wzaq1es6qu0yubyn198.png" alt="cluster1" width="799" height="156"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What "highly available" actually buys here
&lt;/h3&gt;

&lt;p&gt;"HA" gets said so often it stops meaning anything, so I went and checked what mine really survives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ceph survives losing a whole datacenter.&lt;/strong&gt; Not a disk or a node: a building. That comes from how the pools are set up:&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;ceph-blockpool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                     &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;min_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;failure_domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;host&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;ceph-objectstore.rgw.buckets.data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;min_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;failure_domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;host&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;ceph-filesystem-data0&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;              &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;min_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;failure_domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;host&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three copies in three different datacenters, and &lt;code&gt;min_size: 2&lt;/code&gt; means the pools stay &lt;em&gt;writable&lt;/em&gt; with one datacenter gone, not just readable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CNPG survives it too&lt;/strong&gt;, because each cluster has exactly one member per datacenter.&lt;/p&gt;

&lt;p&gt;Two things I only found by looking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two pools were quietly keeping two copies in one box. Both are fixed; every pool now spans three boxes.&lt;/strong&gt; It's the only pool that does. Lose a whole datacenter and CephFS metadata could drop below &lt;code&gt;min_size&lt;/code&gt; even though the data is fine. Only three volumes use CephFS, so the blast radius is small, but it's inconsistent and I want it fixed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Async replication&lt;/strong&gt;, as above. Great uptime, not zero data loss.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Click the image for the interactive version. Direct link: &lt;a href="https://george-storage-garage.terranetes.workers.dev/" rel="noopener noreferrer"&gt;INTERACTIVE-DIAGRAM-URL&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&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%2Fhjx4lx2lhsocf959zy4y.gif" 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%2Fhjx4lx2lhsocf959zy4y.gif" alt="ceph" width="800" height="510"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three datacenters, two OSDs each, one object stored in all three, and the one pool that uses host instead of zone. Links to the interactive diagram.&lt;/p&gt;

&lt;h2&gt;
  
  
  One cluster per app, lots of databases inside
&lt;/h2&gt;

&lt;p&gt;The pattern that mattered most in the end: &lt;strong&gt;one CNPG cluster per trust boundary, with as many logical databases inside it as that boundary needs.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Today that's three clusters and nine Postgres pods:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cluster&lt;/th&gt;
&lt;th&gt;Namespace&lt;/th&gt;
&lt;th&gt;Instances&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Databases&lt;/th&gt;
&lt;th&gt;Used by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postgres-keycloak-cnpg-cluster&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;keycloak&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;25Gi&lt;/td&gt;
&lt;td&gt;&lt;code&gt;keycloak_db&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keycloak&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postgres-sonarqube-cnpg-cluster&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sonarqube&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;10Gi&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sonar_db&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SonarQube&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postgres-mlops-cnpg-cluster&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mlops&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;40Gi&lt;/td&gt;
&lt;td&gt;six, below&lt;/td&gt;
&lt;td&gt;The MLOps platform&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The MLOps cluster is the one that sold me on this. It holds six databases: &lt;code&gt;hive_metastore_db&lt;/code&gt;, &lt;code&gt;mlflow_backend_db&lt;/code&gt;, &lt;code&gt;airflow_metadata_db&lt;/code&gt;, &lt;code&gt;kubeflow_metadata_db&lt;/code&gt;, &lt;code&gt;nessie_db&lt;/code&gt; and &lt;code&gt;openmetadata_db&lt;/code&gt;. It started with four. Nessie and OpenMetadata arrived with the lakehouse work, and adding them was one small resource each, not a new Postgres to look after:&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;postgresql.cnpg.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;Database&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;mlflow&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;mlops&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mlflow_backend_db&lt;/span&gt;
  &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mlflow&lt;/span&gt;
  &lt;span class="na"&gt;cluster&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;postgres-mlops-cnpg-cluster&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each database has its own owner role, and each role's password lives at its own Vault path, written by Terraform and synced into the namespace by the Vault Secrets Operator:&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;secret/homelab/mlops/cnpg-bootstrap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# the cluster's superuser&lt;/span&gt;
&lt;span class="na"&gt;secret/homelab/mlops/hive-db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;         &lt;span class="c1"&gt;# hive role&lt;/span&gt;
&lt;span class="na"&gt;secret/homelab/mlops/mlflow-db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;       &lt;span class="c1"&gt;# mlflow role&lt;/span&gt;
&lt;span class="na"&gt;secret/homelab/mlops/airflow-db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# airflow role&lt;/span&gt;
&lt;span class="na"&gt;secret/homelab/mlops/nessie-db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;       &lt;span class="c1"&gt;# nessie role&lt;/span&gt;
&lt;span class="na"&gt;secret/homelab/mlops/openmetadata-db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# openmetadata role&lt;/span&gt;
&lt;span class="na"&gt;secret/homelab/kubeflow/kubeflow-metadata-db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# kfp role, same cluster&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why not one cluster per database? That would be eight databases times three pods, so 24 Postgres pods instead of 9, eight backup streams to manage, and a control plane spending more time on Postgres than on actual work.&lt;/p&gt;

&lt;p&gt;Why not one giant cluster for everything? Because then one bad migration or one operator bug hits every app at once. Keycloak is security-critical and shouldn't share a database server with my ML experiments. The MLOps apps already read each other's data, so sharing a Postgres among them costs nothing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kubectl get database.postgresql.cnpg.io -A&lt;/code&gt;: six &lt;code&gt;Database&lt;/code&gt; resources inside the one MLOps cluster.&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%2Fb1nzuuddt07x7zihn4mu.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%2Fb1nzuuddt07x7zihn4mu.png" alt="ceph3" width="797" height="124"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The traps that taught me
&lt;/h2&gt;

&lt;p&gt;Every one of these cost me at least an evening, and every one is now written down so it only costs me once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everyone types "longhorn" first.&lt;/strong&gt; Older docs in the repo still mention Longhorn, so people reach for it in their first PVC. The default storage class is &lt;code&gt;ceph-block&lt;/code&gt;. If you don't name a class, you get the right one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CNPG stuck on "Setting up primary" with no pods.&lt;/strong&gt; The &lt;code&gt;initdb&lt;/code&gt; Job was getting &lt;code&gt;FailedCreate&lt;/code&gt; and nothing said why. The namespace was on Pod Security &lt;code&gt;baseline&lt;/code&gt;, and the Istio init container needs &lt;code&gt;NET_ADMIN&lt;/code&gt; and &lt;code&gt;NET_RAW&lt;/code&gt;, so admission quietly refused it. Every namespace that hosts a CNPG cluster is now labelled &lt;code&gt;pod-security.kubernetes.io/enforce=privileged&lt;/code&gt;:&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="s"&gt;kubectl label ns my-app pod-security.kubernetes.io/enforce=privileged --overwrite&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;"Degraded" clusters that weren't degraded.&lt;/strong&gt; CNPG pods showing &lt;code&gt;1/2 Running&lt;/code&gt; looked like a Postgres problem. It was the Istio sidecar restarting. Postgres was fine the whole time. Check which container is unready before you start debugging the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The old plain-HTTP S3 endpoint vanished.&lt;/strong&gt; When RGW went TLS-only, anything still pointing at the old &lt;code&gt;:80&lt;/code&gt; address broke. The Kubeflow Pipelines swap from MinIO had been done with an &lt;code&gt;ExternalName&lt;/code&gt; Service aimed at RGW's in-cluster port 80, and that died with it. Every KFP S3 client now points straight at &lt;code&gt;https://s3.georgehomelab.com:443&lt;/code&gt;, with TLS switched on explicitly in its env. The &lt;code&gt;ExternalName&lt;/code&gt; is still there but nothing relies on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Istio can't route HTTP to a port called &lt;code&gt;https&lt;/code&gt;.&lt;/strong&gt; Moving the gateway to TLS wasn't just a port change. Istio decides a port's protocol from its name, and Rook names its 443 port &lt;code&gt;https&lt;/code&gt;, so Istio treats it as plain TCP and an HTTP route can't target it. The fix in &lt;code&gt;rgw-s3-tls-origination.yaml&lt;/code&gt; is a separate Service whose port Istio sees as HTTP, plus a DestinationRule that does the TLS to RGW itself. The file's comments have the whole story, including the config dump that finally made it click.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bucket ConfigMaps freezing.&lt;/strong&gt; A Kyverno policy marks new ConfigMaps immutable, and the ConfigMaps Rook writes for bucket claims would get caught. If the endpoint ever changes, an immutable ConfigMap silently refuses the update. &lt;code&gt;rook-ceph&lt;/code&gt;, &lt;code&gt;mlops&lt;/code&gt; and &lt;code&gt;kubeflow&lt;/code&gt; are on the policy's exclusion list. Add any new namespace that creates bucket claims to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Moving the OSDs onto the 990 PROs.&lt;/strong&gt; Three lessons in the order they hit me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Name disks by &lt;code&gt;by-id&lt;/code&gt;, never &lt;code&gt;/dev/sdX&lt;/code&gt;.&lt;/em&gt; Letters are handed out in discovery order and move between boots.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;A purged OSD comes back from the dead.&lt;/em&gt; &lt;code&gt;ceph osd purge&lt;/code&gt; removes it from the cluster map but leaves the LVM and BlueStore labels on the disk, so Rook helpfully recreates it on the next reconcile. Scale the operator to zero, wipe the device, then scale it back.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;&lt;code&gt;HEALTH_OK&lt;/code&gt; doesn't mean nothing's wrong.&lt;/em&gt; After the swap, &lt;code&gt;ceph -s&lt;/code&gt; was green while orphaned OSD deployments crash-looped against disks that no longer existed. Ceph had just stopped counting them. Compare &lt;code&gt;kubectl -n rook-ceph get deploy -l app=rook-ceph-osd&lt;/code&gt; with &lt;code&gt;ceph osd tree&lt;/code&gt; yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Backups that silently stopped.&lt;/strong&gt; After a switchover, a former CNPG primary sat at &lt;code&gt;1/2&lt;/code&gt; with a failing startup probe. It has to archive its pending WAL before it can rejoin, and the archive was getting &lt;code&gt;403 Forbidden&lt;/code&gt;. A Ceph rebuild had wiped the &lt;code&gt;cnpg-backup&lt;/code&gt; S3 user, while Vault still held its keys. That broke WAL archiving for every cluster, not just the stuck one. The fix was recreating the user in the Rook toolbox with the exact keys from Vault, because Vault is the source of truth. If your backups go quiet after any Ceph rebuild, check that the user still exists before anything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Rook values aren't GitOps.&lt;/strong&gt; The Rook charts are applied by hand with Helm, not by ArgoCD. Change the device list in Git and nothing happens until someone runs the upgrade. I lost an afternoon wondering why a new disk never became an OSD.&lt;/p&gt;

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

&lt;p&gt;Here's what it costs and what it gives back.&lt;/p&gt;

&lt;p&gt;On capacity: &lt;strong&gt;22 TiB raw, 854 GiB used, 3.82%&lt;/strong&gt;. Everything is stored three times, so the number to plan against is &lt;code&gt;MAX AVAIL&lt;/code&gt;, which is &lt;strong&gt;6.6 TiB&lt;/strong&gt; per pool. I forgot that gap between raw and usable the first time I sized this, and I won't again.&lt;/p&gt;

&lt;p&gt;Before the move, the OSDs were about 600 GiB of slices on the boot disks. Dedicated NVMe gave me roughly 36 times the raw space, and more importantly it got Ceph off the drive etcd lives on.&lt;/p&gt;

&lt;p&gt;What I got for it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One storage system for block, file and object.&lt;/strong&gt; No MinIO plus Longhorn plus an NFS server glued together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A data layer that fixes itself.&lt;/strong&gt; After a whole-lab power cut, it was green before I'd made coffee.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buckets from a YAML file&lt;/strong&gt;, each with its own keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres per trust boundary.&lt;/strong&gt; Keycloak's database can't see SonarQube's, and the MLOps apps share one cluster without stepping on each other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Point-in-time recovery that's actually there.&lt;/strong&gt; WAL to S3 all day, a base backup every night, seven days kept.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;kubectl -n rook-ceph exec -it deploy/rook-ceph-tools -- ceph df&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ceph df&lt;/code&gt;: 22 TiB raw, 854 GiB used, &lt;code&gt;MAX AVAIL&lt;/code&gt; 6.6 TiB. The gap between stored and raw is the replication factor.&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%2F5dgmex0yinpaojsd6o36.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%2F5dgmex0yinpaojsd6o36.png" alt="ceph1" width="800" height="244"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;Storage is the body of the house. The next room is the one that holds the keys to all of it: &lt;strong&gt;HashiCorp Vault&lt;/strong&gt;. Every database password and bucket credential in this article lives there, gets written by Terraform and reaches its pod through the Vault Secrets Operator. That's Episode 6.&lt;/p&gt;

&lt;p&gt;On the storage side, my own to-do list is short:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Move backups out of the cluster&lt;/strong&gt; and &lt;strong&gt;Drives with power-loss protection&lt;/strong&gt; so CephFS survives losing a datacenter like everything else does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Switch the CNPG backups to the Barman Cloud plugin&lt;/strong&gt;, which is installed and waiting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the Rook charts under ArgoCD&lt;/strong&gt;, so a change to the device list in Git actually does something.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building something similar and want to compare notes, or you've got a Ceph horror story that beats mine, I'd love to hear it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.linkedin.com/in/george-ezejiofor-89615a8a/" rel="noopener noreferrer"&gt;Follow me on LinkedIn&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Author: George Ezejiofor&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>ceph</category>
      <category>storage</category>
      <category>devops</category>
    </item>
    <item>
      <title>Tekton, Cosign, Kyverno: A Signed CI/CD Supply Chain on RKE2</title>
      <dc:creator>George Ezejiofor</dc:creator>
      <pubDate>Wed, 23 Sep 2026 17:41:39 +0000</pubDate>
      <link>https://dev.to/ezejioforog/tekton-cosign-kyverno-a-signed-cicd-supply-chain-on-rke2-210p</link>
      <guid>https://dev.to/ezejioforog/tekton-cosign-kyverno-a-signed-cicd-supply-chain-on-rke2-210p</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;The Workshop Where Trust Is Manufactured&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's a confession I'll make out loud: I was running code I'd never read, built by people I've never met, pulled from a registry I don't control—and deploying it with a straight face. &lt;code&gt;docker pull something:latest&lt;/code&gt;, &lt;code&gt;kubectl apply&lt;/code&gt;, ship it. I called it a supply chain. It was really a leap of faith with extra steps.&lt;/p&gt;

&lt;p&gt;Not in this cluster.&lt;/p&gt;

&lt;p&gt;By Episode 3, the house had its locks, its cameras, and its inspector—a four-layer security system that refuses a bad manifest at the door and a bad syscall at the kernel. But a guarded house is only ever as safe as what you carry &lt;em&gt;into&lt;/em&gt; it. And the single most dangerous thing you can carry into a Kubernetes cluster is an image you cannot prove you built.&lt;/p&gt;

&lt;p&gt;So before anything ships, it goes through the workshop.&lt;/p&gt;

&lt;p&gt;This is &lt;strong&gt;Episode 4 — the CI/CD stack&lt;/strong&gt;, and it runs on one non-negotiable rule: &lt;strong&gt;nothing executes in this cluster that wasn't built here, scanned here, and signed here.&lt;/strong&gt; Every image is assembled on my own line, put through a quality gate, stamped with a cryptographic maker's mark, and stored in my own warehouse. And the security system from Episode 3? Its front door—Kyverno—refuses to admit anything that doesn't carry that mark. The workshop and the security system are two ends of the same handshake.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://tektoncosignkyverno.terranetes.workers.dev/" rel="noopener noreferrer"&gt;the supply-chain Interactive diagram:&lt;/a&gt;&lt;/strong&gt;  (Git → Tekton → SonarQube → kaniko → Chains → Harbor → Kyverno), or a Harbor repo showing an image tag with its &lt;code&gt;.sig&lt;/code&gt; + &lt;code&gt;.att&lt;/code&gt; artifacts.&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%2Fluivgpgxp4f8yk8xg0m8.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%2Fluivgpgxp4f8yk8xg0m8.png" alt="argocd" width="800" height="294"&gt;&lt;/a&gt;&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%2Fev5y1e16okf6gef67gnk.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%2Fev5y1e16okf6gef67gnk.png" alt="cicd-stack" width="799" height="260"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Stations
&lt;/h3&gt;

&lt;p&gt;Here's what's on the workshop floor:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Station&lt;/th&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🏭 The Assembly Line&lt;/td&gt;
&lt;td&gt;Tekton&lt;/td&gt;
&lt;td&gt;Builds the image, in-cluster, from source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🔬 The Quality Inspector&lt;/td&gt;
&lt;td&gt;SonarQube&lt;/td&gt;
&lt;td&gt;Fails the build if the code doesn't pass the gate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🧪 The Safety Test&lt;/td&gt;
&lt;td&gt;Trivy&lt;/td&gt;
&lt;td&gt;Fails the build if the &lt;strong&gt;pushed image&lt;/strong&gt; carries a CRITICAL or HIGH CVE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;✍️ The Maker's Mark&lt;/td&gt;
&lt;td&gt;Tekton Chains&lt;/td&gt;
&lt;td&gt;Signs every artifact (cosign) + attaches SLSA provenance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;📦 The Warehouse&lt;/td&gt;
&lt;td&gt;Harbor&lt;/td&gt;
&lt;td&gt;Stores the image, its signature, its provenance, its CVE scan and its SBOM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🚪 The Checkpoint&lt;/td&gt;
&lt;td&gt;Kyverno &lt;code&gt;verifyImages&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Admission rejects anything unsigned — and pins what it admits to the signed digest&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each is independent. But wired in sequence they produce something a public registry never can: an image with a &lt;strong&gt;provable birth certificate&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where I Am in the Build
&lt;/h3&gt;

&lt;p&gt;This is the fourth episode of a homelab that stopped being a project a while ago:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Episode 1 — The Foundation.&lt;/strong&gt; Terraform across three Proxmox datacenters.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Episode 2 — The Framing.&lt;/strong&gt; HA RKE2 control plane via Ansible, kube-vip, Cilium.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Episode 3 — The Security System.&lt;/strong&gt; Kyverno, KubeArmor, Falco, Trivy.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Episode 4 — The Workshop.&lt;/strong&gt; Tekton, SonarQube, Tekton Chains, Harbor — the subject of this piece.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two tools got &lt;em&gt;removed&lt;/em&gt; on the way here, and the removals are as deliberate as the additions: &lt;strong&gt;Jenkins&lt;/strong&gt; (Tekton + Chains + Argo Events cover every trigger without a snowflake controller) and &lt;strong&gt;Nexus&lt;/strong&gt; (Harbor is a purpose-built OCI registry with native cosign + Trivy baked in). Fewer moving parts, every one of them cloud-native.&lt;/p&gt;

&lt;p&gt;The stack lives in &lt;code&gt;kubernetes-addons/cicd-stack/&lt;/code&gt;, deployed as a single ArgoCD &lt;code&gt;ApplicationSet&lt;/code&gt;:&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="s"&gt;cicd-stack/&lt;/span&gt;
&lt;span class="s"&gt;├── README.md&lt;/span&gt;
&lt;span class="s"&gt;├── tekton/&lt;/span&gt;
&lt;span class="s"&gt;│   ├── tekton-operator/         ← the Operator + TektonConfig (one CR, whole estate)&lt;/span&gt;
&lt;span class="s"&gt;│   ├── tekton-pipeline/         ← reference build-and-push Pipeline + PipelineRuns&lt;/span&gt;
&lt;span class="s"&gt;│   └── tekton-chains/           ← cosign keypair + cosign.pub for verifiers&lt;/span&gt;
&lt;span class="s"&gt;├── harbor/                      ← OCI registry (+ Trivy scan, native Keycloak OIDC)&lt;/span&gt;
&lt;span class="s"&gt;│   ├── charts/&lt;/span&gt;
&lt;span class="s"&gt;│   └── vault-integration/       ← admin + registry creds via Vault → VSO&lt;/span&gt;
&lt;span class="s"&gt;├── sonarqube/                   ← code-quality gate (SAML2 SSO)&lt;/span&gt;
&lt;span class="s"&gt;│   ├── charts/&lt;/span&gt;
&lt;span class="s"&gt;│   └── vault-integration/&lt;/span&gt;
&lt;span class="s"&gt;├── github-action-runner/        ← self-hosted GitHub runners (ARC)&lt;/span&gt;
&lt;span class="s"&gt;│   ├── controller/&lt;/span&gt;
&lt;span class="s"&gt;│   └── runner-set/&lt;/span&gt;
&lt;span class="s"&gt;├── cicd-pipeline-e2e-example/   ← the whole chain, end to end, as one example&lt;/span&gt;
&lt;span class="s"&gt;│   ├── app/                     ← trivial Go service + its Dockerfile&lt;/span&gt;
&lt;span class="s"&gt;│   ├── pipeline/                ← e2e-build-scan-sign + a PipelineRun to trigger it&lt;/span&gt;
&lt;span class="s"&gt;│   ├── policy/                  ← the Kyverno verifyImages gate (Enforce)&lt;/span&gt;
&lt;span class="s"&gt;│   └── deploy/                  ← what ArgoCD syncs once the image is signed&lt;/span&gt;
&lt;span class="s"&gt;└── external-dns/                ← Cloudflare DNS sync (scaffolded)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  A quick word on SSO — three tools, three patterns
&lt;/h3&gt;

&lt;p&gt;One detail worth calling out, because it trips people up: the three UIs each authenticate &lt;em&gt;differently&lt;/em&gt;, dictated by what each tool natively supports.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Tekton Dashboard&lt;/strong&gt; → &lt;strong&gt;oauth2-proxy&lt;/strong&gt; reverse-proxy (no native auth).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Harbor&lt;/strong&gt; → &lt;strong&gt;native OIDC&lt;/strong&gt; (Harbor speaks it directly).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;SonarQube&lt;/strong&gt; → &lt;strong&gt;native SAML2&lt;/strong&gt; (Community edition has no OIDC).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three federate to Keycloak (with Google + GitHub behind it), but you don't get to pick one pattern and reuse it—you meet each tool where it lives. (And if the SonarQube/Harbor SAML login ever starts redirect-looping, it's almost always a stale IdP cert after a Keycloak key rotation—re-sync it, don't rebuild the realm.)&lt;/p&gt;

&lt;p&gt;Three UIs with SSO login with &lt;strong&gt;Google&lt;/strong&gt; and &lt;strong&gt;Github&lt;/strong&gt; — the Tekton Dashboard, Harbor, and SonarQube — &lt;strong&gt;nine&lt;/strong&gt; ArgoCD child applications, and a supply chain that ends with a cryptographic proof. I'll walk the floor.&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%2Fgsarg689kz3a1a715zn0.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%2Fgsarg689kz3a1a715zn0.png" alt="tekt0n1" width="799" height="304"&gt;&lt;/a&gt;&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%2F1pl04vn0ei7sl5ppsrx9.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%2F1pl04vn0ei7sl5ppsrx9.png" alt="Sonarqube1" width="799" height="304"&gt;&lt;/a&gt;&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%2F352ppqg2v78nh6jhyxmf.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%2F352ppqg2v78nh6jhyxmf.png" alt="Sonarqube2" width="799" height="304"&gt;&lt;/a&gt;&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%2Fvaqm6wb807mny1rng4c8.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%2Fvaqm6wb807mny1rng4c8.png" alt="harborsso1" width="799" height="304"&gt;&lt;/a&gt;&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%2Fxzlzbgmqcdn64ykxcghe.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%2Fxzlzbgmqcdn64ykxcghe.png" alt="harborsso2" width="799" height="304"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Build Your Own
&lt;/h2&gt;

&lt;p&gt;Let's be blunt about the threat, because it's the reason the whole workshop exists.&lt;/p&gt;

&lt;p&gt;When you pull &lt;code&gt;nginx:latest&lt;/code&gt; or &lt;code&gt;some-vendor/app:v2&lt;/code&gt;, you are trusting: the author, every dependency the author pulled, the CI system that built it, the registry that stored it, and the network path in between—&lt;strong&gt;none of which you can see, and any of which can be compromised without you ever knowing.&lt;/strong&gt; The famous supply-chain attacks of the last few years didn't break in through the front door. They walked in &lt;em&gt;inside a trusted image&lt;/em&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Microsoft Supply Chain Attack (February 2023)&lt;br&gt;
In February 2023, a software supply chain attack also affected Microsoft. The attack exploited a vulnerability in the Jfrog Artifactory, a binary repository manager that Microsoft uses to distribute and store its software components.&lt;br&gt;
The attackers accessed Jfrog Artifactory and injected malicious code into some of Microsoft’s software components, allowing them to access Microsoft’s network while stealing source code and other confidential information.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A homelab that hosts &lt;strong&gt;Vault&lt;/strong&gt;, &lt;strong&gt;Keycloak&lt;/strong&gt;, &lt;strong&gt;private model registries&lt;/strong&gt;, and &lt;strong&gt;real data pipelines&lt;/strong&gt; can't hand-wave that away. So the answer isn't "&lt;strong&gt;scan harder.&lt;/strong&gt;" The answer is &lt;strong&gt;provenance&lt;/strong&gt;: don't run what you can't trace. Build the image yourself, from source you control, on a line you can audit. Prove—cryptographically—that the thing running in production is the exact thing your pipeline produced, and reject everything else at the door.&lt;/p&gt;

&lt;p&gt;That's a chain of custody. And a chain is only as strong as its weakest link, so every station below hardens one link:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;The Assembly Line&lt;/strong&gt; proves &lt;em&gt;where it was built&lt;/em&gt; (in-cluster, from your Git).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The Inspector&lt;/strong&gt; proves &lt;em&gt;the code met a bar&lt;/em&gt; (quality gate).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The Maker's Mark&lt;/strong&gt; proves &lt;em&gt;it wasn't tampered with after the build&lt;/em&gt; (signature + SLSA attestation).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The Warehouse&lt;/strong&gt; proves &lt;em&gt;it hasn't rotted&lt;/em&gt; (continuous CVE scan).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The Checkpoint&lt;/strong&gt; enforces &lt;em&gt;all of the above at runtime&lt;/em&gt; (unsigned → rejected).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The honest head-to-head — assemble best-of-breed, or buy the integrated platform?
&lt;/h3&gt;

&lt;p&gt;There's a fair objection to everything above: &lt;strong&gt;GitLab already does all of this in one product.&lt;/strong&gt; So do GitHub, Azure DevOps, and JFrog. Why bolt together seven tools when a single platform ships SCM + CI + registry + scanning + deploy pre-wired, one login, one vendor to call? That's the real decision at the &lt;em&gt;workshop&lt;/em&gt; level — not "Tekton vs Jenkins" (that's the Assembly-Line post), but &lt;strong&gt;assemble a best-of-breed supply chain, or buy an all-inclusive one.&lt;/strong&gt; The field splits three ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Integrated DevOps platforms&lt;/strong&gt; — &lt;strong&gt;GitLab&lt;/strong&gt; (SCM + CI + registry + SAST/DAST/container scan + deploy in one product), &lt;strong&gt;GitHub&lt;/strong&gt; (Actions + Packages + Advanced Security + Environments), &lt;strong&gt;Azure DevOps&lt;/strong&gt;, &lt;strong&gt;JFrog Platform&lt;/strong&gt; (Artifactory + Xray). One product, one UI, one auth, batteries included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed cloud-native chains&lt;/strong&gt; — &lt;strong&gt;AWS&lt;/strong&gt; (CodePipeline + ECR), &lt;strong&gt;GCP&lt;/strong&gt; (Cloud Build + Artifact Registry + Binary Authorization). Wired into cloud IAM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assembled best-of-breed&lt;/strong&gt; (this workshop) — Tekton + SonarQube + Trivy + Harbor + Chains + Kyverno + ArgoCD. Best-in-class per stage, K8s-native, self-hosted, composable — but &lt;em&gt;you&lt;/em&gt; are the integrator.&lt;/li&gt;
&lt;/ol&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;Integration&lt;/th&gt;
&lt;th&gt;Best-in-class / stage&lt;/th&gt;
&lt;th&gt;Self-host on your K8s&lt;/th&gt;
&lt;th&gt;Supply-chain depth (sign→verify)&lt;/th&gt;
&lt;th&gt;Lock-in&lt;/th&gt;
&lt;th&gt;You integrate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;This workshop&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Self-hosted&lt;/td&gt;
&lt;td&gt;✅ each&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅✅ cosign + SLSA + &lt;strong&gt;admission verify&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;✅ (the tax)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GitLab&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅✅ pre-wired&lt;/td&gt;
&lt;td&gt;⚠️ "good enough"&lt;/td&gt;
&lt;td&gt;✅ (CE/EE)&lt;/td&gt;
&lt;td&gt;⚠️ signs, weaker on-cluster admission&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GitHub&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅✅&lt;/td&gt;
&lt;td&gt;✅ SCM&lt;/td&gt;
&lt;td&gt;❌ (GHES $$)&lt;/td&gt;
&lt;td&gt;⚠️ attestations, off-cluster&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Azure DevOps&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JFrog&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ artifact-centric&lt;/td&gt;
&lt;td&gt;✅✅ registry/scan&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅ Xray&lt;/td&gt;
&lt;td&gt;high ($)&lt;/td&gt;
&lt;td&gt;partial&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Where the integrated platforms &lt;strong&gt;genuinely win&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitLab&lt;/strong&gt; is the strongest all-in-one, full stop. One product, one UI, one auth, SCM through deploy — and it self-hosts. For a team that wants CI/CD to &lt;em&gt;just work&lt;/em&gt; without becoming systems integrators, GitLab is the right answer and I'd recommend it without flinching. My only counter: it's a monolith where each stage is "good enough" rather than best-in-class, and you live on GitLab's rails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub (Actions + GHAS)&lt;/strong&gt; — unmatched SCM, marketplace, Dependabot, and native build attestations. If your code already lives on GitHub, the integrated path is the path of least resistance. It leans cloud (self-hosted GHES is a bill), and the trust story lives &lt;em&gt;off&lt;/em&gt; your cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure DevOps&lt;/strong&gt; — mature and enterprise-solid in Microsoft shops (Boards + Repos + Pipelines + Artifacts); aging, but cohesive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JFrog Platform&lt;/strong&gt; — if artifacts are your center of gravity, Artifactory + Xray is the gold standard for binary management and deep dependency scanning. Commercial, and heavier than Harbor for a homelab.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why &lt;em&gt;assemble&lt;/em&gt; for this cluster: every station is best-in-class and &lt;strong&gt;Kubernetes-native&lt;/strong&gt; — the whole thing runs on my own RKE2 with no external runners, no per-seat SaaS bill, and no cloud — it's &lt;strong&gt;composable&lt;/strong&gt; (swap SonarQube out without touching the rest), there's &lt;strong&gt;zero lock-in&lt;/strong&gt;, and — the part integrated platforms rarely match on-prem — the &lt;strong&gt;supply-chain depth&lt;/strong&gt;: cosign signatures + SLSA provenance from Tekton Chains, &lt;em&gt;enforced at admission&lt;/em&gt; by Kyverno &lt;code&gt;verifyImages&lt;/code&gt;. The cluster refuses to run an image it can't prove it built. That last, load-bearing link is the one most platforms don't give you as rigorously inside your own cluster.&lt;/p&gt;

&lt;p&gt;The honest cost is precisely what GitLab sells against: &lt;strong&gt;I am the systems integrator.&lt;/strong&gt; Seven tools is seven things to deploy, wire, and keep talking — Tekton's robot creds into Harbor, Chains' signing key, Kyverno's &lt;code&gt;cosign.pub&lt;/code&gt;, ArgoCD watching all of it. GitLab hands you that pre-wired; I hand it to myself, and that assembly &lt;em&gt;is&lt;/em&gt; the tax. I pay it for best-of-breed, self-hosting, and a provenance story I control end to end — which, for a homelab hosting &lt;strong&gt;Vault&lt;/strong&gt;, &lt;strong&gt;Keycloak&lt;/strong&gt;, and &lt;strong&gt;real data pipelines&lt;/strong&gt;, is the entire point. If I were shipping a product on a deadline with a small team, I'd probably just buy GitLab and move on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Supply Chain — seven stages
&lt;/h2&gt;

&lt;p&gt;The diagram for this one lives in&lt;br&gt;
&lt;code&gt;CICD-ARCHITECTURE-DIAGRAM-AI-DESIGN-PROMPT.md&lt;/code&gt; next to this article (paste it&lt;br&gt;
into Gemini or ChatGPT for the rendered version). In words, the line runs:&lt;/p&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;Stage&lt;/th&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Produces&lt;/th&gt;
&lt;th&gt;Fails the run if…&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Trigger&lt;/td&gt;
&lt;td&gt;GitHub Actions on a self-hosted ARC runner&lt;/td&gt;
&lt;td&gt;a &lt;code&gt;PipelineRun&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Quality gate&lt;/td&gt;
&lt;td&gt;SonarQube, in-pipeline before the build&lt;/td&gt;
&lt;td&gt;pass/fail&lt;/td&gt;
&lt;td&gt;the quality gate fails (proven: exit 3 on planted violations)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Build&lt;/td&gt;
&lt;td&gt;Tekton + kaniko &lt;code&gt;v1.23.2&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;image + digest&lt;/td&gt;
&lt;td&gt;the build fails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Vulnerability gate&lt;/td&gt;
&lt;td&gt;Trivy &lt;code&gt;0.58.0&lt;/code&gt; against the &lt;strong&gt;pushed digest&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;pass/fail&lt;/td&gt;
&lt;td&gt;a CRITICAL or HIGH CVE is present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Sign + attest&lt;/td&gt;
&lt;td&gt;Tekton Chains (x509, &lt;code&gt;simplesigning&lt;/code&gt; + in-toto)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.sig&lt;/code&gt; + &lt;code&gt;.att&lt;/code&gt; in Harbor&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Label&lt;/td&gt;
&lt;td&gt;the pipeline's own &lt;code&gt;finally&lt;/code&gt; task&lt;/td&gt;
&lt;td&gt;Harbor &lt;code&gt;signed&lt;/code&gt; label&lt;/td&gt;
&lt;td&gt;Chains never published a signature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Admission verify&lt;/td&gt;
&lt;td&gt;Kyverno &lt;code&gt;verifyImages&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;admit / reject&lt;/td&gt;
&lt;td&gt;the image is unsigned or signed by the wrong key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice the shape: it's a &lt;strong&gt;loop that closes on itself&lt;/strong&gt;. The workshop produces a signed image; GitOps deploys it; the security system checks the workshop's own signature before it runs. Nothing about that trust is imported.&lt;/p&gt;

&lt;p&gt;And every one of those gates has been shown to say &lt;strong&gt;no&lt;/strong&gt;, not just yes — which&lt;br&gt;
is the only evidence that any of them are real. A gate you've never seen refuse&lt;br&gt;
something is indistinguishable from a gate that admits everything. The two&lt;br&gt;
negative tests are in the walkthrough below.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;The Assembly Line — Tekton :&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tekton is the line itself—Kubernetes-native CI, where every build step is a container and every pipeline is a CRD. There's no long-lived build server to patch, no plugin hell; a &lt;code&gt;PipelineRun&lt;/code&gt; spins up pods, does its work, and disappears.&lt;/p&gt;

&lt;p&gt;I run it through the &lt;strong&gt;Tekton Operator&lt;/strong&gt;: a single &lt;code&gt;TektonConfig&lt;/code&gt; CR is the whole control surface. From that one resource the operator reconciles Pipelines, Triggers, the Dashboard, &lt;strong&gt;Chains&lt;/strong&gt;, the Pruner, and the ManualApprovalGate into the &lt;code&gt;tekton-pipelines&lt;/code&gt; namespace. One CR, the entire Tekton estate—declarative, versioned, self-healing.&lt;/p&gt;

&lt;p&gt;The working pipeline is &lt;code&gt;e2e-build-scan-sign&lt;/code&gt;, and it runs in supply-chain&lt;br&gt;
order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;prepare-source&lt;/strong&gt; — stages &lt;code&gt;main.go&lt;/code&gt; + &lt;code&gt;Dockerfile&lt;/code&gt; into the build workspace. Source arrives as a &lt;em&gt;content-hashed ConfigMap&lt;/em&gt;, not a &lt;code&gt;git-clone&lt;/code&gt;: this repo is private, and an unauthenticated clone 404s.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;sonar-scan&lt;/strong&gt; — the SonarQube quality gate, with &lt;code&gt;qualitygate.wait=true&lt;/code&gt; so the scanner blocks on the server's verdict instead of posting an analysis and exiting 0.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;trivy-fs-scan&lt;/strong&gt; — a filesystem pre-check for secrets and Dockerfile misconfiguration. &lt;strong&gt;This is not the vulnerability gate.&lt;/strong&gt; More on that in a moment, because it is the most instructive trap in the whole pipeline.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;kaniko&lt;/strong&gt; — builds the OCI image &lt;em&gt;without a Docker daemon&lt;/em&gt; (no privileged socket to escape from) and pushes &lt;code&gt;harbor.georgehomelab.com/library/&amp;lt;app&amp;gt;:&amp;lt;tag&amp;gt;&lt;/code&gt;, writing the digest to &lt;code&gt;IMAGE_DIGEST&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;trivy-image-scan&lt;/strong&gt; — the real gate: &lt;code&gt;trivy image --severity CRITICAL,HIGH --exit-code 1&lt;/code&gt; against the &lt;strong&gt;pushed digest&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;write-image-url&lt;/strong&gt; — emits &lt;code&gt;IMAGE_URL&lt;/code&gt; alongside &lt;code&gt;IMAGE_DIGEST&lt;/code&gt;, so Chains knows exactly which artifact to sign.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;&lt;code&gt;finally&lt;/code&gt;: label-signed&lt;/strong&gt; — waits for Chains to publish the signature, then labels the artifact in Harbor. It is a &lt;code&gt;finally&lt;/code&gt; task for a reason, covered in the Maker's Mark section.
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;kubectl create -f cicd-pipeline-e2e-example/pipeline/pipelinerun.yaml&lt;/span&gt;
&lt;span class="s"&gt;kubectl -n tekton-builds get pipelinerun -w&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Why the filesystem scan is not the gate.&lt;/strong&gt; Both steps are Trivy, both carry&lt;br&gt;
&lt;code&gt;--exit-code 1&lt;/code&gt;, and only one of them can actually fail a build. I proved it by&lt;br&gt;
pinning an EOL &lt;code&gt;golang:1.23-alpine&lt;/code&gt; base image and running 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="s"&gt;prepare-source     → exit &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="s"&gt;trivy-fs-scan      → exit &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;        &lt;span class="c1"&gt;# ← blind to it&lt;/span&gt;
&lt;span class="s"&gt;kaniko             → exit &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;        &lt;span class="c1"&gt;# vulnerable image is pushed, by design&lt;/span&gt;
&lt;span class="s"&gt;trivy-image-scan   → exit &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;        &lt;span class="c1"&gt;# Total: 22 (HIGH: 21, CRITICAL: 1)&lt;/span&gt;
&lt;span class="s"&gt;write-image-url    → never ran&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;trivy filesystem&lt;/code&gt; reads dependency manifests and lockfiles. A stdlib-only Go&lt;br&gt;
program has none, so it reports &lt;code&gt;num=0&lt;/code&gt;, exits 0, and looks exactly like a pass&lt;br&gt;
— while 22 CVEs sit compiled into the binary. Only &lt;code&gt;trivy image&lt;/code&gt; against the&lt;br&gt;
pushed digest sees them. A pipeline carrying just the filesystem scan would&lt;br&gt;
look identical to this one on every green run and gate &lt;strong&gt;nothing&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The CRITICAL was &lt;code&gt;CVE-2025-68121&lt;/code&gt; in &lt;code&gt;crypto/tls&lt;/code&gt;. Putting the toolchain back&lt;br&gt;
to &lt;code&gt;golang:1.27-alpine&lt;/code&gt; went green again with a byte-identical digest —&lt;br&gt;
&lt;code&gt;kaniko --reproducible&lt;/code&gt; means the same source rebuilds to the same bytes, which&lt;br&gt;
is what makes a signature worth anything.&lt;/p&gt;

&lt;p&gt;Scanning &lt;em&gt;after&lt;/em&gt; the push is deliberate, and worth being explicit about: a&lt;br&gt;
failing image does land in Harbor. But the step still gates deployment, because&lt;br&gt;
a failed step fails the TaskRun, so &lt;code&gt;IMAGE_URL&lt;/code&gt; is never written, &lt;strong&gt;Chains never&lt;br&gt;
signs it&lt;/strong&gt;, and the digest never reaches a manifest. The unsigned failure then&lt;br&gt;
gets refused a second time at admission. Two independent gates catch it.&lt;/p&gt;

&lt;p&gt;📸 &lt;strong&gt;SCREENSHOT:&lt;/strong&gt; the Tekton Dashboard mid-run — a &lt;code&gt;PipelineRun&lt;/code&gt; with its Tasks going green, or the &lt;code&gt;kaniko&lt;/code&gt; step streaming a build.&lt;/p&gt;

&lt;p&gt;Two traps I paid for:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;/workspace/source&lt;/code&gt; permission wall.&lt;/strong&gt; Tekton's &lt;code&gt;prepare&lt;/code&gt; initContainer pre-creates the shared workspace as &lt;strong&gt;uid 65532&lt;/strong&gt;. Your Task containers run non-root and then can't write into their own workspace—&lt;code&gt;permission denied&lt;/code&gt;, mid-build, for no obvious reason. The fix is to grant the one capability that unblocks it (or match the uid):&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;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;add&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;DAC_OVERRIDE"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;     &lt;span class="c1"&gt;# or: runAsUser: 65532&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Dashboard's login lie (&lt;code&gt;feedback_oauth2_proxy_authheader_breaks_kube_passthrough&lt;/code&gt;).&lt;/strong&gt; The Tekton Dashboard has no native auth, so it sits behind an oauth2-proxy (Keycloak realm &lt;code&gt;tekton-dashboard&lt;/code&gt;). The overlay &lt;em&gt;must&lt;/em&gt; set &lt;code&gt;PASS_AUTHORIZATION_HEADER=false&lt;/code&gt; and &lt;code&gt;SET_AUTHORIZATION_HEADER=false&lt;/code&gt;. Flip them to &lt;code&gt;true&lt;/code&gt; and oauth2-proxy forwards the Keycloak JWT to the Dashboard, which hands it to the kube-apiserver as a bearer token—the apiserver rejects it (Keycloak isn't a registered OIDC provider &lt;em&gt;at the apiserver&lt;/em&gt;), and the UI shows &lt;code&gt;401 Error loading &amp;lt;kind&amp;gt;&lt;/code&gt; &lt;strong&gt;after a visibly successful login.&lt;/strong&gt; The meanest kind of bug: the one that looks like it worked.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The Quality Inspector — SonarQube :&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A build that compiles isn't a build worth shipping. SonarQube is the inspector standing between "it built" and "it's allowed to leave the workshop": if the code fails the quality gate—coverage, bugs, security hotspots, code smells past a threshold—&lt;strong&gt;the run fails and no image gets signed.&lt;/strong&gt; The gate isn't advisory; it's wired into the line.&lt;/p&gt;

&lt;p&gt;Writing this section is what got the gate fixed. The draft originally said the&lt;br&gt;
scan ran inline, then that it ran from the workflow — both were wrong, and the&lt;br&gt;
truth was that &lt;code&gt;e2e-build-scan-sign&lt;/code&gt; simply had no SonarQube step at all. It had&lt;br&gt;
been dropped because it failed both PipelineRuns this cluster had ever had. The&lt;br&gt;
failures turned out to be a &lt;strong&gt;usage error&lt;/strong&gt;, not a gate saying no:&lt;br&gt;
&lt;code&gt;sonar-scanner&lt;/code&gt; ran without &lt;code&gt;sonar.projectKey&lt;/code&gt;, which exits 1 or 2 before kaniko&lt;br&gt;
is reached. That reads identically to a quality gate rejection in the&lt;br&gt;
PipelineRun view, which is why it stood for three months.&lt;/p&gt;

&lt;p&gt;It is back now, as step 2, before the build — there is no reason to compile and&lt;br&gt;
push code that has already failed review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the second attempt failed more quietly than the first.&lt;/strong&gt; My step passed&lt;br&gt;
&lt;code&gt;-Dsonar.sources=/workspace/build&lt;/code&gt;, an absolute path. &lt;code&gt;sonar.sources&lt;/code&gt; resolves&lt;br&gt;
&lt;em&gt;relative to&lt;/em&gt; &lt;code&gt;sonar.projectBaseDir&lt;/code&gt;, so the scanner matched nothing and said so&lt;br&gt;
in a line nobody reads:&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="s"&gt;0 languages detected in 0 preprocessed files&lt;/span&gt;
&lt;span class="s"&gt;0 files indexed&lt;/span&gt;
&lt;span class="na"&gt;QUALITY GATE STATUS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PASSED&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Green build, gate passed, &lt;strong&gt;nothing analysed&lt;/strong&gt;. I only caught it because I&lt;br&gt;
planted deliberate violations in &lt;code&gt;main.go&lt;/code&gt; and watched them sail through. With&lt;br&gt;
&lt;code&gt;-Dsonar.projectBaseDir=/workspace/build -Dsonar.sources=.&lt;/code&gt; the same source&lt;br&gt;
indexes 2 files, the gate returns FAILED, the step exits 3, and kaniko never&lt;br&gt;
runs. Reverting the violations puts it back to green with a byte-identical&lt;br&gt;
digest.&lt;/p&gt;

&lt;p&gt;Note which failure was better: the original exited 2 and stopped the line. Mine&lt;br&gt;
exited 0 and shipped. A gate that fails loudly is a working gate; a gate that&lt;br&gt;
passes without looking is worse than no gate at all, because it buys you&lt;br&gt;
confidence you have not earned. The only way to tell them apart is to make the&lt;br&gt;
thing fail on purpose.&lt;/p&gt;

&lt;p&gt;The scan Task talks to SonarQube over the &lt;em&gt;cluster-internal&lt;/em&gt; URL (&lt;code&gt;http://sonarqube-sonarqube.sonarqube.svc:9000&lt;/code&gt;) with a scan-only token from Vault—it never touches the SAML2 SSO that gates the human UI. Machines use tokens; people use Keycloak. (SonarQube Community has no native OIDC, so the human path is &lt;strong&gt;SAML2&lt;/strong&gt;, which is its own small saga—see the SSO note below.)&lt;/p&gt;

&lt;p&gt;Two traps here that cost real time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The CrashLoop with no error (&lt;code&gt;feedback_sonarqube_chart_gotchas&lt;/code&gt;).&lt;/strong&gt; SonarQube's most baffling failure mode is a pod that restarts forever with liveness-probe failures and nothing useful in the logs. The cause is almost always a &lt;strong&gt;missing &lt;code&gt;monitoringPasscode&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;SONAR_WEB_SYSTEMPASSCODE&lt;/code&gt;)—without it the liveness endpoint never returns healthy, so the pod is killed and restarted in perpetuity. The Vault path &lt;code&gt;secret/homelab/sonarqube&lt;/code&gt; carries a &lt;code&gt;monitoring_passcode&lt;/code&gt; field for exactly this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The migration dirty-flag deadlock (&lt;code&gt;feedback_golang_migrate_dirty_trap&lt;/code&gt;).&lt;/strong&gt; On an interrupted first boot, the schema-migration table can be left with &lt;code&gt;dirty=true&lt;/code&gt;, and every subsequent start refuses to proceed. The release valve is &lt;code&gt;holdApplicationUntilProxyStarts&lt;/code&gt; (so Istio's sidecar is up before the migration runs) plus a one-time &lt;code&gt;UPDATE schema_migrations SET dirty=false&lt;/code&gt;. Also: don't double-declare SonarQube's ServiceAccount—the chart already makes one, and declaring it again in the kustomization gives ArgoCD a render conflict.&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%2Ftva0dbcu4egj7fqjve80.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%2Ftva0dbcu4egj7fqjve80.png" alt="sonar1" width="799" height="306"&gt;&lt;/a&gt;&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%2Ft6n1fcdy3ygm10l3khhp.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%2Ft6n1fcdy3ygm10l3khhp.png" alt="sonar2" width="799" height="304"&gt;&lt;/a&gt;&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%2Fkxr174ttv4atdzgh4k3w.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%2Fkxr174ttv4atdzgh4k3w.png" alt="sonar3" width="799" height="306"&gt;&lt;/a&gt;&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%2Fldbfgpui9zv5bh8g1cqs.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%2Fldbfgpui9zv5bh8g1cqs.png" alt="sonar4" width="799" height="306"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A SonarQube project's Quality Gate — the green "Passed" (or a red "Failed" that killed a pipeline), with the coverage/bugs/hotspots breakdown.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;The Maker's Mark — Tekton Chains :&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the station that turns "I built it" into "I can &lt;em&gt;prove&lt;/em&gt; I built it, and prove it hasn't changed since." &lt;strong&gt;Tekton Chains&lt;/strong&gt; watches every &lt;code&gt;TaskRun&lt;/code&gt;, and the moment a build produces an image, it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Signs it with cosign&lt;/strong&gt; — a &lt;code&gt;.sig&lt;/code&gt; artifact, keyed to the private half of a keypair that lives &lt;em&gt;only&lt;/em&gt; in the &lt;code&gt;signing-secrets&lt;/code&gt; Secret inside the cluster.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Attaches an in-toto SLSA provenance attestation&lt;/strong&gt; — a &lt;code&gt;.att&lt;/code&gt; artifact that records &lt;em&gt;what was built, from what, by which pipeline&lt;/em&gt;. A machine-readable birth certificate.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The keypair is &lt;strong&gt;owned by Vault&lt;/strong&gt;, not by a bootstrap script. It lives at&lt;br&gt;
&lt;code&gt;secret/homelab/tekton/cosign&lt;/code&gt; and VSO projects it into the &lt;code&gt;signing-secrets&lt;/code&gt;&lt;br&gt;
Secret that the Chains controller mounts. The Vault role is bound to the&lt;br&gt;
&lt;code&gt;tekton-chains-controller&lt;/code&gt; ServiceAccount &lt;em&gt;alone&lt;/em&gt; — deliberately not to&lt;br&gt;
&lt;code&gt;tekton-builder&lt;/code&gt;, the identity kaniko runs as. Folding the key into the robot&lt;br&gt;
credential that already has push rights would collapse "can push" and "can&lt;br&gt;
sign" into one blast radius, which is the exact property signing exists to keep&lt;br&gt;
apart. The &lt;em&gt;public&lt;/em&gt; half is committed so anyone — including the admission&lt;br&gt;
controller — can verify without holding the secret.&lt;/p&gt;

&lt;p&gt;Verifying offline is a two-liner:&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="s"&gt;cosign verify --insecure-ignore-tlog \&lt;/span&gt;
  &lt;span class="s"&gt;--key tekton/tekton-chains/cosign.pub \&lt;/span&gt;
  &lt;span class="s"&gt;harbor.georgehomelab.com/library/hello-supply-chain:v1&lt;/span&gt;

&lt;span class="s"&gt;cosign verify-attestation --insecure-ignore-tlog --type slsaprovenance \&lt;/span&gt;
  &lt;span class="s"&gt;--key tekton/tekton-chains/cosign.pub \&lt;/span&gt;
  &lt;span class="s"&gt;harbor.georgehomelab.com/library/hello-supply-chain:v1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The trap that cost me 101 days: &lt;code&gt;chains.tekton.dev/signed: "true"&lt;/code&gt; is not&lt;br&gt;
evidence of anything.&lt;/strong&gt; The &lt;code&gt;signing-secrets&lt;/code&gt; Secret shipped by the Tekton&lt;br&gt;
operator is created &lt;strong&gt;empty&lt;/strong&gt;, and Chains stamps that annotation on completed&lt;br&gt;
runs regardless. Every run for over three months carried &lt;code&gt;signed=true&lt;/code&gt; while&lt;br&gt;
the signer was failing with &lt;code&gt;no valid private key found, looked for:&lt;br&gt;
[x509.pem, cosign.key]&lt;/code&gt; — a warning in the controller log and nowhere else. The&lt;br&gt;
run is green, the annotation says signed, and there is no signature.&lt;/p&gt;

&lt;p&gt;Only &lt;code&gt;cosign verify&lt;/code&gt; against the public key counts. A related detail: because&lt;br&gt;
the key is a &lt;strong&gt;volume mount&lt;/strong&gt; read once at signer startup, and the Tekton&lt;br&gt;
operator reverts &lt;code&gt;kubectl rollout restart&lt;/code&gt; on its own Deployments within about&lt;br&gt;
two seconds, the only way to load a rotated key is to delete the pod.&lt;/p&gt;

&lt;p&gt;That lesson is also why the &lt;code&gt;finally&lt;/code&gt; task exists. Chains signs a TaskRun only&lt;br&gt;
&lt;em&gt;after&lt;/em&gt; it completes, so a step inside the build cannot see the signature yet —&lt;br&gt;
labelling there would assert something unverified, which is the same bug in a&lt;br&gt;
new costume. Instead the &lt;code&gt;finally&lt;/code&gt; task polls Harbor for the signature&lt;br&gt;
accessory and only then writes the label. No signature inside five minutes and&lt;br&gt;
it &lt;strong&gt;fails the run&lt;/strong&gt;: the build, scan and push all succeeded, but an artifact&lt;br&gt;
admission will refuse is not a green pipeline.&lt;/p&gt;

&lt;p&gt;(&lt;code&gt;--insecure-ignore-tlog&lt;/code&gt; because Chains' transparency-log integration isn't wired to a public Rekor here—signatures are verified against the key, not a public log.)&lt;/p&gt;

&lt;p&gt;The trap (&lt;code&gt;feedback_harbor_chart_gotchas&lt;/code&gt;, last one): &lt;strong&gt;Chains' &lt;code&gt;k8schain&lt;/code&gt; reads &lt;code&gt;imagePullSecrets&lt;/code&gt; from the &lt;em&gt;TaskRun's&lt;/em&gt; ServiceAccount, not the Chains controller's.&lt;/strong&gt; If the controller logs &lt;code&gt;UNAUTHORIZED&lt;/code&gt; reading a manifest it's trying to sign, it's not a Chains bug—the &lt;code&gt;tekton-builder&lt;/code&gt; SA in &lt;code&gt;tekton-builds&lt;/code&gt; needs the Harbor robot secret attached:&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;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;ServiceAccount&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;tekton-builder&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;tekton-builds&lt;/span&gt;
&lt;span class="na"&gt;imagePullSecrets&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;harbor-tekton-builder-creds&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;📸 &lt;strong&gt;SCREENSHOT:&lt;/strong&gt; the &lt;code&gt;cosign verify&lt;/code&gt; output returning &lt;code&gt;Verified OK&lt;/code&gt;, and/or a Harbor tag's "Signatures" column showing the &lt;code&gt;.sig&lt;/code&gt; + &lt;code&gt;.att&lt;/code&gt; sitting next to the manifest.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The Warehouse — Harbor :&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every workshop needs a warehouse it controls, not a public shelf anyone can restock. &lt;strong&gt;Harbor&lt;/strong&gt; (&lt;code&gt;harbor.georgehomelab.com&lt;/code&gt;) is that warehouse: a purpose-built OCI registry that stores the image, displays its cosign signature and SLSA attestation &lt;em&gt;inline&lt;/em&gt;, runs a &lt;strong&gt;native Trivy scan&lt;/strong&gt; on every push, and gates human access with &lt;strong&gt;native Keycloak OIDC&lt;/strong&gt; (callback &lt;code&gt;/c/oidc/callback&lt;/code&gt;; the &lt;code&gt;harbor-admin&lt;/code&gt; Keycloak group maps to Harbor system admin). Machines authenticate as &lt;strong&gt;robot accounts&lt;/strong&gt;—&lt;code&gt;tekton-builder&lt;/code&gt; in the &lt;code&gt;library&lt;/code&gt; project, push+pull, one-shot secret vaulted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A warehouse only shows you what you've asked it to look for.&lt;/strong&gt; My artifact&lt;br&gt;
page displayed a signed, scanned, pipeline-built image as: not scanned, no&lt;br&gt;
SBOM, no labels. Nothing was broken. The &lt;code&gt;library&lt;/code&gt; project carried exactly one&lt;br&gt;
setting — &lt;code&gt;public: true&lt;/code&gt; — and both &lt;code&gt;auto_scan&lt;/code&gt; and &lt;code&gt;auto_sbom_generation&lt;/code&gt;&lt;br&gt;
default to &lt;strong&gt;off&lt;/strong&gt;, so Harbor's own Trivy had never run on a single artifact in&lt;br&gt;
it, despite the chart shipping that scanner and registering it as default.&lt;/p&gt;

&lt;p&gt;Two API calls fixed it permanently, and they now live in a PostSync hook Job&lt;br&gt;
beside the OIDC one, because this configuration lives in Harbor's &lt;strong&gt;database&lt;/strong&gt;,&lt;br&gt;
not the chart — a rebuilt Harbor would silently regress to the same blank page:&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="s"&gt;curl -u admin:$PW -X PUT -H 'Content-Type&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json' \&lt;/span&gt;
  &lt;span class="s"&gt;-d '{"metadata":{"auto_scan":"true","auto_sbom_generation":"true"}}' \&lt;/span&gt;
  &lt;span class="s"&gt;"$H/api/v2.0/projects/1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth saying plainly: Harbor's scan is &lt;strong&gt;not&lt;/strong&gt; a duplicate of the pipeline's.&lt;br&gt;
The pipeline gate proves an image was clean when it was &lt;em&gt;built&lt;/em&gt;. Harbor&lt;br&gt;
re-scans on its own schedule and catches CVEs disclosed &lt;em&gt;after&lt;/em&gt; publication —&lt;br&gt;
something a build-time gate structurally cannot do.&lt;/p&gt;

&lt;p&gt;The signature, meanwhile, had been there the whole time. Harbor 2.x renders&lt;br&gt;
cosign signatures as &lt;strong&gt;accessories&lt;/strong&gt;, behind the expand arrow on the artifact&lt;br&gt;
row — there is no "signed" column any more; that was Notary v1. An artifact&lt;br&gt;
that looks unsigned in the list may be perfectly signed, and&lt;br&gt;
&lt;code&gt;?with_accessory=true&lt;/code&gt; on the API is how you find out.&lt;/p&gt;

&lt;p&gt;Harbor is also, hands down, the fussiest chart in this whole stack. Three traps enforced by the chart itself (&lt;code&gt;feedback_harbor_chart_gotchas&lt;/code&gt;):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;&lt;code&gt;secret_key&lt;/code&gt; must be EXACTLY 16 characters&lt;/strong&gt; — Harbor core has a hardcoded length check, and the &lt;em&gt;same&lt;/em&gt; value has to appear under three Secret keys (&lt;code&gt;secret&lt;/code&gt;, &lt;code&gt;secretKey&lt;/code&gt;, &lt;code&gt;HARBOR_SECRET_KEY&lt;/code&gt;). Sixteen. Not fifteen, not seventeen.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;The htpasswd value must keep its &lt;code&gt;username:&lt;/code&gt; prefix&lt;/strong&gt; — the chart strips trailing newlines but does &lt;em&gt;not&lt;/em&gt; add the prefix, so you generate it with &lt;code&gt;htpasswd -nbBC 10 harbor_registry_user &amp;lt;password&amp;gt;&lt;/code&gt; and leave the &lt;code&gt;user:&lt;/code&gt; on the front.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;The PostgreSQL StatefulSet needs &lt;code&gt;updateStrategy: Recreate&lt;/code&gt;&lt;/strong&gt; — leave it on the default &lt;code&gt;RollingUpdate&lt;/code&gt; and every upgrade &lt;em&gt;deadlocks&lt;/em&gt;, because the new pod can't bind the RWO PVC the old pod still holds.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are in the quickstart. All three will cost you an evening if you don't know them going in.&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%2Fegwk5c2qg73l45suva3x.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%2Fegwk5c2qg73l45suva3x.png" alt=" " width="799" height="306"&gt;&lt;/a&gt;&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%2Fymwcb1bhf9qjxluromok.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%2Fymwcb1bhf9qjxluromok.png" alt=" " width="799" height="306"&gt;&lt;/a&gt;&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%2Fkzdaggwyuibr9xhq2ctw.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%2Fkzdaggwyuibr9xhq2ctw.png" alt=" " width="799" height="306"&gt;&lt;/a&gt;&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%2Fzat93kb0ktljv4nyhbtb.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%2Fzat93kb0ktljv4nyhbtb.png" alt=" " width="799" height="306"&gt;&lt;/a&gt;&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%2Fp1db5s00gvnjwjgh9f7n.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%2Fp1db5s00gvnjwjgh9f7n.png" alt=" " width="799" height="306"&gt;&lt;/a&gt;&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%2F0gimbfcey0bg1bb3n9gf.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%2F0gimbfcey0bg1bb3n9gf.png" alt=" " width="799" height="306"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A Harbor repo view — an image tag with its Trivy CVE summary and the Signatures column populated. The warehouse shelf, labelled and sealed.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Signed Blue/Green images deployed via Argocd &amp;amp; exposed with istio service-mesh
&lt;/h2&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%2Fzhcb0voj9kfj5bevlps2.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%2Fzhcb0voj9kfj5bevlps2.png" alt=" " width="800" height="295"&gt;&lt;/a&gt;&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%2F9mlgqzeqoagq8vur1saq.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%2F9mlgqzeqoagq8vur1saq.png" alt=" " width="800" height="295"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Closing the Loop — where the Workshop meets the Security System
&lt;/h2&gt;

&lt;p&gt;Here's the payoff, and the reason Episodes 3 and 4 are really one system.&lt;/p&gt;

&lt;p&gt;Everything above produces a &lt;em&gt;signed&lt;/em&gt; image. But a signature nobody checks is just decoration. So the last link isn't in the workshop at all—it's the front door from Episode 3: a &lt;strong&gt;Kyverno &lt;code&gt;verifyImages&lt;/code&gt; ClusterPolicy&lt;/strong&gt; that, at admission, cryptographically checks every image against &lt;code&gt;cosign.pub&lt;/code&gt; and &lt;strong&gt;rejects anything unsigned or signed by the wrong key.&lt;/strong&gt;&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="s"&gt;git push → Tekton builds → SonarQube gate → Trivy gate → Chains signs →&lt;/span&gt;
&lt;span class="s"&gt;Harbor stores → ArgoCD deploys → Kyverno verifies the signature → runs&lt;/span&gt;
                                                     &lt;span class="s"&gt;│&lt;/span&gt;
                                        &lt;span class="s"&gt;unsigned? ───┴──▶ REJECTED at the door&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The policy does two things at admission, and they have to be switched on&lt;br&gt;
&lt;strong&gt;together&lt;/strong&gt;: it verifies the signature, and with &lt;code&gt;mutateDigest: true&lt;/code&gt; it&lt;br&gt;
rewrites &lt;code&gt;:v1&lt;/code&gt; to the verified &lt;code&gt;@sha256:…&lt;/code&gt;. Git still says &lt;code&gt;:v1&lt;/code&gt;; what actually&lt;br&gt;
runs is the exact bytes that were signed. I learned the ordering the hard way —&lt;br&gt;
&lt;code&gt;verifyDigest: true&lt;/code&gt; demands a digest on the reference, and with &lt;code&gt;mutateDigest&lt;/code&gt;&lt;br&gt;
still false a tag never acquires one, so Enforce rejected my &lt;strong&gt;own signed&lt;br&gt;
image&lt;/strong&gt; with &lt;code&gt;missing digest&lt;/code&gt;. The signature was fine the entire time; the&lt;br&gt;
report message just reads almost identically to a signature failure.&lt;/p&gt;

&lt;p&gt;One more line earns its place, for anyone running this under GitOps:&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;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pod-policies.kyverno.io/autogen-controllers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;none&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without it Kyverno autogenerates a Deployment-scoped copy of the rule that&lt;br&gt;
validates the pod &lt;em&gt;template&lt;/em&gt;, where the reference is still a tag. Under Enforce&lt;br&gt;
that denies every write to the Deployment — &lt;strong&gt;including ArgoCD's server-side&lt;br&gt;
apply dry run&lt;/strong&gt; — so the Application parks in &lt;code&gt;ComparisonError&lt;/code&gt; while the pods&lt;br&gt;
stay happily Healthy, and the error names &lt;code&gt;validate.kyverno.svc-ignore&lt;/code&gt; rather&lt;br&gt;
than anything that sounds like ArgoCD. Pods are the right enforcement point&lt;br&gt;
anyway: an unsigned Deployment rolls out zero of them, because every Pod the&lt;br&gt;
ReplicaSet creates is refused.&lt;/p&gt;

&lt;p&gt;And the assertion that matters, the one that proves the door is a door — I&lt;br&gt;
pushed an image to the same repository outside the pipeline, so Chains never&lt;br&gt;
saw 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;Error from server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;admission webhook "validate.kyverno.svc-ignore" denied the request&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;verify-hello-supply-chain-signature&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;verify-cosign-signature&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;verify&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;image&lt;/span&gt;
      &lt;span class="s"&gt;harbor.georgehomelab.com/library/hello-supply-chain:unsigned:&lt;/span&gt;
      &lt;span class="s"&gt;.attestors[0].entries[0].keys:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;signatures&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;found'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thesis made real: the cluster doesn't trust an image because it came from a registry. It trusts it because &lt;strong&gt;it can prove the image came from this workshop.&lt;/strong&gt; Provenance in, provenance enforced—a closed loop with no imported faith.&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%2F9c1cfmvvvf5cen77mooj.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%2F9c1cfmvvvf5cen77mooj.png" alt=" " width="798" height="86"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;kubectl apply&lt;/code&gt; of an &lt;em&gt;unsigned&lt;/em&gt; public image being rejected by the Kyverno &lt;code&gt;verifyImages&lt;/code&gt; policy — the checkpoint turning away an artifact with no maker's mark.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next? (Don't Miss Out!)
&lt;/h2&gt;

&lt;p&gt;The workshop is running. Source goes in one end; a scanned, signed, provenance-stamped image comes out the other—and the front door won't admit anything that didn't. The cluster now &lt;em&gt;manufactures its own trust&lt;/em&gt; instead of importing it.&lt;/p&gt;

&lt;p&gt;Which means the platform is finally ready for the thing all of this was for: &lt;strong&gt;real workloads.&lt;/strong&gt; With a security system that guards the house and a workshop that proves everything built for it, the next episodes move the tenants in—the MLOps platform, the data lakehouse, the private services—every one of them running on images this cluster built and signed itself.&lt;/p&gt;

&lt;p&gt;That's the next chapter: &lt;strong&gt;from a secured, self-provisioning platform to a busy one.&lt;/strong&gt; Follow along so you don't miss how the empty, hardened house finally fills up. 🏭&lt;/p&gt;

&lt;p&gt;The ArgoCD &lt;code&gt;cicd-stack&lt;/code&gt; ApplicationSet — Tekton, Harbor, and SonarQube all &lt;code&gt;Synced&lt;/code&gt; + &lt;code&gt;Healthy&lt;/code&gt;. The whole workshop, GitOps-reconciled, in one frame.&lt;/p&gt;

&lt;p&gt;Subscribe and follow me on [&lt;a href="https://www.linkedin.com/in/george-ezejiofor-89615a8a/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;] to catch my upcoming articles.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>security</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Harden &amp; Lockdown RKE2 Cluster with a 4-Layer DevSecOps Stack</title>
      <dc:creator>George Ezejiofor</dc:creator>
      <pubDate>Sun, 13 Sep 2026 23:56:31 +0000</pubDate>
      <link>https://dev.to/ezejioforog/harden-lockdown-rke2-cluster-with-a-4-layer-devsecops-stack-iop</link>
      <guid>https://dev.to/ezejioforog/harden-lockdown-rke2-cluster-with-a-4-layer-devsecops-stack-iop</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Before you move furniture into a new house, you install the locks, the cameras, and the gates. Kubernetes is no different, yet engineers constantly build clusters inside-out, treating security as a cosmetic bolt-on to be handled "later."&lt;/p&gt;

&lt;p&gt;Not this time. The order here is entirely deliberate. You don't move into a finished house and &lt;em&gt;then&lt;/em&gt; start wondering about the security system—you wire the alarms while the building is still empty.&lt;/p&gt;

&lt;p&gt;With my 29-node RKE2 infrastructure fully framed across three distributed Proxmox datacenters, the platform is technically alive. But it is also completely vacant. Before a single tenant workload steps foot inside—long before MLflow, Kubeflow, Tekton, or Harbor’s first private project—the security stack goes live.&lt;/p&gt;

&lt;p&gt;When a homelab scales past a handful of nodes and begins hosting your actual identity providers, encrypted secrets engines, private registries, and core data pipelines, the blast radius of a compromise becomes real. Security transforms from a theoretical checklist into an absolute prerequisite.&lt;/p&gt;

&lt;p&gt;This marks the definitive boundary where this project stops behaving like a handcrafted science project and starts operating like a zero-trust production platform. It also marks the moment I transition completely to a &lt;strong&gt;100% GitOps workflow via ArgoCD&lt;/strong&gt;. The days of ad-hoc terminal manipulation and local &lt;code&gt;kubectl apply&lt;/code&gt; commands are over. From here on out, every single policy configuration is a version-controlled commit.&lt;/p&gt;

&lt;p&gt;This article documents the exact mechanics of how I wired four distinct, cloud-native security engines into a unified defensive front covering the full workload lifecycle:&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;The Four Guards&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Here's what I wired together:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Guard&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Engine&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Job&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🚪 The Bouncer&lt;/td&gt;
&lt;td&gt;Kyverno&lt;/td&gt;
&lt;td&gt;Blocks bad manifests at admission&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🔒 The Locks&lt;/td&gt;
&lt;td&gt;KubeArmor&lt;/td&gt;
&lt;td&gt;Watches syscalls at the kernel — and can block them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;📹 The CCTV&lt;/td&gt;
&lt;td&gt;Falco&lt;/td&gt;
&lt;td&gt;Detects and alerts on abnormal behavior&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🔍 The Inspector&lt;/td&gt;
&lt;td&gt;Trivy&lt;/td&gt;
&lt;td&gt;Continuously scans for CVEs and misconfigurations&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each works independently. If one fails, the others don't notice. That's the point.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What Makes This Different&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;This episode covers the full workload lifecycle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Before&lt;/strong&gt; workloads enter the API (admission control)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;While&lt;/strong&gt; workloads run at the kernel tier (runtime audit, enforcement on demand)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;When&lt;/strong&gt; workloads behave anomalously (behavioural detection)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Long after&lt;/strong&gt; workloads were deployed (continuous scanning)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This isn’t a pristine reference diagram copied from a vendor's whitepaper. These are real, unvarnished operational lessons, architectural deadlocks, and configuration missteps encountered while making these engines work together cleanly. My hope is that sharing these traps saves you time on your own infrastructure journey.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where I Am in the Build
&lt;/h3&gt;

&lt;p&gt;This security layer is part of a larger infrastructure series documenting the evolution of the cluster:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Episode 1 — The Foundation&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Infrastructure provisioning with Terraform across multiple Proxmox datacenters&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Episode 2 — The Framing&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Building the HA RKE2 control plane using Ansible, kube-vip, and Cilium&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Episode 3 — The Security System&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Hardening the platform using Kyverno, KubeArmor, Falco, and Trivy Operator&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The infrastructure exists.&lt;/p&gt;

&lt;p&gt;Now the platform gets secured before anything important is allowed to run inside it.&lt;/p&gt;

&lt;p&gt;The repository layout for this stack lives inside &lt;code&gt;kubernetes-addons/security-stack/&lt;/code&gt; with an intentionally clean directory structure:&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="s"&gt;security-stack/&lt;/span&gt;
&lt;span class="s"&gt;├── README.md                                   ← Walkthrough + disaster recovery runbook&lt;/span&gt;
&lt;span class="s"&gt;│&lt;/span&gt;
&lt;span class="na"&gt;├── kyverno/                                    ← The Bouncer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;admission control&lt;/span&gt;
&lt;span class="s"&gt;│   ├── kustomization.yaml&lt;/span&gt;
&lt;span class="s"&gt;│   ├── namespace.yaml&lt;/span&gt;
&lt;span class="s"&gt;│   ├── kyverno-kubearmor-falco-trivy-values.yaml&lt;/span&gt;
&lt;span class="s"&gt;│   └── policies/                               ← shipped as its OWN ArgoCD app&lt;/span&gt;
&lt;span class="s"&gt;│       ├── kustomization.yaml&lt;/span&gt;
&lt;span class="s"&gt;│       ├── charts/kyverno-policies-3.8.0/      ← vendored upstream PSS baseline&lt;/span&gt;
&lt;span class="s"&gt;│       ├── security-policies.yaml              ← my custom hardening rules&lt;/span&gt;
&lt;span class="s"&gt;│       ├── compliance-policies.yaml&lt;/span&gt;
&lt;span class="s"&gt;│       ├── kubeflow-user-namespace-governance.yaml&lt;/span&gt;
&lt;span class="s"&gt;│       ├── gpu-node-scheduling.yaml&lt;/span&gt;
&lt;span class="s"&gt;│       ├── job-ttl-after-finished.yaml&lt;/span&gt;
&lt;span class="s"&gt;│       ├── cleanup-finished-jobs.yaml&lt;/span&gt;
&lt;span class="s"&gt;│       ├── cleanup-stale-machineconfig-jobs.yaml&lt;/span&gt;
&lt;span class="s"&gt;│       └── rook-csi-apparmor-unconfined.yaml   ← the Ceph CSI scar, as code&lt;/span&gt;
&lt;span class="s"&gt;│&lt;/span&gt;
&lt;span class="na"&gt;├── kubearmor/                                  ← The Locks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;runtime LSM (audit today)&lt;/span&gt;
&lt;span class="s"&gt;│   ├── kustomization.yaml&lt;/span&gt;
&lt;span class="s"&gt;│   ├── namespace.yaml&lt;/span&gt;
&lt;span class="s"&gt;│   ├── kubearmor-falco-trivy-values.yaml&lt;/span&gt;
&lt;span class="s"&gt;│   ├── example-policies.yaml&lt;/span&gt;
&lt;span class="s"&gt;│   ├── webhook-skip-terminating-hook.yaml      ← the wedged-pod workaround&lt;/span&gt;
&lt;span class="s"&gt;│   └── charts/kubearmor-operator-v1.6.18/      ← vendored operator chart&lt;/span&gt;
&lt;span class="s"&gt;│&lt;/span&gt;
&lt;span class="na"&gt;├── falco/                                      ← The CCTV&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;runtime detection&lt;/span&gt;
&lt;span class="s"&gt;│   ├── kustomization.yaml&lt;/span&gt;
&lt;span class="s"&gt;│   ├── namespace.yaml&lt;/span&gt;
&lt;span class="s"&gt;│   ├── falco-trivy-values.yaml&lt;/span&gt;
&lt;span class="s"&gt;│   ├── custom-rules.yaml                       ← scoped exceptions live here&lt;/span&gt;
&lt;span class="s"&gt;│   └── servicemonitor.yaml&lt;/span&gt;
&lt;span class="s"&gt;│&lt;/span&gt;
&lt;span class="na"&gt;└── trivy/                                      ← The Inspector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;continuous scanning&lt;/span&gt;
    &lt;span class="s"&gt;├── kustomization.yaml&lt;/span&gt;
    &lt;span class="s"&gt;├── namespace.yaml&lt;/span&gt;
    &lt;span class="s"&gt;├── trivy-values.yaml&lt;/span&gt;
    &lt;span class="s"&gt;└── servicemonitor.yaml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things in that tree are worth pointing at before I go further, because both are scars rather than design:&lt;br&gt;
&lt;code&gt;kubearmor/webhook-skip-terminating-hook.yaml&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;kyverno/policies/rook-csi-apparmor-unconfined.yaml&lt;/code&gt; only exist because KubeArmor's&lt;br&gt;
AppArmor annotations wedged terminating pods and confined my Ceph CSI plugins. I&lt;br&gt;
get to those later. Each stack also carries its own &lt;code&gt;README.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Five ArgoCD child Applications, one single master ApplicationSet, and two distinct sync waves. The structural walls of my cluster haven't shifted an inch since Episode 2; what is about to change is what I allow to get inside, and what gets flagged the second it steps out of line.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Threat Surface
&lt;/h2&gt;

&lt;p&gt;Let’s be completely transparent: a modern homelab is no longer a casual testing environment where safety defaults can be skipped. This 29-node environment hosts &lt;code&gt;HashiCorp Vault instances, enterprise Keycloak identity providers, MLflow registries, Kubeflow modeling pipelines, and private Harbor registries&lt;/code&gt;. Every single one of these software components holds explicit corporate identities, cryptographic keys, machine learning models, or proprietary container images.&lt;/p&gt;

&lt;p&gt;The blast radius of a single container escape or internal compromise is incredibly high. More importantly, this lab is my production testing sandbox; if I cut corners on security configurations here, I build bad architectural habits that will inevitably leak into the enterprise architectures I design for clients.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The cluster threat vectors break down into four critical layers:&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Misconfigured Manifests (The Illegal Entry):&lt;/strong&gt; Workloads containing unsafe specifications that should never be written to etcd. Think of a deployment requesting &lt;code&gt;runAsUser: 0&lt;/code&gt;, a pod requesting host namespaces &lt;code&gt;hostNetwork: true&lt;/code&gt;, images pointing to mutable tags like &lt;code&gt;:latest&lt;/code&gt;, or a generic network policy leaving wide-open public egress. These should be caught and stopped dead at the API gateway during kubectl apply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Malicious Post-Admission Execution (The Broken Lock):&lt;/strong&gt; Pods that legitimately pass validation checks but once scheduled onto a node, attempt to violate boundaries—such as writing directly to &lt;code&gt;/etc/shadow&lt;/code&gt;, attempting to run an unexpected &lt;code&gt;exec /bin/sh&lt;/code&gt; inside a production database pod, or mapping block devices. The local Linux kernel needs to refuse these operations natively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subtle Runtime Anomalies (The Stealth Intruder):&lt;/strong&gt; Exploits that bypass standard rule boundaries entirely. &lt;code&gt;Fileless malware executions&lt;/code&gt;, &lt;code&gt;unauthorized sudo executions&lt;/code&gt; &lt;code&gt;inside an active application container,&lt;/code&gt; or an &lt;code&gt;application process spawning unexpected binary payloads&lt;/code&gt;. These system calls must be observed, parsed, and logged instantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Zero-Day Decay (The Expired Clearance):&lt;/strong&gt; Images that are fully verified, pristine, and compliant when pushed to the cluster on Friday, but become &lt;code&gt;highly vulnerable on Monday morning because a new critical CVE was disclosed over the weekend&lt;/code&gt;. The cluster must continuously audit its own passive inventory.&lt;/p&gt;

&lt;p&gt;No single security engine can mitigate this entire lifecycle. That is the core tenet of the defense-in-depth model: I install four distinct guards that share zero dependency lines, hook into entirely different layers of the OS and Kubernetes lifecycle, and run side-by-side without interference.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Four Guards
&lt;/h2&gt;

&lt;p&gt;Architecture Here is the exact layout of how my security tooling intercepts threat vectors across the entire deployment and runtime lifecycle:&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%2F1tkgbqm3gj8ysdpchyzh.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%2F1tkgbqm3gj8ysdpchyzh.png" alt="architechture" width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The dotted line is deliberate. Everything drawn with a solid arrow is running in my cluster&lt;br&gt;
today; the alert fan-out is one chart flag away and is the one piece I have not turned on yet.&lt;br&gt;
I would rather draw the gap than pretend the box is filled.&lt;/p&gt;

&lt;p&gt;By segregating these duties, I protect against systemic failure. If the admission webhook is temporarily bypassed or down during a system upgrade, the kernel layer is still watching — and, once policies land, still enforcing. If the kernel layer has no rule for a specific zero-day vector, the behavioural CCTV logging system still records and exposes the activity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Bouncer at the Door — Kyverno :&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Kyverno runs as my primary admission controller. Every single API interaction targeting my cluster passes directly through its webhook filters before the specification can be saved to etcd. If a defined policy evaluates to a failure, the request is outright denied. If a policy specifies an auto-remediation rule, the specification is seamlessly mutated in flight.&lt;/p&gt;

&lt;p&gt;My architecture runs Kyverno Chart &lt;code&gt;3.8.0&lt;/code&gt; isolated completely within the &lt;code&gt;kyverno&lt;/code&gt; namespace, consisting of five core deployments:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kyverno-admission-controller&lt;/code&gt; — The primary engine processing real-time validation and mutation webhooks.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kyverno-background-controller&lt;/code&gt; — Manages asynchronous generate policy reconciliation loops.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kyverno-cleanup-controller&lt;/code&gt; — Garbage cycles dead PolicyReport and EphemeralReport metrics.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kyverno-reports-controller&lt;/code&gt; — Aggregates policy data cluster-wide.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kyverno-policy-reporter&lt;/code&gt; — The reporting visualization plane (disabled in my default values to preserve RAM).&lt;/p&gt;

&lt;p&gt;While clean on paper, syncing this setup via GitOps under ArgoCD presented a massive hurdle. Out of the box, the &lt;code&gt;3.8.0&lt;/code&gt; chart immediately breaks synchronization pipelines due to mutating resource values. To establish an entirely hands-off automated sync loop, I had to introduce four specific structural fixes directly into the ArgoCD configuration:&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="c1"&gt;# Inside my master ApplicationSet / Application definitions:&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;ignoreDifferences&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# 1. Neutralize the Kustomize CRD naming hash mutation&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apiextensions.k8s.io&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;CustomResourceDefinition&lt;/span&gt;
      &lt;span class="na"&gt;jsonPointers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/metadata/annotations/kustomize.config.k8s.io~1needs-hash&lt;/span&gt;
    &lt;span class="c1"&gt;# 2. Prevent permanent diffs on ClusterPolicies defaulting validation modes&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kyverno.io&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;ClusterPolicy&lt;/span&gt;
      &lt;span class="na"&gt;jsonPointers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/spec/validationFailureAction&lt;/span&gt;
    &lt;span class="c1"&gt;# 3. Strip dynamic API mutations from webhook manifests&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apiextensions.k8s.io&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;CustomResourceDefinition&lt;/span&gt;
      &lt;span class="na"&gt;jsonPointers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/spec/conversion/webhook/clientConfig/caBundle&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Additionally, I forced &lt;code&gt;forceFailurePolicyIgnore: true&lt;/code&gt; across all helm configurations. This ensures that during a major cluster upgrade or node restart where the Kyverno pods are initializing, the Kubernetes control plane switches to an &lt;code&gt;Ignore&lt;/code&gt; mode rather than locking up the API server entirely.&lt;/p&gt;

&lt;p&gt;One major day-two operational trap to memorize (&lt;code&gt;feedback_kyverno_chart_upgrade_job_leftovers&lt;/code&gt;): during every chart upgrade, a temporary hook job named &lt;code&gt;Job/kyverno-migrate-resources&lt;/code&gt; is run alongside associated service accounts and cluster roles. ArgoCD marks these resources as unmanaged orphans (&lt;code&gt;sync=None requiresPruning=True&lt;/code&gt;). You can manually execute a safe &lt;code&gt;kubectl delete&lt;/code&gt; once you confirm the job completed its task; however, &lt;strong&gt;never trigger a blind global&lt;/strong&gt; &lt;code&gt;argocd sync --prune&lt;/code&gt;, as it will inadvertently purge active sibling metadata from unassociated applications.&lt;/p&gt;

&lt;p&gt;Here is the Bouncer's actual roster, and what happens to a walk-in who ignores the dress code:&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="s"&gt;kubectl get clusterpolicy&lt;/span&gt;
&lt;span class="s"&gt;kubectl run nginx --image=nginx&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fz52nolicyylsqdtpfgyx.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%2Fz52nolicyylsqdtpfgyx.png" alt="Keyverno-1" width="800" height="306"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;the output of both commands — 36 &lt;code&gt;ClusterPolicies&lt;/code&gt; all &lt;code&gt;READY=True&lt;/code&gt;, then the plain &lt;code&gt;kubectl run nginx&lt;/code&gt; bounced before it ever reaches a node.&lt;/p&gt;

&lt;p&gt;Worth reading that second command's output carefully, because it shows &lt;strong&gt;two guards, not one&lt;/strong&gt;:&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;Error from server (Forbidden)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pods "nginx" is forbidden&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
&lt;span class="s"&gt;violates PodSecurity "restricted:latest": allowPrivilegeEscalation != &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="s"&gt; ...&lt;/span&gt;
&lt;span class="s"&gt;runAsNonRoot != &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="s"&gt; ... seccompProfile ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That rejection says &lt;code&gt;PodSecurity&lt;/code&gt;, not &lt;code&gt;Kyverno&lt;/code&gt; — it is Kubernetes' &lt;strong&gt;native Pod Security Admission&lt;/strong&gt;, the free floor built into the API server, catching the most naive possible mistake before Kyverno is even consulted. The 36 ClusterPolicies sitting above it are what PSA &lt;em&gt;can't&lt;/em&gt; do: mutate a spec in flight, generate a NetworkPolicy into every new namespace, verify a cosign signature, pin GPU pods to GPU nodes.&lt;/p&gt;

&lt;p&gt;I keep both deliberately, and the layering is the point — the cheapest guard catches the cheapest mistake. A plain &lt;code&gt;kubectl run&lt;/code&gt; is exactly the kind of thing a tired operator does at 2am, and it never gets far enough to need the expensive machinery.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The Locks on Every Door — KubeArmor :&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Kyverno decides &lt;em&gt;who gets in&lt;/em&gt;. KubeArmor governs &lt;em&gt;what they can do once they're inside&lt;/em&gt;. This is the distinction most homelabs miss: a pod can pass every admission check — correct &lt;code&gt;securityContext&lt;/code&gt;, no host namespaces, a pinned image digest — and still, the moment it's running, try to read &lt;code&gt;/etc/shadow&lt;/code&gt;, spawn &lt;code&gt;/bin/sh&lt;/code&gt; inside a production database, or map a block device. Admission control is blind to all of it, because admission already happened.&lt;/p&gt;

&lt;p&gt;KubeArmor closes that gap at the only place that can't be argued with: &lt;strong&gt;the Linux kernel itself&lt;/strong&gt;. It enforces per-pod policies through the kernel's Linux Security Modules (AppArmor + BPF-LSM), so a blocked action isn't &lt;em&gt;reported&lt;/em&gt; — it's &lt;em&gt;refused&lt;/em&gt;, natively, before the syscall completes. That is the capability. What I actually have turned on is a step short of it, and I'll show you exactly where I stopped.&lt;/p&gt;

&lt;p&gt;My architecture runs the &lt;strong&gt;KubeArmor operator v1.6.18&lt;/strong&gt; in the &lt;code&gt;kubearmor&lt;/code&gt; namespace, which stands up three pieces:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kubearmor&lt;/code&gt; (DaemonSet) — the per-node enforcement engine that talks to the kernel LSM.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kubearmor-relay&lt;/code&gt; — aggregates the realtime block/audit event stream from every node into one place.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kubearmor-controller&lt;/code&gt; — the admission + annotation controller that wires each pod to its AppArmor profile.&lt;/p&gt;

&lt;p&gt;Policies are per-workload and live right next to the thing they protect — KubeArmor watches every namespace. Locking a pod down is deliberately boring:&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;security.kubearmor.com/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;KubeArmorPolicy&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;no-shell-in-my-app&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;my-ns&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;selector&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="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;my-app&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;process&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchPaths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/bin/sh&lt;/span&gt;
        &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Block&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/bin/bash&lt;/span&gt;
        &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Block&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that applied, an attacker who gets code execution inside &lt;code&gt;my-app&lt;/code&gt; reaches for a shell and the kernel hands them nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Except I haven't applied it.&lt;/strong&gt; Here is the honest state of my cluster, and it is the most useful thing in this section:&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="s"&gt;kubectl get kubearmorpolicy -A&lt;/span&gt;
&lt;span class="s"&gt;No resources found&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero policies. Not one. And the operator's default posture backs that up:&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="s"&gt;kubectl -n kubearmor get kubearmorconfig -o yaml | grep Posture&lt;/span&gt;
  &lt;span class="s"&gt;defaultCapabilitiesPosture&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;audit&lt;/span&gt;
  &lt;span class="s"&gt;defaultFilePosture&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;audit&lt;/span&gt;
  &lt;span class="s"&gt;defaultNetworkPosture&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;audit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So what &lt;em&gt;is&lt;/em&gt; KubeArmor doing on my 29 nodes right now? &lt;strong&gt;Watching, in enormous detail.&lt;/strong&gt; The relay aggregates a live stream of every process exec, file access and network connection, resolved to pod and namespace:&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="s"&gt;kubectl -n kubearmor logs -l kubearmor-app=kubearmor-relay&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fp2i0fu1ro30d2wauznqy.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%2Fp2i0fu1ro30d2wauznqy.png" alt="Kubearmor" width="800" height="109"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;the &lt;code&gt;kubearmor-relay&lt;/code&gt; stream — a live feed of resolved connections (&lt;code&gt;remotehost=pod/harbor/harbor-nginx-...&lt;/code&gt;, &lt;code&gt;svc/rook-ceph/rook-ceph-rgw-...&lt;/code&gt;), alongside &lt;code&gt;kubectl get kubearmorpolicy -A&lt;/code&gt; returning &lt;strong&gt;No resources found&lt;/strong&gt;. Visibility without enforcement, which is exactly what audit posture looks like.&lt;/p&gt;

&lt;p&gt;That gap is deliberate, and it is the correct order of operations. &lt;strong&gt;You do not deploy an LSM in enforce mode on a cluster you have not first watched.&lt;/strong&gt; Audit posture tells you what normal looks like — which binaries actually exec, which paths actually get written, which pods actually talk to which — and &lt;em&gt;that&lt;/em&gt; is what a least-privilege policy is written from. Ship &lt;code&gt;action: Block&lt;/code&gt; on day one and you will spend your week explaining to yourself why CNPG can't start.&lt;/p&gt;

&lt;p&gt;I know this because KubeArmor has already blocked things on this cluster — just not attackers. The baseline AppArmor profile it generates for &lt;strong&gt;every&lt;/strong&gt; pod carries its own deny-list (&lt;code&gt;deny mount&lt;/code&gt;, &lt;code&gt;deny /sys/...&lt;/code&gt; writes), and that alone was enough to break two critical subsystems. Which brings me to the traps.&lt;/p&gt;

&lt;p&gt;Here's the honest part. KubeArmor is the guard that will bite &lt;em&gt;you&lt;/em&gt; before it ever bites an attacker, because it doesn't only lock down &lt;em&gt;your&lt;/em&gt; workloads — by default it generates a restrictive AppArmor profile for &lt;strong&gt;every&lt;/strong&gt; pod, including the critical infrastructure DaemonSets that keep the cluster alive. Three traps cost me real time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap 1 — KubeArmor vs. Cilium's eBPF (&lt;code&gt;feedback_cilium_kubearmor_apparmor_block&lt;/code&gt;).&lt;/strong&gt; After any change that rolls a BPF-using DaemonSet, some nodes wedged with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;mkdir /sys/fs/bpf/tc: permission denied
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;KubeArmor's default-deny profile was refusing Cilium's own eBPF mount. And it &lt;em&gt;survives reboots&lt;/em&gt; — the restrictive profile is reapplied on every pod start, so a reboot "fix" lasts exactly until the next &lt;code&gt;cilium-agent&lt;/code&gt; restart. The fix is to opt the DaemonSet out with a pod-template annotation:&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;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&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;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;container.apparmor.security.beta.kubernetes.io/cilium-agent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unconfined&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Cilium this is now baked into the Ansible RKE2 bootstrap manifest (&lt;code&gt;rke2-cilium-config.yaml&lt;/code&gt;) so a fresh install never rediscovers it. Any other BPF-touching DaemonSet (kube-vip, NFD) can need the same treatment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap 2 — the terminating-pod deadlock (&lt;code&gt;feedback_kubearmor_apparmor_annotation_stuck_pod_k8s134&lt;/code&gt;).&lt;/strong&gt; On newer Kubernetes, the controller's AppArmor-annotation mutation can leave pods stuck &lt;code&gt;Terminating&lt;/code&gt; indefinitely — the annotation it wants to apply races the pod's own teardown. The release valve is blunt but reliable: scale &lt;code&gt;kubearmor-controller&lt;/code&gt; to &lt;code&gt;0&lt;/code&gt;, let the stuck pods drain, then scale it back. A &lt;code&gt;PostSync&lt;/code&gt; hook that skips terminating pods keeps it from recurring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap 3 — don't confine the storage plumbing (&lt;code&gt;feedback_ceph_csi_rook_apparmor_rbd_module&lt;/code&gt;).&lt;/strong&gt; KubeArmor's profile will happily confine the Rook-Ceph CSI plugin pods, and when it does, RBD volume mounts start failing cluster-wide — a storage outage wearing a security costume. The CSI plugins get the &lt;code&gt;unconfined&lt;/code&gt; treatment for the same reason Cilium does: infrastructure that manipulates the kernel directly cannot run under a default-deny LSM profile.&lt;/p&gt;

&lt;p&gt;The lesson across all three: &lt;strong&gt;KubeArmor is a scalpel, not a blanket.&lt;/strong&gt; Lock down &lt;em&gt;tenant&lt;/em&gt; workloads aggressively; leave the infrastructure that talks to the kernel alone.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The CCTV on the Ceiling — Falco :&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Locks stop the break-ins you &lt;em&gt;anticipated&lt;/em&gt;. But security's oldest truth is that the attack you didn't write a rule for is the one that gets you. That's what the cameras are for. &lt;strong&gt;Falco doesn't block anything&lt;/strong&gt; — it watches everything, and the second something moves that shouldn't, it shouts.&lt;/p&gt;

&lt;p&gt;Where KubeArmor enforces a known-bad list at the kernel, Falco taps kernel &lt;strong&gt;tracepoints via eBPF&lt;/strong&gt; and pattern-matches the entire syscall firehose against a ruleset. A fileless payload executing from memory, an unexpected outbound connection from a pod that should only ever talk to Postgres, a &lt;code&gt;sudo&lt;/code&gt; inside a container that has no business having one — none of those may trip a specific KubeArmor lock, but all of them light up the CCTV.&lt;/p&gt;

&lt;p&gt;I run the &lt;strong&gt;Falco chart 8.0.2&lt;/strong&gt; as a DaemonSet in the &lt;code&gt;falco&lt;/code&gt; namespace on the &lt;code&gt;modern_ebpf&lt;/code&gt; driver — one sensor per node, no kernel module to compile. Today, detections go exactly one place: &lt;strong&gt;&lt;code&gt;stdout&lt;/code&gt;&lt;/strong&gt;, which means the camera feed is &lt;code&gt;kubectl logs&lt;/code&gt;:&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="s"&gt;kubectl -n falco logs falco-2289j --tail=5 --max-log-requests=5 | grep -i warning&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;And that is the honest weak point in this stack.&lt;/strong&gt; A camera nobody watches is a camera that catches nothing after the fact. &lt;code&gt;stdout&lt;/code&gt; is fine while I'm actively tailing it and useless at 3am — the detection fires, scrolls past, and ages out of the log buffer. Falco is the one guard here whose value collapses without somewhere to &lt;em&gt;send&lt;/em&gt; the alert.&lt;/p&gt;

&lt;p&gt;Routing it is a chart flag, not a rebuild — Falco ships &lt;strong&gt;Falcosidekick&lt;/strong&gt; as a subchart, so there is no second Helm install:&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="c1"&gt;# falco/falco-trivy-values.yaml&lt;/span&gt;
&lt;span class="na"&gt;falcosidekick&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;        &lt;span class="c1"&gt;# ← the honest current state&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flip that to &lt;code&gt;true&lt;/code&gt;, add a backend (Slack, Discord, Loki, PagerDuty), and the fan-out is done. The only reason it isn't already on is that the webhook is a credential, and I am not putting one in Git — so it waits for the same treatment everything else in this homelab gets: Terraform writes it to Vault, VSO syncs it in as a &lt;code&gt;Secret&lt;/code&gt;. That is the next commit on this stack, and I would rather publish the gap than describe a pipeline I have not wired.&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%2Fr21z2e264a6midez3ied.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%2Fr21z2e264a6midez3ied.png" alt="Falco-1" width="799" height="304"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;a live Falco detection in &lt;code&gt;kubectl -n falco logs falco-2289j --tail=5 --max-log-requests=5 | grep -i warning -f&lt;/code&gt; — rule name, pod, namespace, the offending command — the CCTV catching someone mid-motion.&lt;/p&gt;

&lt;p&gt;Two traps here, both about the sensor itself:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The kernel floor.&lt;/strong&gt; The &lt;code&gt;modern_ebpf&lt;/code&gt; driver needs &lt;code&gt;CAP_PERFMON&lt;/code&gt; + &lt;code&gt;CAP_BPF&lt;/code&gt; and a &lt;strong&gt;kernel ≥ 5.8&lt;/strong&gt;. On a compliant node it just works; drop in an older distro and Falco &lt;code&gt;CrashLoopBackOff&lt;/code&gt;s on startup with a driver-load failure. The fallback is the legacy &lt;code&gt;ebpf&lt;/code&gt; driver (or the &lt;code&gt;kmod&lt;/code&gt; driver, which then wants kernel headers on the node) — set via values. The homelab's Ubuntu 24.04 image is well clear of the floor, but it's the first thing to check when a &lt;em&gt;new&lt;/em&gt; node's Falco pod won't come up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tuning the sensitivity, not silencing the camera.&lt;/strong&gt; A fresh Falco install is noisy — legitimate operations trip generic rules. The wrong reflex is to delete the rule. The right one is to add a scoped &lt;em&gt;exception&lt;/em&gt; in &lt;code&gt;falco/custom-rules.yaml&lt;/code&gt;, commit, and push; ArgoCD reconciles within 30 seconds and &lt;strong&gt;Falco hot-reloads its config with no pod restart&lt;/strong&gt;. You narrow the camera's blind spot deliberately, in version control — you never unplug it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The Inspector on Patrol — Trivy :&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first three guards all police the &lt;em&gt;present&lt;/em&gt;: what's entering, what's running, what's misbehaving right now. Trivy polices the thing none of them can see — &lt;strong&gt;time&lt;/strong&gt;. An image can be pristine, signed, and fully compliant when it's pushed on Friday, and be a critical CVE waiting to happen by Monday morning, because a vulnerability was disclosed over the weekend and &lt;em&gt;nothing about the image changed&lt;/em&gt;. The lock is fine. The camera sees nothing. The image just quietly went bad.&lt;/p&gt;

&lt;p&gt;So the &lt;strong&gt;Trivy operator (0.32.1)&lt;/strong&gt;, in the &lt;code&gt;trivy-system&lt;/code&gt; namespace, walks the beat: it continuously re-scans the cluster's entire passive inventory and writes every finding as a Kubernetes custom resource — no database, no PVC, no UI. Six report types cover the surface:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Report CR&lt;/th&gt;
&lt;th&gt;What it audits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VulnerabilityReport&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Image CVEs, per workload, auto-rescanned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ConfigAuditReport&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Workload misconfiguration (Pod security)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RbacAssessmentReport&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Over-permissioned RBAC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;InfraAssessmentReport&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Node / control-plane CIS checks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SbomReport&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A full SBOM for every pulled image&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ClusterComplianceReport&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;NSA / CIS Benchmark posture, cluster-wide&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Because it's all just CRs, your SIEM is &lt;code&gt;kubectl&lt;/code&gt;:&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%2Fjohekz7t0ffk36698wc5.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%2Fjohekz7t0ffk36698wc5.png" alt="Trivy2" width="796" height="79"&gt;&lt;/a&gt;&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="c1"&gt;# Every workload with a CRITICAL CVE, right now&lt;/span&gt;
&lt;span class="s"&gt;kubectl get vulnerabilityreport -A -o json | \&lt;/span&gt;
  &lt;span class="s"&gt;jq '.items[] | select(.report.summary.criticalCount &amp;gt; 0) |&lt;/span&gt;
      &lt;span class="s"&gt;{ns:.metadata.namespace, name:.metadata.name, crit:.report.summary.criticalCount}'&lt;/span&gt;

&lt;span class="c1"&gt;# Cluster-wide compliance posture — note the REAL report names&lt;/span&gt;
&lt;span class="s"&gt;kubectl get clustercompliancereport&lt;/span&gt;
&lt;span class="c1"&gt;#   k8s-cis-1.23&lt;/span&gt;
&lt;span class="c1"&gt;#   k8s-nsa-1.0&lt;/span&gt;
&lt;span class="c1"&gt;#   k8s-pss-baseline-0.1&lt;/span&gt;
&lt;span class="c1"&gt;#   k8s-pss-restricted-0.1&lt;/span&gt;
&lt;span class="s"&gt;kubectl describe clustercompliancereport k8s-nsa-1.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fhyr9mouov4nxr2kzs41w.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%2Fhyr9mouov4nxr2kzs41w.png" alt="Trivy" width="799" height="304"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;the &lt;code&gt;jq&lt;/code&gt; output above with its CRITICAL counts, and &lt;code&gt;kubectl describe clustercompliancereport k8s-nsa-1.0&lt;/code&gt; — the Inspector's findings board.&lt;/p&gt;

&lt;p&gt;And since the whole point of this stack is that I report what it actually finds rather than what I wish it found: &lt;strong&gt;the worst offender on my cluster is my own registry.&lt;/strong&gt; Harbor's components come back at &lt;strong&gt;26–33 CRITICAL&lt;/strong&gt; apiece — &lt;code&gt;harbor-portal&lt;/code&gt; at 33, &lt;code&gt;harbor-core&lt;/code&gt;, &lt;code&gt;registry&lt;/code&gt; and &lt;code&gt;jobservice&lt;/code&gt; at 26 each — followed by my CNPG Postgres pods at 14, Elasticsearch at 8, and &lt;code&gt;vault-transit&lt;/code&gt; at 6.&lt;/p&gt;

&lt;p&gt;That is an uncomfortable result to publish, and it is exactly why the Inspector earns its place. Harbor is the thing that &lt;em&gt;scans everything else on the way in&lt;/em&gt;, and it is carrying the heaviest CVE load in the cluster. Nothing in the first three guards would ever have told me that — the manifests are valid, the syscalls are ordinary, the behaviour is normal. Only the thing that polices &lt;em&gt;time&lt;/em&gt; finds it.&lt;/p&gt;

&lt;p&gt;Worth being precise about what those numbers mean: a CRITICAL count is not a breach, and most of these are unreachable code paths in base images. The signal is the &lt;strong&gt;trend and the ranking&lt;/strong&gt;, not the absolute number — and the ranking is telling me which upstream images to pin, rebuild, or replace first.&lt;/p&gt;

&lt;p&gt;Pair the &lt;code&gt;SbomReport&lt;/code&gt; output with a Kyverno &lt;code&gt;verifyImages&lt;/code&gt; policy and the Bouncer and the Inspector start working &lt;em&gt;together&lt;/em&gt;: Trivy tells you what's actually in every image, Kyverno refuses anything unsigned at the door. That's a real supply-chain story, not a checkbox.&lt;/p&gt;

&lt;p&gt;The one trap worth naming: &lt;strong&gt;private images silently fail to scan.&lt;/strong&gt; Trivy's &lt;code&gt;k8schain&lt;/code&gt; reads &lt;code&gt;imagePullSecrets&lt;/code&gt; from the workload's ServiceAccount — so anything in an auth-required registry (some of Harbor's projects) won't scan until the namespace SA carries the pull secret:&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;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;ServiceAccount&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;default&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;my-ns&lt;/span&gt;
&lt;span class="na"&gt;imagePullSecrets&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;harbor-tekton-builder-creds&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Miss it and you get the most dangerous kind of failure — a &lt;em&gt;silent&lt;/em&gt; one. The reports look clean not because the images are clean, but because Trivy never got to look.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Honest Head-to-Head — Why These Four, and Not the Alternatives
&lt;/h2&gt;

&lt;p&gt;First, the framing most security comparisons get backwards: &lt;strong&gt;these four tools are not competing with each other.&lt;/strong&gt; "Should I use Falco or Kyverno?" is the wrong question — it's like asking whether a building needs a door lock &lt;em&gt;or&lt;/em&gt; a smoke alarm. Security isn't a &lt;em&gt;tool&lt;/em&gt;, it's a &lt;em&gt;pipeline&lt;/em&gt;, and each guard owns a different stage of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build time&lt;/strong&gt; → &lt;strong&gt;Trivy&lt;/strong&gt; scans the image for CVEs before it ships.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admission time&lt;/strong&gt; → &lt;strong&gt;Kyverno&lt;/strong&gt; refuses the manifest (and cosign-verifies the signature) before it ever reaches etcd.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime, detect&lt;/strong&gt; → &lt;strong&gt;Falco&lt;/strong&gt; watches syscalls and alerts on the abnormal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime, enforce&lt;/strong&gt; → &lt;strong&gt;KubeArmor&lt;/strong&gt; refuses the malicious syscall at the kernel.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the real question is never "which one" — it's "for &lt;em&gt;this layer&lt;/em&gt;, which tool?" Here's that answer, honestly, layer by layer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;My pick&lt;/th&gt;
&lt;th&gt;The alternatives&lt;/th&gt;
&lt;th&gt;Why I picked mine&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Admission / policy-as-code&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Kyverno&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;OPA/Gatekeeper, Kubewarden, native PSA&lt;/td&gt;
&lt;td&gt;YAML-native policies (no Rego to learn) + &lt;code&gt;mutate&lt;/code&gt; + &lt;code&gt;generate&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Runtime enforcement (LSM)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;KubeArmor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;raw AppArmor/SELinux, Tetragon&lt;/td&gt;
&lt;td&gt;K8s-native CRDs over the kernel LSM; per-workload least-privilege&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Runtime detection&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Falco&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Tetragon, Tracee, Sysdig Secure&lt;/td&gt;
&lt;td&gt;CNCF-graduated; biggest rule ecosystem; plugin model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vuln / misconfig scanning&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Trivy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Grype, Clair, Snyk, Docker Scout&lt;/td&gt;
&lt;td&gt;All-in-one (CVE + IaC + secrets + SBOM); free; already in Harbor&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Where the alternatives &lt;strong&gt;genuinely win&lt;/strong&gt; — because every one of my picks has a real rival:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OPA/Gatekeeper vs Kyverno.&lt;/strong&gt; Gatekeeper's &lt;strong&gt;Rego&lt;/strong&gt; is a genuine policy &lt;em&gt;language&lt;/em&gt; — more expressive than Kyverno's YAML for complex cross-resource logic, and the incumbent in heavily-regulated enterprises. I chose Kyverno because I'd rather write policies in the same YAML as everything else, and because Kyverno's &lt;strong&gt;&lt;code&gt;generate&lt;/code&gt;&lt;/strong&gt; (auto-stamp a NetworkPolicy / ResourceQuota / alias Service into every new namespace) and &lt;strong&gt;&lt;code&gt;verifyImages&lt;/code&gt;&lt;/strong&gt; (cosign — the same signatures my Tekton Chains pipeline produces) are first-class. That's load-bearing in my Kubeflow multi-tenancy. If I needed Rego's raw expressiveness, Gatekeeper would win. &lt;strong&gt;Kubewarden&lt;/strong&gt; (WASM policies in any language) is the elegant dark horse; a smaller community kept it off my cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native Pod Security Admission&lt;/strong&gt; is &lt;em&gt;free and built in&lt;/em&gt; — three levels, zero install. If all you need is "no privileged pods," use PSA and skip a policy engine entirely. I run &lt;em&gt;both&lt;/em&gt;: PSA sets the floor, Kyverno does everything PSA can't (mutate, generate, image verification, custom logic).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tetragon vs Falco (and KubeArmor).&lt;/strong&gt; This is the sharpest call in the stack, and the most honest thing I can say is: &lt;strong&gt;I run Cilium, so Tetragon is already right there&lt;/strong&gt; — eBPF-based, and it can both &lt;em&gt;detect and enforce&lt;/em&gt; in one tool, potentially collapsing two of my guards into one. I stayed with Falco + KubeArmor because Falco's rule ecosystem and CNCF maturity are unmatched for &lt;em&gt;detection&lt;/em&gt;, and KubeArmor's CRD model is friendlier than hand-driving Tetragon &lt;code&gt;TracingPolicies&lt;/code&gt; for &lt;em&gt;enforcement&lt;/em&gt;. A leaner cluster could genuinely do this with Tetragon alone — it's on my honest "re-evaluate" list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grype / Clair / Snyk / Docker Scout vs Trivy.&lt;/strong&gt; All fine scanners. &lt;strong&gt;Snyk&lt;/strong&gt; has the best developer DX (and a bill); &lt;strong&gt;Grype&lt;/strong&gt; is fast and SBOM-native; &lt;strong&gt;Clair&lt;/strong&gt; is the registry classic. Trivy won because it's &lt;em&gt;all-in-one&lt;/em&gt; — CVEs &lt;strong&gt;and&lt;/strong&gt; IaC misconfiguration &lt;strong&gt;and&lt;/strong&gt; leaked secrets &lt;strong&gt;and&lt;/strong&gt; SBOM in one binary — it's free, and &lt;strong&gt;my Harbor already scans every push with Trivy natively.&lt;/strong&gt; One scanner, build-time and registry-time, no new tool to learn.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest cost of running all four, stated plainly: it's real overhead — two DaemonSets (KubeArmor + Falco), Kyverno's five controllers, and the Trivy operator — and &lt;strong&gt;KubeArmor is the finickiest thing in my whole cluster.&lt;/strong&gt; Its AppArmor annotations have wedged terminating pods, blocked Cilium's own BPF mounts, and confined CSI plugins — each of those is a scar in my notes. Falco will drown you in alerts until it's tuned. Kyverno's immutable-ConfigMap policy has bitten me more than once. If you want &lt;em&gt;one&lt;/em&gt; layer to start, start with &lt;strong&gt;Kyverno&lt;/strong&gt; — admission is the highest-leverage gate — and add the others as your threat model demands. Defense-in-depth is a spectrum, not all-or-nothing. But the four together are what turns &lt;em&gt;"I hope nothing's wrong"&lt;/em&gt; into &lt;em&gt;"I can prove nothing's wrong."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Bringing the Four Guards Together
&lt;/h2&gt;

&lt;p&gt;Step back and look at what's now standing watch over an empty house:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Guard&lt;/th&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;When it acts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Admission&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Kyverno&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;gate / mutate&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;before&lt;/em&gt; the workload enters etcd&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime audit&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;KubeArmor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;LSM, &lt;code&gt;audit&lt;/code&gt; posture&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;while&lt;/em&gt; it runs, at the kernel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime detect&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Falco&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;tracepoint match&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;when&lt;/em&gt; it behaves anomalously&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Continuous&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Trivy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;scheduled scan&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;long after&lt;/em&gt; it was deployed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four engines, &lt;strong&gt;zero shared dependencies&lt;/strong&gt; — no common operator, no shared CRDs, no single point of failure. If Kyverno's webhook is down mid-upgrade, the kernel layer is still watching. If a zero-day slips past every lock, the cameras still record it. If everything passes today, the Inspector still finds the CVE that lands tomorrow. Defense-in-depth isn't four tools; it's four tools that &lt;em&gt;don't know about each other&lt;/em&gt;, so no single failure blinds the whole system.&lt;/p&gt;

&lt;p&gt;And notice what isn't here: a dashboard to log into, an SSO redirect to configure, a UI to gate. The entire security posture is &lt;code&gt;kubectl get&lt;/code&gt; against custom resources. &lt;strong&gt;Security you query, not security you click.&lt;/strong&gt; For a GitOps platform, that's the point — every policy, every rule, every exception is a commit, reviewable and revertible, with no console drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next? (Don't Miss Out!)
&lt;/h2&gt;

&lt;p&gt;The locks are set. The cameras are live. The inspector is on patrol. The house is finally safe to move into — and that's exactly what happens next.&lt;/p&gt;

&lt;p&gt;With the security system armed &lt;em&gt;first&lt;/em&gt;, the tenants can finally arrive: the private Harbor registry, Tekton pipelines, MLflow, Kubeflow, the data platform — every one of them landing in a cluster that rejects a bad manifest at the door, records every syscall at the kernel, alerts on anything anomalous, and never stops scanning its own inventory. That was the whole point of doing this in the empty house.&lt;/p&gt;

&lt;p&gt;And the two honest gaps stay on the board where I can see them: &lt;strong&gt;Falco's alerts still only reach &lt;code&gt;stdout&lt;/code&gt;&lt;/strong&gt;, and &lt;strong&gt;KubeArmor is still in &lt;code&gt;audit&lt;/code&gt; posture with zero policies&lt;/strong&gt;. Neither is a surprise and neither is an accident — one needs a webhook in Vault, the other needs a few weeks of audit data before enforcement is anything other than an outage generator. Writing them down is how they stay next actions instead of quietly becoming the permanent state.&lt;/p&gt;

&lt;p&gt;That's the next episode: &lt;strong&gt;moving the workloads in — safely.&lt;/strong&gt; Follow along so you don't miss how the platform goes from &lt;em&gt;secured and vacant&lt;/em&gt; to &lt;em&gt;secured and busy&lt;/em&gt;. 🔐&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%2Fp2x888zgm6ydkgxyjyox.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%2Fp2x888zgm6ydkgxyjyox.png" alt="argocd1" width="800" height="184"&gt;&lt;/a&gt;&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%2Fh8de4ppx70cnw9pef30r.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%2Fh8de4ppx70cnw9pef30r.png" alt="argocd2" width="800" height="284"&gt;&lt;/a&gt;&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%2Fr8qkawr0mh129q5oubuf.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%2Fr8qkawr0mh129q5oubuf.png" alt="argocd3" width="800" height="296"&gt;&lt;/a&gt;&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%2Flc3kdcvlntd48uhctpl7.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%2Flc3kdcvlntd48uhctpl7.png" alt="argocd4" width="800" height="296"&gt;&lt;/a&gt;&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%2Fcuzhvejygbnznx2t7j1e.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%2Fcuzhvejygbnznx2t7j1e.png" alt="argocd5" width="800" height="296"&gt;&lt;/a&gt;&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%2Fsorl0inu9j5xrha5wkwg.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%2Fsorl0inu9j5xrha5wkwg.png" alt="argocd6" width="800" height="296"&gt;&lt;/a&gt;&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%2Ffbjws1ov3qx7c6elaidg.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%2Ffbjws1ov3qx7c6elaidg.png" alt="argocd7" width="800" height="296"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;the ArgoCD &lt;code&gt;security-stack&lt;/code&gt; ApplicationSet — all five child apps across two sync waves, &lt;code&gt;Synced&lt;/code&gt; + &lt;code&gt;Healthy&lt;/code&gt;. The whole security system, GitOps-reconciled, in one view.&lt;/p&gt;

&lt;h1&gt;
  
  
  kyverno #kubearmor #falco #trivy #devsecops
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Subscribe and follow me on&lt;/strong&gt; [&lt;a href="https://www.linkedin.com/in/george-ezejiofor-89615a8a/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;] to catch my upcoming articles.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>devsecops</category>
      <category>devops</category>
    </item>
    <item>
      <title>Kubeadm to RKE2: Transformed My K8s Homelab Into Production‑Grade Infra</title>
      <dc:creator>George Ezejiofor</dc:creator>
      <pubDate>Wed, 29 Apr 2026 19:05:09 +0000</pubDate>
      <link>https://dev.to/ezejioforog/kubeadm-to-rke2-transformed-my-k8s-homelab-into-production-grade-infra-m7f</link>
      <guid>https://dev.to/ezejioforog/kubeadm-to-rke2-transformed-my-k8s-homelab-into-production-grade-infra-m7f</guid>
      <description>&lt;p&gt;Scaling to 21 Nodes Across 3 Datacenters With Terraform and Ansible&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;INTRODUCTION:&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;The Evolution of an Empire&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most homelabs start as experiments — a single node, a few containers, and a steep learning curve. Mine stopped being a project the moment it evolved into a &lt;strong&gt;21‑node, multi‑datacenter powerhouse.&lt;/strong&gt; With &lt;strong&gt;control‑plane nodes stretched across physical zones&lt;/strong&gt; and &lt;strong&gt;specialised worker pools for high‑IOPS databases, GPU‑accelerated AI workloads,&lt;/strong&gt; and &lt;strong&gt;latency‑sensitive apps,&lt;/strong&gt; the stakes changed. I wasn’t just “running services” anymore; I was operating real infrastructure.&lt;/p&gt;

&lt;p&gt;That was the turning point.&lt;/p&gt;

&lt;p&gt;For years, kubeadm was my gateway into Kubernetes. It taught me the fundamentals, the internals, and the discipline. But at 21 nodes across three physical zones, the learning phase ended — and the limitations began.&lt;/p&gt;

&lt;p&gt;High availability felt like fragile choreography.&lt;br&gt;
Upgrades demanded a mix of surgical precision and sheer luck.&lt;br&gt;
Security hardening meant manually chasing CIS Benchmark alignment across dozens of nodes.&lt;br&gt;
Automation became a battle against the tool instead of a path to progress.&lt;/p&gt;

&lt;p&gt;I didn’t want a cluster I had to babysit.&lt;br&gt;
I wanted one that was deterministic, repeatable, and rebuildable — a platform that behaved like production, not a handcrafted science project.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;&lt;em&gt;The “Boring” Power of RKE2&lt;/em&gt;&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;RKE2&lt;/strong&gt; — Rancher’s next‑generation Kubernetes distribution, often known as “RKE Government.” It blends the simplicity of K3s with the enterprise‑grade security of RKE1. Unlike kubeadm, which hands you a toolbox and expects you to assemble the cluster yourself, RKE2 is a secure‑by‑default engine. It replaces Docker with containerd, launches control‑plane components as static pods, and enforces hardened defaults from the moment the node boots.&lt;/p&gt;

&lt;p&gt;But the real reason I moved wasn’t the binary.&lt;br&gt;
It was the operational excellence.&lt;/p&gt;

&lt;p&gt;RKE2 didn’t just simplify my cluster — it transformed it. My 21‑node migration proved what the documentation promises: RKE2 is built for environments where security, consistency, and automation matter.&lt;/p&gt;

&lt;p&gt;It gave me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Security‑first architecture&lt;/strong&gt; — &lt;strong&gt;CIS‑hardened by default&lt;/strong&gt; and &lt;strong&gt;FIPS 140‑2&lt;/strong&gt; readiness out of the box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;True HA without the headache&lt;/strong&gt; — native etcd quorum management that thrives across multi‑zone topologies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitOps‑ready automation&lt;/strong&gt; — a single declarative config that makes Terraform and Ansible work with the cluster, not against it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This wasn’t just a software upgrade.&lt;br&gt;
It was the moment my homelab crossed the line from a weekend project into a self‑healing, production‑grade, automated empire.&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.amazonaws.com%2Fuploads%2Farticles%2Fnti2xy6cdpixprffyibd.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.amazonaws.com%2Fuploads%2Farticles%2Fnti2xy6cdpixprffyibd.png" alt="architecture" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;em&gt;The Blueprint - Architecture at&amp;nbsp;Scale&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;The 3‑Zone Topology - Why Three Datacenters&lt;/em&gt;&lt;br&gt;
&lt;strong&gt;Distributed systems love odd numbers&lt;/strong&gt;, and &lt;strong&gt;etcd&lt;/strong&gt; is the perfect example.&lt;br&gt;
&lt;strong&gt;Quorum&lt;/strong&gt; is everything - and three nodes is the sweet spot.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Nodes&lt;/th&gt;
&lt;th&gt;Quorum Required&lt;/th&gt;
&lt;th&gt;Fault Tolerance&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0 failures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0 failures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1 failure&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1 failure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2 failures&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Each zone is:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A separate Proxmox host&lt;/li&gt;
&lt;li&gt;A separate subnet&lt;/li&gt;
&lt;li&gt;A separate failure domain&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If one rack loses power, or a host crashes, the system degrades — but does not fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;💡 Pro Tip: Network Reality&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;You don’t need 10GbE for this to work.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;etcd traffic is lightweight (~1–2 Mbps)&lt;/li&gt;
&lt;li&gt;What matters is latency, not bandwidth&lt;/li&gt;
&lt;li&gt;Keep inter-zone latency under ~5ms for stable consensus&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cilium’s native routing mode handles cross-zone traffic cleanly even on 1GbE.&lt;/p&gt;

&lt;p&gt;Node Categorisation — Designing for Workload Isolation&lt;/p&gt;

&lt;p&gt;At small scale, all nodes look the same.&lt;/p&gt;

&lt;p&gt;At 21 nodes, that approach breaks.&lt;/p&gt;

&lt;p&gt;Different workloads have fundamentally different resource profiles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Databases need consistent IOPS and memory&lt;/li&gt;
&lt;li&gt;Applications need horizontal scalability&lt;/li&gt;
&lt;li&gt;GPU workloads need isolation and scheduling guarantees&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mixing them leads to contention, unpredictability, and performance degradation.&lt;/p&gt;

&lt;p&gt;So the cluster is split into specialised pools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Quorum Trap (Critical for 3‑DC Clusters)&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Even though RKE2 simplifies HA dramatically, &lt;strong&gt;etcd still requires an odd number of masters.&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;3 masters = safe&lt;/li&gt;
&lt;li&gt;2 masters = dangerous&lt;/li&gt;
&lt;li&gt;4 masters = pointles&lt;/li&gt;
&lt;li&gt;5 masters = overkill for a homelab&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;RKE2 handles the etcd cluster automatically, but kubeadm would require:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Manual etcd initialization&lt;/li&gt;
&lt;li&gt;Manual peer configuration&lt;/li&gt;
&lt;li&gt;Manual certificate distribution&lt;/li&gt;
&lt;li&gt;Manual recovery steps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is one of the biggest reasons kubeadm collapses at scale - and &lt;strong&gt;RKE2&lt;/strong&gt; thrives.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;My Physical&amp;nbsp;Layout&lt;/strong&gt;
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Zone&lt;/th&gt;
&lt;th&gt;Proxmox Host&lt;/th&gt;
&lt;th&gt;Network&lt;/th&gt;
&lt;th&gt;Control Plane&lt;/th&gt;
&lt;th&gt;Workers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;dc1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;pve‑01 (Rack A)&lt;/td&gt;
&lt;td&gt;10.0.1.0/24&lt;/td&gt;
&lt;td&gt;rke2-master-dc1-a&lt;/td&gt;
&lt;td&gt;2 app + 3 db + 1 gpu&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;dc2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;pve‑02 (Rack B)&lt;/td&gt;
&lt;td&gt;10.0.2.0/24&lt;/td&gt;
&lt;td&gt;rke2-master-dc2-a&lt;/td&gt;
&lt;td&gt;2 app + 3 db + 1 gpu&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;dc3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;pve‑03 (Rack C)&lt;/td&gt;
&lt;td&gt;10.0.3.0/24&lt;/td&gt;
&lt;td&gt;rke2-master-dc3-a&lt;/td&gt;
&lt;td&gt;2 app + 3 db + 1 gpu&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Node Categorisation — The Power of Specialised Pools
&lt;/h3&gt;

&lt;p&gt;Mixed workloads are dangerous without isolation.&lt;br&gt;
A single noisy ML job can starve your database or API layer.&lt;/p&gt;

&lt;p&gt;That’s why I built dedicated pools, each with its own CPU, GPU, RAM, storage, taints, and scheduling rules.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Node Pools&lt;/strong&gt;
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pool&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;vCPU&lt;/th&gt;
&lt;th&gt;RAM&lt;/th&gt;
&lt;th&gt;Storage&lt;/th&gt;
&lt;th&gt;Taint&lt;/th&gt;
&lt;th&gt;Workloads&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Control-plane&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;16GB&lt;/td&gt;
&lt;td&gt;50GB SSD&lt;/td&gt;
&lt;td&gt;CriticalAddonsOnly=true:NoSchedule&lt;/td&gt;
&lt;td&gt;etcd, API server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;8GB&lt;/td&gt;
&lt;td&gt;30GB SSD&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;APIs, web apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;16GB&lt;/td&gt;
&lt;td&gt;100GB NVMe&lt;/td&gt;
&lt;td&gt;database=true:NoSchedule&lt;/td&gt;
&lt;td&gt;PostgreSQL, Redis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;64GB&lt;/td&gt;
&lt;td&gt;SSD + NVIDIA&lt;/td&gt;
&lt;td&gt;nvidia.com/gpu=true:NoSchedule&lt;/td&gt;
&lt;td&gt;ML workloads&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Taint Strategy&lt;/strong&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# DB nodes: Only workloads that tolerate database=true&lt;/span&gt;
&lt;span class="s"&gt;kubectl taint nodes rke2-worker-dc1-db-a database=true:NoSchedule&lt;/span&gt;

&lt;span class="c1"&gt;# GPU nodes: Only workloads that request GPU resources&lt;/span&gt;
&lt;span class="s"&gt;kubectl taint nodes rke2-worker-dc1-gpu-a nvidia.com/gpu=true:NoSchedule&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;strong&gt;Label Strategy&lt;/strong&gt;
&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;topology.kubernetes.io/zone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dc1&lt;/span&gt;
&lt;span class="na"&gt;node-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app|db|gpu&lt;/span&gt;
&lt;span class="na"&gt;pool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;general|database|ml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;strong&gt;This ensures:&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Databases stay on DB nodes&lt;/li&gt;
&lt;li&gt;ML workloads stay on GPU nodes&lt;/li&gt;
&lt;li&gt;Apps stay on app nodes&lt;/li&gt;
&lt;li&gt;And nothing fights for resources it shouldn't&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is how you prevent noisy-neighbour problems at scale.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Networking Layer - Cilium +&amp;nbsp;kube‑vip
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Why Cilium Instead of Calico?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Calico&lt;/th&gt;
&lt;th&gt;Cilium&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;eBPF-based&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-zone routing&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅ (faster)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network policies&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observability&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅ (Hubble)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service mesh&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;✅ (built-in)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Building the Foundation (Terraform)
&lt;/h2&gt;

&lt;p&gt;If &lt;strong&gt;Proxmox&lt;/strong&gt; is the cleared land for my estate, &lt;strong&gt;Terraform&lt;/strong&gt; is used to work on the foundation. At a 21-node scale, consistency is everything. If the foundation is crooked, the rest of the house will eventually lean. Ansible will be used to elevate the building (Kubernetes setup), then Gitops (ArgoCD) will be used for decoration (Furniture).&lt;/p&gt;

&lt;p&gt;By using Terraform, I ensure that every one of my 21 nodes is a "perfect twin" of its peers in the same pool.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Hybrid Workflow: Local Execution, Cloud Intelligence
&lt;/h3&gt;

&lt;p&gt;I execute my Terraform runs locally for speed, but I offload the "brains" of the operation to &lt;strong&gt;Terraform Cloud (TFC).&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Source of Truth:&lt;/strong&gt; TFC stores my state files securely. This ensures that no matter where I run the code from, the infrastructure state is locked and versioned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secret Vaulting:&lt;/strong&gt; Sensitive data - like my Proxmox API tokens and SSH keys - never touch my local machine or GitHub. They live as encrypted variables within the Terraform Cloud workspace.&lt;/li&gt;
&lt;/ul&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.amazonaws.com%2Fuploads%2Farticles%2Ftgisnt5c5yauo10tpsh8.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.amazonaws.com%2Fuploads%2Farticles%2Ftgisnt5c5yauo10tpsh8.png" alt="tf" width="800" height="608"&gt;&lt;/a&gt;&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.amazonaws.com%2Fuploads%2Farticles%2F0lqsyv4oedbs8smi120c.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.amazonaws.com%2Fuploads%2Farticles%2F0lqsyv4oedbs8smi120c.png" alt="tfc" width="800" height="364"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Constructing the Footprint
&lt;/h3&gt;

&lt;p&gt;Instead of manually clicking through the Proxmox GUI 21 times, I use a modular approach. I've defined a "Blueprint" (module) for a generic Ubuntu 24.04 VM, which I then deploy across my three zones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Strategy: I manage pools, not individual nodes. I tell Terraform I want 9 Database nodes, and it calculates the distribution across the three datacenters automatically.&lt;/li&gt;
&lt;li&gt;Pool-Specific Hardware: Terraform provisions each pool with its unique resource requirements - NVMe storage for database nodes, GPU passthrough for ML nodes, and learner specs for the general app pool.&lt;/li&gt;
&lt;li&gt;The Identity: Terraform handles the static IP assignment and Cloud-Init injection. When a node boots, it already knows its name, its IP, and which Kubernetes pool it belongs to.&lt;/li&gt;
&lt;li&gt;Remote state management: Terraform state file is managed in Terraform Cloud. But I run the command locally. I might use remote run too if I create a self-hosted runner later in future with kubernetes pod.&lt;/li&gt;
&lt;/ul&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.amazonaws.com%2Fuploads%2Farticles%2F0gpwcl992xjiaououxbm.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.amazonaws.com%2Fuploads%2Farticles%2F0gpwcl992xjiaououxbm.png" alt="state" width="800" height="364"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The foundation is poured.&lt;/strong&gt; We have 21 heartbeating Ubuntu 24.04 nodes, perfectly distributed across three zones, with static IPs and pool-specific hardware.&lt;/p&gt;

&lt;p&gt;Now, we hand the baton to &lt;strong&gt;Ansible&lt;/strong&gt; to take us from raw VMs to a security-hardened Kubernetes powerhouse.&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.amazonaws.com%2Fuploads%2Farticles%2Fn6nuy9cwaoy4jpwdg21k.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.amazonaws.com%2Fuploads%2Farticles%2Fn6nuy9cwaoy4jpwdg21k.png" alt="rke2-nodes" width="800" height="419"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Building to the Roof (Ansible)
&lt;/h2&gt;

&lt;p&gt;With the foundation laid by Terraform, Ansible is the construction crew that turns bare VMs into a hardened, HA RKE2 cluster. Think of Terraform as the concrete slab and Ansible as the framing, insulation, and roof - everything that makes the house livable and resilient.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Role of lab-lab-rke2
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;lab-lab-rke2&lt;/code&gt; is the single &lt;strong&gt;Ansible&lt;/strong&gt; role that codifies your cluster build rules.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;strong&gt;What it does, at a glance:&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OS hardening&lt;/strong&gt; for Ubuntu 24.04 (kernel tuning, sysctl, package hygiene)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RKE2 installation&lt;/strong&gt; and configuration (server vs agent)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;kube‑vip and Cilium&lt;/strong&gt; configuration hooks for HA and networking&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node labeling and tainting&lt;/strong&gt; for pool enforcement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPU runtime setup&lt;/strong&gt; and driver validation on GPU nodes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent checks&lt;/strong&gt; so repeated runs are safe&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This role is the repeatable recipe that guarantees every node is built the same way.&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.amazonaws.com%2Fuploads%2Farticles%2Fq6o29nbnpm6w6micppkw.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.amazonaws.com%2Fuploads%2Farticles%2Fq6o29nbnpm6w6micppkw.png" alt="labs-labs-rke2" width="800" height="563"&gt;&lt;/a&gt;&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.amazonaws.com%2Fuploads%2Farticles%2Flx5o560194iji56ozolh.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.amazonaws.com%2Fuploads%2Farticles%2Flx5o560194iji56ozolh.png" alt="labs-labs-rke22" width="800" height="470"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bootstrap Sequence (Deterministic, Step‑by‑Step)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OS Prep&lt;/strong&gt; - apply security hardening, install prerequisites, configure SSH and time sync.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First Master Init&lt;/strong&gt; - install RKE2 server, start the first control‑plane, generate initial certificates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HA Master Join&lt;/strong&gt; - sequentially join the remaining masters to form etcd quorum and control plane.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Worker Join&lt;/strong&gt; - register app, db, and GPU workers; apply labels and taints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Post‑Install Validation&lt;/strong&gt; - verify control‑plane health, etcd quorum, &lt;code&gt;Cilium&lt;/code&gt; status, and &lt;code&gt;kube‑vip&lt;/code&gt; VIP ownership.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why sequential matters:&lt;/strong&gt; the first master must be healthy before others join to avoid split‑brain and to ensure deterministic certificate distribution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency and&amp;nbsp;Safety
&lt;/h2&gt;

&lt;p&gt;Ansible's idempotency is the operational superpower here.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One variable change&lt;/strong&gt; in the role updates only what's necessary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-running the playbook&lt;/strong&gt; is safe and becomes the standard maintenance operation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Playbook checks&lt;/strong&gt; validate preconditions and abort safely if something is off.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This turns maintenance into a predictable, low‑risk activity rather than a manual firefight.&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.amazonaws.com%2Fuploads%2Farticles%2Fplv83o514no31l5yyci0.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.amazonaws.com%2Fuploads%2Farticles%2Fplv83o514no31l5yyci0.png" alt="RKE2-POD" width="800" height="449"&gt;&lt;/a&gt;&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.amazonaws.com%2Fuploads%2Farticles%2Fpkyw4viylzsnno855e5d.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.amazonaws.com%2Fuploads%2Farticles%2Fpkyw4viylzsnno855e5d.png" alt="RKE2-POD2" width="800" height="442"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integration with Terraform Outputs&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Ansible consumes Terraform outputs as its inventory and input source.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;How it flows:&lt;/strong&gt; Terraform outputs node IPs, hostnames, and tags → Ansible reads terraform output -json or the TFC API → lab-lab-rke2 targets nodes by role and zone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Result:&lt;/strong&gt; No guesswork. No manual SSH lists. The bootstrap is fully data‑driven.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This tight coupling is what makes the build deterministic and reproducible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling Upgrades and Day‑2 Operations
&lt;/h2&gt;

&lt;p&gt;Your Ansible role also codifies safe upgrades and routine ops.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rolling upgrades:&lt;/strong&gt; drain → upgrade RKE2 binary → restart → uncordon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi‑DC awareness:&lt;/strong&gt; upgrade order preserves quorum and minimizes risk by upgrading workers per DC and masters with quorum safety.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nuke &amp;amp; Pave:&lt;/strong&gt; destroy a node, let Terraform recreate it, and let Ansible rejoin it - zero drift guaranteed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Upgrades become a scripted, auditable operation rather than an ad‑hoc event.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Control Room (Rancher UI and Unified&amp;nbsp;SSO)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Rancher&lt;/strong&gt; is the single pane of glass for cluster operations, and &lt;strong&gt;Keycloak&lt;/strong&gt; is the identity brain that powers seamless Single Sign‑On across the platform. I configured Rancher to use &lt;strong&gt;OIDC&lt;/strong&gt; via Keycloak, and Keycloak federates authentication to Google and GitHub, so users sign in with familiar accounts.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Security: No more "admin/admin" credentials. Access is governed by GitHub Organisation membership or Google Workspace groups.&lt;/li&gt;
&lt;li&gt;Auditability: Every action taken in the UI is tied to a real identity.&lt;/li&gt;
&lt;li&gt;Centralisation: One login to rule them all. Once you are in via Keycloak, you have access to the entire multi-DC dashboard.&lt;/li&gt;
&lt;/ul&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.amazonaws.com%2Fuploads%2Farticles%2F9cj6p66n38wvpx3msgxg.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.amazonaws.com%2Fuploads%2Farticles%2F9cj6p66n38wvpx3msgxg.png" alt="rancher1" width="800" height="519"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redirects me to keycloak&lt;/strong&gt;&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.amazonaws.com%2Fuploads%2Farticles%2Fzk3g9cmw5btvul7eirmx.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.amazonaws.com%2Fuploads%2Farticles%2Fzk3g9cmw5btvul7eirmx.png" alt="rancher-keycloak" width="800" height="519"&gt;&lt;/a&gt;&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.amazonaws.com%2Fuploads%2Farticles%2F92cqaw6vpb8gomodiwmd.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.amazonaws.com%2Fuploads%2Farticles%2F92cqaw6vpb8gomodiwmd.png" alt="rancher-gmail" width="800" height="491"&gt;&lt;/a&gt;&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.amazonaws.com%2Fuploads%2Farticles%2Ftu0h88rri598psqr0nog.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.amazonaws.com%2Fuploads%2Farticles%2Ftu0h88rri598psqr0nog.png" alt="Rancher-UI" width="800" height="517"&gt;&lt;/a&gt;&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.amazonaws.com%2Fuploads%2Farticles%2Fs8kg505ulynxp3qmugom.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.amazonaws.com%2Fuploads%2Farticles%2Fs8kg505ulynxp3qmugom.png" alt="Rancher-key" width="800" height="518"&gt;&lt;/a&gt;&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.amazonaws.com%2Fuploads%2Farticles%2F4v6za0r8onf2wnx4vpio.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.amazonaws.com%2Fuploads%2Farticles%2F4v6za0r8onf2wnx4vpio.png" alt="Rancher-logout" width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion: From Project to Production
&lt;/h2&gt;

&lt;p&gt;Moving from &lt;code&gt;kubeadm&lt;/code&gt; to &lt;code&gt;RKE2&lt;/code&gt; wasn't just about changing a tool; it was about adopting a &lt;strong&gt;"Production-First"&lt;/strong&gt; mindset. By treating my &lt;code&gt;homelab&lt;/code&gt; like a professional datacenter using Terraform for the foundation, Ansible for the build, and RKE2 for the hardened engine I've created a 21-node environment that is self-healing, deterministic, and secure.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;Proxmox&lt;/code&gt; is my land and &lt;code&gt;RKE2&lt;/code&gt; is my building, I am now ready to &lt;strong&gt;"move in"&lt;/strong&gt; the furniture. The cluster is rock-solid, the foundation is level, and the lights are on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next? (Don't Miss&amp;nbsp;Out!)
&lt;/h2&gt;

&lt;p&gt;This is just the beginning of the "Empire" series. I will be deep-diving into every layer of this stack, showing you how to turn raw compute into a fully automated platform.&lt;br&gt;
&lt;strong&gt;Subscribe and follow me&lt;/strong&gt; on &lt;strong&gt;&lt;a href="https://www.linkedin.com/in/george-ezejiofor-89615a8a/" rel="noopener noreferrer"&gt;[LinkedIn]&lt;/a&gt;&lt;/strong&gt; to catch my upcoming articles on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Advanced Rancher UI:&lt;/strong&gt; Deep-dives into SSO with SAML, Keycloak, and Google/GitHub providers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Storage Stack:&lt;/strong&gt; Deploying Ceph (Operator and Cluster) for distributed persistent data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud Native Postgres (CNPG):&lt;/strong&gt; Running enterprise-grade databases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitOps Mastery:&lt;/strong&gt; Automating everything with ArgoCD.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Security Vault:&lt;/strong&gt; Secrets management with HashiCorp Vault.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI Readiness:&lt;/strong&gt; Installing the NVIDIA GPU-Operator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External Access:&lt;/strong&gt; Securing apps with OAuth2-Proxy and SSO.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Let's connect and build something massive. See you in the next one!&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>terraform</category>
      <category>ansible</category>
      <category>homelab</category>
    </item>
    <item>
      <title>Homelab HA Kubernetes Cluster Upgrade: My New Shrine / Altar</title>
      <dc:creator>George Ezejiofor</dc:creator>
      <pubDate>Fri, 03 Apr 2026 21:24:36 +0000</pubDate>
      <link>https://dev.to/ezejioforog/homelab-ha-kubernetes-cluster-upgrade-my-new-shrine-altar-5c34</link>
      <guid>https://dev.to/ezejioforog/homelab-ha-kubernetes-cluster-upgrade-my-new-shrine-altar-5c34</guid>
      <description>&lt;h2&gt;
  
  
  &lt;strong&gt;INTRODUCTION&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;In the beginning, there was MicroK8s on a Mac Studio. It was fast, it was ARM64, but it was lonely. Today, I stand before a high-availability monument built on Proxmox, orchestrated by Terraform, and kept in holy alignment by FluxCD.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Not long ago, my entire Kubernetes universe lived inside a humble Mac Studio - a single microk8s cluster with 6 nodes running on ARM64. It was cute, quiet, and completely unfit for the kind of multi‑DC, production‑grade nonsense I wanted to learn.&lt;/p&gt;

&lt;p&gt;So I burned it down. And built this new place of worship.&lt;/p&gt;

&lt;p&gt;Today, I run a high‑availability kubeadm cluster across three bare‑metal Proxmox Datacenters, all managed with Terraform, Ansible, and FluxCD. No cloud vendor lock‑in. No magic. Just a rack full of metal, a bunch of cables, and a lot of terminal time.&lt;/p&gt;

&lt;p&gt;This is the story of my shrine - and how you can build one too.&lt;/p&gt;

&lt;h2&gt;
  
  
  UGLY WIRING:
&lt;/h2&gt;

&lt;p&gt;MAJOR REASON WHY I CALLED IT SHRINE 😂  &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.amazonaws.com%2Fuploads%2Farticles%2Fruu71fwpd17d8k0hi99i.jpeg" 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.amazonaws.com%2Fuploads%2Farticles%2Fruu71fwpd17d8k0hi99i.jpeg" alt="homelab1" width="800" height="290"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Traffic Flow at a Glance&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Before we dive into the layers, here's how the traffic moves from my "pulpit" (Mac Studio) to the "shrine" (the cluster):&lt;/p&gt;

&lt;p&gt;No inbound holes – all management traffic originates from my Mac or the cluster itself (GitOps pulls). This is how real datacenters work.&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="s"&gt;┌──────────────────────────────────────────────────────────────────────────────────────┐&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;│                         🖥️ macOS COMMAND CENTER (The Pulpit)                         │&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;│              kubectl  │  Terraform  │  Ansible  │  Flux CLI  │  Git                  │&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;│                        (All management tools installed locally)                      │&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;└─────────────────────────────────────────┬────────────────────────────────────────────┘&lt;/span&gt;
                                          &lt;span class="s"&gt;│&lt;/span&gt;
                          &lt;span class="s"&gt;SSH │ API (HTTPS) │ Git (SSH/HTTPS)&lt;/span&gt;
                                          &lt;span class="s"&gt;│&lt;/span&gt;
                                          &lt;span class="s"&gt;▼&lt;/span&gt;
&lt;span class="s"&gt;┌──────────────────────────────────────────────────────────────────────────────────────┐&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;│                         🛡️ OPNsense Firewall (10.0.1.1)                              │&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;│   ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐                      │&lt;/span&gt;
&lt;span class="s"&gt;│   │ DHCP Server     │  │ Static DHCP     │  │ WireGuard VPN   │                      │&lt;/span&gt;
&lt;span class="s"&gt;│   │ 10.0.1.100-200  │  │ MAC → IP Pinning │  │ Remote Access   │                      │&lt;/span&gt;
&lt;span class="s"&gt;│   └─────────────────┘  └─────────────────┘  └─────────────────┘                      │&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="na"&gt;│   • Split-Horizon DNS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="err"&gt;*&lt;/span&gt;&lt;span class="s"&gt;.georgehomelab.com → 10.0.1.x                               │&lt;/span&gt;
&lt;span class="s"&gt;│   • Gateway for all Proxmox + Kubernetes traffic                                     │&lt;/span&gt;
&lt;span class="na"&gt;│   • Firewall rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;WAN → LAN passes for management                                  │&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;└─────────────────────────────────────────┬────────────────────────────────────────────┘&lt;/span&gt;
                                          &lt;span class="s"&gt;│&lt;/span&gt;
                                          &lt;span class="s"&gt;│ LAN (10.0.1.0/16)&lt;/span&gt;
                                          &lt;span class="s"&gt;│ 2.5GbE Links&lt;/span&gt;
                                          &lt;span class="s"&gt;▼&lt;/span&gt;
&lt;span class="s"&gt;┌──────────────────────────────────────────────────────────────────────────────────────┐&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;│                         🔌 Zyxel XMG1915-10E Switch                                  │&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;│                     Star topology │ 8× 2.5GbE + 2× SFP+                              │&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;└─────────────────────────────────────────┬────────────────────────────────────────────┘&lt;/span&gt;
                                          &lt;span class="s"&gt;│&lt;/span&gt;
              &lt;span class="s"&gt;┌───────────────────────────┼───────────────────────────┐&lt;/span&gt;
              &lt;span class="s"&gt;│                           │                           │&lt;/span&gt;
              &lt;span class="s"&gt;▼                           ▼                           ▼&lt;/span&gt;
&lt;span class="s"&gt;┌─────────────────────────┐ ┌─────────────────────────┐ ┌─────────────────────────┐&lt;/span&gt;
&lt;span class="s"&gt;│                         │ │                         │ │                         │&lt;/span&gt;
&lt;span class="s"&gt;│  🏗️ Proxmox Node 1      │ │  🏗️ Proxmox Node 2      │ │  🏗️ Proxmox Node 3      │&lt;/span&gt;
&lt;span class="s"&gt;│  (proxmox-dc-1)         │ │  (proxmox-dc-2)         │ │  (proxmox-dc-3)         │&lt;/span&gt;
&lt;span class="s"&gt;│  10.0.1.10              │ │  10.0.1.11              │ │  10.0.1.12              │&lt;/span&gt;
&lt;span class="s"&gt;│                         │ │                         │ │                         │&lt;/span&gt;
&lt;span class="s"&gt;│  • Local ZFS Storage    │ │  • Local ZFS Storage    │ │  • Local ZFS Storage    │&lt;/span&gt;
&lt;span class="s"&gt;│  • vmbr0 Bridge         │ │  • vmbr0 Bridge         │ │  • vmbr0 Bridge         │&lt;/span&gt;
&lt;span class="s"&gt;│  • NFS Client (Backups) │ │  • NFS Client (Backups) │ │  • NFS Client (Backups) │&lt;/span&gt;
&lt;span class="s"&gt;│                         │ │                         │ │                         │&lt;/span&gt;
&lt;span class="s"&gt;│  Terraform → VM Creation via Proxmox API (telmate/provider)                          │&lt;/span&gt;
&lt;span class="s"&gt;│  Packer → Ubuntu Cloud-Init Templates                                                │&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;└─────────────────────────┘ └─────────────────────────┘ └─────────────────────────┘&lt;/span&gt;
              &lt;span class="s"&gt;│                           │                           │&lt;/span&gt;
              &lt;span class="s"&gt;│ Cloud-Init DHCP (Static Reservations → Predictable IPs)                │&lt;/span&gt;
              &lt;span class="s"&gt;│                           │                           │&lt;/span&gt;
              &lt;span class="s"&gt;└───────────────────────────┼───────────────────────────┘&lt;/span&gt;
                                          &lt;span class="s"&gt;▼&lt;/span&gt;
&lt;span class="s"&gt;┌──────────────────────────────────────────────────────────────────────────────────────┐&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;│                         ☸️ HA Kubernetes Cluster (kubeadm)                           │&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;│   ┌─────────────────────────┐ ┌─────────────────────────┐ ┌─────────────────────────┐&lt;/span&gt;
&lt;span class="s"&gt;│   │ Control Plane Node 1    │ │ Control Plane Node 2    │ │ Control Plane Node 3    │&lt;/span&gt;
&lt;span class="s"&gt;│   │ k8s-cp-1                │ │ k8s-cp-2                │ │ k8s-cp-3                │&lt;/span&gt;
&lt;span class="s"&gt;│   │ 10.0.1.110              │ │ 10.0.1.111              │ │ 10.0.1.112              │&lt;/span&gt;
&lt;span class="s"&gt;│   │                         │ │                         │ │                         │&lt;/span&gt;
&lt;span class="s"&gt;│   │ • etcd (stacked)        │ │ • etcd (stacked)        │ │ • etcd (stacked)        │&lt;/span&gt;
&lt;span class="s"&gt;│   │ • kube-apiserver        │ │ • kube-apiserver        │ │ • kube-apiserver        │&lt;/span&gt;
&lt;span class="s"&gt;│   │ • kube-vip (VIP)        │ │ • kube-vip (VIP)        │ │ • kube-vip (VIP)        │&lt;/span&gt;
&lt;span class="s"&gt;│   └─────────────────────────┘ └─────────────────────────┘ └─────────────────────────┘&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;│   ┌─────────────────────────┐ ┌─────────────────────────┐ ┌─────────────────────────┐&lt;/span&gt;
&lt;span class="s"&gt;│   │ Worker Node 1           │ │ Worker Node 2           │ │ Worker Node 3           │&lt;/span&gt;
&lt;span class="s"&gt;│   │ k8s-worker-1            │ │ k8s-worker-2            │ │ k8s-worker-3            │&lt;/span&gt;
&lt;span class="s"&gt;│   │ 10.0.1.120              │ │ 10.0.1.121              │ │ 10.0.1.122              │&lt;/span&gt;
&lt;span class="s"&gt;│   │                         │ │                         │ │                         │&lt;/span&gt;
&lt;span class="s"&gt;│   │ • Calico CNI (BGP)      │ │ • Calico CNI (BGP)      │ │ • Calico CNI (BGP)      │&lt;/span&gt;
&lt;span class="s"&gt;│   │ • kube-proxy            │ │ • kube-proxy            │ │ • kube-proxy            │&lt;/span&gt;
&lt;span class="s"&gt;│   │ • Workload Pods         │ │ • Workload Pods         │ │ • Workload Pods         │&lt;/span&gt;
&lt;span class="s"&gt;│   └─────────────────────────┘ └─────────────────────────┘ └─────────────────────────┘&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="na"&gt;│   Pod CIDR: 10.244.0.0/16 │ Service CIDR: 10.245.0.0/16 │ MetalLB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10.0.1.200-210  │&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;│   🔧 Bootstrapped entirely by Ansible (kubeadm playbook)                            │&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;└─────────────────────────────────────────┬────────────────────────────────────────────┘&lt;/span&gt;
                                          &lt;span class="s"&gt;│&lt;/span&gt;
                                          &lt;span class="s"&gt;│ GitOps Sync (Outbound Only)&lt;/span&gt;
                                          &lt;span class="s"&gt;│ FluxCD pulls from GitHub (no inbound!)&lt;/span&gt;
                                          &lt;span class="s"&gt;▼&lt;/span&gt;
&lt;span class="s"&gt;┌──────────────────────────────────────────────────────────────────────────────────────┐&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;│                         🔄 FluxCD System (Inside Cluster)                            │&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;│   ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐                      │&lt;/span&gt;
&lt;span class="s"&gt;│   │ source-         │  │ kustomize-      │  │ helm-           │                      │&lt;/span&gt;
&lt;span class="s"&gt;│   │ controller      │  │ controller      │  │ controller      │                      │&lt;/span&gt;
&lt;span class="s"&gt;│   └─────────────────┘  └─────────────────┘  └─────────────────┘                      │&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;│   ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐                      │&lt;/span&gt;
&lt;span class="s"&gt;│   │ notification-   │  │ image-reflector-│  │ image-          │                      │&lt;/span&gt;
&lt;span class="s"&gt;│   │ controller      │  │ controller      │  │ automation-     │                      │&lt;/span&gt;
&lt;span class="s"&gt;│   └─────────────────┘  └─────────────────┘  └─────────────────┘                      │&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;│   • Deployed as part of Ansible playbook (not a separate step)                       │&lt;/span&gt;
&lt;span class="s"&gt;│   • Continuously reconciles cluster state with Git                                   │&lt;/span&gt;
&lt;span class="s"&gt;│   • Auto-heals configuration drift                                                   │&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;└─────────────────────────────────────────┬────────────────────────────────────────────┘&lt;/span&gt;
                                          &lt;span class="s"&gt;│&lt;/span&gt;
                                          &lt;span class="s"&gt;│ HTTPS/SSH (Outbound Pull)&lt;/span&gt;
                                          &lt;span class="s"&gt;│&lt;/span&gt;
                                          &lt;span class="s"&gt;▼&lt;/span&gt;
&lt;span class="s"&gt;┌──────────────────────────────────────────────────────────────────────────────────────┐&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;│                              📦 GitHub Private Repository                            │&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;│   ┌─────────────────────────────────────────────────────────────────────────────┐    │&lt;/span&gt;
&lt;span class="s"&gt;│   │  clusters/prod/                                                             │    │&lt;/span&gt;
&lt;span class="s"&gt;│   │  ├── flux-system/&lt;/span&gt;          &lt;span class="c1"&gt;# Flux bootstrapping config                      │    │&lt;/span&gt;
&lt;span class="s"&gt;│   │  │   ├── gotk-components.yaml                                               │    │&lt;/span&gt;
&lt;span class="s"&gt;│   │  │   └── gotk-sync.yaml                                                     │    │&lt;/span&gt;
&lt;span class="s"&gt;│   │  ├── apps/&lt;/span&gt;                  &lt;span class="c1"&gt;# Application deployments                       │    │&lt;/span&gt;
&lt;span class="s"&gt;│   │  │   ├── metallb/                                                          │    │&lt;/span&gt;
&lt;span class="s"&gt;│   │  │   ├── istio-ingress/                                                    │    │&lt;/span&gt;
&lt;span class="s"&gt;│   │  │   └── prometheus-stack/                                                 │    │&lt;/span&gt;
&lt;span class="s"&gt;│   │  └── infrastructure/&lt;/span&gt;        &lt;span class="c1"&gt;# Cluster-wide config                          │    │&lt;/span&gt;
&lt;span class="s"&gt;│   │      ├── namespaces.yaml                                                   │    │&lt;/span&gt;
&lt;span class="s"&gt;│   │      └── storage-class.yaml                                                │    │&lt;/span&gt;
&lt;span class="s"&gt;│   └─────────────────────────────────────────────────────────────────────────────┘    │&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="na"&gt;│   🔑 Source of Truth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Every change starts as a PR, reviewed, merged, then applied   │&lt;/span&gt;
&lt;span class="s"&gt;│                                                                                      │&lt;/span&gt;
&lt;span class="s"&gt;└──────────────────────────────────────────────────────────────────────────────────────┘&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Level 1: The Physical Layer (The Foundations)
&lt;/h2&gt;

&lt;p&gt;Every altar begins with something tangible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardware:&lt;/strong&gt; A fleet of &lt;em&gt;Minisforum MS-01&lt;/em&gt; machines acting as compute Datacenter (&lt;em&gt;96GB RAM&lt;/em&gt;  and &lt;em&gt;8GB nvidia GPU&lt;/em&gt; on each machine) . That's Total of &lt;em&gt;288GB RAM&lt;/em&gt; and &lt;em&gt;24GB nvidia GPU&lt;/em&gt; in the three _Minisforum MS-01&lt;br&gt;
_&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Network Entry Point:&lt;/strong&gt; My Mac Studio (the “&lt;em&gt;pulpit&lt;/em&gt;”), connected via Wi-Fi&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Firewall:&lt;/strong&gt; OPNsense bridging external (192.168.1.x) to internal lab network (10.0.1.x)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Out-of-Band Access:&lt;/strong&gt; &lt;em&gt;TinyPilot Voyager 2a&lt;/em&gt; and &lt;em&gt;TESmart 4‑port HDMI KVM&lt;/em&gt; — BIOS-level control even when the OS is down&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Switch:&lt;/strong&gt; &lt;em&gt;Zyxel XMG1915-10E (2.5GbE + SFP+)&lt;/em&gt; is the &lt;em&gt;Central Nervous System&lt;/em&gt;. With &lt;em&gt;High-Velocity East-West Traffic&lt;/em&gt; ( &lt;em&gt;Low Latency / High Throughput&lt;/em&gt; for etcd and storage )&lt;/p&gt;

&lt;p&gt;Why I worship here:&lt;/p&gt;

&lt;p&gt;Physical simplicity enables logical complexity.&lt;/p&gt;

&lt;p&gt;No mystery cables. Everything is deliberate. This playground makes me the opportunity to play with any cloud-native tool with ease.&lt;/p&gt;
&lt;h2&gt;
  
  
  Level 2: The Infrastructure Layer (Proxmox Datacenter)
&lt;/h2&gt;

&lt;p&gt;Before automation, there must be a foundation.&lt;/p&gt;

&lt;p&gt;Proxmox VE installed manually on all three Minisforum MS-01 machine&lt;/p&gt;

&lt;p&gt;Clustered into a single datacenter abstraction&lt;/p&gt;

&lt;p&gt;Networking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;vmbr0 → Kubernetes network&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Static host IPs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;10.0.1.1x&lt;/li&gt;
&lt;li&gt;10.0.1.1x&lt;/li&gt;
&lt;li&gt;10.0.1.1x&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Gateway: 10.0.1.x (OPNsense)&lt;/p&gt;

&lt;p&gt;Storage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Local ZFS (NVMe)&lt;/li&gt;
&lt;li&gt;NFS for shared ISO + backups&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ritual:&lt;/p&gt;

&lt;p&gt;I installed the first Proxmox VE manually on each machine via TinyPilot’s virtual media from my MAC-Studio browser over wifi.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;No HDMI cable ever touched my desk.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Level 3: The Node Layer (Terraform Automation)
&lt;/h2&gt;

&lt;p&gt;I no longer click buttons to create infrastructure.&lt;/p&gt;

&lt;p&gt;I declare it.&lt;/p&gt;

&lt;p&gt;Using the Proxmox Terraform provider, I define:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;VM CPU, memory, disk&lt;/li&gt;
&lt;li&gt;Network interfaces&lt;/li&gt;
&lt;li&gt;Clone source (Ubuntu template from Packer)
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;resource "proxmox_vm_qemu" "k8s_node" {&lt;/span&gt;
  &lt;span class="s"&gt;for_each = var.nodes&lt;/span&gt;

  &lt;span class="s"&gt;name        = each.value.name&lt;/span&gt;
  &lt;span class="s"&gt;target_node = each.value.proxmox_node&lt;/span&gt;
  &lt;span class="s"&gt;clone       = "ubuntu-24-04-template"&lt;/span&gt;
  &lt;span class="s"&gt;cores       = each.value.cores&lt;/span&gt;
  &lt;span class="s"&gt;memory      = each.value.memory&lt;/span&gt;

  &lt;span class="s"&gt;network {&lt;/span&gt;
    &lt;span class="s"&gt;model     = "virtio"&lt;/span&gt;
    &lt;span class="s"&gt;bridge    = "vmbr0"&lt;/span&gt;
    &lt;span class="s"&gt;ipconfig0 = "ip=dhcp"&lt;/span&gt;
  &lt;span class="s"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;The DHCP Decision (And Why It Matters)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This was one of the most important lessons in my journey.&lt;/p&gt;

&lt;p&gt;In my old Mac Studio setup, I used pure DHCP for Kubernetes nodes.&lt;/p&gt;

&lt;p&gt;It worked… until every restart broke my cluster access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What went wrong?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Control plane nodes changed IPs&lt;/li&gt;
&lt;li&gt;kubeconfig became invalid&lt;/li&gt;
&lt;li&gt;API server endpoints broke&lt;/li&gt;
&lt;li&gt;etcd stability was at risk&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even with 3 &lt;strong&gt;control planes&lt;/strong&gt;, the cluster wasn’t truly stable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Not Static IPs?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because static IPs inside the OS mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Manual netplan configuration&lt;/li&gt;
&lt;li&gt;Hardcoding network logic into templates&lt;/li&gt;
&lt;li&gt;Reduced rebuild flexibility&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s not how cloud-native systems behave.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Solution: DHCP + Reservations&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I used DHCP everywhere — but configured static reservations in OPNsense.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✔ Nodes auto-configure&lt;/li&gt;
&lt;li&gt;✔ IPs never change&lt;/li&gt;
&lt;li&gt;✔ Rebuilds are seamless&lt;/li&gt;
&lt;li&gt;✔ etcd remains stable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;💡 The Real Insight&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Kubernetes doesn’t care how IPs are assigned — only that they don’t change.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Level 4: The Cluster Layer (Ansible + Kubeadm)
&lt;/h2&gt;

&lt;p&gt;Once the infrastructure exists, it must be transformed.&lt;/p&gt;

&lt;p&gt;Using &lt;strong&gt;Ansible:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;- OS hardening&lt;/li&gt;
&lt;li&gt;- Swap disabled&lt;/li&gt;
&lt;li&gt;- containerd installed&lt;/li&gt;
&lt;li&gt;kubeadm, kubelet, kubectl configured&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;HA Control Plane&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3 control plane nodes&lt;/li&gt;
&lt;li&gt;Stacked etcd (homelab-friendly)&lt;/li&gt;
&lt;li&gt;kube-vip for API virtual IP&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Level 5: The Application Layer (GitOps with FluxCD)
&lt;/h2&gt;

&lt;p&gt;This is where everything changes.&lt;/p&gt;

&lt;p&gt;Instead of imperative deployments or declarative deployment with kubectl, I use GitOps &lt;strong&gt;FluxCD.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitOps From Day One&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;FluxCD is not an add-on.&lt;/p&gt;

&lt;p&gt;It is deployed &lt;strong&gt;during cluster creation via Ansible.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cluster is GitOps-ready immediately&lt;/li&gt;
&lt;li&gt;No manual bootstrap later&lt;/li&gt;
&lt;li&gt;No drift from day one&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The Pull Model&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Flux runs inside the cluster&lt;/li&gt;
&lt;li&gt;Watches Git repository&lt;/li&gt;
&lt;li&gt;Pulls changes automatically&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No inbound access required.&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.amazonaws.com%2Fuploads%2Farticles%2Flg296c0p1i1pfc2nycib.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.amazonaws.com%2Fuploads%2Farticles%2Flg296c0p1i1pfc2nycib.png" alt="Homelab3" width="800" height="418"&gt;&lt;/a&gt;&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.amazonaws.com%2Fuploads%2Farticles%2Fjivr0wykhduzmj6cifew.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.amazonaws.com%2Fuploads%2Farticles%2Fjivr0wykhduzmj6cifew.png" alt="Homelab4" width="800" height="418"&gt;&lt;/a&gt;&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.amazonaws.com%2Fuploads%2Farticles%2F8irk4e2v8dg8l1f52ukh.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.amazonaws.com%2Fuploads%2Farticles%2F8irk4e2v8dg8l1f52ukh.png" alt="Homelab5" width="800" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Traffic Flow&lt;/strong&gt;&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="s"&gt;Mac Studio (192.168.1.x)&lt;/span&gt;
        &lt;span class="s"&gt;│&lt;/span&gt;
        &lt;span class="s"&gt;▼&lt;/span&gt;
&lt;span class="s"&gt;OPNsense Firewall (10.0.1.x)&lt;/span&gt;
        &lt;span class="s"&gt;│&lt;/span&gt;
        &lt;span class="s"&gt;▼&lt;/span&gt;
&lt;span class="s"&gt;Proxmox Cluster (10.0.1.1x–1x)&lt;/span&gt;
        &lt;span class="s"&gt;│&lt;/span&gt;
        &lt;span class="s"&gt;▼&lt;/span&gt;
&lt;span class="s"&gt;Kubernetes Nodes (DHCP → Reserved IPs)&lt;/span&gt;
        &lt;span class="s"&gt;│&lt;/span&gt;
        &lt;span class="s"&gt;▼&lt;/span&gt;
&lt;span class="s"&gt;FluxCD Controllers (inside cluster)&lt;/span&gt;
        &lt;span class="s"&gt;│&lt;/span&gt;
        &lt;span class="s"&gt;▼&lt;/span&gt;
&lt;span class="s"&gt;GitHub (OUTBOUND pull model)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key Insight:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;❌ GitHub never connects to your cluster&lt;/li&gt;
&lt;li&gt;❌ No firewall holes needed&lt;/li&gt;
&lt;li&gt;✅ Flux initiates outbound sync&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;** Current State of the Shrine**&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3 control plane nodes ✅&lt;/li&gt;
&lt;li&gt;3 worker nodes ✅&lt;/li&gt;
&lt;li&gt;etcd cluster healthy ✅&lt;/li&gt;
&lt;li&gt;Flux controllers distributed across nodes ✅&lt;/li&gt;
&lt;li&gt;Calico networking active ✅&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is no longer a lab.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;It is a self-healing platform.&lt;/p&gt;
&lt;/blockquote&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.amazonaws.com%2Fuploads%2Farticles%2F9y003v3qkbbsr4llvqny.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.amazonaws.com%2Fuploads%2Farticles%2F9y003v3qkbbsr4llvqny.png" alt="Homelab2" width="800" height="639"&gt;&lt;/a&gt;&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.amazonaws.com%2Fuploads%2Farticles%2Fiu5ziz6sov9q4npljq4f.gif" 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.amazonaws.com%2Fuploads%2Farticles%2Fiu5ziz6sov9q4npljq4f.gif" alt="Homelab6" width="760" height="682"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I Learned&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DHCP + reservations is the sweet spot&lt;/li&gt;
&lt;li&gt;etcd requires stable identity, not static config&lt;/li&gt;
&lt;li&gt;GitOps removes human drift completely&lt;/li&gt;
&lt;li&gt;Terraform + Ansible + FluxCD = powerful combination&lt;/li&gt;
&lt;li&gt;Firewalls must allow internal routing for automation&lt;/li&gt;
&lt;li&gt;Never use root API for automation — use scoped tokens&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What’s Next on the Altar&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ceph or Longhorn for HA storage&lt;/li&gt;
&lt;li&gt;Velero for cluster backups&lt;/li&gt;
&lt;li&gt;External Secrets + Vault&lt;/li&gt;
&lt;li&gt;Cluster autoscaler experiments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Final Words&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This homelab is more than a project.&lt;/p&gt;

&lt;p&gt;It is a &lt;strong&gt;practice ground for real-world platform engineering.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The move from a single ARM node to a distributed HA cluster wasn’t just an upgrade in hardware — it was an upgrade in mindset.&lt;/p&gt;

&lt;p&gt;My Mac Studio is no longer the host.&lt;/p&gt;

&lt;p&gt;It is the &lt;strong&gt;pulpit&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The Shrine runs independently.&lt;/p&gt;

&lt;p&gt;If you’re thinking of building something like this — do it.&lt;br&gt;
Start small. Break things. Rebuild them better.&lt;/p&gt;

&lt;p&gt;Now go build your own altar. 🛐&lt;/p&gt;

&lt;p&gt;🤝 Stay Connected&lt;/p&gt;

&lt;p&gt;Found this guide helpful? Follow my journey into Homelabing on LinkedIn! Click the blue LinkedIn button to connect: &lt;a href="https://www.linkedin.com/in/george-ezejiofor-89615a8a/" rel="noopener noreferrer"&gt;George Ezejiofor&lt;/a&gt; . Let’s keep building scalable, secure cloud-native systems, one project at a time! 🌐🔧&lt;/p&gt;

</description>
      <category>devops</category>
      <category>homelab</category>
      <category>kubernetes</category>
      <category>automation</category>
    </item>
  </channel>
</rss>
