<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Argocd on hippotion</title><link>https://blog.hippotion.com/tags/argocd/</link><description>Recent content in Argocd on hippotion</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Fri, 01 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://blog.hippotion.com/tags/argocd/index.xml" rel="self" type="application/rss+xml"/><item><title>I Run GitOps for My Brain</title><link>https://blog.hippotion.com/posts/gitops-for-my-brain/</link><pubDate>Fri, 01 May 2026 00:00:00 +0000</pubDate><guid>https://blog.hippotion.com/posts/gitops-for-my-brain/</guid><description>An AI agent on a scheduled idle walk through my notes pointed out that I&amp;rsquo;d built the same architecture three times — at work, in my homelab, and in my second brain — and that the third copy was missing the part that makes GitOps work. It was right. So we shipped the missing piece the same day.</description><content:encoded><![CDATA[<h2 id="the-pattern-i-didnt-know-i-had">The pattern I didn&rsquo;t know I had</h2>
<p>This week an AI agent told me something about my own systems that I&rsquo;d never
noticed, and it was correct: I have one favorite architecture, and I&rsquo;ve built
it three times.</p>
<ul>
<li><strong>At work</strong>: git holds Terraform code → Terraform derives the S3 buckets.
Nobody clicks around in the AWS console; the repo is the truth.</li>
<li><strong>In the homelab</strong>: git holds Kubernetes manifests → ArgoCD derives the
cluster. Every app on my rack is a folder in a repo.</li>
<li><strong>In my second brain</strong>: a vault of markdown notes → an indexer derives the
search database (SQLite FTS + a link graph) that my AI tools query.</li>
</ul>
<p>Same shape everywhere: a plain-text source of truth in git, and a machine that
builds the real thing from it. Master copy, derived state. I never decided
this consciously — it&rsquo;s just how my hands build things now.</p>
<h2 id="gitops-isnt-the-git-part">GitOps isn&rsquo;t the git part</h2>
<p>Here&rsquo;s the thing that the third copy got wrong, and it took me embarrassingly
long to see because I <em>teach</em> this pattern at the infrastructure layer.</p>
<p>&ldquo;Configuration in git&rdquo; existed long before GitOps. What made GitOps an actual
shift was the <strong>reconciler</strong>: ArgoCD doesn&rsquo;t apply your manifests once and
wish you luck. It watches, continuously. When the cluster drifts from the
repo, you get an <code>OutOfSync</code> badge, and with <code>selfHeal</code> enabled it puts
reality back where the repo says it should be. The loop is the product. Git
is just where the loop points.</p>
<p>My vault had no loop. If I edited a note and forgot to rebuild the index, the
search results my AI agents rely on were silently stale — no badge, no error,
nothing. The only protection was a rule in the repo&rsquo;s agent instructions:
<em>&ldquo;if files and index disagree, the files win — run the indexer.&rdquo;</em></p>
<p>A policy that agents must remember. In other words: I was running Kubernetes
with a sticky note on the monitor that says <em>please redeploy after editing
the YAML</em>. I would never accept that on my cluster. My brain ran on it for
months.</p>
<h2 id="the-fix-took-an-afternoon">The fix took an afternoon</h2>
<p>Two pieces, both boring on purpose.</p>
<p><strong><code>exo status</code></strong> — the OutOfSync badge. The indexer now stores a content hash
per note; <code>status</code> re-hashes the vault and diffs:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;status&#34;</span><span class="p">:</span> <span class="s2">&#34;OutOfSync&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;modified&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;vault/10-notes/interests-themes.md&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;new&#34;</span><span class="p">:</span> <span class="p">[],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;deleted&#34;</span><span class="p">:</span> <span class="p">[],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;repair&#34;</span><span class="p">:</span> <span class="s2">&#34;exo index&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Exit code 0 when synced, 1 when not — so scripts and CI can ask the question
too, exactly like <code>argocd app get</code>.</p>
<p><strong>Git hooks</strong> — the selfHeal. Versioned hooks (<code>core.hooksPath .githooks</code>) on
<code>post-commit</code> and <code>post-merge</code> rebuild the index after every commit and pull:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="nb">command</span> -v exo &gt;/dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="o">||</span> <span class="nb">exit</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl"><span class="nv">EXO_ROOT</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>git rev-parse --show-toplevel<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">exo index &gt;/dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">&#34;exo: index reconciled (Synced)&#34;</span>
</span></span></code></pre></div><p>Now every <code>git commit</code> in the vault prints <code>exo: index reconciled (Synced)</code>
on its way out. The rule didn&rsquo;t change — <em>files win</em> — but it stopped being
something agents must remember and became something a machine enforces.
That&rsquo;s the entire difference between configuration management and GitOps,
replayed at the knowledge layer.</p>
<h2 id="the-part-where-it-gets-a-little-strange">The part where it gets a little strange</h2>
<p>The reason I&rsquo;m writing this post at all: I didn&rsquo;t have this idea. A scheduled
agent did, on what I can only describe as an idle walk.</p>
<p>My vault has a weekly cron job — we call it the Wanderer — that samples pairs
of notes that are <em>far apart</em>: different folders, different months, almost no
shared vocabulary. A headless Claude gets the pairs with exactly one task:
<em>read both notes in full and say whether anything genuinely connects. &ldquo;Nothing
connects&rdquo; is a successful run.</em> That last sentence is load-bearing — the run
always reports its result either way, so the agent never needs to manufacture
a finding to have done its job.</p>
<p>On its very first walk, it collided a work note about Terraform-driven S3
provisioning with the architecture map of the vault itself, and wrote: <em>same
sentence in different clothes — and the brain copy is missing its
reconciler.</em> Then it listed the two fixes you just read about.</p>
<p>Retrieval answers the questions you ask. Distant collisions surface the
questions you didn&rsquo;t know you had. It turns out my second brain didn&rsquo;t need
to get better at remembering — it needed to occasionally interrupt me.</p>
<h2 id="if-you-keep-a-vault">If you keep a vault</h2>
<p>Whatever your stack — Obsidian, org-mode, a folder of markdown — if anything
<em>derives</em> from your notes (an index, embeddings, a published site), then you
have source of truth and derived state, and the GitOps question applies: <strong>who
notices when they drift?</strong> If the answer is &ldquo;I do, hopefully,&rdquo; you&rsquo;re running
the sticky-note era. Give it a badge and a loop. It&rsquo;s an afternoon.</p>
]]></content:encoded></item><item><title>📦 Five Ways to Manage Kubernetes Manifests (and Why They're Not All Equal)</title><link>https://blog.hippotion.com/posts/gitops-manifest-approaches/</link><pubDate>Fri, 10 Oct 2025 00:00:00 +0000</pubDate><guid>https://blog.hippotion.com/posts/gitops-manifest-approaches/</guid><description>Raw YAML, Kustomize, Helm, Jsonnet — there&amp;rsquo;s more than one way to describe what you want running in a cluster. Here&amp;rsquo;s what each actually looks like in practice and where each one breaks.</description><content:encoded><![CDATA[<h2 id="the-problem-everyone-hits">The problem everyone hits</h2>
<p>You&rsquo;ve got a Kubernetes cluster. Now you need to describe what should run in it. You write some YAML, apply it, it works.</p>
<p>Then you need a second environment. Or a second service. Or someone else joins the project and asks &ldquo;how do I add an app to this?&rdquo; and you don&rsquo;t have a good answer.</p>
<p>This is the manifest management problem, and there are five common solutions — ranging from &ldquo;this works until it doesn&rsquo;t&rdquo; to &ldquo;this is what production platforms actually look like.&rdquo;</p>
<hr>
<h2 id="approach-1-raw-manifests">Approach 1: Raw manifests</h2>
<p>The starting point for almost everyone. Write a YAML file, <code>kubectl apply -f</code>, done.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">apps/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Deployment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">replicas</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">selector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">template</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">containers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">myapp:v1.2.3</span><span class="w">
</span></span></span></code></pre></div><p><strong>Where it works:</strong> one service, one environment, learning Kubernetes. The feedback loop is immediate — write YAML, see what happens.</p>
<p><strong>Where it breaks:</strong></p>
<ul>
<li><strong>No templating.</strong> Want to change the image tag across ten services? Ten files, ten edits, ten chances to get it wrong.</li>
<li><strong>Live state leaks in.</strong> If you export existing resources with <code>kubectl get -o yaml</code>, you get <code>resourceVersion</code>, <code>generation</code>, <code>creationTimestamp</code>, and <code>managedFields</code> in the output. Commit that to Git and you&rsquo;ve created a permanent source of conflicts — ArgoCD compares what&rsquo;s in Git against what&rsquo;s in the cluster, sees stale version counters, and the diff never clears.</li>
<li><strong>Copy-paste hell.</strong> A Deployment, a Service, an IngressRoute, a ServiceAccount, a NetworkPolicy — five files per app. Add a new app, copy five files, change the names, forget to update one. This is how environments drift apart silently.</li>
</ul>
<p>The fix for the live-state problem is: only commit desired state. Strip every field that Kubernetes manages internally back to its clean spec. It&rsquo;s tedious and easy to forget, which is exactly why people move on from raw manifests.</p>
<hr>
<h2 id="approach-2-kustomize">Approach 2: Kustomize</h2>
<p>Kustomize is built into <code>kubectl</code> (<code>kubectl apply -k</code>) and natively supported by ArgoCD. The idea: you have a <code>base/</code> with your raw manifests, and overlays that patch on top of them for different environments.</p>
<pre tabindex="0"><code>app/
├── base/
│   ├── deployment.yaml
│   ├── service.yaml
│   └── kustomization.yaml
└── overlays/
    ├── staging/
    │   ├── kustomization.yaml    # patches replicas to 1, image to :staging
    └── production/
        └── kustomization.yaml    # patches replicas to 3, image to :v1.2.3
</code></pre><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># overlays/production/kustomization.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">../../base</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">patches</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">patch</span><span class="p">:</span><span class="w"> </span><span class="p">|-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      - op: replace
</span></span></span><span class="line"><span class="cl"><span class="sd">        path: /spec/replicas
</span></span></span><span class="line"><span class="cl"><span class="sd">        value: 3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">target</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Deployment</span><span class="w">
</span></span></span></code></pre></div><p><strong>Where it works:</strong> multi-environment setups where the difference between environments is mostly configuration values, not structure. Kustomize is good at this — you write the base once and patch only what differs.</p>
<p><strong>Where it breaks:</strong></p>
<ul>
<li><strong>No real parameterization.</strong> Kustomize patches are surgical edits, not templates. If your base structure needs to vary (different resource shapes per environment, conditional blocks), you&rsquo;re fighting the tool.</li>
<li><strong>Patching deep structures is ugly.</strong> JSON patches on nested YAML are verbose and hard to read. You end up writing more patch YAML than it would take to just copy the file.</li>
<li><strong>Still repetitive across apps.</strong> Each app still gets its own base directory. You&rsquo;re not abstracting the shared patterns across apps, only the differences between environments of the same app.</li>
</ul>
<p>Kustomize is a significant step up from raw manifests for multi-environment setups. For complex templating or platform-level abstractions, it runs out of power quickly.</p>
<hr>
<h2 id="approach-3-helm">Approach 3: Helm</h2>
<p>Helm adds real templating. Charts are parameterized bundles — templates with variables, conditionals, and loops — and values files supply the parameters.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># templates/deployment.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">apps/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Deployment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span>{{<span class="w"> </span><span class="l">.Values.name }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span>{{<span class="w"> </span><span class="l">.Release.Namespace }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">replicas</span><span class="p">:</span><span class="w"> </span>{{<span class="w"> </span><span class="l">.Values.replicas | default 1 }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">template</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">containers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span>{{<span class="w"> </span><span class="l">.Values.name }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span>{{<span class="w"> </span><span class="l">.Values.image.repository }}:{{ .Values.image.tag }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>{{- <span class="l">if .Values.resources }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">resources</span><span class="p">:</span><span class="w"> </span>{{<span class="w"> </span><span class="l">.Values.resources | toYaml | nindent 12 }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>{{- <span class="l">end }}</span><span class="w">
</span></span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># values-production.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">replicas</span><span class="p">:</span><span class="w"> </span><span class="m">3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">image</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">repository</span><span class="p">:</span><span class="w"> </span><span class="l">myorg/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">tag</span><span class="p">:</span><span class="w"> </span><span class="l">v1.2.3</span><span class="w">
</span></span></span></code></pre></div><p>Helm renders the templates at deploy time. What lands in the cluster is clean rendered YAML — no internal state, no conflicts.</p>
<p><strong>Where it works:</strong> almost everywhere. The Helm Hub has charts for most common software already. For custom apps, writing a chart once and parameterizing per-environment is straightforwardly better than copying YAML.</p>
<p><strong>Where it breaks:</strong></p>
<ul>
<li><strong>Chart authoring is verbose.</strong> Writing a Helm chart from scratch involves a lot of Go templating boilerplate. For a simple app, it can feel like more scaffolding than application.</li>
<li><strong>Debugging rendered output is annoying.</strong> <code>helm template</code> is your friend, but errors in templates produce unhelpful messages. The indentation rules (<code>nindent</code>, <code>indent</code>, <code>toYaml</code>) have sharp edges.</li>
<li><strong>Values files still pile up.</strong> If every app has its own values file and there&rsquo;s no shared structure between them, you&rsquo;re back to copy-paste but now in YAML-that-configures-YAML.</li>
</ul>
<p>Helm is the right tool for most Kubernetes deployments. The ecosystem support alone (upstream charts for Postgres, Redis, Vault, every CNCF project) makes it the pragmatic default.</p>
<hr>
<h2 id="approach-4-jsonnet--cue">Approach 4: Jsonnet / CUE</h2>
<p>For teams that need programmatic config generation — actual code, not templates — Jsonnet and CUE are the serious alternatives.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-jsonnet" data-lang="jsonnet"><span class="line"><span class="cl"><span class="c1">// deployment.jsonnet
</span></span></span><span class="line"><span class="cl"><span class="k">local</span><span class="w"> </span><span class="nv">k</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">import</span><span class="w"> </span><span class="s">&#34;k.libsonnet&#34;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">local</span><span class="w"> </span><span class="nf">deployment</span><span class="p">(</span><span class="nv">name</span><span class="p">,</span><span class="w"> </span><span class="nv">image</span><span class="p">,</span><span class="w"> </span><span class="nv">replicas</span><span class="o">=</span><span class="mf">1</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nv">k</span><span class="p">.</span><span class="nv">apps</span><span class="p">.</span><span class="nv">v1</span><span class="p">.</span><span class="nv">deployment</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="nv">name</span><span class="p">,</span><span class="w"> </span><span class="nv">replicas</span><span class="p">,</span><span class="w"> </span><span class="p">[</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nv">k</span><span class="p">.</span><span class="nv">core</span><span class="p">.</span><span class="nv">v1</span><span class="p">.</span><span class="nv">container</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="nv">name</span><span class="p">,</span><span class="w"> </span><span class="nv">image</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">]);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nv">&#34;deployment.yaml&#34;</span><span class="p">:</span><span class="w"> </span><span class="nf">deployment</span><span class="p">(</span><span class="s">&#34;myapp&#34;</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;myorg/myapp:v1.2.3&#34;</span><span class="p">,</span><span class="w"> </span><span class="nv">replicas</span><span class="o">=</span><span class="mf">3</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p><strong>Where it works:</strong> large platforms where configuration is genuinely complex — many environments, many apps, deep interdependencies. Jsonnet lets you write real functions, share libraries, compose abstractions properly.</p>
<p><strong>Where it breaks:</strong></p>
<ul>
<li><strong>Steep learning curve.</strong> Jsonnet is a full language. CUE even more so — it has types, schemas, and a constraint system that takes time to internalise.</li>
<li><strong>Small community.</strong> Excellent tooling, but you&rsquo;re solving problems that have fewer Stack Overflow answers.</li>
<li><strong>Overkill for most setups.</strong> If you&rsquo;re not managing hundreds of services across multiple clusters, Helm is simpler and has everything you need.</li>
</ul>
<p>Jsonnet is used seriously at Google-scale infrastructure teams and in some CNCF projects. For a homelab or a small-to-medium platform, it&rsquo;s the right answer to a question you probably aren&rsquo;t asking yet.</p>
<hr>
<h2 id="approach-5-app-of-apps-with-generated-application-crds">Approach 5: App-of-apps with generated Application CRDs</h2>
<p>This is the ArgoCD-native meta-layer. Instead of managing manifests, you manage <code>Application</code> resources — and potentially use a chart or tool to generate those too.</p>
<p>A naive version: commit a folder of <code>Application</code> YAML files to Git, one per service. ArgoCD watches the folder and deploys each app.</p>
<p>A more sophisticated version: one &ldquo;root app&rdquo; that points to a chart, which generates all the other <code>Application</code> resources dynamically from a single config file.</p>
<p><strong>Where it works:</strong> at the platform level, not the individual app level. App-of-apps is how you manage what ArgoCD manages, not how you write the service manifests themselves. Combined with Helm, it gives you centralized control over the entire cluster&rsquo;s structure.</p>
<p><strong>Where it breaks:</strong></p>
<ul>
<li><strong>Manual <code>Application</code> CRDs are painful.</strong> If you&rsquo;re maintaining a folder of hand-written <code>Application</code> YAML files — one per service — you&rsquo;ve traded manifest copy-paste for Application copy-paste. Each app needs its own CRD with its repo URL, path, sync policy, project reference.</li>
<li><strong>Sync ordering matters.</strong> The root app must exist before children can sync. Get the wave ordering wrong and apps try to deploy before their namespaces exist.</li>
</ul>
<hr>
<h2 id="how-this-homelab-compares">How this homelab compares</h2>
<p>My setup sits at the far end of approach 5, using Helm throughout.</p>
<p>There&rsquo;s a single <code>applications.yml</code> file that describes every service in the cluster. A root Helm chart reads it and generates all the ArgoCD <code>Application</code> and <code>AppProject</code> CRDs automatically. Adding a service means adding an entry to that file — not touching five different places across five different files.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># applications.yml — this is the entire service catalog</span><span class="w">
</span></span></span><span class="line"><span class="cl">- <span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">web-vaultwarden</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">networkPolicies</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">profile</span><span class="p">:</span><span class="w"> </span><span class="l">web-app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">applications</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">applicationCode</span><span class="p">:</span><span class="w"> </span><span class="l">web-vaultwarden</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">helm-charts/extra-objects</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">autoSync</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span></code></pre></div><p>That one entry generates: a Namespace, an ArgoCD AppProject, an ArgoCD Application, a set of Cilium NetworkPolicies (deny-all with ingress from Traefik and DNS/HTTPS egress), and a ServiceAccount. Nothing is written by hand.</p>
<p>The actual service manifests live in an <code>extra-objects</code> chart — a thin wrapper that renders raw YAML from values files. No templating in the service manifests themselves (they&rsquo;re simple enough not to need it), but the infrastructure scaffolding around each app is entirely generated.</p>
<p>The result: every service gets the same operational properties. Same GitOps workflow, same secret management, same network isolation, same TLS termination. The platform work was done once. Adding a new app is writing manifests for the app&rsquo;s specific behavior, not recreating the scaffolding.</p>
<hr>
<h2 id="the-honest-spectrum">The honest spectrum</h2>
<table>
	<thead>
			<tr>
					<th>Approach</th>
					<th>Templating</th>
					<th>Abstraction</th>
					<th>Ecosystem</th>
					<th>Complexity</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Raw manifests</td>
					<td>None</td>
					<td>None</td>
					<td>None</td>
					<td>Low</td>
			</tr>
			<tr>
					<td>Kustomize</td>
					<td>Patches only</td>
					<td>Overlays</td>
					<td>Medium</td>
					<td>Low-medium</td>
			</tr>
			<tr>
					<td>Helm</td>
					<td>Full</td>
					<td>Per-chart</td>
					<td>Large</td>
					<td>Medium</td>
			</tr>
			<tr>
					<td>Jsonnet/CUE</td>
					<td>Full + typed</td>
					<td>Libraries</td>
					<td>Small</td>
					<td>High</td>
			</tr>
			<tr>
					<td>App-of-apps</td>
					<td>Depends</td>
					<td>Platform-level</td>
					<td>ArgoCD-native</td>
					<td>High</td>
			</tr>
	</tbody>
</table>
<p>Most setups should start at Helm. Kustomize if you&rsquo;re multi-environment and comfortable with patching. App-of-apps when you&rsquo;re managing the platform layer, not individual services. Jsonnet/CUE when you know you&rsquo;ve outgrown Helm — which is a specific and relatively rare problem to have.</p>
<p>Raw manifests are fine for learning. They&rsquo;re the wrong answer for anything you intend to maintain.</p>
<hr>
<p><em>More on how the homelab is structured: <a href="/posts/homelab-gitops/">My Homelab Runs on GitOps</a>.</em></p>
]]></content:encoded></item><item><title>🔄 Someone kubectl apply'd a Hotfix Directly. How Do You Detect and Prevent It?</title><link>https://blog.hippotion.com/posts/k8s-config-drift/</link><pubDate>Fri, 06 Jun 2025 00:00:00 +0000</pubDate><guid>https://blog.hippotion.com/posts/k8s-config-drift/</guid><description>Manual kubectl in production is the Kubernetes equivalent of SSH&amp;rsquo;ing into a server and editing files. It works until it doesn&amp;rsquo;t, and when it doesn&amp;rsquo;t, nobody knows why.</description><content:encoded><![CDATA[<h2 id="the-question">The question</h2>
<p><em>&ldquo;How do you prevent configuration drift in a Kubernetes cluster?&rdquo;</em></p>
<p>Configuration drift: the cluster&rsquo;s actual state diverges from what&rsquo;s declared in your source of truth. Someone runs <code>kubectl edit deployment myapp</code> to bump a memory limit during an incident. Someone adds a debug sidecar directly. Someone applies a YAML file from their laptop that was never committed to Git. The fix works. It goes undocumented. Six months later, a new deployment overwrites it. The incident recurs.</p>
<p>There are two distinct problems here that require different solutions:</p>
<ol>
<li><strong>Detection and remediation</strong>: how do you notice drift and revert it?</li>
<li><strong>Prevention</strong>: how do you stop non-compliant resources from being created in the first place?</li>
</ol>
<hr>
<h2 id="detection-and-remediation-argo-cd-selfheal">Detection and remediation: Argo CD selfHeal</h2>
<p>If you&rsquo;re using GitOps with Argo CD, detection and remediation are handled for you:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">syncPolicy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">automated</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">prune</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">selfHeal</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span></code></pre></div><p><code>selfHeal: true</code> means Argo CD continuously compares the cluster state to the Git repo and reverts any divergence. Someone runs <code>kubectl edit deployment myapp</code> and changes the replica count? Argo CD detects the diff on its next reconciliation cycle (default: every 3 minutes) and reverts it.</p>
<p><code>prune: true</code> means resources that exist in the cluster but not in Git are deleted. Someone <code>kubectl apply</code>&rsquo;d a debug pod directly? Gone on the next sync.</p>
<p>This is the audit trail story too. Every legitimate change is a Git commit with an author, a timestamp, and a commit message. Everything that isn&rsquo;t in Git doesn&rsquo;t survive past the next reconciliation. If you want to know what changed and when, <code>git log</code> is the answer.</p>
<hr>
<h2 id="the-gap-selfheal-doesnt-close">The gap selfHeal doesn&rsquo;t close</h2>
<p><code>selfHeal</code> reverts drift after the fact. There&rsquo;s a window — up to 3 minutes — where a drifted resource is serving traffic. For most changes, that&rsquo;s fine. For a bad resource (wrong RBAC, missing network policy, container running as root), 3 minutes is enough to be a problem.</p>
<p>The other gap: <code>selfHeal</code> doesn&rsquo;t tell you <em>who</em> made the change or generate an alert. It just silently fixes it. You need audit logging (<code>kube-apiserver --audit-log-path</code>) or an alerting rule on Argo CD&rsquo;s health events to know that drift happened.</p>
<hr>
<h2 id="prevention-kyverno">Prevention: Kyverno</h2>
<p>Kyverno is a policy engine that runs as a Kubernetes admission webhook. Every resource creation or modification goes through it before being persisted. If the resource violates a policy, Kyverno can reject it outright (enforce mode) or allow it with a warning (audit mode).</p>
<p>The policies are Kubernetes resources themselves — they live in Git, they&rsquo;re applied via GitOps, they&rsquo;re versioned. No separate policy language to learn.</p>
<p>A policy that requires readiness probes on all Deployments:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">kyverno.io/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">ClusterPolicy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">require-readiness-probe</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">validationFailureAction</span><span class="p">:</span><span class="w"> </span><span class="l">Enforce</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">rules</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">check-readiness-probe</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">match</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">any</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>- <span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">kinds</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span>- <span class="l">Deployment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">validate</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">message</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Deployments must define a readiness probe.&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">pattern</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">template</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span><span class="nt">containers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                  </span>- <span class="nt">(name)</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;*&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                    </span><span class="nt">readinessProbe</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                      </span><span class="nt">(httpGet | tcpSocket | exec)</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;*&#34;</span><span class="w">
</span></span></span></code></pre></div><p>With this policy active: <code>kubectl apply -f deployment-without-probe.yaml</code> is rejected at the API server. The error message is the one you defined in <code>message</code>. The deployment never reaches etcd.</p>
<p>A policy that blocks containers running as root:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">kyverno.io/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">ClusterPolicy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">disallow-root-containers</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">validationFailureAction</span><span class="p">:</span><span class="w"> </span><span class="l">Enforce</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">rules</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">check-runAsNonRoot</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">match</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">any</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>- <span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">kinds</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">Deployment, StatefulSet, DaemonSet]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">validate</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">message</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Containers must not run as root.&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">pattern</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">template</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span><span class="nt">containers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                  </span>- <span class="nt">(name)</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;*&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                    </span><span class="nt">securityContext</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                      </span><span class="nt">runAsNonRoot</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span></code></pre></div><p>A policy that enforces resource limits (common in multi-tenant clusters):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">kyverno.io/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">ClusterPolicy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">require-resource-limits</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">validationFailureAction</span><span class="p">:</span><span class="w"> </span><span class="l">Enforce</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">rules</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">check-limits</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">match</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">any</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>- <span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">kinds</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">Deployment]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">validate</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">message</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;CPU and memory limits are required.&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">pattern</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">template</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span><span class="nt">containers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                  </span>- <span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                      </span><span class="nt">limits</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                        </span><span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;?*&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                        </span><span class="nt">cpu</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;?*&#34;</span><span class="w">
</span></span></span></code></pre></div><hr>
<h2 id="kyverno-can-also-mutate-and-generate">Kyverno can also mutate and generate</h2>
<p>Policies aren&rsquo;t only for validation. Kyverno can mutate incoming resources (add default labels, inject sidecars, set default resource requests) and generate new resources in response to events (create a NetworkPolicy whenever a new namespace is created).</p>
<p>Auto-add a standard label to every Deployment:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">kyverno.io/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">ClusterPolicy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">add-labels</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">rules</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">add-team-label</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">match</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">any</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>- <span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">kinds</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">Deployment]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">mutate</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">patchStrategicMerge</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">managed-by</span><span class="p">:</span><span class="w"> </span><span class="l">kyverno</span><span class="w">
</span></span></span></code></pre></div><p>Auto-create a default NetworkPolicy when a namespace is created:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">kyverno.io/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">ClusterPolicy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">add-default-networkpolicy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">rules</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">default-deny</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">match</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">any</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span>- <span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">kinds</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">Namespace]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">generate</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">NetworkPolicy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">default-deny-all</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;{{request.object.metadata.name}}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">data</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">podSelector</span><span class="p">:</span><span class="w"> </span>{}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">policyTypes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span>- <span class="l">Ingress</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span>- <span class="l">Egress</span><span class="w">
</span></span></span></code></pre></div><hr>
<h2 id="the-complete-drift-prevention-picture">The complete drift prevention picture</h2>
<pre tabindex="0"><code>Developer runs: kubectl apply -f bad-deployment.yaml
  → API server receives request
  → Kyverno admission webhook intercepts
  → Policy check: no readiness probe → Rejected
  → API server returns 403 with Kyverno&#39;s message
  → Resource never reaches etcd

