<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Josh Siviour</title>
    <description>The latest articles on DEV Community by Josh Siviour (@josh_siv).</description>
    <link>https://dev.to/josh_siv</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4116547%2F5dcbf268-58d9-48b6-9e80-753330dd3e0b.webp</url>
      <title>DEV Community: Josh Siviour</title>
      <link>https://dev.to/josh_siv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/josh_siv"/>
    <language>en</language>
    <item>
      <title>We moved a real homelab onto Spinifex and deleted six operators</title>
      <dc:creator>Josh Siviour</dc:creator>
      <pubDate>Mon, 12 Oct 2026 23:23:56 +0000</pubDate>
      <link>https://dev.to/josh_siv/we-moved-a-real-homelab-onto-spinifex-and-deleted-six-operators-ehe</link>
      <guid>https://dev.to/josh_siv/we-moved-a-real-homelab-onto-spinifex-and-deleted-six-operators-ehe</guid>
      <description>&lt;p&gt;Building a cloud platform that can understand the AWS API is one thing; rigorously testing it is something else.&lt;/p&gt;

&lt;p&gt;The AWS API is open, well documented and can be tested against for conformance fairly easily. Provisioned resource behaviour is also documented to an extent, and can be observed directly in AWS itself by spinning up resources. Internal AWS logic is closed, but as long as we understand requests and can return the correct responses, plus exhibit expected behaviour and resource lifecycle to the user - the tools should work and the experience should be similar.&lt;/p&gt;

&lt;p&gt;Spinifex internal logic can and does differ from AWS. We aren't making a clone of AWS; we aim to improve the cloud experience for users - particularly at the edge and resource-constrained environments, while offering a familiar interface via the well-known and loved(?) AWS CLI and IaC tools like Terraform.&lt;/p&gt;

&lt;p&gt;Unit, integration and end-to-end tests are all necessary in the pipeline to make sure we are building to the AWS spec. Weird edge-case bugs still pop up during manual deployment and fiddling, so we add in specific stress and fault injection testing to monitor any future regressions.&lt;/p&gt;

