<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Posts on fhoekstra</title><link>https://fhoekstra.eu/posts/</link><description>Recent content in Posts on fhoekstra</description><generator>Hugo -- gohugo.io</generator><language>en</language><copyright>&lt;a href="https://creativecommons.org/licenses/by-nc/4.0/" target="_blank" rel="noopener"&gt;CC BY-NC 4.0&lt;/a&gt;</copyright><lastBuildDate>Sat, 23 May 2026 23:53:30 +0200</lastBuildDate><atom:link href="https://fhoekstra.eu/posts/index.xml" rel="self" type="application/rss+xml"/><item><title>If I Had a Nickel...</title><link>https://fhoekstra.eu/posts/if-i-had-a-nickel/</link><pubDate>Sat, 23 May 2026 23:53:30 +0200</pubDate><guid>https://fhoekstra.eu/posts/if-i-had-a-nickel/</guid><description>&lt;h4 id="-for-every-time-i-wrote-a-blog-whose-reason-for-existence-was-invalidated-within-weeks-of-publication-id-have-2-nickels-which-isnt-a-lot-but-its-weird-that-it-happened-twice"&gt;&amp;hellip; for every time I wrote a blog whose reason for existence was invalidated within weeks of publication, I&amp;rsquo;d have 2 nickels. Which isn&amp;rsquo;t a lot, but it&amp;rsquo;s weird that it happened twice.&lt;/h4&gt;
&lt;h2 id="first"&gt;First,&lt;/h2&gt;
&lt;p&gt;I put some effort into &lt;a href="https://fhoekstra.eu/posts/howto-cloudnative-ferretdb-with-automated-recovery-from-continuous-backups/"&gt;writing down exactly how to run FerretDB, an open-source document database&lt;/a&gt;. While large parts of it are still useful to show how to setup a self-recovering database with CloudNativePG, FerretDB has been slowly disappearing since &lt;a href="https://www.linkedin.com/posts/farkasp_in-2021-we-founded-ferretdb-with-a-bold-activity-7365677216912859136-jNNJ?utm_source=share&amp;amp;utm_medium=member_desktop&amp;amp;rcm=ACoAACQTOwgBXyo1Mulw2ukvtA4TiIFH5m5iO5M"&gt;after legal issues&lt;/a&gt; and the Linux Foundation adopting a different open source document database project. I cannot claim to know the how or why, but of course I regret this, as they were a community-focused small company taking on a huge corporation and fighting for an open standard. That open standard is there now: unfortunately, FerretDB isn&amp;rsquo;t anymore.&lt;/p&gt;</description><content type="html"><![CDATA[<h4 id="-for-every-time-i-wrote-a-blog-whose-reason-for-existence-was-invalidated-within-weeks-of-publication-id-have-2-nickels-which-isnt-a-lot-but-its-weird-that-it-happened-twice">&hellip; for every time I wrote a blog whose reason for existence was invalidated within weeks of publication, I&rsquo;d have 2 nickels. Which isn&rsquo;t a lot, but it&rsquo;s weird that it happened twice.</h4>
<h2 id="first">First,</h2>
<p>I put some effort into <a href="/posts/howto-cloudnative-ferretdb-with-automated-recovery-from-continuous-backups/">writing down exactly how to run FerretDB, an open-source document database</a>. While large parts of it are still useful to show how to setup a self-recovering database with CloudNativePG, FerretDB has been slowly disappearing since <a href="https://www.linkedin.com/posts/farkasp_in-2021-we-founded-ferretdb-with-a-bold-activity-7365677216912859136-jNNJ?utm_source=share&amp;utm_medium=member_desktop&amp;rcm=ACoAACQTOwgBXyo1Mulw2ukvtA4TiIFH5m5iO5M">after legal issues</a> and the Linux Foundation adopting a different open source document database project. I cannot claim to know the how or why, but of course I regret this, as they were a community-focused small company taking on a huge corporation and fighting for an open standard. That open standard is there now: unfortunately, FerretDB isn&rsquo;t anymore.</p>
<h2 id="the-second-time-it-happened">The second time it happened,</h2>
<p>is just this week, and is a happier story. I wrote a month ago <a href="/posts/the-kube-the-smallest-enterprise-home-lab/">that the Rockchip RK3588-based SBCs are the ideal low-power machines</a> for network attached storage (NAS) and distributed storage systems, with their 2.5 Gb/s network interface:</p>
<blockquote>
<p>Because ten Gigabit tends to be expensive and power-hungry</p>
</blockquote>
<p>So guess what happened: 10 Gb/s is now available on an incredibly power-efficient single-board computer: <a href="https://www.hardkernel.com/shop/odroid-h5/">the Odroid H5</a></p>
<p>Again, there&rsquo;s still value in the article: the Odroid H5 does not come with 32GB of DDR5, it has to be purchased separately, so it is still a lot more expensive. And the tips on how to safely connect a bunch of SATA SSDs to a small computer that only has M.2 connectors, as well as how to handle volumes in Talos, are just as applicable to the Odroid H5 as to the Rockhip.</p>
<p>Still, I can&rsquo;t help but feel like there is some negative predictive power to my blog. Every time I say &ldquo;do X because Y&rdquo;, Y ends up becoming untrue within weeks.</p>
<h2 id="so-now">So now,</h2>
<p>to better serve the community, let me state some more tips with reasons to do them, see if it happens again:</p>
<ul>
<li>because RAM and SSD prices will stay very high for a long time, sit on your hardware</li>
<li>because the Steam Frame is not coming out for a while, get a used VR headset</li>
<li>&hellip;</li>
<li>&hellip;</li>
</ul>
<p>I was going to list some more things that I hope won&rsquo;t come true, related to European (digital) sovereignty, but it gets really dark, so I&rsquo;ve left some space for you to fill that out yourself.</p>
]]></content></item><item><title>The kube: the Smallest Enterprise Home Lab</title><link>https://fhoekstra.eu/posts/the-kube-the-smallest-enterprise-home-lab/</link><pubDate>Mon, 20 Apr 2026 21:30:00 +0200</pubDate><guid>https://fhoekstra.eu/posts/the-kube-the-smallest-enterprise-home-lab/</guid><description>&lt;img src="./3_kubes_finished.jpg" alt="An overview of 3 brightly colored small 3D-printed computer cases, each composed of 3 modules. The left module in each case has some USB and HDMI ports, and is connected with a USB-C cable and an RJ45 connector." class="center" style="border-radius: 8px;" /&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Disclaimer&lt;/em&gt;:
This blog was written without the help of LLMs (although that should be obvious) or any kind of financial incentive. I bought all the hardware myself, don&amp;rsquo;t have stocks in any of these companies and don&amp;rsquo;t use affiliate links.
The result is entirely my own, unprofessional rambling. Though no one can claim to be devoid of bias, this article is as honest as I dare to be with myself. Which may not be a whole lot after spending hundreds on a weird homelab, but it&amp;rsquo;s the best you&amp;rsquo;re going to get.&lt;/p&gt;</description><content type="html"><![CDATA[
    <img src="./3_kubes_finished.jpg"  alt="An overview of 3 brightly colored small 3D-printed computer cases, each composed of 3 modules. The left module in each case has some USB and HDMI ports, and is connected with a USB-C cable and an RJ45 connector."  class="center"  style="border-radius: 8px;"  />


<blockquote>
<p><em>Disclaimer</em>:
This blog was written without the help of LLMs (although that should be obvious) or any kind of financial incentive. I bought all the hardware myself, don&rsquo;t have stocks in any of these companies and don&rsquo;t use affiliate links.
The result is entirely my own, unprofessional rambling. Though no one can claim to be devoid of bias, this article is as honest as I dare to be with myself. Which may not be a whole lot after spending hundreds on a weird homelab, but it&rsquo;s the best you&rsquo;re going to get.</p>
</blockquote>
<p>You&rsquo;ve seen Kubernetes run on big EPYC servers in data centers, and <a href="https://www.cncf.io/blog/2020/05/07/with-kubernetes-the-u-s-department-of-defense-is-enabling-devsecops-on-f-16s-and-battleships/">on F-16 fighter jets</a>.
You&rsquo;ve seen it run on <a href="https://www.jeffgeerling.com/blog/2020/raspberry-pi-cluster-episode-1-introduction-clusters/">Raspberry</a> <a href="https://some-natalie.dev/blog/raspberry-pi-kubernetes/">Pis</a> and even <a href="https://blog.denv.it/posts/pmos-k3s-cluster/">on phones</a>.</p>
<p>The big professional setups guzzle electricity, water (and jet fuel), but the smallest setups are limited, mostly due to storage and network bandwidth.</p>
<p>What if we could run a distributed, reasonably powerful Kubernetes cluster, including distributed storage, using efficient single-board computers (SBCs) without the limitations that they usually have?</p>
<p>With the help of some friends, I built a setup that is exactly that: the smallest enterprise homelab. I call the nodes <code>kube</code>s.</p>
<p>Let&rsquo;s start with the why. Or just skip to a section you are interested in.</p>
<h2 id="why-small">Why small?</h2>
<p>You might want your homelab to:</p>
<ul>
<li>take little physical space</li>
<li>need little electrical power</li>
<li>make little noise</li>
</ul>
<p>So you may think you can just buy a Raspberry Pi with Raspberry OS and run Docker Compose on it.
Think again.<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup></p>
<h2 id="why-enterprise">Why enterprise?</h2>
<p>Kubernetes is cool. GitOps is cool. Not needing to run a number of scripts in a very specific order, or remember a bunch of sysctls you need to set, and just being able to copy from <a href="https://kubesearch.dev">kubesearch</a> to deploy an app without any commands except just adding code to a Git repo is really cool.
But you can run Kubernetes anywhere, so why do I need specific hardware?
Even <a href="https://talos.dev">Talos</a>, which removes the traditional Linux administration layer and gives an OS that only runs Kubernetes, runs on any x86 hardware and a bunch of SBCs (single board computers). What is special about my hardware setup and why did I pick such a weird setup?</p>
<h3 id="enterprise-ssds">Enterprise SSDs</h3>
<p>You probably want to use Enterprise SSDs in your homelab eventually. The property of the drive that matters is <em>Power Loss Protection (PLP)</em>.
Especially for databases like <code>etcd</code> (Kubernetes control plane), Postgresql, and distributed storage like Ceph.
Why? The short version is: those databases are very slow on consumer SSDs and they destroy them as well, especially if they write to them often. Etcd does in a normal Kubernetes setup. My friend <a href="https://6f.io/"><code>@uhthomas</code></a> is the big Enterprise SSD evangelist in <a href="https://home-operations.com/">Home Operations</a>, he&rsquo;ll write a blog post on it soon with more details. Ask him when the blog post goes live on Discord.</p>
<p>And no, enterprise SSDs are actually not that expensive if you buy them used.</p>
<h3 id="the-problem-with-raspberries-and-laptops">The problem with Raspberries and laptops</h3>
<p>So why don&rsquo;t we just add enterprise SSDs to a laptop or Raspbery Pi and use that as a node?</p>
<p>I started homelabbing with 3 laptops and that&rsquo;s exactly what I did. The problem is form factor and connectivity:</p>
<ul>
<li>Most laptops have either mSATA or SATA via M2 connections</li>
<li>Enterprise SSDs typically (exclusively?) come in 2.5&quot; SATA, M2 NVMe or U.2 form factors</li>
</ul>
<p>The solution seemed simple: a USB 3 port offers enough bandwidth and power for a SATA 2.5&quot; SSD, so I&rsquo;ll just connect an Enterprise drive to every laptop via USB 3 and run Ceph on that.
This seems to work at first but there were hiccups. Apparently, the USB controller resets sometimess and this would cause a node to hang. After a while, it became more frequent, and I decided to switch to better and smaller hardware. Not sure if Longhorn would have fared better here.</p>
<p>The problem with the Raspberry Pi is similar: besides USB, the Pi 5 has a proper PCIe connection, which is used by most Pi-NAS solutions. Unfortunately, it only has a single PCIe 2.0 lane available, which caps bandwidth at 500MB/s. OK for a single drive, but expensive: at the same price, you can get a machine with much more storage bandwidth (and RAM).</p>
<p>Mini-PCs like the NUC are the obvious solution. But most people don&rsquo;t know that there is a step in between the Pi and the mini-pc, using a more power-efficient SBC with plenty of cores, RAM, storage and ..</p>
<h3 id="networking">Networking</h3>
<p>The other big thing, is network bandwidth. You will use networked storage: using NAS or a distributed storage solution like Rook-Ceph or Longhorn is a requirement for a multi-node cluster.  Getting more than the default <code>1Gb</code> of networking bandwidth will make your storage faster.
Because ten Gigabit tends to be expensive and power-hungry, let&rsquo;s go for 2.5 Gigabit: it&rsquo;s the same as most gaming motherboards come with and is still 2.5 times faster than the normal 1 Gigabit you get on a Pi.</p>
<blockquote>
<p>EDIT (2026-05-23):
10 Gb/s is now available on an incredibly power-efficient single-board computer: <a href="https://www.hardkernel.com/shop/odroid-h5/">the Odroid H5</a></p>
</blockquote>
<h2 id="the-requirements">The requirements</h2>
<p>So if you are looking to build a small multi-node Kubernetes homelab, you&rsquo;d want:</p>
<ul>
<li>8 cores per node</li>
<li>24-32 GB of RAM per node</li>
<li>2.5 Gb/s network bandwidth</li>
<li>Much more than 500 MB/s of storage bandwidth</li>
</ul>
<p>You could get a NUC, or a Mac Mini, especially if you want to run local AI, but then you can&rsquo;t use your Enterprise SSDs.</p>
<p>So if you have a bunch of SATA (and maybe NVMe) Enterprise disks, and you want a small energy efficient homelab, this is it.</p>
<h2 id="parts-list">Parts list</h2>
<p>This is the full parts list for a single <code>kube</code>, excluding the SSDs themselves, which you should buy used.</p>
<table>
  <thead>
      <tr>
          <th>Part</th>
          <th>Cost<sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup> in €</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>RK3588-based SBC<sup id="fnref:3"><a href="#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup> with 24-32 GB</td>
          <td>200-260</td>
      </tr>
      <tr>
          <td>Rock CPU/memory heatsink</td>
          <td>5</td>
      </tr>
      <tr>
          <td>60W USB-PD power supply<sup id="fnref:4"><a href="#fn:4" class="footnote-ref" role="doc-noteref">4</a></sup></td>
          <td>15</td>
      </tr>
      <tr>
          <td>M.2 to 6x SATA adapter</td>
          <td>20</td>
      </tr>
      <tr>
          <td>12V barrel plug power supply</td>
          <td>25</td>
      </tr>
      <tr>
          <td>DC 5525 to SATA converter</td>
          <td>15</td>
      </tr>
      <tr>
          <td>3D printing material</td>
          <td>15</td>
      </tr>
      <tr>
          <td><a href="https://www.delock.com/produkt/1828/merkmale.html">M.2 mounting kit</a></td>
          <td>1x   10</td>
      </tr>
      <tr>
          <td>(if using NVMe) USB-PWM fan controller</td>
          <td>1x<sup id="fnref:5"><a href="#fn:5" class="footnote-ref" role="doc-noteref">5</a></sup> 15</td>
      </tr>
      <tr>
          <td>(if using NVMe) M.2 heatsink</td>
          <td>10</td>
      </tr>
      <tr>
          <td>(if using NVMe) 90 mm PWM fan</td>
          <td>10</td>
      </tr>
      <tr>
          <td>various shipping costs</td>
          <td>20</td>
      </tr>
      <tr>
          <td>&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;-</td>
          <td>&mdash;&mdash;&mdash;&mdash;&mdash;&mdash;-</td>
      </tr>
      <tr>
          <td>Total Cost for SATA only, 32GB RAM</td>
          <td>10 + 375 per node</td>
      </tr>
      <tr>
          <td>Total Cost for SATA + NVMe, 32GB RAM</td>
          <td>25 + 395 per node</td>
      </tr>
      <tr>
          <td>Total Cost for NVMe only, 32GB RAM</td>
          <td>25 + 335 per node</td>
      </tr>
  </tbody>
</table>
<p>About the same as for a used Mac Mini, provided you already have some Enterprise drives you want to use.</p>
<p>I expect you have:</p>
<ul>
<li>a small microSD card as a boot disk (this won&rsquo;t be written to except on talos config changes or upgrades)</li>
<li>Enterprise SSDs and SATA data cables to put into this</li>
<li>Wago fixture (5B / 5B+ only)</li>
<li>Dupont cables (5B / 5B+ only)</li>
<li>screws and inserts to finish the 3D-printed case</li>
</ul>
<h2 id="build-hardware">Build: hardware</h2>
<p>I went for the Rock 5B+ with 24GB of RAM and equipped each node with a single Micron 7450 Pro NVMe SSD, and one or more of the various SATA Enterprise SSDs I had <del>hoarded</del> <em>strategically gathered</em>. You could also use an Orange Pi 5 Plus or Max, as they are based on the same RK3588 chip and have the same PCIe 3.0 x4 storage bandwidth available. The normal Orange Pi 5 is limited to PCIe 2.0 x1 which limits bandwidth to 500 MB/s, just like the Raspberry Pi 5.</p>
<p>After unboxing the SBC, I first added the CPU heatsink to it. With some force, the heatsink snapped on, and then it was time to add both units to the M.2 slot on the back.</p>

    <img src="./cpu_heatsink_on.jpg"  alt="CPU heatsink installed on the SBC"  class="center"  style="border-radius: 8px;"  />


<p>This is where the fun begins.</p>
<h3 id="the-thick-power-resistor">The thick power resistor</h3>
<p>From what I can gather, this seems specific to the Rock 5B <em>Plus</em> model. There is a big fat power resistor, which looks like it could get quite hot, sitting exactly where one of the two M.2 SSDs should be once plugged in. This means you have to bend one of the M.2 units over that resistor to fit it in. I would not do this with a Micron 7450 SSD (it&rsquo;s not quite that cheap either), so the SATA adapter had to get bent.</p>

    <img src="./view_at_big_resistor_adapter_bent.jpg"  alt="M2 SATA adapter board bending into an arc due to a very thick power resistor on the PCB."  class="center"  style="border-radius: 8px;"  />


<p>An M.2 SSD is held in place by a screw at the end that keeps it pushed down. Fun detail: the M.2 screw is not included! The screw should go into a little pillar on the motherboard. It turns out that the cantilever force of the adapter PCB trying to straighten itself, combined with the force needed to pull a SATA data cable out of it, was more than the little pillar could handle. It was just barely clamped into the PCB, not soldered or attached in any way.</p>

    <img src="./m2_screw_holder_got_loose.jpg"  alt="M2 SATA adapter floating on one end because the pillar holding the M2 screw to the board got loose."  class="center"  style="border-radius: 8px;"  />


<p>This is where the <a href="https://www.delock.com/produkt/1828/merkmale.html">M.2 mounting kit</a> comes in. It has screws, standoffs, nuts and bolts and a little screwdriver. This is enough to reconstruct the M.2 standoff in a more robust way. The standoff is a little taller than the one that the Rock 5 came with, which helps to ease the tension on the M.2 stick.</p>
<h3 id="micron-7450-nvme-ssd-cooling">Micron 7450 NVMe SSD cooling</h3>
<p>The Micron 7450 SSD is one of the few models of NVMe Enterprise PLP SSDs that comes in a 2280 form factor, and physically fits on boards like these. Most others come in either 22110 or U.2 form factor.
But there is one issue with these drives. It&rsquo;s probably related to the Power-Loss Protection circuitry, but they get very hot. If you don&rsquo;t apply any cooling, they will throttle.</p>
<p>So I got myself some M2 coolers from Arctic that looked small and easy to fit. All M.2 SSD coolers however, use something that goes around the bottom of the SSD to clamp into place. This needs 1 or 2 mm of space on each side of the SSD to be able to wrap around.
Unfortunately, on the Rock 5B+ specifically, there is no space between the 2 M.2 units when both slots are filled. Less than a mm in any case. This is of course not an issue if you are using only one M.2 slot, on the Rock 5B or Orange Pi 5 Max or Plus, and it even seems like there might be enough space between both M.2 slots on the bigger Rock 5T. Haven&rsquo;t tried that one yet though.</p>

    <img src="./m2s_too_close_together.jpg"  alt="Picture of the 2 occupied M2 slots: one with an NVMe, one with a SATA adapter, showing there is much less than a millimeter of space between them"  class="center"  style="border-radius: 8px;"  />


<p>So I asked my friend <a href="https://bo0tzz.me/">@bo0tzz</a>, who was making a modular case design for these <code>kube</code>s, to add some hooks for tie-wraps to keep the M.2 cooler from falling off.</p>

    <img src="./both-m2-in-case-module-no-heatsink-with-tie-wrap.jpg"  alt="The backside of the SBC in the 3D-printed module, with tie wraps sticking out above a hole that both M2 units are visible through"  class="center"  style="border-radius: 8px;"  />



    <img src="./both-m2-in-case-module-with-heatsink.jpg"  alt="The backside of the SBC in the 3D-printed module, now with the heatsink for the NVMe SSD in place and the tie wraps tightened over it"  class="center"  style="border-radius: 8px;"  />


<p>I discovered later that without any airflow over them, these drives can still get up to 70 degrees C, so that is why I bought some 90mm PWM fans and a single USB-powered PWM fan controller to control the fans on all nodes. If you buy Noctua, you get a nice vibration-damping piece of rubber with them. If you buy Arctic, it&rsquo;s a lot cheaper, only slightly less silent but you do have to cut the vibration-damping rubber out of an old bike tyre yourself.</p>

    <img src="./m2-cooling-arctic-fan-with-bicycle-tyre.jpg"  alt="An Arctic PWM P9 PST CO fan with a cutout from a bike tyre covering the frame to act as vibration damping"  class="center"  style="border-radius: 8px;"  />


<p>Now we have the NVMe in place, and cooled and we have the required SATA data connections to connect SATA drives. How do we power them?</p>
<h3 id="connecting-externally-powered-sata-drives-to-an-sbc">Connecting externally-powered SATA drives to an SBC</h3>
<p>The SBC does not have SATA power connector, or Molex cable sticking out, and it can&rsquo;t deliver enough power on its GPIO pins either to power a few SATA drives.</p>
<p>We could use an external 12V barrel plug power supply, and connect it to a buck converter (you can buy these pre-made with up to 4 SATA power connectors: search for &ldquo;DC 5525 SATA adapter&rdquo;), so the drives get both 12V and 5V, as they would get from an ATX power supply.</p>
<p>However, we need to be careful not to fry the drives: if the negative side of the power supply is not connected to the SBC&rsquo;s ground, they might end up at a different voltage. This voltage difference would cause a current to flow through the SSDs, from the <code>power-</code> pin to the <code>data ground</code>. That is not healthy, so we need to ensure that the external power supply shares a common ground with the SBC.</p>
<p>The easiest way to do that is to cut open the power supply&rsquo;s negative wires (not while it is plugged in of course), strip the ends, and connect them in a 5-slot wire connector / fixture. Double check if you really cut the negative and not the positive side!</p>
<p>Then connect that to the SBC ground on the GPIO pins via Dupont wires. Maximum is 5 (check Radxa docs on powering via GPIO or the pinout to see which).</p>

    <img src="./sata_power_supply_with_fixture.jpg"  alt="SATA power supply connected to fixture with Dupont wires sticking out"  class="center"  style="border-radius: 8px;"  />



    <img src="./sata_power_supply_connected_on_desk.jpg"  alt="The bare SBC on the desk, connected to the Dupont wires and the SATA data cable, demoing that the setup works"  class="center"  style="border-radius: 8px;"  />


<p>The pins marked in black on the outside of the Rock 5B+&rsquo;s GPIO connector are the ground ones, but do check the official spec sheet before you connect the power.</p>
<h2 id="the-case-making-it-beautiful">The case: Making it beautiful</h2>
<p>After making it work, I asked <a href="https://bo0tzz.me/">@bo0tzz</a> to make it beautiful by designing and 3D-printing a modular case for each <code>kube</code>.</p>
<p>The design can be downloaded at <a href="https://www.printables.com/model/1659104-modular-sbc-case">https://www.printables.com/model/1659104-modular-sbc-case</a></p>
<p>There are 3 modules:</p>
<ul>
<li>On the left, the Rock 5B(+) module can be screwed in. On the right, a module that holds SATA 2.5&quot; SSDs is placed.</li>
<li>In the middle, a blank slot covers the space that is occupied by SATA data cables and/or the NVMe SSD.</li>
<li>At the bottom, there is enough space for the SATA data cables and the buck converter.</li>
</ul>
<p>Air can come in from the sides and back if it is pulled through by putting a 90 mm fan on top.</p>

    <img src="./kube-case-internals-sata-ssds-side-with-cables.jpg"  class="center"  style="border-radius: 8px;"  />


<p>Here&rsquo;s a view of the internals</p>

    <img src="./kube-case-internals-sata-ssd-top-down-with-cables.jpg"  class="center"  style="border-radius: 8px;"  />


<p>Both M2 slots occupied for maximum storage</p>

    <img src="./kube-case-internals-blank-panel-off-towards-sbc.jpg"  class="center"  style="border-radius: 8px;"  />


<p>You can see why a blank panel in the middle was needed.</p>

    <img src="./kube-case-internals-blank-panel-off-front.jpg"  class="center"  style="border-radius: 8px;"  />


<p>You can see the 2 SATA SSDs that I put in there now (there is room for 4)</p>

    <img src="./kube-case-internals-blank-panel-off-towards-ssds.jpg"  class="center"  style="border-radius: 8px;"  />


<p>The SSD module was adorned with a picture of <a href="https://6f.io/"><code>@uhthomas</code></a>&rsquo; dog holding a 2.5&quot; SATA SSD. It is thanks to Thomas that I was convinced to use power-loss protected enterprise SSDs and he also helped me find them. And the dog is how I know him, since that is his profile picture.
The blank slot where all the heat goes, has a line tracing of my old cat, who loved to lay on his back on warm days.</p>
<p>The SVGs are here, you can apply them to a 3D model in the slicer before printing:</p>
<ul>
<li>My cat: <a href="https://fhoekstra.eu/animal-linetraces/cat-1.svg">https://fhoekstra.eu/animal-linetraces/cat-1.svg</a></li>
<li>Thomas&rsquo; dog: <a href="https://fhoekstra.eu/animal-linetraces/dog-1.svg">https://fhoekstra.eu/animal-linetraces/dog-1.svg</a></li>
</ul>

    <img src="./kube-face-with-both-animals.jpg"  alt="The faces of the 3 modules, showing the animal line tracings on them"  class="center"  style="border-radius: 8px;"  />


<h2 id="build-software">Build: software</h2>
<p>If you came here for the code, not the pictures, strap in!</p>
<p>This is a <code>kube</code>, it&rsquo;s going to run Kubernetes and nothing else, so we will choose the most declarative and secure way to run Kubernetes: <a href="https://talos.dev">Talos Linux</a>.</p>
<p>It may seem like a bit of a hassle to set up if you&rsquo;re not familiar with it, but once you have this set up (and you can start with <a href="https://github.com/onedr0p/cluster-template">cluster-template</a>, then copy the rest from <a href="https://github.com/fhoekstra/home-ops">my repo</a>), you will be able to manage your entire cluster, including node maintenance, by simply updating the YAML files in your Git repo.
Talos completely removes the traditional Linux layer, is immutable, very small and very secure: it is designed to run only Kubernetes, nothing else, so there is no bloat and a very small attack surface. This is simpler, faster, and much more reliable than Ansible scripts.
It makes it such that working with the OS feels just like working with Kubernetes manifests.</p>
<p>If you use the Orange Pi 5 Plus or the Rock 5B or Rock 5T, you can follow the regular process for installing Talos and just walk through the Talos Factory at <code>https://factory.talos.dev</code>.</p>
<p>If you are using a different board, like the 5B+, that does have a Talos overlay in <a href="https://github.com/siderolabs/sbc-rockchip">https://github.com/siderolabs/sbc-rockchip</a> but is not yet selectable in the Image Factory UI, you need to use the API directly. That is what I will be explaining here.</p>
<blockquote>
<p>EDIT (2026-05-31):
The rock 5B+ is now available in the Talos Image Factory UI, so you can skip the &ldquo;Getting Talos Image Factory IDs&rdquo; section and just get your raw disk image from <code>https://factory.talos.dev</code> and flash it</p>
</blockquote>
<h3 id="initial-boot-off-of-the-sd-card">Initial boot off of the SD card</h3>
<p>While it is theoretically possible to boot an RK3588 machine off of an SSD, it&rsquo;s really not worth the hassle. We just don&rsquo;t want write-heavy directories to be on the SD card, but for the immutable root filesystem, and the boot partition, it&rsquo;s not an issue. Write-heavy directories on a Talos Kubernetes machine are: <code>/var/log</code>, <code>/var/lib/etcd</code>, <code>/var/lib/rook</code>, <code>/var/lib/contained</code>, <code>/var/mnt/local-hostpath</code>, etc.
They all start with <code>/var</code>. And Talos has this concept of the <a href="https://docs.siderolabs.com/talos/v1.12/configure-your-talos-cluster/storage-and-disk-management/disk-management/raw#ephemeral-volume"><code>EPHEMERAL</code> system volume</a>, which is a magic name that means: put <code>/var</code> on this partition. So we can just put that on an SSD via the config, and boot normally off of the SD card.</p>
<p>To flash the SD card, I mostly followed <a href="https://docs.siderolabs.com/talos/v1.12/build-and-extend-talos/custom-images-and-development/overlays">this doc</a> initially, but later learned there is an easier way.</p>
<p>As part of your Talos config, you&rsquo;ll need to put in a <code>talosImageURL</code>. You can fetch this from the Talos Image Factory <a href="https://factory.talos.dev/">using the Web GUI</a> with most hardware, but if you have a Rock 5B+, it&rsquo;s not too hard either, you just need to use the REST API. Follow along:</p>
<h4 id="image-factory-rest-api-getting-talos-image-factory-ids">Image Factory REST API: Getting Talos Image Factory IDs:</h4>
<p>Write a <code>schematic-rock-5b-plus.yaml</code> like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">customization</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">systemExtensions</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">officialExtensions</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">siderolabs/realtek-firmware</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">overlay</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">name</span>: <span style="color:#ae81ff">rock5b-plus</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">image</span>: <span style="color:#ae81ff">ghcr.io/siderolabs/sbc-rockchip:v0.2.0</span>
</span></span></code></pre></div><p>Then just POST it to the Talos Image Factory <code>schematics</code> endpoint, and you&rsquo;ll get an ID back.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>curl -X POST --data-binary @schematic-rock-5b-plus.yaml https://factory.talos.dev/schematics
</span></span></code></pre></div><p>Put this in an installer URL like this: <code>factory.talos.dev/installer/id-goes-here</code> and that is your <code>talosImageURL</code> for your config.
This logic is also encoded in the task <a href="https://github.com/fhoekstra/home-ops/blob/talos-different-storage-setups/.taskfiles/talos/Taskfile.yaml#L56"><code>talos:fetch-schematic-id</code></a></p>
<h4 id="image-factory-rest-api-bootstrapping-talos-on-sbc-with-sd-card">Image Factory REST API: Bootstrapping Talos on SBC with SD card</h4>
<p>For bootstrap, you&rsquo;ll need a raw disk image to flash to an SD card, which you can get from this URL: <code>https://factory.talos.dev/image/id-goes-here/v1.12.6/metal-arm64.raw.xz</code> (obviously, replace the Talos version if you want a newer version)</p>
<p>Then uncompress it:
<code>xz -d metal-arm64.raw.xz</code></p>
<p>The above logic is also encoded in the task <a href="https://github.com/fhoekstra/home-ops/blob/talos-different-storage-setups/.taskfiles/talos/Taskfile.yaml#L42"><code>talos:fetch-raw-disk-image-for-sbc</code></a>. It uses your talconfig to determine the Talos version and image ID.</p>
<p>Check which dev the SD card is on (check via KDE partition manager what the <code>/dev/sdX</code> path is)
Then write to SD card with dd: replace sdX with the SD card&rsquo;s path
<code>sudo dd if=./metal-arm64.raw of=/dev/sdX conv=fsync oflag=direct status=progress bs=4M</code></p>
<p>Insert it, connect the power and you should be able to connect to the Talos API server after a minute or 2 on the local IP address of your node:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>talosctl get machinestatus --insecure -n &lt;IP&gt;
</span></span></code></pre></div><p>You can then follow the <a href="https://docs.siderolabs.com/talos/v1.12/getting-started/getting-started">regular Talos bootstrap instructions</a>, either from the Talos docs, or <a href="https://github.com/onedr0p/cluster-template">cluster-template</a>, if you&rsquo;re using it.</p>
<p>If this is your first time setting up a Gitops Talos cluster, I highly recommend using <a href="https://github.com/onedr0p/cluster-template">cluster-template</a>.</p>
<h3 id="day-2-upgrades">Day 2: Upgrades</h3>
<p>Talos version and Kubernetes version upgrades can be done according to the standard instructions in the <a href="https://github.com/onedr0p/cluster-template">cluster-template</a> repo.</p>
<p>Upgrading the version of the <code>siderolabs/sbc-rockchip</code> overlay requires a different procedure. For each node:</p>

    <img src="./tweezers-remove-sd-card.jpg"  alt="Using tweezers to pull out the SD card is easy, thanks to the hole that bo0tzz designed into the case."  class="center"  style="border-radius: 8px;"  />


<ul>
<li>cordon and drain the node</li>
<li>then remove the power</li>
<li>Use tweezers to pull out the microSD card</li>
<li>Get the new factory ID as per the instructions above, or use the task <a href="https://github.com/fhoekstra/home-ops/blob/talos-different-storage-setups/.taskfiles/talos/Taskfile.yaml#L56C3-L64C19"><code>talos:fetch-schematic-id</code></a></li>
<li>Update the <code>talconfig.yaml</code> with the new schematic ID.</li>
<li>Fetch the new raw disk image using the instructions above, or use the task <a href="https://github.com/fhoekstra/home-ops/blob/talos-different-storage-setups/.taskfiles/talos/Taskfile.yaml#L42"><code>talos:fetch-raw-disk-image-for-sbc</code></a></li>
<li>flash a new raw disk image as per instructions above</li>
<li>Put the microSD back in and power up the node</li>
<li>Run an adapted version of the <code>talos:apply-node</code> task, with &ndash;insecure and a different precondition. I named it <a href="https://github.com/fhoekstra/home-ops/blob/talos-different-storage-setups/.taskfiles/talos/Taskfile.yaml#L30C1-L40C36"><code>talos:apply-node-from-maintenance</code></a></li>
</ul>
<h3 id="creating-the-talos-config-for-different-disk-setups">Creating the Talos config for different disk setups</h3>
<p>So how do we configure all those disks in Talos?
They have a great documentation site, with a chat bot that is actually helpful, on how to do it with raw Talos manifests.
Since I started with <a href="https://github.com/onedr0p/cluster-template">cluster-template</a>, I got <a href="https://github.com/budimanjojo/talhelper">Talhelper</a> with that.
Its main benefit is that it integrates SOPS-encrypted secrets into Talos configuration for a full GitOps setup.</p>
<p>So I&rsquo;ll show you how to do it with Talhelper. For this demo, I&rsquo;ve set up each node with a different storage/disk configuration, then tagged it with <a href="https://github.com/fhoekstra/home-ops/tree/talos-different-storage-setups"><code>talos-different-storage-setups</code></a> since I&rsquo;ll probably change that later. Here&rsquo;s the code:</p>
<ul>
<li>Talos setups: <a href="https://github.com/fhoekstra/home-ops/blob/talos-different-storage-setups/kubernetes/bootstrap/talos/talconfig.yaml">https://github.com/fhoekstra/home-ops/blob/talos-different-storage-setups/kubernetes/bootstrap/talos/talconfig.yaml</a></li>
<li>Rook-Ceph setup: <a href="https://github.com/fhoekstra/home-ops/blob/talos-different-storage-setups/kubernetes/apps/rook-ceph/rook-ceph/cluster/helmrelease.yaml#L92">https://github.com/fhoekstra/home-ops/blob/talos-different-storage-setups/kubernetes/apps/rook-ceph/rook-ceph/cluster/helmrelease.yaml#L92</a></li>
</ul>
<h4 id="single-nvme-only">Single NVMe only</h4>
<blockquote>
<p>DISCLAIMER:
In the 3 months I ran with these different node setups, I had issues with Rook-Ceph on a node twice, needing a wipe and re-bootstrap. Both times it was this node, where everything is on a single shared disk. If you can do multiple disks, please give rook-ceph its own disk for the OSD.</p>
</blockquote>
<p>We can actually run Talos with Rook-Ceph on a single disk, provided it has PLP and is fast enough.
To do this, I create 2 volumes:</p>
<ul>
<li>one EPHEMERAL volume: <a href="https://docs.siderolabs.com/talos/v1.12/configure-your-talos-cluster/storage-and-disk-management/disk-management/system#ephemeral-volume">documented here</a>, this is a magic name in Talos which means it gets mounted on <code>/var</code> and contains all the write-heavy directories underneath it: logs, container images, local storage</li>
<li>one raw volume: <a href="https://docs.siderolabs.com/talos/v1.12/configure-your-talos-cluster/storage-and-disk-management/disk-management/raw">documented here</a>, for Ceph as it needs its own block device without a file system</li>
</ul>
<p>In Talhelper&rsquo;s <a href="https://github.com/fhoekstra/home-ops/blob/talos-different-storage-setups/kubernetes/bootstrap/talos/talconfig.yaml#L28C1-L43C22">talconfig</a>, I created a 250 GiB EPHEMERAL volume on the Micron 7450 like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">nodes</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">hostname</span>: <span style="color:#e6db74">&#34;talos-rock-1&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ipAddress</span>: <span style="color:#e6db74">&#34;192.168.1.21&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">installDisk</span>: <span style="color:#ae81ff">/dev/mmcblk1</span> <span style="color:#75715e"># This is the microSD card, as there is no onboard MMC on my Rock 5B+</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">EPHEMERAL</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">provisioning</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">diskSelector</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">match</span>: <span style="color:#e6db74">&#34;disk.model.startsWith(&#39;Micron_7450_&#39;)&#34;</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">minSize</span>: <span style="color:#ae81ff">250GiB</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">maxSize</span>: <span style="color:#ae81ff">250GiB</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">grow</span>: <span style="color:#66d9ef">false</span>
</span></span></code></pre></div><p>Then I include the raw volume manifest by pointing at it in the node&rsquo;s patches section:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">nodes</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">hostname</span>: <span style="color:#e6db74">&#34;talos-rock-1&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">here</span>: <span style="color:#ae81ff">go the rest of the keys, as shown above</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">patches</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#75715e"># We also create a raw volume for Ceph on the same NVMe disk</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;@./patches/talos-rock-1/ceph-raw-volume.yaml&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#75715e"># And bind-mount a path for local host storage to the kubelet</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;@./patches/talos-rock-1/csi-local-hostpath.yaml&#34;</span>
</span></span></code></pre></div><p>The raw volume manifest creates a volume without a filesystem, which is what Ceph wants:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span>---
</span></span><span style="display:flex;"><span><span style="color:#f92672">apiVersion</span>: <span style="color:#ae81ff">v1alpha1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">RawVolumeConfig</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">name</span>: <span style="color:#ae81ff">osd-data</span> <span style="color:#75715e"># Do not use &#34;ceph&#34; in this name, or rook will refuse to use it for an OSD!!</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">provisioning</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">diskSelector</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">match</span>: <span style="color:#e6db74">&#34;disk.model.startsWith(&#39;Micron_7450_&#39;)&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">minSize</span>: <span style="color:#ae81ff">400GiB</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">maxSize</span>: <span style="color:#ae81ff">400GiB</span>
</span></span></code></pre></div><p>Then we point the Ceph cluster at the raw volume by using the partition label, which for a raw volume in Talos is <code>r-volumename</code>, so <code>r-osd-data</code> for us:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">cephClusterSpec</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">storage</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">useAllNodes</span>: <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">useAllDevices</span>: <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">nodes</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">name</span>: <span style="color:#e6db74">&#34;talos-rock-1&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">devices</span>:
</span></span><span style="display:flex;"><span>          -  <span style="color:#f92672">name</span>: <span style="color:#e6db74">&#34;/dev/disk/by-partlabel/r-osd-data&#34;</span>
</span></span></code></pre></div><p>You may be wondering: how did I know to select the Micron 7450 with the <code>match: &quot;disk.model.startsWith('Micron_7450_')&quot;</code> selector?</p>
<p>Remember how I said Talos works just like you&rsquo;re used to in Kubernetes? You can see all the disks on a node with <code>talosctl -n &lt;NODE_IP&gt; get disks</code> (add <code>--insecure</code> if your node is still in maintenance mode and hasn&rsquo;t had a Talos config applied yet). Then to inspect the properties of an individual disk, you just do: <code>talosctl -n &lt;NODE_IP&gt; get disk &lt;DISK_NAME&gt; -o yaml --insecure</code>. You can then use its spec to write a selector, exactly as you&rsquo;re used to when dealing with Kubernetes manifests and <code>kubectl</code>.</p>
<h4 id="using-nvme-and-sata-disks-together">Using NVMe and SATA disks together</h4>
<p>The other nodes in my example are simpler, even though they use more disks.
For both of them, I just used a full SATA disk for the Talos <code>EPHEMERAL</code> system volume, and gave the full NVMe to Ceph:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">cephClusterSpec</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">storage</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">useAllNodes</span>: <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">useAllDevices</span>: <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">nodes</span>:
</span></span><span style="display:flex;"><span>          - <span style="color:#f92672">name</span>: <span style="color:#e6db74">&#34;talos-rock-1&#34;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">devices</span>:
</span></span><span style="display:flex;"><span>              - <span style="color:#f92672">name</span>: <span style="color:#e6db74">&#34;/dev/disk/by-partlabel/r-osd-data&#34;</span>
</span></span><span style="display:flex;"><span>          - <span style="color:#f92672">name</span>: <span style="color:#e6db74">&#34;talos-rock-2&#34;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">devices</span>:
</span></span><span style="display:flex;"><span>              - <span style="color:#f92672">name</span>: <span style="color:#e6db74">&#34;/dev/nvme0n1&#34;</span> <span style="color:#75715e"># Micron 7450 Pro 1TB</span>
</span></span><span style="display:flex;"><span>          - <span style="color:#f92672">name</span>: <span style="color:#e6db74">&#34;talos-rock-3&#34;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">devices</span>:
</span></span><span style="display:flex;"><span>              - <span style="color:#f92672">name</span>: <span style="color:#e6db74">&#34;/dev/nvme0n1&#34;</span> <span style="color:#75715e"># Micron 7450 Pro 1TB</span>
</span></span></code></pre></div><p>For node 2, I used a second SATA disk for the local storage provisioner.
This is done by democratic-csi in my cluster, though openebs is another popular solution.</p>
<p>This provisioner can create PVCs backed by local storage, from a volume that is mounted to the kubelet. On the other nodes, I just bind-mount a directory from the <code>EPHEMERAL</code> system volume.</p>
<p>For node 2, I instead provision a <code>UserVolume</code> (documented <a href="https://docs.siderolabs.com/talos/v1.12/configure-your-talos-cluster/storage-and-disk-management/disk-management/user">here</a>, almost completing our tour of Talos volume provisioning docs). What you need to know is this:</p>
<p>A UserVolume gets a filesystem, XFS by default, and gets a partition label <code>u-name</code>. In addition, it gets mounted at <code>/var/mnt/name</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">nodes</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">hostname</span>: <span style="color:#e6db74">&#34;talos-rock-2&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ipAddress</span>: <span style="color:#e6db74">&#34;192.168.1.22&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">installDisk</span>: <span style="color:#ae81ff">/dev/mmcblk1</span> <span style="color:#75715e"># This is the microSD card, as there is no onboard MMC on my Rock 5B+</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">EPHEMERAL</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">provisioning</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">diskSelector</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">match</span>: <span style="color:#e6db74">&#34;disk.model.startsWith(&#39;SSDSC2KG480G8R&#39;)&#34;</span> <span style="color:#75715e"># Intel DC S4610, 480 GB</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">minSize</span>: <span style="color:#ae81ff">250GiB</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">grow</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">userVolumes</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">csi-local-hostpath</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">provisioning</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">diskSelector</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">match</span>: <span style="color:#e6db74">&#34;disk.model.startsWith(&#39;INTEL SSDSC2BX40&#39;)&#34;</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">minSize</span>: <span style="color:#e6db74">&#34;50GiB&#34;</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">grow</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">patches</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#75715e"># Here we bind the local hostpath from the separate device so we have a different patch than in talos-rock-1</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;@./patches/talos-rock-2/csi-local-hostpath.yaml&#34;</span>
</span></span></code></pre></div><p>And then the kubelet patch for the local host path uses the <code>/var/mnt/name</code> path for the <code>UserVolume</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">machine</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">kubelet</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">extraMounts</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">destination</span>: <span style="color:#ae81ff">/var/mnt/csi-local-hostpath </span> <span style="color:#75715e"># In other nodes, this was /var/lib/csi-local-hostpath</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">type</span>: <span style="color:#ae81ff">bind</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">source</span>: <span style="color:#ae81ff">/var/lib/csi-local-hostpath</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">options</span>: [<span style="color:#e6db74">&#34;bind&#34;</span>, <span style="color:#e6db74">&#34;rshared&#34;</span>, <span style="color:#e6db74">&#34;rw&#34;</span>]
</span></span></code></pre></div><p>If you lost track of what goes where, the full code is here:</p>
<ul>
<li>Talos setup: <a href="https://github.com/fhoekstra/home-ops/blob/talos-different-storage-setups/kubernetes/bootstrap/talos/talconfig.yaml">https://github.com/fhoekstra/home-ops/blob/talos-different-storage-setups/kubernetes/bootstrap/talos/talconfig.yaml</a></li>
<li>Rook-Ceph setup: <a href="https://github.com/fhoekstra/home-ops/blob/talos-different-storage-setups/kubernetes/apps/rook-ceph/rook-ceph/cluster/helmrelease.yaml#L92">https://github.com/fhoekstra/home-ops/blob/talos-different-storage-setups/kubernetes/apps/rook-ceph/rook-ceph/cluster/helmrelease.yaml#L92</a></li>
</ul>
<h2 id="addendum-numbers">Addendum: numbers</h2>
<h3 id="power-use">Power use</h3>
<p>I measured the power use of my entire homelab by plugging it into a power monitoring Zigbee outlet.</p>
<p>Just my micro-NAS in the form of a  Raspberry Pi 4B with a single consumer 1TB SATA SSD connected via USB, and an old 1GbE router: <strong>8W</strong> after power-on, settle on <strong>6W</strong> after 2 minutes.</p>
<p>Add in the three Radxa Rock 5B+ nodes, each with a Micro 7450 Pro NVMe SSD, and two of them with a SATA-M.2 adapter and two 400GB Enterprise SATA drives, for a total of 4 SATA and 3 NVMe drives, all Enterprise, PLP-cache equipped.
The Rocks themselves are powered by a single UGREEN 100W USB-PD GaN charger each, while the SATA drives are powered by one 12V barrel plug PSU per node. (2 PSUs for 4 drives)</p>
<p>Plugging in the 2 12V barrel plug PSUs (I didn&rsquo;t get the Radxa ones, but adjustable voltage supplies which are probably less efficient) alone pushes the total power use to just above <strong>11W</strong>. So they use about 3 W a piece even when the SSDs are not in use.</p>
<p>Finally, plugging in the Rocks and thus starting them bumps power use first to <strong>65W</strong>, then settles around <strong>50W</strong>. <strong>16W</strong> per Rock node with around 30% CPU usage. This can probably be done more efficiently, with less power supplies, or more appropriately sized ones. The easiest way to improve this would be to get a Rock 5T, which is a slightly larger board with the same chipset, but powered via DC barrel plug, so you could easily use the same power supply for the SBC and the SSDs.</p>
<h3 id="disk-performance">Disk performance</h3>
<p>I measured the raw disk performance of the Rock 5B+ under Radxa OS (an older Debian with KDE that is provided by the manufacturer) with a <a href="https://docs.radxa.com/en/rock5/rock5b/getting-started/interface-usage/pcie-m-key#ssd-compatibility-list">Micron 7450 Pro NVMe drive at 1638 MB/s read, and 1441 MB/s write</a> with the other M.2 slot empty. This is more than 3x the storage bandwidth you get with a Raspberry Pi 5 or a Rock 5C and the NAS solutions for them, no matter how many disks you connect.</p>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>Of course, if you&rsquo;re doing your first homelab, feel free to use an old laptop or 3, some Raspberry Pis you had laying around, or whatever else. And run k3s on it.&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>You may have noticed that 2026 is a weird time for computer hardware prices and availability. I am listing the prices at which I bought these things&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:3">
<p>If you only need 1 NVMe or only need up to 6 SATA connections, you can make do with a Rock 5B, or Orange Pi 5 Plus or Max. If you want the same PCIe 3.0 x4 split over 2 M.2 slots, get a Rock 5B+ or Rock 5T. I went with Rock 5B+ for my <code>kube</code>s.&#160;<a href="#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:4">
<p>The Radxa Rock 5B and 5B+ are powered exclusively via USB-PD (12V), but there are some quirks around USB-PD negotiation as they are starting up. Not all USB-PD chargers can give these SBCs the voltage they need as they are booting, so I recommend getting the 60W ones from Radxa themselves if you get a Rock 5B. I was lucky to get them to work with UGREEN GaN 100W USB-PD chargers. The Rock 5T is powered by 12V 5525 barrel plug adapter, so that one is easier to power from the same power brick that powers your SATA SSDs.&#160;<a href="#fnref:4" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:5">
<p>(you only need one to power the fans on multiple <code>kube</code>s if they are close together)&#160;<a href="#fnref:5" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content></item><item><title>Ingress Nginx to Envoy Gateway Migration</title><link>https://fhoekstra.eu/posts/ingress-nginx-to-envoy-gateway-migration/</link><pubDate>Wed, 25 Feb 2026 09:17:05 +0100</pubDate><guid>https://fhoekstra.eu/posts/ingress-nginx-to-envoy-gateway-migration/</guid><description>&lt;p&gt;Last november, the Ingress NGINX &lt;a href="https://kubernetes.io/blog/2025/11/11/ingress-nginx-retirement/"&gt;retirement was announced&lt;/a&gt; for March 2026.&lt;/p&gt;
&lt;p&gt;Are you still using Ingress NGINX? Do you want a complete example of how to migrate to the future-proof Gateway API? Wondering how to migrate forward auth?&lt;/p&gt;
&lt;p&gt;I did the migration on my homelab only this month (I know, I&amp;rsquo;m late), but I made sure to have a clean string of commits with no other changes or dependency bumps between them. I wanted to make it easy to view the full extent of the migration from nginx-ingress to Gateway API with everything it entails: forward auth, Anubis bot protection, etc.&lt;/p&gt;</description><content type="html"><![CDATA[<p>Last november, the Ingress NGINX <a href="https://kubernetes.io/blog/2025/11/11/ingress-nginx-retirement/">retirement was announced</a> for March 2026.</p>
<p>Are you still using Ingress NGINX? Do you want a complete example of how to migrate to the future-proof Gateway API? Wondering how to migrate forward auth?</p>
<p>I did the migration on my homelab only this month (I know, I&rsquo;m late), but I made sure to have a clean string of commits with no other changes or dependency bumps between them. I wanted to make it easy to view the full extent of the migration from nginx-ingress to Gateway API with everything it entails: forward auth, Anubis bot protection, etc.</p>
<p>I&rsquo;ve tagged them for your convenience. This should be especially useful if your networking setup is from <a href="https://github.com/onedr0p/cluster-template">cluster-template</a> 1 to 2 years ago, or if you&rsquo;re using forward auth with <a href="https://www.authelia.com/">Authelia</a> or <a href="https://goauthentik.io/">Authentik</a>, or if you have some public endpoints protected by <a href="https://anubis.techaro.lol/">the Anubis bot blocker</a>.</p>
<ul>
<li><code>git clone https://github.com/fhoekstra/home-ops</code></li>
<li>Or compare the <a href="https://github.com/fhoekstra/home-ops/tags">tags</a> right here in your browser by choosing <code>Compare</code> on one of the following pages:
<ul>
<li><a href="https://github.com/fhoekstra/home-ops/releases/tag/before-gateway-migration">before-gateway-migration</a></li>
<li><a href="https://github.com/fhoekstra/home-ops/releases/tag/first-app-working-with-gateway">first-app-working-with-gateway</a></li>
<li><a href="https://github.com/fhoekstra/home-ops/releases/tag/after-gateway-migration">after-gateway-migration</a></li>
</ul>
</li>
</ul>
]]></content></item><item><title>Disable Internal Laptop Keyboard When External Keyboard Is Plugged In On Linux</title><link>https://fhoekstra.eu/posts/linux-disable-internal-laptop-keyboard-when-external-keyboard-plugged-in/</link><pubDate>Thu, 01 Jan 2026 11:25:24 +0100</pubDate><guid>https://fhoekstra.eu/posts/linux-disable-internal-laptop-keyboard-when-external-keyboard-plugged-in/</guid><description>&lt;img src="./split_keyboard_on_top_of_laptop_on_lap_in_train.jpg" alt="Split keyboard on top of a laptop on a lap, sat in a train, with various bags and suitcases in the background" class="center" style="border-radius: 8px;" /&gt;
&lt;p&gt;Do you also find yourself wanting to use your custom keyboard while travelling with your Linux laptop? Without triggering keypresses through the built-in keyboard underneath of course!&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ll first show you how to disable the built-in laptop keyboard with a bash script. And then automatically disable the laptop keyboard when, and only when, your custom keyboard is plugged in, and enable it again when your custom keyboard is unplugged.&lt;/p&gt;</description><content type="html"><![CDATA[
    <img src="./split_keyboard_on_top_of_laptop_on_lap_in_train.jpg"  alt="Split keyboard on top of a laptop on a lap, sat in a train, with various bags and suitcases in the background"  class="center"  style="border-radius: 8px;"  />


<p>Do you also find yourself wanting to use your custom keyboard while travelling with your Linux laptop? Without triggering keypresses through the built-in keyboard underneath of course!</p>
<p>I&rsquo;ll first show you how to disable the built-in laptop keyboard with a bash script. And then automatically disable the laptop keyboard when, and only when, your custom keyboard is plugged in, and enable it again when your custom keyboard is unplugged.</p>
<h2 id="how-to-disable-the-built-in-keyboard">How to disable the built-in keyboard</h2>
<p>You can disable the keyboard dynamically by writing <code>1</code> to the inhibited property in <code>sys/devices/.../input/input3</code>. Enabling is a matter of writing <code>0</code> to the same path.</p>
<p>First we need to find the precise SysFs path of the internal keyboard:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>cat /proc/bus/input/devices | less
</span></span></code></pre></div><p>If necessary, type <code>/</code> then <code>keyboard</code> to search the output.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>[...]
</span></span><span style="display:flex;"><span>N: Name=&#34;AT Translated Set 2 keyboard&#34;
</span></span><span style="display:flex;"><span>P: Phys=isa0060/serio0/input0
</span></span><span style="display:flex;"><span>S: Sysfs=/devices/platform/i8042/serio0/input/input3
</span></span><span style="display:flex;"><span>[...]
</span></span></code></pre></div><p>Fill in the SYSFS_PATH in the following script, which I save to <code>/home/freek/Scripts/laptop-kb.sh</code> (and make it executable with <code>chmod +x &lt;filepath&gt;</code>):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span><span style="color:#75715e">#!/usr/bin/env bash
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span>SYSFS_PATH<span style="color:#f92672">=</span>/devices/platform/i8042/serio0/input/input3
</span></span><span style="display:flex;"><span>DEV_PATH<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;/sys/</span><span style="color:#e6db74">${</span>SYSFS_PATH<span style="color:#e6db74">}</span><span style="color:#e6db74">/inhibited&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">case</span> <span style="color:#e6db74">&#34;</span>$1<span style="color:#e6db74">&#34;</span> in
</span></span><span style="display:flex;"><span>    disable<span style="color:#f92672">)</span>
</span></span><span style="display:flex;"><span>        echo <span style="color:#ae81ff">1</span> | sudo tee <span style="color:#e6db74">&#34;</span>$DEV_PATH<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>        ;;
</span></span><span style="display:flex;"><span>    enable<span style="color:#f92672">)</span>
</span></span><span style="display:flex;"><span>        echo <span style="color:#ae81ff">0</span> | sudo tee <span style="color:#e6db74">&#34;</span>$DEV_PATH<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>        ;;
</span></span><span style="display:flex;"><span>    toggle<span style="color:#f92672">)</span>
</span></span><span style="display:flex;"><span>        cur<span style="color:#f92672">=</span><span style="color:#66d9ef">$(</span>cat <span style="color:#e6db74">&#34;</span>$DEV_PATH<span style="color:#e6db74">&#34;</span><span style="color:#66d9ef">)</span>
</span></span><span style="display:flex;"><span>        new<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;1&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> <span style="color:#f92672">[</span> <span style="color:#e6db74">&#34;</span>$cur<span style="color:#e6db74">&#34;</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;1&#34;</span> <span style="color:#f92672">]</span>; <span style="color:#66d9ef">then</span> $new<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;0&#34;</span>; <span style="color:#66d9ef">fi</span>
</span></span><span style="display:flex;"><span>        echo $new | sudo tee <span style="color:#e6db74">&#34;</span>$DEV_PATH<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>        ;;
</span></span><span style="display:flex;"><span>    *<span style="color:#f92672">)</span>
</span></span><span style="display:flex;"><span>        echo <span style="color:#e6db74">&#34;Usage: </span>$0<span style="color:#e6db74"> {enable|disable|toggle}&#34;</span> &gt;&amp;<span style="color:#ae81ff">2</span>
</span></span><span style="display:flex;"><span>        exit <span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>        ;;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">esac</span>
</span></span></code></pre></div><h2 id="trigger-on-plug--unplug-external-keyboard">Trigger on plug / unplug external keyboard</h2>
<p>To only disable it when a specific external keyboard is plugged in, write a udev rule that will trigger different actions on plug and unplug of the external keyboard.
<code>udev</code> is part of <code>systemd</code> and included in most modern Linux distributions, including Arch Linux, by default. Even distributions that ship without systemd, such as Void Linux, often have a compatible implementation that also accepts udev rules, such as <code>eudev</code>.</p>
<p>First find the external keyboard&rsquo;s PRODUCT id by watching the output of this command:</p>
<p><code>sudo udevadm monitor --kernel --property --subsystem-match=usb</code></p>
<p>While plugging and unplugging the external keyboard.</p>
<p>For my keyboard, it is: <code>PRODUCT=a8f8/1836/200</code></p>
<p>Then write a udev rules file with the following rules. Replace your PRODUCT id and script path and save this to <code>/etc/udev/rules.d/99-disable-internal-keyboard.rules</code></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>SUBSYSTEM<span style="color:#f92672">==</span><span style="color:#e6db74">&#34;usb&#34;</span>, ACTION<span style="color:#f92672">==</span><span style="color:#e6db74">&#34;add&#34;</span>, ENV<span style="color:#f92672">{</span>DEVTYPE<span style="color:#f92672">}==</span><span style="color:#e6db74">&#34;usb_device&#34;</span>, ENV<span style="color:#f92672">{</span>PRODUCT<span style="color:#f92672">}==</span><span style="color:#e6db74">&#34;a8f8/1836/200&#34;</span>, RUN<span style="color:#f92672">+=</span><span style="color:#e6db74">&#34;/home/freek/Scripts/laptop-kb.sh disable&#34;</span>
</span></span><span style="display:flex;"><span>SUBSYSTEM<span style="color:#f92672">==</span><span style="color:#e6db74">&#34;usb&#34;</span>, ACTION<span style="color:#f92672">==</span><span style="color:#e6db74">&#34;remove&#34;</span>, ENV<span style="color:#f92672">{</span>DEVTYPE<span style="color:#f92672">}==</span><span style="color:#e6db74">&#34;usb_device&#34;</span>, ENV<span style="color:#f92672">{</span>PRODUCT<span style="color:#f92672">}==</span><span style="color:#e6db74">&#34;a8f8/1836/200&#34;</span>, RUN<span style="color:#f92672">+=</span><span style="color:#e6db74">&#34;/home/freek/Scripts/laptop-kb.sh enable&#34;</span>
</span></span></code></pre></div><p>Then reload the udev rules by rebooting or run: <code>udevadm control --reload-rules &amp;&amp; udevadm trigger</code></p>
<p>Have fun working on your Linux laptop anywhere with your custom keyboard!</p>
<hr>
<p>Thanks to <a href="https://koen.vervloesem.eu/blog/automatically-disable-your-internal-webcam-on-linux/">Koen Vervloesem</a> for figuring this stuff out for webcams.</p>
]]></content></item><item><title>CKS Tips</title><link>https://fhoekstra.eu/posts/cks-tips/</link><pubDate>Tue, 09 Dec 2025 21:32:27 +0100</pubDate><guid>https://fhoekstra.eu/posts/cks-tips/</guid><description>&lt;h1 id="cks-tips-and-takeaways"&gt;CKS tips and takeaways&lt;/h1&gt;
&lt;p&gt;Yay! I got my &amp;lsquo;CKS - Certified Kubernetes Security Specialist&amp;rsquo; certification.&lt;/p&gt;
&lt;p&gt;It was quite a ride, as I had almost forgotten about it. You see, I had 2 goals this year: &lt;a href="https://github.com/fhoekstra/home-ops"&gt;the GitOps homelab&lt;/a&gt; and CKS. I have done and learned a lot in the homelab, so much so that I had neglected the CKS a bit. I was under the impression that I had some time left when I was reminded by The Linux Foundation that I only had 4 weeks left to plan the exam (and potential retake). After those 4 weeks, the money my employer generously paid for me to get certified would be down the drain. So I applied myself and studied as hard as I could.&lt;/p&gt;</description><content type="html"><![CDATA[<h1 id="cks-tips-and-takeaways">CKS tips and takeaways</h1>
<p>Yay! I got my &lsquo;CKS - Certified Kubernetes Security Specialist&rsquo; certification.</p>
<p>It was quite a ride, as I had almost forgotten about it. You see, I had 2 goals this year: <a href="https://github.com/fhoekstra/home-ops">the GitOps homelab</a> and CKS. I have done and learned a lot in the homelab, so much so that I had neglected the CKS a bit. I was under the impression that I had some time left when I was reminded by The Linux Foundation that I only had 4 weeks left to plan the exam (and potential retake). After those 4 weeks, the money my employer generously paid for me to get certified would be down the drain. So I applied myself and studied as hard as I could.</p>
<p>I knew CKA had taken me some practice, not because it is so hard, but because you need to be fast to get through all the assignments. CKA, CKS and CKAD are hands on exams where you get a terminal, <code>vim</code> <code>kubectl</code> (and the <code>k</code> alias luckily), and a Firefox browser that can only browse the official documentation pages.</p>
<p>In order to get through all the assignments in the allotted time, you have to know your way around the documentation very well.</p>
<h2 id="practice-practice-practice">Practice, practice, practice</h2>
<p>For CKA, I prepared using the Linux Foundation course. I did some of the labs multiple times to ensure I could do an &ldquo;open&rdquo; assignment unguided using just the documentation. Being able to do the labs where every step is instructed in detail is not enough.</p>
<p>For CKS, I heard from friends that there is a serious Linux sysadmin component to it, that happens on the Linux (Debian on the exam) nodes. As a former developer turned Kubernetes Engineer, I felt that the Linux Foundation course labs did not prepare me sufficiently for that. I am not affiliated with Kodekloud, but their course is great, and the labs being prepared for you in the cloud is a huge productivity booster. So I highly recommend taking a Kodekloud subscription for a month to do their labs.</p>
<p>And remember to stay focused in your practice: are you just following instructions, or can you find these instructions and example configs to edit in the official documentation? If what you are doing in a lab is not obvious to you, try doing it again using just the official documentation.</p>
<h2 id="embrace-vim">Embrace Vim</h2>
<p>I spent the last 2 years (since the CKA exam) using (neo)vim as my main IDE, but if you don&rsquo;t yet, spend some time getting used to vim.
It really helps with speed on the exam to be able to indent the next 5 lines at once (<code>^</code>, <code>Ctrl+V</code>, <code>5j</code>, space space, <code>Esc</code>), move or replace an entire line(<code>dd</code>, then <code>P</code> to paste above current line, <code>p</code> to paste below, <code>cc</code> to replace, <code>Shift+V</code> to select multiple lines, then <code>d</code> for delete or <code>c</code> for change), or change text within <code>&quot;&quot;</code> (simply <code>ci&quot;</code>, <em>c</em>hange <em>i</em>nside <code>&quot;</code>).</p>
<p>You will never get this fast with <code>nano</code>. Embrace vim, leave the mouse alone as much as possible. Your shoulders, your wrists and <a href="https://neovim.io/doc/user/uganda.html">the children in Uganda</a> will thank you.</p>
<h2 id="practice-exams">Practice exams</h2>
<p>The Linux Foundation gives you 2 Killer.sh practice exams, and kodekloud also provides 3 practice exams. I found the kodekloud exams had a lot of overlap (I did 2 and felt like most of it was the exact same assignment), and their environment is more helpful/easier: zsh with better autocomplete, whereas the real exam has a bare bash shell where autocomplete only happens with some delay after you press tab, not with greyed out text appearing as you&rsquo;re typing (like zsh).</p>
<p>So keep that in mind and use your killer.sh practice exams when you feel you&rsquo;ve mastered the labs!</p>
<h2 id="aliases-and-shortcuts">aliases and shortcuts</h2>
<p><em>Don&rsquo;t</em>. For CKA 2 years ago, I had trained myself to write out some 3-letter aliases of the <a href="https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/kubectl">oh-my-zsh kubectl plugin</a>, as well as <code>kubectl config set-context --current --namespace</code> and <code>--dry-run -oyaml</code> as a var.
Now, there was a different node you ssh&rsquo;ed into for every assignment, so those aliases wouldn&rsquo;t stick around anyway, and the assignments and files were set up such that you usually didn&rsquo;t need to <code>k get deploy name -o yaml</code>. I still did often, purely out of habit, before noticing the yaml was already on the filesystem.</p>
<h2 id="yq--jq-chops">yq / jq chops</h2>
<p>Like vim, these skills are generally good to have, not just for the exam. I spent the night before the exam practicing them a bit, because I noticed I was scrolling through YAML and searching through json in <code>less</code> (with <code>/</code>, <code>n</code>, <code>N</code>) for things that could have been simple queries. In my quest to save any minute I could off of the time I need, I decided to practice my jq/yq query writing.</p>
<p>In the end, <em>none of the assignments in the actual exam required these skills</em>, but I am still grateful to finally understand when I need to do <code>.someArraywithbrackets[] | select .key == &quot;value&quot;</code> and when I need to do <code>.someArraywithoutbrackets | group_by(.key)</code>.</p>
<h2 id="enjoy-the-process">Enjoy the process!</h2>
<p>If you follow these tips, you should have no trouble learning all the required tools and skills for CKS, or any other Kubernetes exam, such as CKA or CKAD. Then the last tip is to enjoy it!</p>
<p>I find these hands-on exams very satisfying to do: they give me a feeling of mastery and confidence that no multiple choice or pen and paper exam has ever given me. Engineers and tinkerers who love doing and fixing things should really try these hands-on exams. Coming out of the exam, I felt like a Kubernetes God: &ldquo;I can fix any cluster, write any policy manifest and find any misbehaving container now!&rdquo;
It sounds stupid, but if you know, you know. It&rsquo;s a really good feeling and one of the best ways to demonstrate your technical ability.</p>
]]></content></item><item><title>Changelog From Conventional Commits: Git-Cliff</title><link>https://fhoekstra.eu/posts/changelog-from-conventional-commits-git-cliff/</link><pubDate>Sat, 08 Nov 2025 10:56:04 +0100</pubDate><guid>https://fhoekstra.eu/posts/changelog-from-conventional-commits-git-cliff/</guid><description>&lt;h2 id="the-changelog-in-platform-engineering"&gt;The changelog in platform engineering&lt;/h2&gt;
&lt;p&gt;At my current job, we maintain a package that is used by developers in their own codebases to use a database service on the platform. Like any package or artifact that is released to then be used by application developers, this requires a clear release with a &lt;a href="https://semver.org/"&gt;semantic version&lt;/a&gt; that communicates what users can expect from it: Does it contain only fixes (patch), or are there new features in the release (minor)? Most importantly, do users (potentially) need to change &lt;em&gt;how&lt;/em&gt; they use the package when upgrading (major)?&lt;/p&gt;</description><content type="html"><![CDATA[<h2 id="the-changelog-in-platform-engineering">The changelog in platform engineering</h2>
<p>At my current job, we maintain a package that is used by developers in their own codebases to use a database service on the platform. Like any package or artifact that is released to then be used by application developers, this requires a clear release with a <a href="https://semver.org/">semantic version</a> that communicates what users can expect from it: Does it contain only fixes (patch), or are there new features in the release (minor)? Most importantly, do users (potentially) need to change <em>how</em> they use the package when upgrading (major)?</p>
<p>Ideally, the version communicates the category of the release: patch (fixes only), minor (new features), or major (breaking changes), and the details are found in a changelog. Without a changelog, your users have a hard time figuring out what to expect from a new release.</p>
<h2 id="cant-i-just-write-a-changelog-myself">Can&rsquo;t I just write a changelog myself?</h2>
<p>If your users are normal end users, you can have the product and/or UX people write a changelog that is aimed at the end-user experience. But when your users are developers who integrate your package with their own application, completeness is key! You cannot know <a href="https://xkcd.com/1172/">all the ways</a> and context in which developers use your software, so it is important to be careful and complete and mark changes that are potentially breaking as such. If you forget to mention a change, that could waste a lot of developer time on debugging.</p>
<p>The reason why you could forget relevant changes, is because the changelog is fundamentally a <em>duplication</em>. In a changelog, you describe the changes to the software since the previous release. But as a platform engineer, you should already have this information somewhere else: in the git log!</p>
<p>If you follow <a href="https://conventionalcommits.org/">conventional commits</a>, your commits already contain this information:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>feat(api)!: Make retentionPeriod a required property on orderDetails
</span></span><span style="display:flex;"><span>fix: OOM on submission of large orderList
</span></span><span style="display:flex;"><span>chore: Bump Python to 3.14.0
</span></span></code></pre></div><p>Thus, I needed to generate a changelog (and a next version number) based on the conventional commits in the git log between the last release and now. Seems like it should be a common problem.</p>
<h2 id="what-if-youre-not-on-github">What if you&rsquo;re not on Github?</h2>
<p>To my surprise, I saw that the most commonly used tool for this problem does not use plain Git: <a href="https://github.com/googleapis/release-please">release-please</a> is built around the Github API. We use a less commonly used forge, so this did not work for us. The alternative <a href="https://github.com/release-it/release-it">release-it</a> has bindings for the GitLab and GitLab APIs, but also has a way to generate a changelog from pure Git.
Since we do not directly depend on any NodeJS projects in the team I work, I looked a bit further for a self-contained binary that really only does changelog and version generation from git.</p>
<h2 id="git-cliff">Git Cliff</h2>
<p>Git Cliff is precisely what I was looking for:</p>
<ul>
<li>only does changelog and semver generation from git</li>
<li>self-contained binary</li>
<li>sensible defaults, works out of the box</li>
<li>very customizable</li>
<li><a href="https://git-cliff.org/docs/">great documentation</a></li>
<li>written in Rust and available from many repositories</li>
</ul>
<p>To generate a configuration file with the defaults and a lot of helpful comments, run <code>git-cliff --init</code>.</p>
<p>The <code>cliff.toml</code> default configuration has a section that looks like:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-toml" data-lang="toml"><span style="display:flex;"><span><span style="color:#a6e22e">commit_parsers</span> = [
</span></span><span style="display:flex;"><span>    { <span style="color:#a6e22e">message</span> = <span style="color:#e6db74">&#34;^feat&#34;</span>, <span style="color:#a6e22e">group</span> = <span style="color:#e6db74">&#34;&lt;!-- 0 --&gt;🚀 Features&#34;</span> },
</span></span><span style="display:flex;"><span>    { <span style="color:#a6e22e">message</span> = <span style="color:#e6db74">&#34;^fix&#34;</span>, <span style="color:#a6e22e">group</span> = <span style="color:#e6db74">&#34;&lt;!-- 1 --&gt;🐛 Bug Fixes&#34;</span> },
</span></span><span style="display:flex;"><span>    { <span style="color:#a6e22e">message</span> = <span style="color:#e6db74">&#34;^doc&#34;</span>, <span style="color:#a6e22e">group</span> = <span style="color:#e6db74">&#34;&lt;!-- 3 --&gt;📚 Documentation&#34;</span> },
</span></span><span style="display:flex;"><span>    ...
</span></span><span style="display:flex;"><span>]
</span></span></code></pre></div><p>You can add or remove parsers easily: the message field is the regex pattern that is matched to the commit message, and the number in the group determines the order of the sections in the generated changelog.</p>
<p>Some useful commands:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span># Generate the changelog file by running:
</span></span><span style="display:flex;"><span>git-cliff -o CHANGELOG.md
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span># Get the last released (tagged) version:
</span></span><span style="display:flex;"><span>git-cliff --context | jq &#39;.[0].version&#39;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span># Get the version for the next release (based on what has been committed since)
</span></span><span style="display:flex;"><span>git-cliff --bumped-version
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span># Generate the changelog for that next release
</span></span><span style="display:flex;"><span>git-cliff --bump -o CHANGELOG.md
</span></span></code></pre></div><p>Whichever Git Forge you use, these commands should allow you to create a changelog and release process that works for your platform! Happy crafting!</p>
]]></content></item><item><title>Tip: Zigbee Device Stuck During Pairing</title><link>https://fhoekstra.eu/posts/tip-zigbee-device-stuck-during-pairing/</link><pubDate>Mon, 29 Sep 2025 19:09:28 +0200</pubDate><guid>https://fhoekstra.eu/posts/tip-zigbee-device-stuck-during-pairing/</guid><description>&lt;h2 id="quick-tip"&gt;Quick tip&lt;/h2&gt;
&lt;p&gt;I recently replaced the batteries on two of my Zigbee Tradfri remotes and had
to pair them again to ZHA in Home Assistant because they had been offline for a while.&lt;/p&gt;
&lt;img src="./interview-complete-configuring.jpeg" alt="Screenshot from Home Assistant showing `Interview complete - Configuring` state of a Zigbee device" class="center" style="border-radius: 8px;" /&gt;
&lt;p&gt;I noticed the same thing happening for both devices: they got stuck on the &lt;code&gt;Interview complete - Configuring&lt;/code&gt; step. They are different models, so this made me suspect a software problem. I had a look and found &lt;a href="https://community.home-assistant.io/t/solution-workaround-for-zigbee-zha-devices-stuck-in-interview-complete-configuring/777247"&gt;this topic on the Home Assistant community forum&lt;/a&gt;.&lt;/p&gt;</description><content type="html"><![CDATA[<h2 id="quick-tip">Quick tip</h2>
<p>I recently replaced the batteries on two of my Zigbee Tradfri remotes and had
to pair them again to ZHA in Home Assistant because they had been offline for a while.</p>

    <img src="./interview-complete-configuring.jpeg"  alt="Screenshot from Home Assistant showing `Interview complete - Configuring` state of a Zigbee device"  class="center"  style="border-radius: 8px;"  />


<p>I noticed the same thing happening for both devices: they got stuck on the <code>Interview complete - Configuring</code> step. They are different models, so this made me suspect a software problem. I had a look and found <a href="https://community.home-assistant.io/t/solution-workaround-for-zigbee-zha-devices-stuck-in-interview-complete-configuring/777247">this topic on the Home Assistant community forum</a>.</p>
<p>In it, 2 potential root causes with their respective workarounds are proposed:</p>
<ol>
<li>Cause: The Zigbee device is falling asleep.</li>
</ol>
<ul>
<li>Workaround: Keep pressing one of its regular buttons to keep it awake during pairing</li>
</ul>
<ol start="2">
<li>Cause: The ZHA controller is missing a step or edgecase or the device is not sending the appropriate signal when it is paired.</li>
</ol>
<ul>
<li>Workaround: When you get to the Interview complete step, go to <code>Integrations -&gt; ZHA -&gt; &quot;your coordinator&quot;</code>, press the 3 dots next to it and select: <code>Reload</code>. Afterwards, you&rsquo;ll see your device added to ZHA.</li>
</ul>
<p>Number 1 didn&rsquo;t work for my Tradfri remotes, but number 2 did. I hope writing this helps someone else who runs into this.</p>
]]></content></item><item><title>How-to: Cloudnative FerretDB with Automated Recovery from Continuous Backups</title><link>https://fhoekstra.eu/posts/howto-cloudnative-ferretdb-with-automated-recovery-from-continuous-backups/</link><pubDate>Thu, 25 Sep 2025 10:01:41 +0200</pubDate><guid>https://fhoekstra.eu/posts/howto-cloudnative-ferretdb-with-automated-recovery-from-continuous-backups/</guid><description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;FerretDB, the company, seems to have disappeared. This blog is still useful as a reference, as most of it applies to CloudnativePG and the Postgres DocumentDB extension in general.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="why"&gt;Why?&lt;/h2&gt;
&lt;p&gt;&lt;a href="https://www.ferretdb.com/"&gt;FerretDB&lt;/a&gt; is an open source compatibility layer that serves a MongoDB-compatible database server, and stores the data in PostgreSQL. I think this is amazing, but I will save the &amp;ldquo;Why FerretDB?&amp;rdquo; talk for another blog.&lt;/p&gt;
&lt;p&gt;This is a technical guide that will walk you through how to get the following:&lt;/p&gt;</description><content type="html"><![CDATA[<blockquote>
<p><strong>FerretDB, the company, seems to have disappeared. This blog is still useful as a reference, as most of it applies to CloudnativePG and the Postgres DocumentDB extension in general.</strong></p>
</blockquote>
<h2 id="why">Why?</h2>
<p><a href="https://www.ferretdb.com/">FerretDB</a> is an open source compatibility layer that serves a MongoDB-compatible database server, and stores the data in PostgreSQL. I think this is amazing, but I will save the &ldquo;Why FerretDB?&rdquo; talk for another blog.</p>
<p>This is a technical guide that will walk you through how to get the following:</p>
<ol>
<li>FerretDB deployment backed by highly available and secured cloudnative-pg postgres cluster.</li>
<li>Barman-cloud plugin backups for point-in-time restores</li>
<li>Automatic recovery on redeploy</li>
</ol>
<p>In those 3 steps. What this means for me is that I can get a completely open source MongoDB-compatible database that behaves like the rest of <a href="https://github.com/fhoekstra/home-ops">my Kubernetes homelab</a>: if I delete the whole cluster and redeploy it from manifests using GitOps, all my data automatically comes back from backups, without needing any manual intervention. This allows me to play with my homelab without worrying about data restore: everything will come back up on a fresh bootstrap, no manual intervention required.</p>
<h2 id="step-0-prerequisites">Step 0: Prerequisites</h2>
<ul>
<li>An S3-compatible object storage. Either get one <a href="https://european-alternatives.eu/alternative-to/amazon-s3">from a cloud provider near you</a>, or set up something like <a href="https://garagehq.deuxfleurs.fr/">Garage</a> or <a href="https://github.com/versity/versitygw">Versity Gateway</a> on your NAS. If you get a cloud-provided object storage, make sure to get one that is not too expensive per write transaction, as postgres will be writing there constantly.</li>
<li>A Kubernetes cluster (I&rsquo;m using Kubernetes 1.34 on <a href="https://www.talos.dev/">Talos</a> 1.11 at the time of writing)</li>
<li>cloudnative-pg operator installed on it: <a href="https://cloudnative-pg.io/documentation/current/">one of these commands should get you going</a> (I&rsquo;m running 1.27 at the moment)</li>
<li>barman-cloud plugin for backups of cloudnative-pg provisioned databases. Check the <a href="https://cloudnative-pg.io/plugin-barman-cloud/docs/installation/">instructions here</a>. I tested this with 0.6.0.</li>
<li>Both cloudnative-pg and barman-cloud require cert-manager to be installed on the cluster</li>
<li>In addition, to run postgresql databases, you need some kind of provisioner to provision volumes, preferably on local host storage. Check out <a href="https://github.com/democratic-csi/democratic-csi">democratic-csi</a> or <a href="https://openebs.io/docs/concepts/data-engines/localstorage">openebs-local</a></li>
</ul>
<p>With that out of the way, let&rsquo;s get started.</p>
<h2 id="step-1-ferretdb-backed-by-cloudnative-pg">Step 1: FerretDB backed by cloudnative-pg</h2>
<p>I will be sharing YAML throughout this tutorial. You can copy it to your machine, and then run <code>kubectl apply -f filename.yaml</code> to apply it to your kubernetes cluster, or put it in your GitOps repo, whatever you want.</p>
<p>The following YAML gives you a highly available cloudnative-pg cluster of 3 nodes, with 2 FerretDB servers in front to serve your application with MongoDB-compatible requests.
Feel free to change these numbers, or add a resources block, if you prefer.</p>
<p>The comments explain some of the how and why.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span>---
</span></span><span style="display:flex;"><span><span style="color:#75715e"># yaml-language-server: $schema=https://github.com/datreeio/CRDs-catalog/raw/refs/heads/main/postgresql.cnpg.io/cluster_v1.json</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">apiVersion</span>: <span style="color:#ae81ff">postgresql.cnpg.io/v1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">Cluster</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">metadata</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">name</span>: <span style="color:#ae81ff">ferretdb-pg-cluster</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">namespace</span>: <span style="color:#ae81ff">ferretdb</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">spec</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># Keep this in sync with the correct image for FerretDB itself</span>
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">#  read FerretDB release notes and upgrade them together</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">imageName</span>: <span style="color:#ae81ff">ghcr.io/ferretdb/postgres-documentdb:17-0.106.0-ferretdb-2.5.0</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">instances</span>: <span style="color:#ae81ff">3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># postgres-documentdb needs these IDs</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">postgresUID</span>: <span style="color:#ae81ff">999</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">postgresGID</span>: <span style="color:#ae81ff">999</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">storage</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">size</span>: <span style="color:#ae81ff">10Gi</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># This should be the name of the storage class </span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># as configured in your provisioner</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">storageClass</span>: <span style="color:#ae81ff">local-hostpath</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">postgresql</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">shared_preload_libraries</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">pg_cron</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">pg_documentdb_core</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">pg_documentdb</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">parameters</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#75715e"># pg_cron needs to know which database FerretDB uses</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">cron.database_name</span>: <span style="color:#ae81ff">ferretDB</span>
</span></span><span style="display:flex;"><span>      <span style="color:#75715e"># These parameters are necessary to run ferretdb without superuser access</span>
</span></span><span style="display:flex;"><span>      <span style="color:#75715e"># Copied from https://github.com/FerretDB/documentdb/blob/ferretdb/packaging/10-preload.sh</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">documentdb.enableCompact</span>: <span style="color:#e6db74">&#34;true&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">documentdb.enableLetAndCollationForQueryMatch</span>: <span style="color:#e6db74">&#34;true&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">documentdb.enableNowSystemVariable</span>: <span style="color:#e6db74">&#34;true&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">documentdb.enableSortbyIdPushDownToPrimaryKey</span>: <span style="color:#e6db74">&#34;true&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">documentdb.enableSchemaValidation</span>: <span style="color:#e6db74">&#34;true&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">documentdb.enableBypassDocumentValidation</span>: <span style="color:#e6db74">&#34;true&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">documentdb.enableUserCrud</span>: <span style="color:#e6db74">&#34;true&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">documentdb.maxUserLimit</span>: <span style="color:#e6db74">&#34;100&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">pg_hba</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#75715e"># pg_cron always runs as `postgres`</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">host ferretDB postgres localhost trust</span>
</span></span><span style="display:flex;"><span>      <span style="color:#75715e"># This is needed to prevent fe_sendauth error</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">host ferretDB ferret localhost trust</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">bootstrap</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">initdb</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">database</span>: <span style="color:#ae81ff">ferretDB</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">owner</span>: <span style="color:#ae81ff">ferret</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">postInitApplicationSQL</span>:
</span></span><span style="display:flex;"><span>        - <span style="color:#ae81ff">create extension if not exists pg_cron;</span>
</span></span><span style="display:flex;"><span>        - <span style="color:#ae81ff">create extension if not exists documentdb cascade;</span>
</span></span><span style="display:flex;"><span>        - <span style="color:#ae81ff">grant documentdb_admin_role to ferret;</span>
</span></span><span style="display:flex;"><span>---
</span></span><span style="display:flex;"><span><span style="color:#f92672">apiVersion</span>: <span style="color:#ae81ff">apps/v1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">Deployment</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">metadata</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">name</span>: <span style="color:#ae81ff">ferretdb</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">namespace</span>: <span style="color:#ae81ff">ferretdb</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">spec</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">replicas</span>: <span style="color:#ae81ff">2</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">selector</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">matchLabels</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">app</span>: <span style="color:#ae81ff">ferretdb</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">template</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">metadata</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">labels</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">app</span>: <span style="color:#ae81ff">ferretdb</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">spec</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">containers</span>:
</span></span><span style="display:flex;"><span>        - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">ferretdb</span>
</span></span><span style="display:flex;"><span>          <span style="color:#75715e"># Keep this in sync with the correct image for the postgresql cluster,</span>
</span></span><span style="display:flex;"><span>          <span style="color:#75715e">#  always read FerretDB release notes and upgrade them together</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">image</span>: <span style="color:#ae81ff">ghcr.io/ferretdb/ferretdb:2.5.0</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>            - <span style="color:#f92672">containerPort</span>: <span style="color:#ae81ff">27017</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">env</span>:
</span></span><span style="display:flex;"><span>            - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">FERRETDB_POSTGRESQL_URL</span>
</span></span><span style="display:flex;"><span>              <span style="color:#f92672">valueFrom</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">secretKeyRef</span>:
</span></span><span style="display:flex;"><span>                  <span style="color:#75715e"># This secret gets automatically generated by cloudnative-pg</span>
</span></span><span style="display:flex;"><span>                  <span style="color:#f92672">name</span>: <span style="color:#ae81ff">ferretdb-pg-cluster-app </span>
</span></span><span style="display:flex;"><span>                  <span style="color:#f92672">key</span>: <span style="color:#ae81ff">uri</span>
</span></span><span style="display:flex;"><span>---
</span></span><span style="display:flex;"><span><span style="color:#f92672">apiVersion</span>: <span style="color:#ae81ff">v1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">Service</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">metadata</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">name</span>: <span style="color:#ae81ff">ferretdb-service</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">namespace</span>: <span style="color:#ae81ff">ferretdb</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">spec</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">selector</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">app</span>: <span style="color:#ae81ff">ferretdb</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">protocol</span>: <span style="color:#ae81ff">TCP</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">port</span>: <span style="color:#ae81ff">27017</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">targetPort</span>: <span style="color:#ae81ff">27017</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">type</span>: <span style="color:#ae81ff">NodePort</span>
</span></span></code></pre></div><p>This is a bit longer than <a href="https://blog.ferretdb.io/run-ferretdb-postgres-documentdb-extension-cnpg-kubernetes/">the basic example</a> that was on FerretDB&rsquo;s blog earlier this year, but that is because we need to run postgres without connecting as the <code>postgres</code> superuser.</p>
<p>After applying, watch the pods come up one by one:</p>
<p><code>kubectl -n ferretdb get pods -w</code></p>
<p>After startup is done, check it all works by running:</p>
<p><code>kubectl get cluster.postgresql.cnpg.io -n ferretdb</code></p>
<p>And check that it says: <code>Cluster in healthy state</code> under <code>STATUS</code> like in the below example output:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>NAMESPACE   NAME                  AGE     INSTANCES   READY   STATUS                     PRIMARY
</span></span><span style="display:flex;"><span>ferretdb    ferretdb-pg-cluster   33h     3           3       Cluster in healthy state   ferretdb-pg-cluster-1
</span></span></code></pre></div><p>This is <a href="https://www.gabrielebartolini.it/articles/2024/03/cloudnativepg-recipe-3-what-no-superuser-access/">great for security</a>, and is necessary to work with the way cloudnative-pg handles backups in our case.</p>
<p>Try out your FerretDB service now: just connect to the node it is running on (since we used NodePort), or if you&rsquo;re using a cloud Kubernetes outside your home network, change that NodePort to ClusterIP and use <a href="https://kubernetes.io/docs/tasks/access-application-cluster/port-forward-access-application-cluster/#forward-a-local-port-to-a-port-on-the-pod">kubectl port-forward</a>. You could use any Mongo client you wish, this is what it looks like with mongosh:
<code>mongosh 'mongodb://ferret:the-password-from-the-cloudnative-pg-secret@ip-of-the-node-it-is-running-on-or-localhost-if-port-forwarding:27017'</code></p>
<p>Then <a href="https://www.mongodb.com/docs/mongodb-shell/run-commands/">run some Mongo commands</a>.</p>
<h2 id="step-2-enable-backups">Step 2: Enable backups</h2>
<p>We will use the barman-cloud plugin to take backups. You may read about a legacy way of taking backups with cloudnative-pg, but there are 2 reasons why we use the plugin instead: the legacy way will be removed from the next cloudnative-pg release; and the legacy way requires barman-cloud (the backup tool) to be a part of the postgres image used. Our FerretDB provided image does not have barman-cloud.
Luckily the new way with the plugin creates a separate container with barman-cloud and adds it to each database pod, so that it works regardless of which postgres container is running.</p>
<p>Postgres backups come in multiple flavors. Today we&rsquo;ll look at the 2 that barman provides: base backups are like &ldquo;save points&rdquo; that can be restored from scratch in full. Write-Ahead-Logs (WALs) backup is a log of all the database transactions that can be replayed. These can be used to replay all the way to the latest state, or up to some point in time between the earliest base backup and the latest WAL.</p>
<p>So the combination of WALs and base backups give us both point in time restore, and restore up to the latest state, with no or very little data loss in case of an accidental (or intentional) cluster-wide outage. How these 2 should be used, is entirely handled by the tooling. All we need to do, is ensure backups are automatically made, and to specify a point in time to restore to.</p>
<p>So let&rsquo;s get those backups rolling. First we connect the postgres cluster to the S3 bucket using a BarmanObjectStore object:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span>---
</span></span><span style="display:flex;"><span><span style="color:#f92672">apiVersion</span>: <span style="color:#ae81ff">v1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">Secret</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">metadata</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">name</span>: <span style="color:#ae81ff">s3-creds</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">namespace</span>: <span style="color:#ae81ff">ferretdb</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">stringData</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">ACCESS_KEY_ID</span>: <span style="color:#ae81ff">your S3 access key ID goes here</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">SECRET_KEY</span>: <span style="color:#ae81ff">your S3 secret key goes here</span>
</span></span><span style="display:flex;"><span>---
</span></span><span style="display:flex;"><span><span style="color:#75715e"># yaml-language-server: $schema=https://github.com/datreeio/CRDs-catalog/raw/refs/heads/main/barmancloud.cnpg.io/objectstore_v1.json</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">apiVersion</span>: <span style="color:#ae81ff">barmancloud.cnpg.io/v1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">ObjectStore</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">metadata</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">name</span>: <span style="color:#ae81ff">ferretdb-backupstore</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">namespace</span>: <span style="color:#ae81ff">ferretdb</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">spec</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">retentionPolicy</span>: <span style="color:#ae81ff">14d</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">configuration</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">destinationPath</span>: <span style="color:#ae81ff">s3://bucketname/optionalsubfolder/</span> <span style="color:#75715e"># Change this</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Change the endpoint URL to whatever your cloud provider told you to use</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># NOTE: should be https if using cloud bucket</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">endpointURL</span>: <span style="color:#ae81ff">http://versity.storage.svc.cluster.local:7070 </span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">s3Credentials</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">accessKeyId</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">name</span>: <span style="color:#ae81ff">s3-creds</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">key</span>: <span style="color:#ae81ff">ACCESS_KEY_ID</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">secretAccessKey</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">name</span>: <span style="color:#ae81ff">s3-creds</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">key</span>: <span style="color:#ae81ff">SECRET_KEY</span>
</span></span></code></pre></div><p>Fill in the following S3 connection data in the above YAML before applying:</p>
<ul>
<li>credentials:
<ul>
<li>access key ID</li>
<li>secret key</li>
</ul>
</li>
<li>bucket name (and optional subfolder) NOTE: ensure bucket exists first</li>
<li>endpoint URL</li>
</ul>
<p>And never commit unencrypted secrets to your Git repo (the <code>ObjectStore</code> is safe but the <code>Secret</code> should be encrypted with <a href="https://github.com/getsops/sops">SOPS</a> or filled using <a href="https://external-secrets.io/latest/">external-secrets</a> if you are doing GitOps). Alternatively, <code>.gitignore</code> it.</p>
<p>Then add this <code>plugins</code> section to the <code>spec</code> of your postgresql cluster:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span>---
</span></span><span style="display:flex;"><span><span style="color:#75715e"># yaml-language-server: $schema=https://github.com/datreeio/CRDs-catalog/raw/refs/heads/main/postgresql.cnpg.io/cluster_v1.json</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">apiVersion</span>: <span style="color:#ae81ff">postgresql.cnpg.io/v1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">Cluster</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">metadata</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">name</span>: <span style="color:#ae81ff">ferretdb-pg-cluster</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">namespace</span>: <span style="color:#ae81ff">ferretdb</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">spec</span>:
</span></span><span style="display:flex;"><span>  [<span style="color:#ae81ff">...]</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">plugins</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">barman-cloud.cloudnative-pg.io</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">isWALArchiver</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">parameters</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">barmanObjectName</span>: <span style="color:#ae81ff">ferretdb-backupstore</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">serverName</span>: <span style="color:#ae81ff">pg</span>
</span></span></code></pre></div><p>Check that the barman-cloud pods are all started after a few seconds. You should see <code>2/2</code> for every pod that is part of the cluster under the <code>READY</code> column of the output of the following command:</p>
<p><code>kubectl -n ferretdb get pods</code></p>
<p>Like the <code>ferretdb-pg-cluster</code> pods here:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>NAME                              READY   STATUS    RESTARTS        AGE
</span></span><span style="display:flex;"><span>ferretdb-79dccb7d5d-8kbnq         1/1     Running   0               34h
</span></span><span style="display:flex;"><span>ferretdb-79dccb7d5d-wnlc9         1/1     Running   0               34h
</span></span><span style="display:flex;"><span>ferretdb-pg-cluster-1             2/2     Running   0               33h
</span></span><span style="display:flex;"><span>ferretdb-pg-cluster-2             2/2     Running   0               33h
</span></span><span style="display:flex;"><span>ferretdb-pg-cluster-3             2/2     Running   0               33h
</span></span></code></pre></div><p>You can also check your S3 storage to see if the WALs are actually ending up in there.
For example, you could install <a href="https://rclone.org/">rclone</a> and connect it to your cloud storage by running <code>rclone config</code>.
Then afterwards, you could run <code>rclone ls name-of-your-remote:</code> to see all the files in there. You should see files in a <code>wals/</code> subdirectory of the path you configured in your ObjectStore.</p>
<p>So now we have the barman-cloud containers streaming the WALs to your cloud object storage. But in order to be able to restore, we also need a &ldquo;save point&rdquo; or base backup. I like to set this up in such a way that it is taken periodically, for example every night, and cloudnative-pg makes this very easy. No cronjob needed, just this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span>---
</span></span><span style="display:flex;"><span><span style="color:#75715e"># yaml-language-server: $schema=https://raw.githubusercontent.com/datreeio/CRDs-catalog/refs/heads/main/postgresql.cnpg.io/scheduledbackup_v1.json</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">apiVersion</span>: <span style="color:#ae81ff">postgresql.cnpg.io/v1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">ScheduledBackup</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">metadata</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">name</span>: <span style="color:#ae81ff">ferretdb</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">spec</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">backupOwnerReference</span>: <span style="color:#ae81ff">self</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">cluster</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">name</span>: <span style="color:#ae81ff">ferretdb-pg-cluster</span> <span style="color:#75715e"># Should be the name of your postgres cluster</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">schedule</span>: <span style="color:#e6db74">&#34;0 0 1 * * *&#34;</span> <span style="color:#75715e"># This is a slightly unusual cron format</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">immediate: true # Means</span>: <span style="color:#ae81ff">take a snapshot right now as well</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">method</span>: <span style="color:#ae81ff">plugin</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">pluginConfiguration</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">name</span>: <span style="color:#ae81ff">barman-cloud.cloudnative-pg.io</span>
</span></span></code></pre></div><p>Check that it is applied:</p>
<p><code>kubectl -n ferretdb get scheduledbackup</code></p>
<p>Check that it spawns a regular <code>Backup</code> object:</p>
<p><code>kubectl -n ferretdb get scheduledbackup</code></p>
<p>And check the phase. You can watch it to not have to keep refreshing by adding <code>-w</code>. Eventually the <code>PHASE</code> should be <code>completed</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>NAMESPACE   NAME                        AGE     CLUSTER               METHOD              PHASE       ERROR
</span></span><span style="display:flex;"><span>ferretdb    ferretdb-20250924091153     33h     ferretdb-pg-cluster   plugin              completed   
</span></span></code></pre></div><p>And you should see a recovery window in the status of your <code>ObjectStore</code>:
<code>kubectl -n ferretdb describe objectstore ferretdb-backupstore</code>
On the bottom of the output you should see something like:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Status:
</span></span><span style="display:flex;"><span>  Server Recovery Window:
</span></span><span style="display:flex;"><span>    Pg:
</span></span><span style="display:flex;"><span>      First Recoverability Point:   2025-09-24T09:12:01Z
</span></span><span style="display:flex;"><span>      Last Successful Backup Time:  2025-09-25T01:00:07Z
</span></span></code></pre></div><p>And that&rsquo;s it. Now you could leave it running for a while, write some data, delete some data and try out point in time restore using <a href="https://cloudnative-pg.io/documentation/1.27/recovery/#pitr-from-an-object-store">cloudnative-pg documentation</a></p>
<p>But for this tutorial, we will go to the initially promised final step.</p>
<h1 id="step-3-automatic-recovery">Step 3. Automatic recovery</h1>
<p>It is important that your backups are properly wired up and you have a recovery window in your <code>ObjectStore</code> before you start this step.</p>
<p>The usecase is the following: imagine your whole kubernetes cluster is wiped. Maybe there&rsquo;s water damage to your house, a lightning hit, or (more common in the homelab) you have so seriously messed up your cluster that it is easier to start over and re-apply all the YAML files you have been keeping track of, than to actually fix the problem. Or if you are using a proper GitOps solution like <a href="https://fluxcd.io/">FluxCD</a>, applying all the YAMLs is the same as just deploying Flux into a fresh cluster.</p>
<p>Let&rsquo;s set up our postgres cluster configuration to automatically restore from the <code>ObjectStore</code>. Add the lines marked with <code>+</code> to the postgres cluster document (without the +), comment out the <code>initdb</code> section and re-apply it:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span>---
</span></span><span style="display:flex;"><span><span style="color:#75715e"># yaml-language-server: $schema=https://github.com/datreeio/CRDs-catalog/raw/refs/heads/main/postgresql.cnpg.io/cluster_v1.json</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">apiVersion</span>: <span style="color:#ae81ff">postgresql.cnpg.io/v1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">Cluster</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">metadata</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">name</span>: <span style="color:#ae81ff">ferretdb-pg-cluster</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">namespace</span>: <span style="color:#ae81ff">ferretdb</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">+ annotations</span>:
</span></span><span style="display:flex;"><span><span style="color:#f92672">+   # required for seamless bootstrap</span>: <span style="color:#ae81ff">https://github.com/cloudnative-pg/cloudnative-pg/issues/5778#issuecomment-2783417464</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">+   cnpg.io/skipEmptyWalArchiveCheck</span>: <span style="color:#e6db74">&#34;enabled&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">spec</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># Keep this in sync with the correct image for FerretDB itself</span>
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">#  read FerretDB release notes and upgrade them together</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">imageName</span>: <span style="color:#ae81ff">ghcr.io/ferretdb/postgres-documentdb:17-0.106.0-ferretdb-2.5.0</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">instances</span>: <span style="color:#ae81ff">3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># postgres-documentdb needs these IDs</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">postgresUID</span>: <span style="color:#ae81ff">999</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">postgresGID</span>: <span style="color:#ae81ff">999</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">storage</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">size</span>: <span style="color:#ae81ff">10Gi</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># This should be the name of the storage class as configured in your provisioner</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">storageClass</span>: <span style="color:#ae81ff">local-hostpath</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">postgresql</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">shared_preload_libraries</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">pg_cron</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">pg_documentdb_core</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">pg_documentdb</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">parameters</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#75715e"># pg_cron needs to know which database FerretDB uses</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">cron.database_name</span>: <span style="color:#ae81ff">ferretDB</span>
</span></span><span style="display:flex;"><span>      <span style="color:#75715e"># The parameters below are necessary to run ferretdb without superuser access</span>
</span></span><span style="display:flex;"><span>      <span style="color:#75715e"># Copied from https://github.com/FerretDB/documentdb/blob/ferretdb/packaging/10-preload.sh</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">documentdb.enableCompact</span>: <span style="color:#e6db74">&#34;true&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">documentdb.enableLetAndCollationForQueryMatch</span>: <span style="color:#e6db74">&#34;true&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">documentdb.enableNowSystemVariable</span>: <span style="color:#e6db74">&#34;true&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">documentdb.enableSortbyIdPushDownToPrimaryKey</span>: <span style="color:#e6db74">&#34;true&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">documentdb.enableSchemaValidation</span>: <span style="color:#e6db74">&#34;true&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">documentdb.enableBypassDocumentValidation</span>: <span style="color:#e6db74">&#34;true&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">documentdb.enableUserCrud</span>: <span style="color:#e6db74">&#34;true&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">documentdb.maxUserLimit</span>: <span style="color:#e6db74">&#34;100&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">pg_hba</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#75715e"># pg_cron always runs as `postgres`</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">host ferretDB postgres localhost trust</span>
</span></span><span style="display:flex;"><span>      <span style="color:#75715e"># This is needed to prevent fe_sendauth error</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">host ferretDB ferret localhost trust</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">bootstrap</span>:
</span></span><span style="display:flex;"><span><span style="color:#f92672">+   recovery</span>:
</span></span><span style="display:flex;"><span><span style="color:#f92672">+     source</span>: <span style="color:#75715e">&amp;source</span> <span style="color:#ae81ff">pg</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#    initdb:</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#      database: ferretDB</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#      owner: ferret</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#      postInitApplicationSQL:</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#        - create extension if not exists pg_cron;</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#        - create extension if not exists documentdb cascade;</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#        - grant documentdb_admin_role to ferret;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">plugins</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">barman-cloud.cloudnative-pg.io</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">isWALArchiver</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">parameters</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">barmanObjectName</span>: <span style="color:#ae81ff">ferretdb-backupstore</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">serverName</span>: <span style="color:#ae81ff">pg</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">+ externalClusters</span>:
</span></span><span style="display:flex;"><span><span style="color:#f92672">+   - name</span>: <span style="color:#ae81ff">pg</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">+     plugin</span>:
</span></span><span style="display:flex;"><span><span style="color:#f92672">+       name</span>: <span style="color:#ae81ff">barman-cloud.cloudnative-pg.io</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">+       parameters</span>:
</span></span><span style="display:flex;"><span><span style="color:#f92672">+         barmanObjectName</span>: <span style="color:#ae81ff">ferretdb-backupstore</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">+         serverName</span>: <span style="color:#ae81ff">pg</span>
</span></span></code></pre></div><p>Don&rsquo;t forget the annotation! Without it, barman-cloud will refuse to continue writing backups to the same ObjectStore and serverName after recovery. It is generally safe to use this annotation<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>.</p>
<p>Now check this has applied successfully:</p>
<p><code>kubectl get cluster.postgresql.cnpg.io -n ferretdb</code></p>
<p><code>kubectl describe cluster.postgresql.cnpg.io -n ferretdb</code></p>
<p>(you can also shorten these to just use <code>cluster</code> if you have no other resources named <code>cluster</code> except the cnpg cluster.)</p>
<p>If it has, go ahead and delete your entire postgres cluster:</p>
<p><code>kubectl delete -n ferretdb cluster.postgresql.cnpg.io ferretdb-pg-cluster --force</code></p>
<p>Check that all the postgres cluster pods and PVCs are gone (you might have to <code>kubectl delete --force</code> some of them):</p>
<p><code>kubectl -n ferretdb get pods</code></p>
<p><code>kubectl -n ferretdb get pvc</code></p>
<p>If they are all gone, here comes the test: re-apply the postgresql YAML and it should start a full-recovery:</p>
<p><code>kubectl apply -f postgres-cluster.yaml</code></p>
<p>Check the pods:</p>
<p><code>kubectl -n ferretdb get pods -w</code>
Check the logs:</p>
<p><code>kubectl -n ferretdb logs name-of-the-pod</code></p>
<p>And soon, your cluster should be back and healthy. Connect to it as in Step 1 and check that your data is restored. Make a happy dance, then go to bed and sleep soundly, knowing your data is safe, no manual intervention is required for restore, and you are using a completely open source document database solution.</p>
<p>If things change and you want to check how I am running FerretDB, go to <a href="https://github.com/fhoekstra/home-ops">github.com/fhoekstra/home-ops</a>, press <code>t</code> and type <code>ferretdb</code>.</p>
<p>If you found an error, I would love to accept your PR to this blog at <a href="https://codeberg.org/fhoekstra/blog">https://codeberg.org/fhoekstra/blog</a></p>
<p>If you need help setting this up at home, come to the <a href="https://github.com/home-operations">Home Operations</a> <a href="https://discord.gg/home-operations">Discord</a>.</p>
<h2 id="attribution">Attribution</h2>
<p>I did not come up with any of this myself, I just combined the ideas from some amazing people around me, tested it and wrote it down. Special thanks go to <a href="https://github.com/eaglesemanation">@eaglesemanation</a> for figuring out how to run FerretDB against cloudnative-pg without superuser access. That made this setup possible.</p>
<p>I should also thank <a href="https://github.com/tholinka">@tholinka</a> and <a href="https://github.com/phycoforce">@Phycoforce</a> from the <a href="https://discord.gg/home-operations">Home-Operations</a> community for their help with barman-cloud backups. Their home-ops repos are a great resource.</p>
<p>You can browse many more repos using cloudnative-pg and barman-cloud by searching <a href="https://kubesearch.dev">kubesearch.dev</a> to get ideas of other ways to set this up.</p>
<hr>
<h2 id="footnotes">Footnotes</h2>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>But if you are going to do a major upgrade (for example to Postgresql 18), you might want to do that using a fresh cluster, with its own new <code>serverName</code> instead of keeping the backups in the same place. Thanks to <a href="https://github.com/aclerici38">@MASTERBLASTER</a> in the <a href="https://discord.gg/home-operations">Home-Operations</a> community for this addition.&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content></item></channel></rss>