Developer runs: kubectl edit deployment myapp (valid change, just not via Git)
  → Edit succeeds (no policy violation)
  → Argo CD reconciliation fires (within 3 minutes)
  → Diff detected: cluster state ≠ Git state
  → selfHeal: revert to Git state
  → If audit logging enabled: event recorded with username and timestamp
</code></pre><p>Git is the audit trail for what <em>should</em> be there. kube-apiserver audit logs are the trail for what <em>was attempted</em>. Kyverno is the enforcer at admission time. Argo CD is the continuous reconciler. Four layers, each with a different job.</p>
<hr>
<h2 id="what-interviewers-are-actually-testing">What interviewers are actually testing</h2>
<p>The follow-up is usually: <em>&ldquo;What&rsquo;s the difference between Kyverno and OPA Gatekeeper?&rdquo;</em></p>
<p>Both are admission webhook policy engines. The practical differences:</p>
<ul>
<li><strong>Kyverno</strong>: policies are k8s-native YAML, no separate language to learn. Generate and mutate policies built in. Easier to get started with.</li>
<li><strong>OPA Gatekeeper</strong>: policies are written in Rego, a purpose-built policy language that&rsquo;s more expressive but has a steeper learning curve. Better if you&rsquo;re already using OPA elsewhere (Terraform, microservice authorization).</li>
</ul>
<p>For a Kubernetes-only environment, Kyverno is the pragmatic choice. For a platform team that uses OPA across the stack, Gatekeeper gives you policy consistency.</p>
<p>The deeper follow-up: <em>&ldquo;How do you test policies before enforcing them?&rdquo;</em> Use <code>Audit</code> mode first (<code>validationFailureAction: Audit</code>). Violations are logged as PolicyReport objects but requests aren&rsquo;t rejected. Review the reports, fix the existing violations, then switch to <code>Enforce</code>. Never flip directly to Enforce in production — you&rsquo;ll break things that were already running.</p>
<hr>
<p><em>This is part of a series on Kubernetes interview questions. Previously: <a href="/posts/k8s-network-isolation/">network isolation between services</a>.</em></p>
]]></content:encoded></item><item><title>🔑 Deploy to Kubernetes Without Storing Any Cluster Credentials in CI</title><link>https://blog.hippotion.com/posts/k8s-cicd-no-credentials/</link><pubDate>Fri, 09 May 2025 00:00:00 +0000</pubDate><guid>https://blog.hippotion.com/posts/k8s-cicd-no-credentials/</guid><description>A common interview question in 2026. If your answer is &amp;lsquo;kubeconfig in a CI secret&amp;rsquo;, you&amp;rsquo;re not wrong — but you&amp;rsquo;re also not getting the job.</description><content:encoded><![CDATA[<h2 id="the-question">The question</h2>
<p><em>&ldquo;How would you design a CI/CD pipeline that deploys to Kubernetes without storing any cluster credentials anywhere?&rdquo;</em></p>
<p>The expected wrong answer: export your kubeconfig, base64-encode it, paste it into a CI secret named <code>KUBE_CONFIG</code>, and call it a day. This works. Most clusters that got hacked had this setup.</p>
<p>There are two correct answers in 2026, and which one you reach for depends on what you&rsquo;re actually deploying.</p>
<hr>
<h2 id="answer-1-gitops-the-one-your-interviewer-probably-wants">Answer 1: GitOps (the one your interviewer probably wants)</h2>
<p>In a GitOps setup, your CI pipeline never touches the cluster. It can&rsquo;t leak credentials it doesn&rsquo;t have.</p>
<p>The flow:</p>
<pre tabindex="0"><code>Developer pushes code
  → CI builds and tests
  → CI updates the image tag in the Git repo (a commit, not a kubectl command)
  → Argo CD detects the change
  → Argo CD applies it to the cluster