&lt;p&gt;If there is one thing that unearths all of the dormant and hidden gremlins in your system - it is real-world testing. Unfortunately in the early days of an infrastructure project like this, we become stuck in a place where we would love to get our hands on some real production-grade AWS workloads, but the people that have said workloads want to see more evidence that the system functions (with someone else's workloads). Classic chicken and egg.&lt;/p&gt;

&lt;p&gt;What do we do? To get good reference data, we need workloads and to get workloads, we need good reference data! Short of building our own SaaS product so we can "dogfood"&lt;sup id="fnref1"&gt;1&lt;/sup&gt; Spinifex, we have been limited to a few basic Terraform examples we have created that make use of the official AWS provider, and self-hosting some internal tooling. Not a bad start, but quite tame and not the chaos that I know a real workload could create...&lt;/p&gt;

&lt;p&gt;So I asked a good friend if I could try and get his homelab running on Spinifex. Now Jellyfin and Home Assistant are not exactly "production" or "enterprise", but it's a step in the right direction and serves the purpose of loading the system in ways we may not have anticipated - (un)hopefully surfacing enough things to keep us busy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workload
&lt;/h2&gt;

&lt;p&gt;A GitOps homelab repository running bare-metal &lt;a href="https://www.siderolabs.com/talos-linux" rel="noopener noreferrer"&gt;Talos Linux&lt;/a&gt; and Flux, reconciling state from git.&lt;/p&gt;

&lt;p&gt;The original &lt;a href="https://github.com/philgood/home-ops" rel="noopener noreferrer"&gt;homelab&lt;/a&gt; belongs to my friend Ryan, who runs it permanently at home, serving 80+ services across 16 namespaces... so I shortened that to a more modest eight apps for our initial testing purposes. The template it grew from is onedr0p's &lt;a href="https://github.com/onedr0p/cluster-template" rel="noopener noreferrer"&gt;cluster-template&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I also wanted to run this on its own on commodity hardware so I acquired some decommissioned Dell 7090 micro PCs and built a little Mulga themed mini rack:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe5du5fc7lt3dua0vvm28.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe5du5fc7lt3dua0vvm28.jpeg" alt="Mulga lab 10 inch rack" width="800" height="1067"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each host has an i5 CPU (6C/12T) and 16GB RAM - not much to play with, but enough for now.&lt;/p&gt;

&lt;p&gt;This workload is Kubernetes based, running on Talos bare-metal hosts. Porting this to test/showcase Spinifex features left me with a couple of options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Convert everything to managed services including EKS in place of Talos itself.&lt;/li&gt;
&lt;li&gt;Run Talos EC2 instances and pull some internal services out to replace with Spinifex managed services.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I went with option 2 - for now.&lt;/p&gt;

&lt;p&gt;Replacing Talos and that entire workflow wholesale wouldn't have been in the spirit of porting the homelab across. It would essentially have been a complete rebuild and we would have only really taken some of the app choices as inspiration. More importantly - it would likely have introduced too much chaos at once and made it difficult to determine the root cause of any issues that arise.&lt;/p&gt;

&lt;p&gt;So from here, I set out to move the homelab onto &lt;a href="https://github.com/mulgadc/spinifex" rel="noopener noreferrer"&gt;Spinifex&lt;/a&gt;, our AWS-compatible cloud for bare&lt;br&gt;
metal.&lt;/p&gt;
&lt;h2&gt;
  
  
  The headline is a deletion
&lt;/h2&gt;

&lt;p&gt;Six operators left the cluster entirely.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fno9se0lcihpmy1183142.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fno9se0lcihpmy1183142.gif" alt="Six things you run become four things you call. The cluster gets smaller; the capability doesn't." width="799" height="406"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Six things you run become four things you call. The cluster gets smaller; the capability doesn't.&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;In the original lab&lt;/th&gt;
&lt;th&gt;On Spinifex&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;block storage&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;rook-ceph&lt;/code&gt; and &lt;code&gt;ceph-csi-drivers&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Viperblock (EBS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hostpath volumes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;openebs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Viperblock (EBS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;garage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Predastore&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postgres&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cloudnative-pg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Spinifex RDS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets&lt;/td&gt;
&lt;td&gt;&lt;code&gt;external-secrets&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sops&lt;/code&gt; (from the template; no platform service needed)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This seemed like a good enough cut to test our core Spinifex infrastructure, while leaving the applications inside K8s.&lt;/p&gt;

&lt;p&gt;The capability of the homelab stays the same, but there is now a clearer boundary between infrastructure and user-facing applications.&lt;/p&gt;
&lt;h2&gt;
  
  
  So where does the infrastructure live now?
&lt;/h2&gt;

&lt;p&gt;I know that the original Talos bare-metal homelab is actually more appealing to many hobbyists and homelabbers - configuring, tinkering and managing every part of the system is all part of the journey and fun of the game.&lt;/p&gt;

&lt;p&gt;Yes, Flux now only reconciles application changes, but the infrastructure we pulled out still needs to be configured and provisioned somewhere - now via OpenTofu with the AWS provider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a VPC, subnet, internet gateway and route table&lt;/li&gt;
&lt;li&gt;3 x instances (EC2) running Talos from the Talos Image Factory's AWS image,
spread one per physical host by a placement group, plus an Elastic IP for the API&lt;/li&gt;
&lt;li&gt;4 x security groups - nodes, edge, database, NFS - referencing each other
rather than hard-coded ranges&lt;/li&gt;
&lt;li&gt;1 x load balancer (ALB), its listeners and target group&lt;/li&gt;
&lt;li&gt;1 x wildcard certificate from the tenant CA&lt;/li&gt;
&lt;li&gt;1 x RDS instance, its subnet group and its generated password&lt;/li&gt;
&lt;li&gt;1 x instance (EC2) serving NFS (and SMB), a separate EBS volume for the library,
and the attachment joining them&lt;/li&gt;
&lt;li&gt;1 x S3 bucket for backups, with an IAM user scoped to it&lt;/li&gt;
&lt;li&gt;the IAM role and instance profile the nodes assume&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thirty-three resources in all.&lt;/p&gt;

&lt;p&gt;It's still declarative and still in version control. What changed is where it&lt;br&gt;
sits.&lt;/p&gt;
&lt;h2&gt;
  
  
  We cut the app count on purpose
&lt;/h2&gt;

&lt;p&gt;Many of the apps in the original homelab were similar in nature so porting all of them really only meant longer build times and more load on the system. For a first test I wanted it to be reasonably quick to build and tear down, and I didn't want to overwhelm my poor little 7090s straight up.&lt;/p&gt;

&lt;p&gt;I chose a select few apps that would demonstrate/test different Spinifex components, without too much overlap:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Application&lt;/th&gt;
&lt;th&gt;In the original lab&lt;/th&gt;
&lt;th&gt;On Spinifex&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;echo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cilium LoadBalancer IP, cert-manager via Cloudflare&lt;/td&gt;
&lt;td&gt;Shared listener behind an ELB, ACM certificate from the tenant CA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vikunja&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a &lt;code&gt;ceph-block&lt;/code&gt; claim&lt;/td&gt;
&lt;td&gt;an &lt;code&gt;ebs-gp3&lt;/code&gt; claim, snapshotted and backed up to Predastore&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;atuin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CloudNativePG, running in the cluster&lt;/td&gt;
&lt;td&gt;RDS, which the cluster doesn't run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jellyfin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a NAS over NFS, and an Intel iGPU&lt;/td&gt;
&lt;td&gt;an EC2 instance serving NFS from an EBS volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dragonfly&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;StatefulSet on &lt;code&gt;ceph-block&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;StatefulSet &lt;code&gt;volumeClaimTemplate&lt;/code&gt; on &lt;code&gt;ebs-gp3&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kopia&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;backup UI over &lt;code&gt;garage&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the same UI, over Predastore&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kube-prometheus-stack&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;claims on &lt;code&gt;ceph-block&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;claims on &lt;code&gt;ebs-gp3&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;homepage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;dashboard over the whole lab&lt;/td&gt;
&lt;td&gt;the same, over these seven&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three control-plane nodes at 8 GiB each, no separate workers, running the whole&lt;br&gt;
workload. Twenty HelmReleases, thirty-three Kustomizations.&lt;/p&gt;
&lt;h2&gt;
  
  
  Nothing had to be told it wasn't AWS
&lt;/h2&gt;

&lt;p&gt;This is what we want to see - Spinifex indistinguishable from real AWS from OpenTofu's perspective.&lt;/p&gt;

&lt;p&gt;Every tool in the chain is the stock one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The CSI driver is &lt;code&gt;aws-ebs-csi-driver&lt;/code&gt;, the upstream chart, unmodified.&lt;/li&gt;
&lt;li&gt;The infrastructure is OpenTofu with the &lt;code&gt;aws&lt;/code&gt; provider, the same one used to deploy to AWS.&lt;/li&gt;
&lt;li&gt;The nodes boot Talos' own AWS platform image, reading their configuration out of instance metadata exactly as they would in a real AWS region.&lt;/li&gt;
&lt;li&gt;Certificates come from ACM, routing from an ELB with isolation from security groups referencing other security groups.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of it is Spinifex-aware. There is no plugin, no fork, no patched chart. The&lt;br&gt;
only thing any of those components needed was an endpoint to point at, and a CA&lt;br&gt;
bundle to trust it with - which is configuration, not code.&lt;/p&gt;

&lt;p&gt;Now to be fair - I have not actually changed the endpoints in OpenTofu and deployed to a real AWS region - that was not within the initial scope of this experiment, but it could be a nice follow-on activity.&lt;/p&gt;
&lt;h3&gt;
  
  
  Where the API did differ
&lt;/h3&gt;

&lt;p&gt;Matching exact AWS capability isn't simple. It's thousands of small behaviours, nuanced by the particular combination of resources in a workload and the timing of when actions are occurring. Some are not consistent across similar resources and others are not at all what you would intuitively expect to happen. All of the things we hadn't planned for show up in experiments like this - such as:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;th&gt;What AWS does&lt;/th&gt;
&lt;th&gt;Why it mattered&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;An IAM user's access key went missing underneath it. &lt;code&gt;ListAccessKeys&lt;/code&gt; showed none, but &lt;code&gt;DeleteUser&lt;/code&gt; still refused with &lt;code&gt;DeleteConflict&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The two calls always agree&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tofu destroy&lt;/code&gt; couldn't remove the user, and a rebuild couldn't add a new key either: the two-key limit counted the ones nobody could see&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A launch with no host able to take it came back as a generic &lt;code&gt;500&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;InsufficientInstanceCapacity&lt;/code&gt;, a client error&lt;/td&gt;
&lt;td&gt;The AWS SDK retries 500s, so OpenTofu sat waiting out its timeout instead of failing in seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;DeleteSnapshot&lt;/code&gt; refused with "in use by one or more AMIs" when a volume was the dependency&lt;/td&gt;
&lt;td&gt;Names what is actually holding it&lt;/td&gt;
&lt;td&gt;Correct error code, misleading message: it sent us looking for images that didn't exist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;describe-volumes&lt;/code&gt; with a &lt;code&gt;snapshot-id&lt;/code&gt; filter returned every volume&lt;/td&gt;
&lt;td&gt;Applies the filter&lt;/td&gt;
&lt;td&gt;Any script narrowing by snapshot would have acted on the wrong volumes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these would show up in a test that checks one handler at a time. Error&lt;br&gt;
codes, retry semantics and consistency between calls are part of the contract&lt;br&gt;
as much as the response shapes are, and every one of these is now tracked with&lt;br&gt;
a reproduction. Finding this kind of gap is the reason to run something real.&lt;/p&gt;
&lt;h2&gt;
  
  
  The NAS becomes an instance
&lt;/h2&gt;

&lt;p&gt;An interesting divergence between the original homelab and my Spinifex remix was that I did not have a separate NAS appliance for &lt;code&gt;jellyfin&lt;/code&gt; to use.&lt;/p&gt;

&lt;p&gt;The workaround was to stand up a separate vanilla Ubuntu EC2 instance outside of the Talos cluster, with a 20 GiB encrypted &lt;code&gt;gp3&lt;/code&gt; volume that only the cluster node group could talk to via port 2049 (NFS).&lt;/p&gt;

&lt;p&gt;This volume is a separate resource from the instance's root disk so it does not live and die with the instance it is attached to. It can also be snapshotted and reattached on fresh builds.&lt;/p&gt;

&lt;p&gt;I initially had cloud-init install &lt;code&gt;ffmpeg&lt;/code&gt; and generate a&lt;br&gt;
30 second test pattern, so the demo depends on nothing being copied in by&lt;br&gt;
hand.&lt;/p&gt;

&lt;p&gt;From inside the pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Filesystem              Size  Used Avail Use% Mounted on
172.31.0.30:/srv/media   20G  2.8M   19G   1% /media

/media/movies/Test Pattern (2026)/Test Pattern (2026).mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application's mount is the one thing that didn't change - same &lt;code&gt;type: nfs&lt;/code&gt;,&lt;br&gt;
same &lt;code&gt;readOnly&lt;/code&gt;, different server.&lt;/p&gt;

&lt;p&gt;Moving the NAS inside Spinifex did mean adding a way to transfer media to it. I ended up configuring an SMB share on the instance including user credentials and allowing ingress through the VPC Security Group - all provisioned via IaC.&lt;/p&gt;

&lt;p&gt;One thing is untested. The original homelab passes an Intel iGPU&lt;br&gt;
through for Quick Sync transcoding. The hosts this lab runs on have no GPU to give&lt;br&gt;
it, so transcoding is on the CPU and both the &lt;code&gt;gpu.intel.com/i915&lt;/code&gt; resource and the&lt;br&gt;
render group are gone from the manifest.&lt;/p&gt;

&lt;p&gt;Spinifex does support GPU pass-through to EC2 instances (and ECS on them), so a discrete GPU would be possible, just not on this hardware. An iGPU shared the way the original lab uses Quick Sync is a different question, and one for that follow-up.&lt;/p&gt;
&lt;h2&gt;
  
  
  Then we tried to break it
&lt;/h2&gt;

&lt;p&gt;Bootstrap and deployment issues aside, it was time to test some operational resilience and see where the wheels fall off. Just a couple of things to start, but will likely build on this:&lt;/p&gt;
&lt;h3&gt;
  
  
  Deleting the volume for real
&lt;/h3&gt;

&lt;p&gt;Most backup tests restore alongside the original. This one destroyed the&lt;br&gt;
original first.&lt;/p&gt;

&lt;p&gt;We created a user, a project and three tasks through vikunja's own API - records&lt;br&gt;
the application wrote itself, not files we placed on a disk - and backed them up&lt;br&gt;
to Predastore. Then we deleted the claim.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr7wuvdso35l6pk1kan7z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr7wuvdso35l6pk1kan7z.png" alt="The volume wasn't detached or unmounted. It was deleted, and the platform confirmed it was gone." width="800" height="260"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The volume wasn't detached or unmounted. It was deleted, and the platform confirmed it was gone.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The PersistentVolume went with it, and &lt;code&gt;describe-volumes&lt;/code&gt; on the underlying EBS&lt;br&gt;
volume answered &lt;code&gt;InvalidVolume.NotFound&lt;/code&gt;. At that point the only copy of that&lt;br&gt;
data in existence was in an object store on other machines.&lt;/p&gt;

&lt;p&gt;Flux recreated the claim, the populator restored it onto a &lt;em&gt;different&lt;/em&gt; volume,&lt;br&gt;
and all three tasks came back with the user still able to log in.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A hiccup with &lt;code&gt;WaitForFirstConsumer&lt;/code&gt;: a restore will not start&lt;br&gt;
if nothing is asking for the volume. The claim sat &lt;code&gt;Pending&lt;/code&gt; for five minutes&lt;br&gt;
with one event - &lt;code&gt;waiting for first consumer to be created before binding&lt;/code&gt; -&lt;br&gt;
because the workload had been scaled to zero to release the old claim and&lt;br&gt;
nothing had scaled it back.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Killing a node
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;stop-instances --force&lt;/code&gt; on the node running &lt;code&gt;vikunja&lt;/code&gt;, &lt;code&gt;atuin&lt;/code&gt;, &lt;code&gt;kopia&lt;/code&gt; and a&lt;br&gt;
&lt;code&gt;dragonfly&lt;/code&gt; replica. Not a drain, not a graceful shutdown - the power-loss case.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8vwdq6iujc0dtc3cx3p2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8vwdq6iujc0dtc3cx3p2.png" alt="Two minutes, unattended, with no operator involvement." width="800" height="243"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Two minutes, unattended, with no operator involvement.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;etcd kept quorum on two of three throughout. The Kubernetes API never became&lt;br&gt;
unavailable. Every other application stayed up.&lt;/p&gt;

&lt;p&gt;The detail that matters is the volume. &lt;code&gt;vikunja&lt;/code&gt;'s claim was attached to the&lt;br&gt;
node that died - the CSI driver detached it, reattached it on a survivor, and&lt;br&gt;
the pod came up against the same data. The seeded tasks were still there. &lt;strong&gt;The&lt;br&gt;
storage followed the workload&lt;/strong&gt;, which is the entire reason for keeping it off&lt;br&gt;
the node in the first place.&lt;/p&gt;
&lt;h3&gt;
  
  
  The neighbour that didn't notice
&lt;/h3&gt;

&lt;p&gt;A side bonus I didn't plan for: the whole time, the same Spinifex cluster was&lt;br&gt;
also serving one of our internal dashboards inside a container on ECS.&lt;/p&gt;

&lt;p&gt;Across every build, teardown and half-failed rebuild of the homelab, it kept serving, with the exception of when its host had to be hard reset after the &lt;code&gt;e1000e&lt;/code&gt; NIC hang (more on that below), but it recovered by itself each time.&lt;/p&gt;

&lt;p&gt;A bonus of multi-tenancy I guess - the homelab could make a mess of its own account without the platform's other tenants feeling it!&lt;/p&gt;
&lt;h2&gt;
  
  
  Building it from nothing
&lt;/h2&gt;

&lt;p&gt;The whole thing is in a public repository, linked at the end, with a README&lt;br&gt;
that goes step by step. This is the shorter version: the shape of a build from&lt;br&gt;
bare metal to running apps, and the one thing at each step that caught me out.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ninr8bkgxzlh91v0u1d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ninr8bkgxzlh91v0u1d.png" alt="Each layer is built by a different tool, and each only needs the one below it." width="799" height="414"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Each layer is built by a different tool, and each only needs the one below it.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Install Spinifex
&lt;/h3&gt;

&lt;p&gt;A multi-node install from the public docs, across the three 7090s. On top of a&lt;br&gt;
plain install the lab needs three things from the platform:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;addresses on the LAN it can hand out for Elastic IPs and the load balancer,&lt;/li&gt;
&lt;li&gt;a tenant CA for the domain, so ACM can issue the wildcard certificate, and&lt;/li&gt;
&lt;li&gt;the base images for the NFS server and the database.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;
  
  
  Gotcha: distributing the tenant CA
&lt;/h4&gt;

&lt;p&gt;Every private CA has two distribution problems: the signing key has to reach&lt;br&gt;
everything that issues certificates, and the root certificate has to reach&lt;br&gt;
everything that should trust them.&lt;/p&gt;

&lt;p&gt;Spinifex does not currently have an easy way to create then distribute a private CA to all nodes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;spx admin cert create-tenant-ca&lt;/code&gt; writes the key pair to &lt;code&gt;/etc/spinifex/&lt;/code&gt; on the node you ran it on, and each node's daemon loads it from its own disk. Whichever node answers an ACM request needs the key, so certificate requests fail depending on where they land, with a &lt;code&gt;no tenant CA is configured&lt;/code&gt; error.&lt;/p&gt;

&lt;p&gt;The fix is to create it once and copy the pair to every node, keeping the private key readable only by root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# on each of the other nodes, after copying both files over ssh&lt;/span&gt;
&lt;span class="nb"&gt;sudo chown &lt;/span&gt;root:root /etc/spinifex/tenant-ca.&lt;span class="o"&gt;{&lt;/span&gt;pem,key&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;0644 /etc/spinifex/tenant-ca.pem
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;0600 /etc/spinifex/tenant-ca.key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't run &lt;code&gt;create-tenant-ca&lt;/code&gt; on each node - you'd get three different CAs. The client side is the usual internal-PKI chore: add &lt;code&gt;tenant-ca.pem&lt;/code&gt; to the trust store of every machine that browses the lab. Replicating the CA through the platform, rather&lt;br&gt;
than by hand, is on our list.&lt;/p&gt;
&lt;h4&gt;
  
  
  Gotcha: the e1000e NIC hang
&lt;/h4&gt;

&lt;p&gt;This one has nothing to do with Spinifex, and it can bite anyone running virtual machines on hardware with an onboard Intel &lt;code&gt;e1000e&lt;/code&gt; NIC. One of the 7090s kept vanishing from the network mid-build, badly enough to need a hard reset. The&lt;br&gt;
kernel log had the answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;e1000e 0000:00:1f.6 enp0s31f6: Detected Hardware Unit Hang
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The NIC's transmit queue hangs under heavy traffic when TCP segmentation offload is on, and tunnelled VM traffic was the trigger here. Turning the offloads off fixes it, at the cost of a little CPU on very high throughput:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ethtool &lt;span class="nt"&gt;-i&lt;/span&gt; enp0s31f6 | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;          &lt;span class="c"&gt;# driver: e1000e?&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ethtool &lt;span class="nt"&gt;-K&lt;/span&gt; enp0s31f6 tso off gso off gro off
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That only lasts until reboot, so persist it with a small systemd unit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/e1000e-offload.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Disable offloads that hang the e1000e NIC under load&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-pre.target&lt;/span&gt;
&lt;span class="py"&gt;Before&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/sbin/ethtool -K enp0s31f6 tso off gso off gro off&lt;/span&gt;
&lt;span class="py"&gt;RemainAfterExit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a distributed system, a locked-up network interface is not great. This caused mild frustration and more than a couple of fresh cluster builds before it was identified, but it actually highlights work required in the way Spinifex manages state and recovery if a node vanishes mid-write.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Fork it and describe your cluster
&lt;/h3&gt;

&lt;p&gt;Everything specific to your cluster goes in one file, &lt;code&gt;cluster.toml&lt;/code&gt;: the domain,&lt;br&gt;
the gateway and object-store endpoints, how many controllers, the Git repository&lt;br&gt;
Flux should watch. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;just init           &lt;span class="c"&gt;# age key for secrets, a deploy key for Flux, a webhook token&lt;/span&gt;
just configure      &lt;span class="c"&gt;# renders the tofu variables and the Talos machine config&lt;/span&gt;
./scripts/preflight.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Preflight imports (or gives you instructions to import) the Talos image for your machine config, checks your&lt;br&gt;
credentials against the gateway, and checks Flux will be able to read your&lt;br&gt;
repository with exactly the access it will have.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Build the environment
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;just bootstrap talos     &lt;span class="c"&gt;# the machine configs the nodes boot from&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;tofu &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; tofu apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That's the thirty-three resources from earlier. The&lt;br&gt;
environment creates its own VPC from the node range in &lt;code&gt;cluster.toml&lt;/code&gt;, and owns&lt;br&gt;
it.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Bring the cluster up
&lt;/h3&gt;

&lt;p&gt;By default the bootstrap reaches the Kubernetes API by name, so the names have to resolve&lt;br&gt;
first. I used a spare domain that I had and set DNS pointing at the&lt;br&gt;
private addresses the build hands out (&lt;code&gt;just hosts --print&lt;/code&gt; lists them). The&lt;br&gt;
repo can also write them into &lt;code&gt;/etc/hosts&lt;/code&gt; if you'd rather not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;just bootstrap cluster kubernetes.&amp;lt;your domain&amp;gt;
./scripts/render-secrets.sh &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git add &lt;span class="nt"&gt;-A&lt;/span&gt; kubernetes &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s1"&gt;'chore: my cluster'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git push
just bootstrap apps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From there Flux does the rest, in order. The first time every app deployed at&lt;br&gt;
once, the burst of image pulls slowed etcd down enough to destabilise the&lt;br&gt;
control plane, so the repo now declares what each app depends on and Flux works&lt;br&gt;
through them two at a time:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxw8zexlefz75fctwckdh.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxw8zexlefz75fctwckdh.gif" alt="Each wave waits for the one before it to be ready, which keeps the load on the platform steady." width="799" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Each wave waits for the one before it to be ready, which keeps the load on the platform steady.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Use it
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;just secrets&lt;/code&gt; prints every login in one place.&lt;/li&gt;
&lt;li&gt;Media goes onto the share from the desktop over SMB.&lt;/li&gt;
&lt;li&gt;Each device that browses the lab also has to trust the tenant CA once (or you click to accept the warnings).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  6. Tear it down in the right order
&lt;/h3&gt;

&lt;p&gt;Kubernetes creates EBS volumes and snapshots of its own, outside OpenTofu, and&lt;br&gt;
deletes them along with their claims, but only while the cluster is still&lt;br&gt;
running. So:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Delete the workloads and claims&lt;/li&gt;
&lt;li&gt;Wait for the volumes to go&lt;/li&gt;
&lt;li&gt;Empty the backup bucket&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tofu destroy&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do it the other way round and every volume the cluster made is left behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we got out of it
&lt;/h2&gt;

&lt;p&gt;It would be easy to end this with "and now the homelab is easier to run"... but it isn't, really. Ceph, the Postgres operator and the object store moved out of the cluster, but their replacements don't magically take care of themselves in Spinifex. It's a homelab, so from bare metal to app is still on you to maintain.&lt;/p&gt;

&lt;p&gt;Mulga ships the platform releases and the system images, so there are no packages to manage per instance, but it's still your infrastructure.&lt;/p&gt;

&lt;p&gt;The real payoff was the one this started with: a workload we didn't write, poking holes in all of our E2E test suites. The bugs it surfaced aren't unexpected or wild edge cases, but would have likely taken considerably more thought and building of test scenarios up front to discover in a test harness directly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a hardware fault on one host cascading into platform state on the others&lt;/li&gt;
&lt;li&gt;state that only goes wrong between components after an unclean restart&lt;/li&gt;
&lt;li&gt;load nobody writes a test for, like every chart deploying at once&lt;/li&gt;
&lt;li&gt;API calls that are each correct alone and contradict each other together&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of them is now tracked, and the ones that can be reproduced are&lt;br&gt;
becoming regression and fault-injection tests.&lt;/p&gt;

&lt;p&gt;The homelab repository picked up its share too, and will hopefully save the next person from hitting the same walls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;checks that fail early instead of late&lt;/li&gt;
&lt;li&gt;a bring-up order&lt;/li&gt;
&lt;li&gt;a teardown order&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The side effect is a different way to run a homelab.&lt;/p&gt;

&lt;p&gt;The cluster is disposable - storage, the database and backups live in the platform underneath it, and the&lt;br&gt;
whole thing is built with the same AWS tooling people already use, on hardware&lt;br&gt;
they own.&lt;/p&gt;

&lt;p&gt;It's not easier than the original. It's another option, and it's yours to &lt;a href="https://github.com/joshsiv-mulga/spinifex-home-ops" rel="noopener noreferrer"&gt;fork&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you want to learn more about the Mulga mission, check out our &lt;a href="https://mulgadc.com" rel="noopener noreferrer"&gt;website&lt;/a&gt;!&lt;/p&gt;

&lt;p&gt;What's next: a host with a GPU to try transcoding, deploying the same OpenTofu to&lt;br&gt;
a real AWS region to compare, and handing the lab's DNS to the platform instead&lt;br&gt;
of editing records after every rebuild.&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;I thought this was an AI-coined phrase, but turns out it was popularised at &lt;a href="https://www.splunk.com/en_us/blog/learn/dogfooding.html" rel="noopener noreferrer"&gt;Microsoft&lt;/a&gt;.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>spinifex</category>
      <category>kubernetes</category>
      <category>gitops</category>
      <category>talos</category>
    </item>
  </channel>
</rss>