</code></pre><p>The cluster reaches out to Git. CI never reaches into the cluster. The only thing with cluster credentials is Argo CD itself — running inside the cluster, with no credentials to leak externally.</p>
<p>For self-hosted setups on Hetzner or Vultr, this is particularly clean because there&rsquo;s no cloud IAM to configure. You point Argo CD at your GitLab repo, tell it which branch to watch, and you&rsquo;re done.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># The Argo CD Application CRD — the only thing you need</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">argoproj.io/v1alpha1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Application</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">argocd</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">source</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">repoURL</span><span class="p">:</span><span class="w"> </span><span class="l">https://gitlab.example.com/myorg/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">targetRevision</span><span class="p">:</span><span class="w"> </span><span class="l">main</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">helm-charts/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">destination</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">server</span><span class="p">:</span><span class="w"> </span><span class="l">https://kubernetes.default.svc</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">syncPolicy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">automated</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">prune</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">selfHeal</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span></code></pre></div><p><code>selfHeal: true</code> means if someone manually <code>kubectl apply</code>s something, Argo CD reverts it. The Git repo is the only source of truth.</p>
<p>The CI image-tag update step looks like this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .gitlab-ci.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">stage</span><span class="p">:</span><span class="w"> </span><span class="l">deploy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">script</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      # Update the image tag in values.yaml and push
</span></span></span><span class="line"><span class="cl"><span class="sd">      sed -i &#34;s/tag: .*/tag: ${CI_COMMIT_SHORT_SHA}/&#34; values/myapp.yml
</span></span></span><span class="line"><span class="cl"><span class="sd">      git config user.email &#34;ci@example.com&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">      git config user.name &#34;CI&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">      git add values/myapp.yml
</span></span></span><span class="line"><span class="cl"><span class="sd">      git commit -m &#34;chore: bump myapp to ${CI_COMMIT_SHORT_SHA}&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">      git push</span><span class="w">
</span></span></span></code></pre></div><p>CI needs write access to the Git repo — but that&rsquo;s a deploy key, not a cluster credential. If it leaks, someone can push code. You&rsquo;d rotate the deploy key and audit the commits. If a cluster credential leaks, someone owns your cluster.</p>
<hr>
<h2 id="answer-2-oidc-federation-for-when-you-genuinely-need-push-based">Answer 2: OIDC federation (for when you genuinely need push-based)</h2>
<p>Some operations don&rsquo;t fit the GitOps model. Infrastructure provisioning (<code>terraform apply</code>), one-off database migrations, or initial cluster bootstrapping — these need direct cluster access. The correct pattern here is OIDC federation.</p>
<p>The idea: your CI platform (GitLab, GitHub Actions) already issues JWT tokens to every job. These JWTs are signed by the CI platform and contain claims like which repo, which branch, which pipeline triggered the job. You configure your Kubernetes API server to trust those JWTs, and the CI job authenticates directly using the token it already has.</p>
<p>No stored credentials. Every job gets a fresh token. The token expires when the job ends.</p>
<p>For a self-hosted GitLab, configure your k8s API server to trust GitLab as an OIDC issuer:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># /etc/rancher/k3s/config.yaml (or kube-apiserver flags)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kube-apiserver-arg</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="s2">&#34;oidc-issuer-url=https://gitlab.example.com&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="s2">&#34;oidc-client-id=your_client_id&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="s2">&#34;oidc-username-claim=sub&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="s2">&#34;oidc-groups-claim=groups_direct&#34;</span><span class="w">
</span></span></span></code></pre></div><p>Then create a <code>ClusterRoleBinding</code> that maps a specific GitLab identity to a Kubernetes role:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">rbac.authorization.k8s.io/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">ClusterRoleBinding</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">gitlab-ci-deployer</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">subjects</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">User</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;project_path:myorg/myapp:ref_type:branch:ref:main&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">apiGroup</span><span class="p">:</span><span class="w"> </span><span class="l">rbac.authorization.k8s.io</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">roleRef</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">ClusterRole</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">deploy-role</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">apiGroup</span><span class="p">:</span><span class="w"> </span><span class="l">rbac.authorization.k8s.io</span><span class="w">
</span></span></span></code></pre></div><p>The subject name is the <code>sub</code> claim from the GitLab JWT — it encodes the repo path and branch. Only jobs running on <code>main</code> in <code>myorg/myapp</code> get this binding. A job on a feature branch gets nothing.</p>
<p>In the CI job:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">stage</span><span class="p">:</span><span class="w"> </span><span class="l">deploy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">id_tokens</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">K8S_TOKEN</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">aud</span><span class="p">:</span><span class="w"> </span><span class="l">your_client_id</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">script</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      kubectl config set-credentials gitlab-ci \
</span></span></span><span class="line"><span class="cl"><span class="sd">        --token=&#34;${K8S_TOKEN}&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">      kubectl config set-context deploy \
</span></span></span><span class="line"><span class="cl"><span class="sd">        --cluster=mycluster \
</span></span></span><span class="line"><span class="cl"><span class="sd">        --user=gitlab-ci
</span></span></span><span class="line"><span class="cl"><span class="sd">      kubectl config use-context deploy
</span></span></span><span class="line"><span class="cl"><span class="sd">      kubectl rollout restart deployment/myapp -n myapp</span><span class="w">
</span></span></span></code></pre></div><p>The token in <code>K8S_TOKEN</code> is injected by GitLab. It expires with the job. The API server validates the signature against GitLab&rsquo;s JWKS endpoint on every request.</p>
<hr>
<h2 id="which-one-to-use">Which one to use</h2>
<table>
	<thead>
			<tr>
					<th></th>
					<th>GitOps</th>
					<th>OIDC federation</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>CI needs cluster access</td>
					<td>No</td>
					<td>Yes (short-lived token)</td>
			</tr>
			<tr>
					<td>Audit trail</td>
					<td>Git history</td>
					<td>kube-apiserver audit log</td>
			</tr>
			<tr>
					<td>Revocability</td>
					<td>Revert the commit</td>
					<td>Token expires with the job</td>
			</tr>
			<tr>
					<td>Self-hosted setup effort</td>
					<td>Low</td>
					<td>Moderate (OIDC config)</td>
			</tr>
			<tr>
					<td>Works for infra provisioning</td>
					<td>Not really</td>
					<td>Yes</td>
			</tr>
	</tbody>
</table>
<p>For application deployments: GitOps. The cluster reconciles continuously, drift is impossible, and CI is completely decoupled from cluster state.</p>
<p>For infrastructure provisioning or one-off operations: OIDC federation. Short-lived credentials, branch-scoped permissions, nothing to rotate.</p>
<p>What you should never do: store a kubeconfig or a long-lived ServiceAccount token in CI secrets. Not because it&rsquo;s hard to make work — it&rsquo;s easy — but because the blast radius of a leak is unbounded, there&rsquo;s no audit trail, and there&rsquo;s no expiry. Everything that goes wrong with static secrets goes wrong eventually.</p>
<hr>
<p><em>This is part of a series on Kubernetes interview questions. Next: <a href="/posts/k8s-gitops-secrets/">how to handle secrets in a GitOps repository</a>.</em></p>
]]></content:encoded></item><item><title>🏗️ My Homelab Runs on GitOps. Here's What That Actually Means.</title><link>https://blog.hippotion.com/posts/homelab-gitops/</link><pubDate>Fri, 28 Mar 2025 00:00:00 +0000</pubDate><guid>https://blog.hippotion.com/posts/homelab-gitops/</guid><description>I wanted to learn production-grade Kubernetes patterns without breaking production. One node, a full GitOps stack, and a hard rule: no manual kubectl after bootstrap.</description><content:encoded><![CDATA[<h2 id="why-this-exists">Why this exists</h2>
<p>I&rsquo;ve been working in DevOps and platform engineering long enough to know what I don&rsquo;t know. The patterns that separate robust infrastructure from &ldquo;it works on my machine&rdquo; infrastructure — GitOps, admission policies, network segmentation, secrets management — are easy to read about. They&rsquo;re harder to actually internalise without running them yourself.</p>
<p>So I built a homelab. An old ThinkCentre I had sitting around, k3s, and a rule I set for myself before writing a single line of configuration: <strong>GitLab is the only source of truth. No manual <code>kubectl</code> after bootstrap. All changes go through <code>git push</code>.</strong></p>
<p>That rule turned out to be more consequential than I expected.</p>
<hr>
<h2 id="the-stack">The stack</h2>
<p>The cluster runs about thirty services across two categories: infrastructure that makes the platform work, and applications that actually do things.</p>
<p>Infrastructure:</p>
<ul>
<li><strong>k3s</strong> — lightweight Kubernetes, single-node</li>
<li><strong>Cilium</strong> — CNI with NetworkPolicy support (Flannel, k3s&rsquo;s default, silently ignores NetworkPolicies)</li>
<li><strong>Argo CD</strong> — GitOps reconciler, watches the repo, applies changes</li>
<li><strong>Traefik</strong> — ingress controller, two entrypoints</li>
<li><strong>Cloudflare tunnel</strong> — external access without open ports</li>
<li><strong>cert-manager</strong> — wildcard TLS cert via Let&rsquo;s Encrypt DNS-01</li>
<li><strong>oauth2-proxy</strong> — GitLab SSO protecting everything by default</li>
<li><strong>Vault + External Secrets Operator</strong> — secrets management</li>
<li><strong>Pi-hole</strong> — local DNS for <code>*.hippotion.com</code></li>
</ul>
<p>Applications: a media server (Jellyfin, *arr stack), Immich for photos, Vaultwarden for passwords, Home Assistant, n8n for automation, a Hugo blog, Obsidian via browser-based KasmVNC, and a few custom-built things I&rsquo;ll get to below.</p>
<hr>
<h2 id="traffic-reaches-the-cluster-in-two-ways">Traffic reaches the cluster in two ways</h2>
<p>External traffic (from anywhere on the internet) goes through a Cloudflare tunnel. The cloudflared pod dials out to Cloudflare — no open ports on the server, no firewall rules, no exposed IP. Cloudflare terminates TLS and forwards plain HTTP to Traefik on port 7080. Cloudflare handles the certificate for external visitors.</p>
<p>Local traffic (home WiFi) goes through Pi-hole, which resolves <code>*.hippotion.com</code> to the server&rsquo;s LAN IP. Traefik receives HTTPS on port 443, served with a wildcard certificate that cert-manager issues from Let&rsquo;s Encrypt via DNS-01 challenge. Port 80 redirects to 443; the <code>cloudflare</code> entrypoint on 7080 does not redirect, because it&rsquo;s already receiving plain HTTP from cloudflared.</p>
<p>The result: the same IngressRoute handles both paths.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">entryPoints</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">cloudflare  </span><span class="w"> </span><span class="c"># plain HTTP from the cloudflared pod</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">websecure   </span><span class="w"> </span><span class="c"># local HTTPS with wildcard cert</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">routes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">match</span><span class="p">:</span><span class="w"> </span><span class="l">Host(`myapp.hippotion.com`)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Rule</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">middlewares</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">oauth-auth</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">sys-oauth2-gitlab</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">8080</span><span class="w">
</span></span></span></code></pre></div><p>Every IngressRoute has both entrypoints. If you forget one, the service is unreachable from half your access paths. Learned that the first time I added an app and couldn&rsquo;t reach it from the phone.</p>
<hr>
<h2 id="one-file-generates-everything">One file generates everything</h2>
<p>The centrepiece of the setup is <code>applications.yml</code> — a single file that is the complete list of everything running in the cluster. Every entry generates a Namespace, an Argo CD AppProject, an Application, NetworkPolicies, and RBAC. Nothing is created anywhere else.</p>
<p>An entry looks like this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl">- <span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">web-vaultwarden</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">networkPolicies</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">profile</span><span class="p">:</span><span class="w"> </span><span class="l">web-app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">applications</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">applicationCode</span><span class="p">:</span><span class="w"> </span><span class="l">web-vaultwarden</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">helm-charts/extra-objects</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">autoSync</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span></code></pre></div><p>Six lines. That deploys a namespace, an Argo CD app that watches <code>helm-charts/extra-objects/values-web-vaultwarden.yml</code>, a full set of Cilium NetworkPolicies based on the <code>web-app</code> profile (deny-all with ingress from Traefik and egress to external), and a ServiceAccount. Adding a new service to the cluster is this file plus a values file with the actual Kubernetes manifests.</p>
<p>The <code>profile: web-app</code> notation deserves a word. Raw NetworkPolicy YAML is repetitive and error-prone — every namespace needs a deny-all base plus specific allows. I template it. A Helm chart maps profile names to concrete policy sets. <code>web-app</code> means: deny all ingress except from the ingress namespace, deny all egress except DNS and external HTTPS. <code>web-app-internal</code> means the same but no external egress — suitable for services that only talk to other in-cluster services. <code>media-server</code> adds port 6881 for BitTorrent. The policies are generated; no one writes them by hand.</p>
<hr>
<h2 id="secrets-without-storing-them-in-git">Secrets without storing them in Git</h2>
<p>Kubernetes <code>Secret</code> objects are not secrets. They&rsquo;re base64-encoded blobs in etcd, and base64 is not encryption. Committing them to a Git repo — even a private one — is the wrong answer.</p>
<p>The setup here uses HashiCorp Vault as the actual secret store, with External Secrets Operator syncing Vault paths to Kubernetes Secrets. What lives in Git is an <code>ExternalSecret</code> CRD:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">external-secrets.io/v1beta1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">ExternalSecret</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">myapp-credentials</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">secretStoreRef</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">vault</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">ClusterSecretStore</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">target</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">myapp-credentials</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">data</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">secretKey</span><span class="p">:</span><span class="w"> </span><span class="l">DB_PASSWORD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">remoteRef</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">key</span><span class="p">:</span><span class="w"> </span><span class="l">secret/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">property</span><span class="p">:</span><span class="w"> </span><span class="l">db-password</span><span class="w">
</span></span></span></code></pre></div><p>This is safe to commit. It says where the secret lives, not what it is. Vault contains the actual value. ESO syncs it to the cluster and refreshes every hour. Rotation means updating the value in Vault — no Git commit, no deployment.</p>
<p>Vault runs in-cluster with a sidecar that auto-unseals on restart. Not production-grade (the unseal key is on the same PVC as Vault itself), but pragmatic for a homelab where availability matters more than a sophisticated key management ceremony.</p>
<hr>
<h2 id="three-things-i-built-that-were-worth-building">Three things I built that were worth building</h2>
<h3 id="local-ai-inference">Local AI inference</h3>
<p>The cluster runs a local LLM. The <code>web-ai-engine</code> namespace has Open WebUI fronting a llama-server serving Phi-3.5 Mini in GGUF format. The model file lives on the node&rsquo;s filesystem, mounted as a hostPath volume.</p>
<p><code>web-openclaw</code> is a personal AI assistant UI that can route requests to either external providers (via NVIDIA&rsquo;s API) or the local llama-server, depending on the task. The local model handles things that don&rsquo;t need to leave the house; the external API handles things that do. The network policy for <code>web-openclaw</code> explicitly allows egress to <code>web-ai-engine</code> and nowhere else for local inference.</p>
<p>Running a 3.8B parameter model on homelab hardware is genuinely useful and costs nothing per query. It&rsquo;s not GPT-4, but for summarisation, first drafts, and things you don&rsquo;t want sending to a third-party API, it&rsquo;s more than good enough.</p>
<h3 id="brew-buddy">Brew Buddy</h3>
<p>I make kombucha. I was tracking fermentation batches in a notes app and getting annoyed at not being able to see history across batches. So I built a tracker.</p>
<p>Brew Buddy is a React frontend and a Go API backed by PostgreSQL, all running in the <code>web-brew-buddy</code> namespace. The images are built locally and imported into the cluster&rsquo;s container runtime with <code>k3s ctr images import</code>. It&rsquo;s deployed like any other app — a values file, an entry in <code>applications.yml</code>, a Vault secret for the database password.</p>
<p>The point isn&rsquo;t the app. The point is that the platform handles a custom hobby project with the same operational properties as Vaultwarden or Immich. Same GitOps workflow, same secret management, same network isolation, same TLS termination. Adding an app to this cluster takes an afternoon of writing manifests and a few seconds of git push. The platform work was done once.</p>
<h3 id="qr-device-login">QR device login</h3>
<p>This one has <a href="/posts/qr-device-login/">its own post</a> because it took three days and four complete rewrites of oauth2-proxy&rsquo;s session format to get right.</p>
<p>The short version: the Homer dashboard on the living room TV needed a way to log in without typing credentials on a TV keyboard. I built a device-flow OAuth service — phone scans QR, phone authenticates with GitLab, TV session is created. End session from the phone kills the TV&rsquo;s session immediately by deleting the oauth2-proxy Redis ticket.</p>
<p>It&rsquo;s the most overengineered solution to a problem I have, and I don&rsquo;t regret a minute of it.</p>
<hr>
<h2 id="what-operating-this-way-actually-changes">What operating this way actually changes</h2>
<p>The practical difference of the no-manual-kubectl rule is larger than it sounds.</p>
<p><strong>The audit trail is automatic.</strong> Every change to the cluster is a git commit with an author, a timestamp, and a diff. There&rsquo;s no &ldquo;what did I change last Tuesday?&rdquo; — I know exactly what changed last Tuesday, and I can revert it with <code>git revert</code>. The Argo CD UI shows the diff between what&rsquo;s in Git and what&rsquo;s running. If there&rsquo;s a diff, something went wrong.</p>
<p><strong>New services are cheap to add.</strong> The platform does the repetitive work — namespace, RBAC, network policies, TLS termination, OAuth protection. Adding a new app is writing the manifests and updating <code>applications.yml</code>. The infrastructure concerns are handled.</p>
<p><strong>Recovery is straightforward.</strong> If I rebuild the node (which I&rsquo;ve done), I run two bootstrap scripts, apply one Argo CD manifest, and the cluster reconciles itself from Git over the next few minutes. The only things that require manual work are the secrets that can&rsquo;t live in Git — two OAuth credentials and the Cloudflare tunnel token, all recreated by <code>scripts/create-secrets.sh</code>.</p>
<p><strong>Experimentation is safe.</strong> I run things on <code>toggleable: true</code> apps that I&rsquo;m not sure I&rsquo;ll keep. Turning them off is removing the entry from <code>applications.yml</code> and pushing. Turning them back on is adding it back.</p>
<hr>
<h2 id="what-it-doesnt-solve">What it doesn&rsquo;t solve</h2>
<p>Bootstrap is manual. The first <code>kubectl apply -f argocd/root-app.yaml</code> happens outside of GitOps by definition. The three bootstrap secrets can&rsquo;t be in Git. This is unavoidable — you need to trust something before GitOps can take over, and that something is a short manual procedure.</p>
<p>Some things fight the model. k3s&rsquo;s built-in addon controller rewrites the metrics-server Deployment on every k3s restart, removing a patch needed for Cilium compatibility. The fix is a pod that watches for the revert and reapplies the patch. It works, but it&rsquo;s a workaround for a component I don&rsquo;t control.</p>
<p>Single-node means single point of failure. For a homelab, that&rsquo;s acceptable. For anything important, it&rsquo;s not.</p>
<hr>
<h2 id="the-honest-summary">The honest summary</h2>
<p>I set out to learn production-grade Kubernetes patterns, and I did. The GitOps constraint turned out to be the best engineering decision in the project — not because it made things easier in the short term (it didn&rsquo;t), but because it forced every change through a path that is auditable, reversible, and consistent.</p>
<p>The cluster is a single ThinkCentre running about thirty services, secured by Cilium network policies, authenticated via GitLab SSO, with secrets managed by Vault and all configuration in a Git repo that I could hand to someone tomorrow and they&rsquo;d understand what&rsquo;s running and why.</p>
<p>That&rsquo;s the goal. For a homelab, I&rsquo;ll call it achieved.</p>
]]></content:encoded></item></channel></rss>