<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet href="pretty-atom-feed.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  
  <title>spinstate notes</title>
  <subtitle>Operational notes on delivery, infrastructure, and governance.</subtitle>
  <link href="https://spinstate.dev/feed/feed.xml" rel="self" />
  <link href="https://spinstate.dev/" />
  <updated>2026-07-11T00:00:00Z</updated>
  <id>https://spinstate.dev/</id>
  <author>
    <name>Rutger Clarke</name>
  </author>
  <entry>
    <title>The Same Pattern, Two Platforms: From Proxmox to Azure AVM</title>
    <link href="https://spinstate.dev/notes/proxmox-to-azure-avm/" />
    <updated>2026-07-11T00:00:00Z</updated>
    <id>https://spinstate.dev/notes/proxmox-to-azure-avm/</id>
    <summary>The Terraform-to-Ansible contract I designed on my homelab is the same contract I deploy at work with Azure Verified Modules. The pattern transfers; only the providers change.</summary>
    <content type="html">&lt;p&gt;&lt;a href=&quot;https://spinstate.dev/notes/roles-all-the-way-down/&quot;&gt;Roles All the Way Down&lt;/a&gt; showed how Terraform &lt;code&gt;tier&lt;/code&gt; and &lt;code&gt;role&lt;/code&gt; fields feed an Ansible inventory, which feeds k3s cluster formation. That post ended at the homelab: bare-metal Proxmox, three nodes, a cluster that assembles itself from a single playbook run.&lt;/p&gt;
&lt;p&gt;This post picks up the same architectural pattern and applies it to Azure using &lt;a href=&quot;https://aka.ms/avm&quot;&gt;Azure Verified Modules&lt;/a&gt; (AVM). The hook is simple: the interface between Terraform and Ansible I designed on my homelab is the same interface I deploy at work. The pattern transfers. Only the providers change.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-pattern-recapped&quot;&gt;The pattern, recapped&lt;/h2&gt;
&lt;p&gt;The homelab pattern has three moving parts:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Terraform declares infrastructure with structured fields.&lt;/strong&gt; Not just names — &lt;code&gt;tier&lt;/code&gt; and &lt;code&gt;role&lt;/code&gt; fields that carry intent.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ansible consumes a derived inventory.&lt;/strong&gt; The inventory is generated from Terraform outputs at apply time. It doesn&#39;t exist independently.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The contract between layers is explicit and machine-readable.&lt;/strong&gt; One source of truth for what machines exist and what they are. Ansible reads from it; it doesn&#39;t maintain a parallel copy.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That&#39;s the whole pattern. It&#39;s substrate-independent. It works on Proxmox because the contract is the thing that matters, not the hypervisor underneath.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;why-avm-changes-things&quot;&gt;Why AVM changes things&lt;/h2&gt;
&lt;p&gt;Most Azure infrastructure-as-code content still talks about CARML (the now-deprecated module library) or raw ARM/Bicep templates written from scratch. AVM replaced both. It splits into two tiers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Resource Modules&lt;/strong&gt; (&lt;code&gt;avm-res-*&lt;/code&gt;): one module per Azure resource type — virtual machines, virtual networks, storage accounts. Each ships with &lt;a href=&quot;https://learn.microsoft.com/azure/well-architected/&quot;&gt;Well-Architected Framework&lt;/a&gt; defaults baked in: availability zones, Entra ID authentication, RBAC, private endpoints where applicable.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pattern Modules&lt;/strong&gt; (&lt;code&gt;avm-ptn-*&lt;/code&gt;): opinionated compositions that solve a complete problem — landing zones, hub-and-spoke networking, policy-driven governance.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The shift is this: you don&#39;t write the module. You compose it. Microsoft maintains the module, keeps it aligned with WAF guidance, and you consume it. This is the Azure equivalent of writing a Terraform module for Proxmox VMs — except someone else maintains the module and it already encodes hundreds of hours of hard-won Azure opinion.&lt;/p&gt;
&lt;p&gt;On the homelab, I wrote my own VM module because there was no one to write it for me. On Azure, AVM gives me that module for free, and it&#39;s better than what I&#39;d write alone.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-bridge-terraform-avm-to-ansible&quot;&gt;The bridge: Terraform AVM to Ansible&lt;/h2&gt;
&lt;p&gt;This is the interesting technical bit.&lt;/p&gt;
&lt;p&gt;The AVM resource module for virtual machines (&lt;a href=&quot;https://github.com/Azure/terraform-azurerm-avm-res-compute-virtualmachine&quot;&gt;&lt;code&gt;avm-res-compute-virtualmachine&lt;/code&gt;&lt;/a&gt;) provisions a VM with WAF defaults. What it does not do is configure the OS. It makes the machine exist. Making the machine &lt;em&gt;what it&#39;s supposed to be&lt;/em&gt; is still your problem.&lt;/p&gt;
&lt;p&gt;On the homelab, Ansible handles all of it because Proxmox doesn&#39;t have cloud-init in the way Azure does. On Azure, you split the work between cloud-init and Ansible — but the Terraform-to-Ansible bridge is identical.&lt;/p&gt;
&lt;p&gt;The pattern: declare VMs with structured fields in Terraform, render those fields into an Ansible inventory at apply time.&lt;/p&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;module&lt;span class=&quot;token type variable&quot;&gt; &quot;vm&quot; &lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;source&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Azure/avm-res-compute-virtualmachine/azurerm&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;version&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;~&gt; 0.18&quot;&lt;/span&gt;

  &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;                &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;vm-app-01&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;resource_group_name&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; module.rg.name
  &lt;span class=&quot;token property&quot;&gt;location&lt;/span&gt;            &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; module.rg.location
  &lt;span class=&quot;token property&quot;&gt;zone&lt;/span&gt;                &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;

  &lt;span class=&quot;token property&quot;&gt;os_type&lt;/span&gt;             &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;linux&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;sku_name&lt;/span&gt;            &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Standard_D2s_v5&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;image_reference&lt;/span&gt;    &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; local.ubuntu_image

  &lt;span class=&quot;token property&quot;&gt;admin_username&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; local.admin_user
  &lt;span class=&quot;token property&quot;&gt;ssh_public_key&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; local.ssh_pubkey

  &lt;span class=&quot;token property&quot;&gt;tags&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;tier&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;app&quot;&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;role&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;webserver&quot;&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;tier&lt;/code&gt; and &lt;code&gt;role&lt;/code&gt; tags are the same fields from the homelab. They&#39;re not Azure tags for billing or policy — they&#39;re the vocabulary the next layer consumes. The VM gets provisioned with WAF defaults (zone-pinned, Entra ID auth, RBAC). Then Terraform renders the inventory:&lt;/p&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;resource &lt;span class=&quot;token type variable&quot;&gt;&quot;local_file&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;ansible_inventory&quot;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;filename&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token type variable&quot;&gt;module&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;/../ansible/inventory.yml&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;content&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; templatefile(&lt;span class=&quot;token string&quot;&gt;&quot;inventory.yml.tftpl&quot;&lt;/span&gt;, &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token property&quot;&gt;hosts&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;
      for vm in module.vm : &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;        &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; vm.name
        &lt;span class=&quot;token property&quot;&gt;ip_address&lt;/span&gt;  &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; vm.private_ip_address
        &lt;span class=&quot;token property&quot;&gt;tier&lt;/span&gt;        &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; vm.tags.tier
        &lt;span class=&quot;token property&quot;&gt;role&lt;/span&gt;        &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; vm.tags.role
      &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;)
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same template, same derived inventory, same single source of truth. The provider changed from &lt;code&gt;bpg/proxmox&lt;/code&gt; to &lt;code&gt;hashicorp/azurerm&lt;/code&gt; and the module changed from my hand-written VM module to AVM. The contract between layers didn&#39;t change at all.&lt;/p&gt;
&lt;p&gt;Azure offers a dynamic inventory plugin (&lt;code&gt;azure_rm&lt;/code&gt;) that queries the Azure API at playbook runtime instead of reading a static file. It&#39;s the right tool when your fleet is large and churns frequently. But for a platform where you control the Terraform and the Ansible in the same pipeline, derived inventory from Terraform state gives you one source of truth and a reproducible build. The homelab taught me that. It transfers.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;cloud-init-vs-ansible-when-each-wins&quot;&gt;cloud-init vs Ansible: when each wins&lt;/h2&gt;
&lt;p&gt;This is where Azure genuinely differs from the homelab, and the split is worth being explicit about.&lt;/p&gt;
&lt;p&gt;cloud-init handles first-boot basics:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Package installation&lt;/li&gt;
&lt;li&gt;User creation and SSH key injection&lt;/li&gt;
&lt;li&gt;Hostname and network config&lt;/li&gt;
&lt;li&gt;Any one-shot bootstrap that doesn&#39;t need to run again&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Ansible handles everything after that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Domain join (where applicable)&lt;/li&gt;
&lt;li&gt;Monitoring agent configuration&lt;/li&gt;
&lt;li&gt;Application stack install and config&lt;/li&gt;
&lt;li&gt;Anything that might need to re-run or drift-correct&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On the homelab, Ansible does both because Proxmox doesn&#39;t have cloud-init in the same way. On Azure, cloud-init takes the first-boot work, and Ansible picks up once the machine is up and addressable. The split keeps cloud-init small (first-boot should be fast and idempotent) and keeps Ansible authoritative for ongoing configuration state.&lt;/p&gt;
&lt;p&gt;The mistake is treating them as competitors. They&#39;re not. cloud-init is the chassis; Ansible is the role. Same conceptual split as Terraform (chassis) and Ansible (role) on the homelab — just with cloud-init inserted as a first-boot shim.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-landing-zone-as-platform&quot;&gt;The landing zone as platform&lt;/h2&gt;
&lt;p&gt;The AVM pattern module for landing zones (&lt;a href=&quot;https://github.com/Azure/terraform-azurerm-avm-ptn-alz&quot;&gt;&lt;code&gt;avm-ptn-alz&lt;/code&gt;&lt;/a&gt;) deploys the governance layer: management group hierarchy, policy definitions and assignments, RBAC role assignments, and the connectivity foundation. This is the Azure Landing Zone (ALZ) as code.&lt;/p&gt;
&lt;p&gt;This is the governance layer. On the homelab, my governance layer is VLANs and deny-by-default inter-VLAN routing — the network defines what can talk to what, and everything else is least-privilege on top. On Azure, Azure Policy definitions are the equivalent: they define what &lt;em&gt;can&#39;t&lt;/em&gt; exist, not just what can.&lt;/p&gt;
&lt;p&gt;Policy assignments are the Azure equivalent of firewall rules. A policy that denies public IP on storage accounts is the same shape as a firewall rule that denies a VLAN from reaching the internet. Both define the boundary of the allowed. Both are enforced before the workload runs, not after.&lt;/p&gt;
&lt;p&gt;The landing zone is the platform the product teams build on. On the homelab, that platform is the VLAN and routing fabric plus the base Ansible role every machine gets. On Azure, it&#39;s the management group hierarchy, policy assignments, and RBAC that every subscription inherits. Same concept, different substrate.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-i-d-do-differently&quot;&gt;What I&#39;d do differently&lt;/h2&gt;
&lt;p&gt;Some things translate cleanly. Some don&#39;t.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Transfers cleanly:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The Terraform-to-Ansible inventory bridge. Identical pattern, different provider. This was the biggest surprise — I expected to redesign the contract for Azure and didn&#39;t.&lt;/li&gt;
&lt;li&gt;Role and tier as structured tags feeding downstream consumers. AVM modules support tags natively, so the vocabulary travels.&lt;/li&gt;
&lt;li&gt;The chassis/role split. Terraform makes the machine exist; Ansible makes it what it&#39;s supposed to be. cloud-init slots in as a first-boot shim. The three-layer model holds.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Doesn&#39;t transfer:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Proxmox&#39;s &lt;code&gt;vm_id&lt;/code&gt; as a stable identifier. Azure resources have lifecycle-independent resource IDs, so the integer IDs I used on the homelab are unnecessary. Let the provider manage identity.&lt;/li&gt;
&lt;li&gt;Linked clones from a template. Azure uses marketplace images or custom images in a compute gallery. The template-clone pattern doesn&#39;t exist; image references replace it.&lt;/li&gt;
&lt;li&gt;Manual IP assignment in Terraform locals. Azure allocates private IPs dynamically by default and lets you override. On the homelab I had to manage the whole IP range in code. On Azure, let the platform allocate and export what it allocated into the inventory.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Where AVM&#39;s opinions saved me from mistakes I&#39;d already made:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Availability zones. On the homelab I have one node, so HA isn&#39;t an option. AVM VM modules default to zone-pinned deployments, which I would have had to design from scratch with raw Terraform.&lt;/li&gt;
&lt;li&gt;Entra ID-based RBAC instead of SSH key management. AVM VM modules default to Entra ID SSH login, which is more defensible than the static-key approach on my homelab.&lt;/li&gt;
&lt;li&gt;Private endpoints for PaaS. AVM resource modules default to private endpoints where the resource supports them. I had to build that discipline manually on the homelab; AVM hands it to you.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The pattern I designed in the basement is the pattern I deploy at work. That&#39;s not a coincidence. Platform engineering isn&#39;t about the substrate — it&#39;s about the contracts between layers. Get those right on bare metal and they hold up in the cloud.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-takeaway&quot;&gt;The takeaway&lt;/h2&gt;
&lt;p&gt;The homelab and the Azure platform run on different providers, different modules, and different substrates. The thing that&#39;s the same is the contract: Terraform declares intent with structured fields, Ansible consumes a derived inventory, and the layers compose because their interface is explicit.&lt;/p&gt;
&lt;p&gt;AVM didn&#39;t change that. It made the Terraform layer cheaper to maintain by handing me well-architected modules I&#39;d otherwise write myself. The bridge to Ansible is the same bridge.&lt;/p&gt;
&lt;p&gt;If you&#39;re moving from self-hosted IaC to cloud IaC and wondering whether your patterns survive the move: the ones worth keeping will. The ones that don&#39;t were probably substrate-specific all along, and AVM will tell you which is which by handing you a better default than the one you wrote.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Where have you seen a homelab pattern survive the move to a cloud platform? Let me know in the comments.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Cost as a Design Constraint: FinOps on a £0 Budget</title>
    <link href="https://spinstate.dev/notes/finops-zero-budget-platform/" />
    <updated>2026-07-10T00:00:00Z</updated>
    <id>https://spinstate.dev/notes/finops-zero-budget-platform/</id>
    <summary>How a £0 infrastructure budget forced architectural decisions that map directly to cloud cost governance, and what FinOps looks like when you can&#39;t just throw money at the problem.</summary>
    <content type="html">&lt;p&gt;When people hear &amp;quot;£0 budget,&amp;quot; they usually think &amp;quot;hobby project.&amp;quot; But the &lt;a href=&quot;https://cyclekirklees.org.uk&quot;&gt;Cycle Kirklees&lt;/a&gt; platform runs production workloads for real users on a self-hosted stack that costs nothing per month to operate. That constraint wasn&#39;t a limitation. It was the best design pressure I&#39;ve ever worked under.&lt;/p&gt;
&lt;h2 id=&quot;the-constraint-is-the-architecture&quot;&gt;The constraint is the architecture&lt;/h2&gt;
&lt;p&gt;Most FinOps conversations start with a cloud bill and work backwards: &amp;quot;How do we reduce this?&amp;quot; The £0 budget inverts the question. You start with nothing and ask: &amp;quot;What can we build that costs nothing to run?&amp;quot;&lt;/p&gt;
&lt;p&gt;That sounds restrictive, but it forces clarity about what actually matters. When you can&#39;t spin up a managed service to solve a problem, you have to understand the problem. When you can&#39;t pay for a monitoring tier, you have to know exactly what you need to monitor and why. The constraint strips away the luxury of over-provisioning.&lt;/p&gt;
&lt;h2 id=&quot;where-the-0-maps-to-cloud-cost-patterns&quot;&gt;Where the £0 maps to cloud cost patterns&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What I did&lt;/th&gt;
&lt;th&gt;Cloud equivalent&lt;/th&gt;
&lt;th&gt;FinOps parallel&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;k3s on bare metal instead of AKS&lt;/td&gt;
&lt;td&gt;Azure Kubernetes Service&lt;/td&gt;
&lt;td&gt;Compute right-sizing: using hardware you already own instead of paying per-node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitLab CE self-hosted instead of GitHub Actions&lt;/td&gt;
&lt;td&gt;Managed CI/CD with per-minute billing&lt;/td&gt;
&lt;td&gt;CI minute optimisation: no metering anxiety, no pipeline rationing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prometheus/Grafana/Loki self-hosted instead of Azure Monitor&lt;/td&gt;
&lt;td&gt;Managed observability with ingestion-based pricing&lt;/td&gt;
&lt;td&gt;Observability cost control: you own the retention window, not a pricing tier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Traefik + Cloudflare free tier instead of Azure Front Door&lt;/td&gt;
&lt;td&gt;Application Gateway / Front Door&lt;/td&gt;
&lt;td&gt;Edge routing without per-rule billing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted Umami instead of Google Analytics&lt;/td&gt;
&lt;td&gt;Managed analytics with per-hit pricing&lt;/td&gt;
&lt;td&gt;First-party analytics with no per-event cost&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The pattern is the same in every row: &lt;strong&gt;the cost governance decision was made at architecture time, not at invoice time.&lt;/strong&gt; That&#39;s what good FinOps looks like: not chasing savings after the bill arrives, but designing the system so the bill never arrives.&lt;/p&gt;
&lt;p&gt;We also used Azure to back up critical membership data for fractions of a penny per day. We&#39;d be surprised if we get billed before 2028—unless the platform grows beyond my wildest imagination.&lt;/p&gt;
&lt;p&gt;The honest accounting is that £0 isn&#39;t free. I pay in time: mine, on evenings and weekends. The Proxmox host consumes electricity. The DNS records cost nothing because Cloudflare&#39;s free tier covers them, but that&#39;s a deliberate choice, not a gift.&lt;/p&gt;
&lt;p&gt;The FinOps framing matters here because platform engineering teams face the same trade-off at scale. &amp;quot;Should we run this ourselves or pay someone to run it?&amp;quot; is the fundamental platform cost question. My £0 budget forced me to answer it honestly for every single component, and the answer was never &amp;quot;self-host because it&#39;s cheaper.&amp;quot; It was &amp;quot;self-host because the cost of the managed alternative doesn&#39;t justify the cognitive load it removes, given the volume we&#39;re running.&amp;quot;&lt;/p&gt;
&lt;p&gt;That&#39;s the same calculation a platform team makes when deciding whether to offer a managed database as a golden path or let teams self-serve. The difference is that most teams have enough budget to avoid the question. I didn&#39;t, so I had to answer it.&lt;/p&gt;
&lt;h2 id=&quot;what-i-d-do-differently-with-budget&quot;&gt;What I&#39;d do differently with budget&lt;/h2&gt;
&lt;p&gt;If I had a cloud budget for this platform, here&#39;s where I&#39;d spend it:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;AKS for the Kubernetes workloads.&lt;/strong&gt; The operational overhead of patching k3s nodes and managing etcd backups is real. AKS removes that. The cost is justified at any scale where downtime costs more than the node fee.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Azure Monitor instead of self-hosted Prometheus.&lt;/strong&gt; The self-hosted stack works, but Azure Monitor&#39;s correlation across services and infrastructure is genuinely better for multi-workload platforms. The ingestion pricing stings at high volume, but the operational simplicity wins.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Managed CDN with WAF.&lt;/strong&gt; Cloudflare&#39;s free tier is excellent, but the WAF rules and bot management in the Pro tier would have saved me hours of hand-tuning rate limits and access controls.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The point isn&#39;t that self-hosting is always right. It&#39;s that the £0 constraint taught me exactly where the break-even points are, and that&#39;s the most useful thing a platform engineer can know when advising on cloud spend.&lt;/p&gt;
&lt;h2 id=&quot;the-takeaway-for-platform-teams&quot;&gt;The takeaway for platform teams&lt;/h2&gt;
&lt;p&gt;FinOps isn&#39;t about cutting costs. It&#39;s about making cost-visible decisions at architecture time. When you build with a £0 budget, every decision is cost-visible by definition: there is no hidden spend to discover later. That discipline transfers directly to cloud platform work: if you can design a production platform that costs nothing, you can design one that costs the right amount.&lt;/p&gt;
&lt;p&gt;The constraint was the best teacher. Not because it forced me to be cheap, but because it forced me to be deliberate.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Where&#39;s the break-even point you&#39;ve found between self-hosting and managed services? Share it in the comments.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Service Sovereignty in an Age of Withheld Intelligence</title>
    <link href="https://spinstate.dev/notes/service-sovereignty/" />
    <updated>2026-06-29T00:00:00Z</updated>
    <id>https://spinstate.dev/notes/service-sovereignty/</id>
    <summary>The decision to withhold advanced AI models from general access isn&#39;t a sudden shift. It is the continuation of a tension that has been building for years across the technology landscape.</summary>
    <content type="html">&lt;p&gt;The decisions to withhold two frontier models from general consumer access have brought a long-simmering issue into sharper focus: service sovereignty. For many, this will feel like a sudden shift. It is not. It is the continuation of a tension that has been building for years across the technology landscape, one that anyone who has worked inside an enterprise IT function will recognise immediately.&lt;/p&gt;
&lt;p&gt;Modern software consumption has trended relentlessly toward convenience. Cloud platforms, managed services, and API-driven ecosystems promised speed, scale, and reduced operational overhead. The pitch was compelling, and organisations bought into it fast, often faster than their thinking could keep up. The true costs of that decision, loss of control, vendor lock-in, price-hikes and exposure to the strategic choices of third parties, were deferred, underestimated, or simply brushed aside in the rush to modernise.&lt;/p&gt;
&lt;p&gt;This is the enduring challenge of technology adoption: innovation and systems thinking are not natural allies. Moving quickly delivers competitive advantage; but the systems underpinning critical capabilities require deliberate design, clear ownership, and a clear-eyed view of failure modes. When those systems are externalised, so too is a portion of your agency.&lt;/p&gt;
&lt;p&gt;In enterprise IT, this tension predates the cloud. It showed up in every outsourcing contract, every SaaS migration, every decision to trade operational control for someone else&#39;s SLA. The questions were always there: what happens when a critical service changes its terms, degrades its performance, or simply disappears? For a long time, those questions stayed theoretical. Increasingly, they are becoming operational realities, especially in a fast moving domain like genAI.&lt;/p&gt;
&lt;p&gt;What is unique now is the expanding influence of geopolitical and state-level forces. Access to advanced technology is no longer governed by market dynamics alone. National strategies, export controls, and policy decisions are actively shaping who can use what, and under what conditions. The whims of states, once considered ideological allies, are now a material factor in business continuity planning, and very few organisations have modelled that risk or understand how to react.&lt;/p&gt;
&lt;p&gt;This is where service sovereignty matters. At its core, it is the ability to maintain meaningful control over the tools and systems you depend on. It does not necessarily mean rejecting cloud services or external providers. It means understanding and mitigating the risks of dependency, knowing which capabilities are load-bearing, which can be replicated, and which represent single points of failure you cannot tolerate.&lt;/p&gt;
&lt;p&gt;Self-hosting and self-provisioning with open source software are not new ideas. They predate the cloud by decades. What has changed is the urgency of treating them as deliberate strategic choices rather than a niche preference for those who distrust convenience. The tooling has matured. The models exist. The barrier is not technical, it is organisational will and the discipline to think in systems rather than just solutions.&lt;/p&gt;
&lt;p&gt;The obvious objection is that most organisations are not in a position to run their own AI infrastructure. The compute requirements are significant, the expertise is scarce, and the operational overhead is not trivial. That objection is fair, but it is becoming less decisive by the month. Open-weights models are closing the gap with proprietary frontier systems faster than most anticipated. The capability threshold at which a self-hosted model becomes genuinely useful for enterprise workloads has already been crossed for a wide range of applications. The question is shifting from whether open-weights models are good enough to whether your organisation has the architectural foundations to take advantage of them when the time comes.&lt;/p&gt;
&lt;p&gt;Sovereignty comes with real costs. Running your own infrastructure introduces complexity, demands expertise, and requires ongoing investment. None of that is insurmountable. With genuine systems thinking, deliberate architecture, and a willingness to sit with trade-offs rather than optimise them away, organisations can build hybrid approaches that preserve meaningful control without abandoning the benefits of external services.&lt;/p&gt;
&lt;p&gt;The key is intentionality. Innovation without systems planning creates hidden fragility that only reveals itself at the worst moment. Every dependency is a decision about trust, resilience, and autonomy, whether you made it consciously or not.&lt;/p&gt;
&lt;p&gt;The question is no longer whether to think about this. It is which capabilities are too critical to leave in someone else&#39;s hands, and whether you have the architecture and the will to do anything about it.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Which capability in your stack is too critical to leave in someone else&#39;s hands? I&#39;d like to hear your view in the comments.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Observing What You&#39;ve Built: Secrets, Alerting, and Blast Radius</title>
    <link href="https://spinstate.dev/notes/observability-and-secrets/" />
    <updated>2026-06-18T00:00:00Z</updated>
    <id>https://spinstate.dev/notes/observability-and-secrets/</id>
    <summary>Scoped CI/CD secrets, Prometheus and Grafana on k3s, and four alert rules routed by audience and urgency: how a self-hosted platform limits blast radius without an SRE team to page.</summary>
    <content type="html">&lt;p&gt;Apologies for the radio silence since the last post. I started a new job recently, which has eaten into the time I&#39;d normally spend on this kind of writing, and the rest of my spare hours have gone into &lt;a href=&quot;https://cyclekirklees.org.uk&quot;&gt;Cycle Kirklees&lt;/a&gt;, the cycling campaign group I volunteer for. More on that platform soon, for now back to the self-hosted platform.&lt;/p&gt;
&lt;p&gt;When you&#39;ve built infrastructure on your own hardware, in your own home office, the stakes feel different. There&#39;s no &amp;quot;we&#39;ll fix it Monday&amp;quot;: the pipes are yours to maintain. And there&#39;s no SRE team to page when something breaks at 2am.&lt;/p&gt;
&lt;p&gt;This is part 4 in a platform engineering series. The previous posts covered &lt;a href=&quot;https://spinstate.dev/notes/selfhosting-gitlab/&quot;&gt;CI/CD infrastructure&lt;/a&gt;, &lt;a href=&quot;https://spinstate.dev/notes/terraforming-proxmox-platform/&quot;&gt;declarative VM provisioning&lt;/a&gt;, and &lt;a href=&quot;https://spinstate.dev/notes/roles-all-the-way-down/&quot;&gt;Kubernetes cluster automation&lt;/a&gt;. But once you have a working cluster and pipelines that deploy code, you hit two problems that feel like afterthoughts but absolutely aren&#39;t:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;How do you keep secrets secret?&lt;/strong&gt; (Even from yourself, when you&#39;re tired and just want to ship something.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How do you know what&#39;s happening&lt;/strong&gt; when your infrastructure is running 26 services across multiple VMs?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;And underneath both: &lt;strong&gt;how do you expose capabilities to other people without exposing the entire system?&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-secret-you-meant-to-revoke&quot;&gt;The Secret You Meant to Revoke&lt;/h2&gt;
&lt;p&gt;Here&#39;s the thing about hardcoding a secret in your pipeline, with a mental note to &amp;quot;revoke it later&amp;quot;: you won&#39;t remember. You&#39;ll &lt;em&gt;intend&lt;/em&gt; to. You&#39;ll think &amp;quot;I&#39;ll just test this locally first, then rotate the credential before I commit.&amp;quot; Then you push at 11pm because the thing works and you&#39;re tired. Six months later, that secret is still live.&lt;/p&gt;
&lt;p&gt;This only matters if anyone ever sees your repository. My GitLab instance isn&#39;t on the public internet, so the blast radius is limited to: anyone who compromises my self-hosted platform, anyone on my LAN with physical access, anyone who gains shell access to a Proxmox VM. All real threats, none of them zero-probability.&lt;/p&gt;
&lt;p&gt;But the &lt;em&gt;lesson&lt;/em&gt; isn&#39;t &amp;quot;you&#39;re safe, so never mind.&amp;quot; The lesson is: &lt;strong&gt;don&#39;t rely on future-you to remember operational discipline&lt;/strong&gt;. Structure the system so that the easy path and the safe path are the same path.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;gitlab-ci-cd-variables-the-parallel-to-azure-key-vault&quot;&gt;GitLab CI/CD Variables: The Parallel to Azure Key Vault&lt;/h2&gt;
&lt;p&gt;In my day job, we use Azure Key Vault to manage secrets across infrastructure and applications. The pattern is straightforward:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Secrets live in a centralised, encrypted store.&lt;/li&gt;
&lt;li&gt;Access is gated by identity and RBAC.&lt;/li&gt;
&lt;li&gt;Audit trails log every read and write.&lt;/li&gt;
&lt;li&gt;Applications request secrets at runtime; they&#39;re never committed to code.&lt;/li&gt;
&lt;li&gt;Secret rotation happens in one place; all consumers pick up the new value immediately.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;GitLab&#39;s CI/CD variable system is the self-hosted equivalent. Not as featureful as Key Vault (no audit trail, no separate identity layer), but it solves the core problem: &lt;strong&gt;keep credentials out of your repository.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Here&#39;s how it works in practice.&lt;/p&gt;
&lt;h3 id=&quot;variable-scoping-environment-project-and-group&quot;&gt;Variable Scoping: Environment, Project, and Group&lt;/h3&gt;
&lt;p&gt;GitLab variables can be scoped at three levels:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Project-level variables&lt;/strong&gt; apply to all CI/CD jobs in a project. Useful for values that don&#39;t change by environment.&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;# Available in all jobs in this project&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;REGISTRY_USERNAME&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; my_docker_registry_user&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Environment-level variables&lt;/strong&gt; apply only to jobs that explicitly deploy to that environment. This is where the separation of concerns lives.&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;# Only available when deploying to staging&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;STAGING_DATABASE_URL&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; postgres&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;//staging&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;db&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;5432/app

&lt;span class=&quot;token comment&quot;&gt;# Only available when deploying to production&lt;/span&gt;
&lt;span class=&quot;token key atrule&quot;&gt;PRODUCTION_DATABASE_URL&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; postgres&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;//prod&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;db&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;5432/app&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Group-level variables&lt;/strong&gt; (if you have multiple projects) are inherited by all projects in the group. Useful for shared credentials like container registry tokens.&lt;/p&gt;
&lt;p&gt;In my Cycle Kirklees setup:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;project&lt;/strong&gt; has a shared CI/CD token (read-only) for pulling from my internal registry.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;staging environment&lt;/strong&gt; has credentials for pushing to the staging Docker registry and deploying to the k3s staging namespace.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;production environment&lt;/strong&gt; has separate credentials for the production registry and k3s namespace, and it&#39;s locked behind a manual gate: no auto-deploy to prod.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;stages&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; build
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; test
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; deploy_staging
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; deploy_production

&lt;span class=&quot;token key atrule&quot;&gt;build&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;stage&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; build
  &lt;span class=&quot;token key atrule&quot;&gt;script&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; docker build &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;t app&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;$CI_COMMIT_SHA .
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; docker push $REGISTRY/$DOCKER_NAMESPACE/app&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;$CI_COMMIT_SHA

&lt;span class=&quot;token key atrule&quot;&gt;deploy_to_staging&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;stage&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; deploy_staging
  &lt;span class=&quot;token key atrule&quot;&gt;environment&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; staging
    &lt;span class=&quot;token key atrule&quot;&gt;kubernetes&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;token key atrule&quot;&gt;namespace&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; cyclekirklees&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;staging
  &lt;span class=&quot;token key atrule&quot;&gt;script&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; helm upgrade &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;install cyclekirklees ./helm
      &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;namespace cyclekirklees&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;staging
      &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;set image.tag=$CI_COMMIT_SHA
  &lt;span class=&quot;token key atrule&quot;&gt;only&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; main

&lt;span class=&quot;token key atrule&quot;&gt;deploy_to_production&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;stage&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; deploy_production
  &lt;span class=&quot;token key atrule&quot;&gt;environment&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; production
    &lt;span class=&quot;token key atrule&quot;&gt;kubernetes&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;token key atrule&quot;&gt;namespace&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; cyclekirklees&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;prod
  &lt;span class=&quot;token key atrule&quot;&gt;script&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; helm upgrade &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;install cyclekirklees ./helm
      &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;namespace cyclekirklees&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;prod
      &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;set image.tag=$CI_COMMIT_SHA
  &lt;span class=&quot;token key atrule&quot;&gt;when&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; manual  &lt;span class=&quot;token comment&quot;&gt;# Explicit human gate&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;only&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; main&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When the &lt;code&gt;deploy_to_staging&lt;/code&gt; job runs, GitLab injects the &lt;strong&gt;staging environment variables&lt;/strong&gt;. When someone manually triggers &lt;code&gt;deploy_to_production&lt;/code&gt;, the &lt;strong&gt;production variables&lt;/strong&gt; are injected instead. The same pipeline, different secrets.&lt;/p&gt;
&lt;h3 id=&quot;why-this-matters-blast-radius&quot;&gt;Why This Matters: Blast Radius&lt;/h3&gt;
&lt;p&gt;The philosophy here is simple: &lt;strong&gt;if something goes wrong, limit the damage.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If a staging credential leaks (or is accidentally logged), only staging is affected. The attacker, or the tired sysadmin, can&#39;t accidentally deploy to production. That requires a separate credential, manually gated.&lt;/p&gt;
&lt;p&gt;In my setup:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Staging auto-deploys from main.&lt;/strong&gt; If I push a commit, the staging environment is updated automatically. This is low-risk; staging can be broken.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Production requires manual approval.&lt;/strong&gt; Even if CI passes, even if staging works, production won&#39;t update until I explicitly click &amp;quot;Deploy&amp;quot; in the GitLab UI.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Production credentials are environment-scoped.&lt;/strong&gt; They&#39;re never used in the staging pipeline.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is &amp;quot;least privilege&amp;quot; in practice: each role gets the minimum credentials it needs, and no more.&lt;/p&gt;
&lt;h3 id=&quot;protecting-the-protected-branch&quot;&gt;Protecting the Protected Branch&lt;/h3&gt;
&lt;p&gt;GitLab also lets you lock down which branches people can merge into, and require sign-off before changes go live. Here&#39;s how that works for Cycle Kirklees.&lt;/p&gt;
&lt;p&gt;The main branch is what actually gets deployed to production, so it&#39;s protected: nobody can merge into it without approval from a Maintainer. Only committee members (and me) have that level of access.&lt;/p&gt;
&lt;p&gt;Committee members add and edit website content through Sveltia, a CMS that isn&#39;t exposed to the open internet. Sveltia needs to push changes directly to a branch to save content, so I gave it its own branch, called content, that isn&#39;t locked down the same way main is.&lt;/p&gt;
&lt;p&gt;That sounds like a hole: why leave a branch unprotected? Because changes on the content branch still have to go through an approval before they reach main. So a committee member can publish content updates any time, without waiting on me, but they can&#39;t merge straight to production. If something goes wrong, the damage is limited to draft content sitting on a branch, not a live deploy.&lt;/p&gt;
&lt;p&gt;Everything is a trade-off. The point is: &lt;strong&gt;the trade-off is visible and justified.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;observability-seeing-what-you-cant-see&quot;&gt;Observability: Seeing What You Can&#39;t See&lt;/h2&gt;
&lt;p&gt;You have a cluster. Workloads are running. But:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Is memory pressure building on a worker node?&lt;/li&gt;
&lt;li&gt;Which service is causing the most load?&lt;/li&gt;
&lt;li&gt;When did that pod start crashing, and why?&lt;/li&gt;
&lt;li&gt;How much network bandwidth is the database pod consuming?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Without observability, these questions have one answer: SSH into a node, run commands, hope you ask the right questions. With observability, you have a single pane of glass.&lt;/p&gt;
&lt;p&gt;I use Prometheus and Grafana. Prometheus scrapes metrics from k3s nodes and workloads. Grafana visualises them and provides alerting.&lt;/p&gt;
&lt;h3 id=&quot;prometheus-scraping-the-cluster&quot;&gt;Prometheus: Scraping the Cluster&lt;/h3&gt;
&lt;p&gt;Kubernetes exposes metrics on every node and pod via the &lt;code&gt;/metrics&lt;/code&gt; endpoint. Prometheus polls those endpoints, stores time-series data, and makes it queryable.&lt;/p&gt;
&lt;p&gt;The k3s setup includes the Prometheus Operator, which watches for &lt;code&gt;ServiceMonitor&lt;/code&gt; and &lt;code&gt;PrometheusRule&lt;/code&gt; resources and automatically wires them into the Prometheus configuration. No static config files; infrastructure as code.&lt;/p&gt;
&lt;p&gt;A simple ServiceMonitor to scrape the Cycle Kirklees application:&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;apiVersion&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; monitoring.coreos.com/v1
&lt;span class=&quot;token key atrule&quot;&gt;kind&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; ServiceMonitor
&lt;span class=&quot;token key atrule&quot;&gt;metadata&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; cyclekirklees&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;app
  &lt;span class=&quot;token key atrule&quot;&gt;namespace&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; cyclekirklees&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;prod
&lt;span class=&quot;token key atrule&quot;&gt;spec&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;selector&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;matchLabels&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;token key atrule&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; cyclekirklees
  &lt;span class=&quot;token key atrule&quot;&gt;endpoints&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;port&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; metrics
      &lt;span class=&quot;token key atrule&quot;&gt;interval&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; 30s&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This tells Prometheus: &amp;quot;Every 30 seconds, scrape the metrics port on any service labelled &lt;code&gt;app: cyclekirklees&lt;/code&gt;.&amp;quot;&lt;/p&gt;
&lt;p&gt;The Cycle Kirklees application (built with Node.js and Express) exports metrics via prom-client:&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-javascript&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; prometheus &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;require&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&#39;prom-client&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; httpRequestDuration &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;prometheus&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;Histogram&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&#39;http_request_duration_seconds&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;help&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&#39;Duration of HTTP requests in seconds&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;labelNames&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&#39;method&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&#39;route&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&#39;status_code&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token literal-property property&quot;&gt;buckets&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0.1&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0.5&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

app&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;use&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token parameter&quot;&gt;req&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; res&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; next&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&gt;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; start &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; Date&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;now&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  res&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&#39;finish&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&gt;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; duration &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;Date&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;now&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;-&lt;/span&gt; start&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1000&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
    httpRequestDuration
      &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;labels&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;req&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;method&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; req&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;route&lt;span class=&quot;token operator&quot;&gt;?.&lt;/span&gt;path &lt;span class=&quot;token operator&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&#39;unknown&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; res&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;statusCode&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
      &lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;observe&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;duration&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token function&quot;&gt;next&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;

app&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&#39;/metrics&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token parameter&quot;&gt;req&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; res&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&gt;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  res&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;set&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&#39;Content-Type&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; prometheus&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;register&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;contentType&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  res&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;end&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;prometheus&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;register&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;metrics&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now Prometheus knows: request latency, error rates, how many requests per route. That&#39;s data.&lt;/p&gt;
&lt;h3 id=&quot;grafana-seeing-the-data&quot;&gt;Grafana: Seeing the Data&lt;/h3&gt;
&lt;p&gt;Grafana queries Prometheus and renders dashboards. A simple dashboard for Cycle Kirklees might show:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Request latency (p50, p95, p99)&lt;/li&gt;
&lt;li&gt;Error rate (5xx responses per minute)&lt;/li&gt;
&lt;li&gt;Pod restart count&lt;/li&gt;
&lt;li&gt;Memory and CPU usage&lt;/li&gt;
&lt;li&gt;Network I/O&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When you look at a latency spike, you can cross-reference it with an error rate spike. If they align, you know the service is struggling under load. If error rate spikes &lt;em&gt;without&lt;/em&gt; latency, you know the issue is application logic, not resources.&lt;/p&gt;
&lt;p&gt;Example Grafana dashboard JSON (snippet):&lt;/p&gt;
&lt;pre class=&quot;language-json&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;&quot;panels&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;&quot;title&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Request Latency (p95)&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;&quot;targets&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;
        &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
          &lt;span class=&quot;token property&quot;&gt;&quot;expr&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))&quot;&lt;/span&gt;
        &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
      &lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;&quot;title&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Error Rate&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;&quot;targets&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;
        &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
          &lt;span class=&quot;token property&quot;&gt;&quot;expr&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;rate(http_requests_total{status_code=~&#92;&quot;5..&#92;&quot;}[1m])&quot;&lt;/span&gt;
        &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
      &lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;&quot;title&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Pod Restarts (24h)&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;token property&quot;&gt;&quot;targets&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;
        &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
          &lt;span class=&quot;token property&quot;&gt;&quot;expr&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;increase(kube_pod_container_status_restarts_total[24h])&quot;&lt;/span&gt;
        &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
      &lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the &amp;quot;single pane of glass.&amp;quot; When something feels off, you start here.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;alerting-the-right-message-to-the-right-person-at-the-right-time&quot;&gt;Alerting: The Right Message to the Right Person at the Right Time&lt;/h2&gt;
&lt;p&gt;Observability without alerting is a dashboard you check after something breaks. Alerting means you know &lt;em&gt;before&lt;/em&gt; it affects users.&lt;/p&gt;
&lt;p&gt;But alerting is easy to get wrong. Fire too many alerts, and you&#39;ll ignore them. Too few, and you&#39;ll miss the real problems. The goal is: &lt;strong&gt;signal, not noise.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In my setup, I use four alert rules, each routed to a different channel:&lt;/p&gt;
&lt;h3 id=&quot;alert-1-pod-restart-spike-telegram-cycle-kirklees-team&quot;&gt;Alert 1: Pod Restart Spike → Telegram (Cycle Kirklees Team)&lt;/h3&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;apiVersion&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; monitoring.coreos.com/v1
&lt;span class=&quot;token key atrule&quot;&gt;kind&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; PrometheusRule
&lt;span class=&quot;token key atrule&quot;&gt;metadata&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; pod&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;restarts
&lt;span class=&quot;token key atrule&quot;&gt;spec&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;groups&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; pod&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;restarts
      &lt;span class=&quot;token key atrule&quot;&gt;rules&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;alert&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; PodRestartingTooOften
          &lt;span class=&quot;token key atrule&quot;&gt;expr&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;token scalar string&quot;&gt;
            increase(kube_pod_container_status_restarts_total{namespace=&quot;cyclekirklees-prod&quot;}[15m]) &gt; 3&lt;/span&gt;
          &lt;span class=&quot;token key atrule&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; 5m
          &lt;span class=&quot;token key atrule&quot;&gt;annotations&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;token key atrule&quot;&gt;summary&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Pod {{ $labels.pod }} restarted {{ $value }} times in 15 minutes&quot;&lt;/span&gt;
            &lt;span class=&quot;token key atrule&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;High restart rate detected. Check pod logs for crashes.&quot;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When this fires, a Webhook sends a Telegram message to the &lt;strong&gt;Cycle Kirklees committee channel&lt;/strong&gt;. The committee doesn&#39;t need to know &lt;em&gt;why&lt;/em&gt; it restarted, just that it did, so they can tell me to fix it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why Telegram?&lt;/strong&gt; It&#39;s a group channel. Committee members see the alert, they know something&#39;s up, they can ask questions before escalating to me.&lt;/p&gt;
&lt;h3 id=&quot;alert-2-high-error-rate-email-me&quot;&gt;Alert 2: High Error Rate → Email (Me)&lt;/h3&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;alert&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; HighErrorRate
  &lt;span class=&quot;token key atrule&quot;&gt;expr&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;token scalar string&quot;&gt;
    rate(http_requests_total{status_code=~&quot;5..&quot;}[5m]) &gt; 0.05&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; 2m
  &lt;span class=&quot;token key atrule&quot;&gt;annotations&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;summary&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;{{ $labels.service }} error rate {{ $value | humanizePercentage }}&quot;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Email goes to my personal account. It&#39;s slower than Telegram, but it&#39;s deliberate. A 5% error rate is bad, but it&#39;s not &amp;quot;wake me at 2am&amp;quot; bad. I&#39;ll see it in the morning and investigate.&lt;/p&gt;
&lt;h3 id=&quot;alert-3-node-disk-space-personal-email-ai-agent-alert&quot;&gt;Alert 3: Node Disk Space → Personal Email + AI Agent Alert&lt;/h3&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;alert&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; NodeDiskPressure
  &lt;span class=&quot;token key atrule&quot;&gt;expr&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;token scalar string&quot;&gt;
    (1 - (node_filesystem_avail_bytes / node_filesystem_size_bytes)) &gt; 0.85&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; 10m
  &lt;span class=&quot;token key atrule&quot;&gt;annotations&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;summary&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Node {{ $labels.node }} is {{ $value | humanizePercentage }} full&quot;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When disk usage hits 85%, two things happen:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Email alert goes to me.&lt;/li&gt;
&lt;li&gt;A webhook triggers an n8n flow (my local agentic automation platform) that:
&lt;ul&gt;
&lt;li&gt;Checks which pods are consuming the most space&lt;/li&gt;
&lt;li&gt;Identifies old logs that could be safely rotated&lt;/li&gt;
&lt;li&gt;Sends me an AI-generated summary: &amp;quot;Disk pressure on node-2. Postgres logs are 40GB. Consider rotating logs or adding storage.&amp;quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The AI agent doesn&#39;t make decisions. It gathers context and presents options. But it&#39;s better than just &amp;quot;disk full,&amp;quot; because it&#39;s already done half the troubleshooting.&lt;/p&gt;
&lt;h3 id=&quot;alert-4-cluster-api-unavailable-telegram-email-heartbeat-monitor&quot;&gt;Alert 4: Cluster API Unavailable → Telegram + Email + Heartbeat Monitor&lt;/h3&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;alert&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; KubernetesAPIDown
  &lt;span class=&quot;token key atrule&quot;&gt;expr&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; up&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;job=&quot;kubernetes&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;apiservers&quot;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt; == 0
  &lt;span class=&quot;token key atrule&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; 1m
  &lt;span class=&quot;token key atrule&quot;&gt;annotations&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;summary&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Kubernetes API is unreachable&quot;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is a &amp;quot;hard stop&amp;quot; alert. If the API is down, the cluster is down. It goes everywhere:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Telegram (committee, so they know the platform is offline)&lt;/li&gt;
&lt;li&gt;Email (me, so I know to check the lab)&lt;/li&gt;
&lt;li&gt;A heartbeat monitor (externally hosted) pings my self-hosted platform every 5 minutes. If it doesn&#39;t respond, &lt;em&gt;that&lt;/em&gt; sends a backup alert to my phone.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The heartbeat monitor is paranoia, a belt-and-suspenders approach. But the idea is sound: if my platform is down, the alerts on my platform won&#39;t fire. So I need an &lt;em&gt;external&lt;/em&gt; check that says &amp;quot;hey, your platform just went dark.&amp;quot;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;why-this-structure-works&quot;&gt;Why This Structure Works&lt;/h2&gt;
&lt;p&gt;Look at the four alerts. They all solve a real problem:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Pod restarts:&lt;/strong&gt; The committee needs to know the service is flaky.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Error rate:&lt;/strong&gt; I need to know to check logs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Disk pressure:&lt;/strong&gt; I need context, not just a binary &amp;quot;full/not full&amp;quot; signal.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;API down:&lt;/strong&gt; Everyone needs to know immediately.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each alert goes to a different channel because each has a different audience and urgency. Telegram for &amp;quot;the platform is acting weird&amp;quot;; email for &amp;quot;investigate when you get a chance&amp;quot;; AI context for &amp;quot;here&#39;s what&#39;s probably wrong.&amp;quot;&lt;/p&gt;
&lt;p&gt;No alert goes to a channel it doesn&#39;t belong in. No Telegram spam about disk space. No &amp;quot;alert about alerts.&amp;quot; Just: the right information, to the right person, at the right time.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;bringing-it-together-platform-principles&quot;&gt;Bringing It Together: Platform Principles&lt;/h2&gt;
&lt;p&gt;This is what a self-hosted platform looks like:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Secrets are scoped and gated.&lt;/strong&gt; Different environments get different credentials. Production requires explicit approval.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Visibility is built in.&lt;/strong&gt; Every service exports metrics. Grafana shows the picture. Alerting catches problems early.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Blast radius is limited.&lt;/strong&gt; A mistake in staging doesn&#39;t touch production. A misconfigured alert doesn&#39;t spam everyone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Operational discipline is structural, not aspirational.&lt;/strong&gt; You don&#39;t &lt;em&gt;promise&lt;/em&gt; to remember to rotate credentials. The system makes it easy to avoid hardcoding them in the first place. You don&#39;t &lt;em&gt;hope&lt;/em&gt; you notice a problem. Alerts tell you.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For Cycle Kirklees, this means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Committee members can edit content in Sveltia CMS without being able to deploy to production.&lt;/li&gt;
&lt;li&gt;Staging automatically reflects the latest content changes, so they can preview before going live.&lt;/li&gt;
&lt;li&gt;Production deployments require my explicit approval, so I&#39;m in control of when changes ship.&lt;/li&gt;
&lt;li&gt;If something goes wrong, I see it in Grafana or via Telegram before users complain.&lt;/li&gt;
&lt;li&gt;If a node fills up, I get context-aware suggestions, not just a &amp;quot;full&amp;quot; alert.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is how you keep hosting costs down (self-hosted hardware) whilst keeping the blast radius down (staged rollout, scoped secrets, alerting). And it&#39;s how you expose capabilities to other people, in this case a volunteer-run cycling advocacy organisation, without exposing the entire system to risk.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;closing-the-loop&quot;&gt;Closing the Loop&lt;/h2&gt;
&lt;p&gt;This started as a roadmap: declarative provisioning, configuration management, a cluster, and finally the two things that make a platform trustworthy rather than just functional. Secrets that don&#39;t leak. Problems that surface before someone complains about them.&lt;/p&gt;
&lt;p&gt;None of this is finished in the sense that software is ever finished. Credentials still need rotating. Alert thresholds still need tuning as the platform grows. But the structure is in place, and that&#39;s the point of a platform: not that nothing goes wrong, but that when something does, the damage is contained and you find out about it on your terms.&lt;/p&gt;
&lt;p&gt;That&#39;s where this series ends. The lab runs Cycle Kirklees, this portfolio, and a handful of internal tools on infrastructure I provisioned, configured, and now watch over, end to end, with no cloud safety net and no one else to call.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;resources&quot;&gt;Resources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.gitlab.com/ee/ci/variables/&quot;&gt;GitLab CI/CD Variables Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/prometheus-operator/prometheus-operator/blob/main/Documentation/user-guides/getting-started.md&quot;&gt;Prometheus Operator: ServiceMonitor CRD&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://grafana.com/docs/grafana/latest/alerting/&quot;&gt;Grafana Alerting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/siimon/prom-client&quot;&gt;prom-client (Node.js Prometheus client)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://kubernetes.io/docs/tasks/debug-application-cluster/resource-metrics-pipeline/&quot;&gt;Kubernetes Metrics Server&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;This post is part of my platform engineering portfolio. See more at &lt;a href=&quot;https://www.spinstate.dev&quot;&gt;spinstate.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;How do you scope secrets and alerts on a platform with no dedicated SRE team? I&#39;d like to hear your approach in the comments.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Roles All the Way Down</title>
    <link href="https://spinstate.dev/notes/roles-all-the-way-down/" />
    <updated>2026-02-24T00:00:00Z</updated>
    <id>https://spinstate.dev/notes/roles-all-the-way-down/</id>
    <summary>The roles declared in Terraform were designed to feed Ansible. Ansible was built to stand up k3s. This is how a three-node cluster assembles itself from a single playbook run.</summary>
    <content type="html">&lt;p&gt;This is part 2 in a 3 part series. The &lt;a href=&quot;https://spinstate.dev/notes/terraforming-proxmox-platform/&quot;&gt;last post&lt;/a&gt; covered how I brought Terraform into my self-hosted production environment. VMs went from click-ops to declared infrastructure. Cloned from a template, assigned a static IP, given a role name, done.&lt;/p&gt;
&lt;p&gt;But the role name was still just a name. k3s_control in a Terraform module told anyone reading the code what the machine was supposed to be. It didn&#39;t do anything about it.&lt;/p&gt;
&lt;p&gt;This post is about closing that gap: introducing a roles structure in Terraform that Ansible could consume, so that machines could be configured for what they were supposed to do. Specifically, to stand up a Kubernetes cluster.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-design-decision&quot;&gt;The Design Decision&lt;/h2&gt;
&lt;p&gt;The previous post ended with a roadmap. Phase 3 was Ansible. Phase 4 was Kubernetes. But phases on paper don&#39;t tell you how the layers connect.&lt;/p&gt;
&lt;p&gt;The connection needed to be designed, not improvised. Specifically: Ansible needs to know which machines exist and what groups they belong to so it can run the configuration in a deliberate fasion. If that information lives in one place in Terraform and a separate &lt;code&gt;hosts.yml&lt;/code&gt; in Ansible, you will eventually have two sources of truth. They will drift apart, and drift is the enemy of platforms.&lt;/p&gt;
&lt;p&gt;So the Terraform VM definitions got two new fields:&lt;/p&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token property&quot;&gt;k3s-cp-01&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;vm_id&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;210&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;ip_address&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;192.168.2.50/24&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;tier&lt;/span&gt;       &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;k3s&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;role&lt;/span&gt;       &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;control&quot;&lt;/span&gt;

  &lt;span class=&quot;token property&quot;&gt;cores&lt;/span&gt;     &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;4&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;memory&lt;/span&gt;    &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;8192&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;disk_size&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;20&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token property&quot;&gt;k3s-wk-01&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;vm_id&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;211&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;ip_address&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;192.168.2.61/24&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;tier&lt;/span&gt;       &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;k3s&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;role&lt;/span&gt;       &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;workers&quot;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token property&quot;&gt;k3s-wk-02&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;vm_id&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;212&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;ip_address&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;192.168.2.62/24&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;tier&lt;/span&gt;       &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;k3s&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;role&lt;/span&gt;       &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;workers&quot;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So we have &lt;code&gt;tier&lt;/code&gt; and &lt;code&gt;role&lt;/code&gt;. No, not documentation. Not naming conventions. Structured fields with a specific purpose: to be consumed by the next layer, Ansible.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-interface-between-layers&quot;&gt;The Interface Between Layers&lt;/h2&gt;
&lt;p&gt;A Terraform template renders those fields directly into an Ansible inventory:&lt;/p&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;all:
  children:
%&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; for tier in distinct(&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;for h in hosts : h.tier&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;) ~&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
    $&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;tier&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;_hosts:
      children:

%&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;   for role in distinct(&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;for h in hosts : h.role if h.tier &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; tier&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;) ~&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
        $&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;tier&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;_$&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;role&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;:
          hosts:
%&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;     for name, host in hosts ~&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
%&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;       if host.tier &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; tier &amp;amp;&amp;amp; host.role &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; role ~&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
            $&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;name&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;:
              ansible_host: $&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;replace(host.ip_address, &lt;span class=&quot;token string&quot;&gt;&quot;/24&quot;&lt;/span&gt;, &lt;span class=&quot;token string&quot;&gt;&quot;&quot;&lt;/span&gt;)&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
              ansible_user: ubuntu
              ansible_python_interpreter: /usr/bin/python3

%&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;       endif ~&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
%&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;     endfor ~&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
%&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;   endfor ~&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

%&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; endfor ~&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;tier = &amp;quot;k3s&amp;quot;&lt;/code&gt; and &lt;code&gt;role = &amp;quot;control&amp;quot;&lt;/code&gt; produce the k3s_control Ansible group. &lt;code&gt;tier = &amp;quot;k3s&amp;quot;&lt;/code&gt; and &lt;code&gt;role = &amp;quot;workers&amp;quot;&lt;/code&gt; produce k3s_workers. The inventory is generated at &lt;code&gt;terraform apply&lt;/code&gt; time. It doesn&#39;t exist independently. It&#39;s derived.&lt;/p&gt;
&lt;p&gt;There is one source of truth for what machines exist and what they are. Ansible reads from it; it doesn&#39;t maintain a parallel copy. Adding a new VM to &lt;code&gt;locals.tf&lt;/code&gt; automatically puts it in the right Ansible group.&lt;/p&gt;
&lt;p&gt;This is the interface between layers, and it was the part that needed to exist before Ansible could do anything useful.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-ansible-does-with-it&quot;&gt;What Ansible Does With It&lt;/h2&gt;
&lt;p&gt;Once the inventory was sorted, Ansible could be structured around it. The playbook runs in sequence:&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Apply base configuration to all servers
  &lt;span class=&quot;token key atrule&quot;&gt;hosts&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; all
  &lt;span class=&quot;token key atrule&quot;&gt;become&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean important&quot;&gt;true&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;roles&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; base_linux

&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Install K3s control plane
  &lt;span class=&quot;token key atrule&quot;&gt;hosts&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; k3s_control
  &lt;span class=&quot;token key atrule&quot;&gt;become&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean important&quot;&gt;true&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;roles&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; k3s_control_plane

&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Join K3s workers
  &lt;span class=&quot;token key atrule&quot;&gt;hosts&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; k3s_workers
  &lt;span class=&quot;token key atrule&quot;&gt;become&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean important&quot;&gt;true&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;roles&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; k3s_workers&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every node gets &lt;code&gt;base_linux&lt;/code&gt; first. The platform baseline. Then the cluster roles run against their respective groups. The same groups that came from &lt;code&gt;tier&lt;/code&gt; and &lt;code&gt;role&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;base_linux&lt;/code&gt; is the contract every machine must satisfy: a platform user created with a known SSH key and passwordless sudo, base utilities installed, SSH password auth disabled, hostname set. Whatever else a machine will become, it starts here.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;k3s_control_plane&lt;/code&gt; is specific: disable swap (Kubernetes requires it), install k3s with the bundled Traefik disabled (there&#39;s already a Traefik instance at the network level, so no need to duplicate ingress inside the cluster), wait for the API to become ready, read the node token and set it as a host fact.&lt;/p&gt;
&lt;p&gt;That last step matters. The workers need the token to join the cluster:&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Set join token fact
  &lt;span class=&quot;token key atrule&quot;&gt;set_fact&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;k3s_join_token&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;&quot;&lt;/span&gt;

&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Install K3s worker node
  &lt;span class=&quot;token key atrule&quot;&gt;shell&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;token scalar string&quot;&gt;
    curl -sfL https://get.k3s.io | &#92;
    K3S_URL=https://:6443 &#92;
    K3S_TOKEN= &#92;
    sh -&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The worker role looks up the token from the control plane&#39;s host variables and uses it to join. No manual token copying. No shared secrets file. The playbook runs sequentially (control plane first, workers after) and the cluster forms.&lt;/p&gt;
&lt;p&gt;Cluster formation is declared. You run the playbook; the cluster assembles itself.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;why-the-roles-structure-matters&quot;&gt;Why the Roles Structure Matters&lt;/h2&gt;
&lt;p&gt;The original Terraform setup could provision a VM named &lt;code&gt;k3s-control&lt;/code&gt;. It couldn&#39;t make that VM a control plane. Naming something and making it what it&#39;s named are different operations. The roles structure closes that gap.&lt;/p&gt;
&lt;p&gt;The fields in &lt;code&gt;locals.tf&lt;/code&gt; aren&#39;t just metadata. They&#39;re the vocabulary that Ansible operates on. When you add a new worker to the Terraform definition, it appears in the &lt;code&gt;k3s_workers&lt;/code&gt; Ansible group automatically, and the next playbook run will configure it, join it to the cluster, and it will be available for workloads. The work is in declaring the machine. The configuration follows.&lt;/p&gt;
&lt;p&gt;This is what it means for infrastructure to be composable: each layer produces outputs the next layer can consume, and the contract between them is explicit and machine-readable. Terraform&#39;s job is to make VMs exist. Ansible&#39;s job is to make them what they&#39;re supposed to be.&lt;/p&gt;
&lt;p&gt;The roles structure was the thing that made one layer&#39;s output useful to the next.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;where-things-stand&quot;&gt;Where Things Stand&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Phase 1&lt;/strong&gt; ✓ Declarative VM provisioning with Terraform&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Phase 2&lt;/strong&gt; ✓ Role abstraction and resource profiles&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Phase 3&lt;/strong&gt; ✓ Ansible configuration management: baseline and cluster roles&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Phase 4&lt;/strong&gt; ✓ Kubernetes via k3s: control plane and workers operational&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Phase 5:&lt;/strong&gt; Observability: Prometheus, Grafana, alerting&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Observability is next. A cluster that can&#39;t be inspected has the same problem as infrastructure that can&#39;t be reproduced: the knowledge is implicit and the failure modes are invisible until they aren&#39;t.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;How far do you take role abstraction before it becomes its own complexity problem? Let me know in the comments.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>From Click-Ops to Roles: Terraforming My Proxmox Platform</title>
    <link href="https://spinstate.dev/notes/terraforming-proxmox-platform/" />
    <updated>2026-02-19T00:00:00Z</updated>
    <id>https://spinstate.dev/notes/terraforming-proxmox-platform/</id>
    <summary>I automated forgetting how to create VMs. A shift from manual provisioning to declarative, role-based platform thinking on Proxmox.</summary>
    <content type="html">&lt;p&gt;This is part 1 in a 3 part series.&lt;/p&gt;
&lt;p&gt;Not every self-hosted platform is a sandpit.&lt;/p&gt;
&lt;p&gt;Mine sits closer to a small data centre than an experiment bench. It runs things I depend on and things others depend on.&lt;/p&gt;
&lt;p&gt;Which changes what good infrastructure management looks like.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;this-host-runs-real-services&quot;&gt;This Host Runs Real Services&lt;/h2&gt;
&lt;p&gt;GitLab. Containers. Monitoring. My portfolio. Community infrastructure. Internal tooling. Backups.&lt;/p&gt;
&lt;p&gt;When I rebuild something, it matters.&lt;/p&gt;
&lt;p&gt;For a long time, provisioning a new VM meant:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Clone template&lt;/li&gt;
&lt;li&gt;Set CPU and RAM&lt;/li&gt;
&lt;li&gt;Attach storage&lt;/li&gt;
&lt;li&gt;Configure networking&lt;/li&gt;
&lt;li&gt;Inject SSH key&lt;/li&gt;
&lt;li&gt;Start&lt;/li&gt;
&lt;li&gt;Hope I did not miss a setting&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It worked. It was not broken.&lt;/p&gt;
&lt;p&gt;But it was fragile.&lt;/p&gt;
&lt;p&gt;And fragile systems do not scale cognitively.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-constraint-set&quot;&gt;The Constraint Set&lt;/h2&gt;
&lt;p&gt;This is not a cloud environment.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Single Proxmox node&lt;/li&gt;
&lt;li&gt;Thin LVM storage&lt;/li&gt;
&lt;li&gt;pfSense upstream&lt;/li&gt;
&lt;li&gt;Flat network with segmented services&lt;/li&gt;
&lt;li&gt;Linked clones for efficiency&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No managed control plane. No autoscaling groups. No cloud safety net.&lt;/p&gt;
&lt;p&gt;Just deliberate engineering.&lt;/p&gt;
&lt;p&gt;Those constraints forced clarity. Every design choice has weight when there is no abstraction layer hiding it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-problem-was-not-speed&quot;&gt;The Problem Was Not Speed&lt;/h2&gt;
&lt;p&gt;I can provision a VM manually in five minutes.&lt;/p&gt;
&lt;p&gt;The issue was repetition.&lt;/p&gt;
&lt;p&gt;The real problem was this:&lt;/p&gt;
&lt;p&gt;Provisioning lived in my head.&lt;/p&gt;
&lt;p&gt;The template ID. The correct bridge. The RAM profile for Docker hosts vs GitLab. Which disk size I normally assign. Every one of those decisions was correct, but none of them were written down.&lt;/p&gt;
&lt;p&gt;That knowledge was implicit.&lt;/p&gt;
&lt;p&gt;Implicit knowledge is operational risk.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-mental-shift-machines-vs-roles&quot;&gt;The Mental Shift: Machines vs Roles&lt;/h2&gt;
&lt;p&gt;The turning point was subtle.&lt;/p&gt;
&lt;p&gt;I stopped thinking in terms of VMs and started thinking in roles.&lt;/p&gt;
&lt;p&gt;Not:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;VM 200, 2 cores, 4GB RAM&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;But:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;docker_host
gitlab
k3s_control
monitoring&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Each role carries intent:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Resource profile&lt;/li&gt;
&lt;li&gt;Storage expectations&lt;/li&gt;
&lt;li&gt;Network assumptions&lt;/li&gt;
&lt;li&gt;Future configuration layering&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I was no longer provisioning machines.&lt;/p&gt;
&lt;p&gt;I was declaring responsibilities.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;encoding-intent-with-terraform&quot;&gt;Encoding Intent with Terraform&lt;/h2&gt;
&lt;p&gt;I refactored into a reusable module and began expressing infrastructure declaratively.&lt;/p&gt;
&lt;p&gt;Instead of manually creating a Docker host, I now declare it:&lt;/p&gt;
&lt;pre class=&quot;language-hcl&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-hcl&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;module&lt;span class=&quot;token type variable&quot;&gt; &quot;docker_host&quot; &lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;source&lt;/span&gt;         &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;./modules/vm&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;vm_id&lt;/span&gt;          &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;200&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;           &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;docker-host&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;template_id&lt;/span&gt;    &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; local.template_id
  &lt;span class=&quot;token property&quot;&gt;cores&lt;/span&gt;          &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;memory&lt;/span&gt;         &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;4096&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;disk_size&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;40&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;bridge&lt;/span&gt;         &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;vmbr0&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;ip_address&lt;/span&gt;     &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;192.168.2.50/24&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;dns_servers&lt;/span&gt;    &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;192.168.2.1&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;ssh_public_key&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; local.ssh_key
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;module&lt;span class=&quot;token type variable&quot;&gt; &quot;gitlab&quot; &lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;source&lt;/span&gt;         &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;./modules/vm&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;vm_id&lt;/span&gt;          &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;201&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;name&lt;/span&gt;           &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;gitlab&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;template_id&lt;/span&gt;    &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; local.template_id
  &lt;span class=&quot;token property&quot;&gt;cores&lt;/span&gt;          &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;4&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;memory&lt;/span&gt;         &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;8192&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;disk_size&lt;/span&gt;      &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;80&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;bridge&lt;/span&gt;         &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;vmbr0&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;ip_address&lt;/span&gt;     &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;192.168.2.51/24&quot;&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;dns_servers&lt;/span&gt;    &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;192.168.2.1&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;token property&quot;&gt;ssh_public_key&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;=&lt;/span&gt; local.ssh_key
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two roles. Same module. Different intent. The shape of the infrastructure is visible in the code itself.&lt;/p&gt;
&lt;p&gt;Underneath, the module handles:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Linked clone from cloud-init template&lt;/li&gt;
&lt;li&gt;Storage mapping&lt;/li&gt;
&lt;li&gt;Network attachment&lt;/li&gt;
&lt;li&gt;Deterministic configuration&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If I destroy and reapply, the outcome is predictable.&lt;/p&gt;
&lt;p&gt;That predictability is the real feature.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-changed&quot;&gt;What Changed&lt;/h2&gt;
&lt;p&gt;Provisioning time dropped, but that was not the main win.&lt;/p&gt;
&lt;p&gt;The real gains were:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Rebuild confidence&lt;/li&gt;
&lt;li&gt;Reduced cognitive load&lt;/li&gt;
&lt;li&gt;Clear separation between chassis and role&lt;/li&gt;
&lt;li&gt;Faster experimentation&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I can now:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Destroy a service VM without hesitation&lt;/li&gt;
&lt;li&gt;Iterate on templates safely&lt;/li&gt;
&lt;li&gt;Standardise resource profiles&lt;/li&gt;
&lt;li&gt;Spin up test roles in seconds&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The infrastructure is no longer memory dependent.&lt;/p&gt;
&lt;p&gt;It is declared.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-went-wrong&quot;&gt;What Went Wrong&lt;/h2&gt;
&lt;p&gt;This was not frictionless.&lt;/p&gt;
&lt;p&gt;Along the way I hit:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Provider quirks between Telmate and BPG&lt;/li&gt;
&lt;li&gt;Cloud-init disk attachment issues&lt;/li&gt;
&lt;li&gt;Permission errors on thin LVM volumes&lt;/li&gt;
&lt;li&gt;Template lifecycle confusion&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The provider choice alone cost me hours. I started with the Telmate provider because most tutorials reference it, but hit issues with cloud-init disk handling and inconsistent plan diffs. Switching to the BPG provider resolved most of those problems, but meant rewriting every resource block. If you are starting fresh, save yourself the detour and go straight to BPG.&lt;/p&gt;
&lt;p&gt;Each issue forced me deeper into how Proxmox actually works:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;How disks are mapped at the block layer&lt;/li&gt;
&lt;li&gt;What happens during clone operations&lt;/li&gt;
&lt;li&gt;Why cloud-init ISO generation can fail&lt;/li&gt;
&lt;li&gt;Where Terraform state becomes authoritative&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I now understand my hypervisor better than I did when everything was manual.&lt;/p&gt;
&lt;p&gt;Automation did not abstract the system away.&lt;/p&gt;
&lt;p&gt;It made me confront it properly.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-platform-perspective&quot;&gt;The Platform Perspective&lt;/h2&gt;
&lt;p&gt;This is still a single node.&lt;/p&gt;
&lt;p&gt;There is no HA. No distributed storage. No control plane redundancy.&lt;/p&gt;
&lt;p&gt;But the thinking has shifted.&lt;/p&gt;
&lt;p&gt;Infrastructure is now:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Declarative&lt;/li&gt;
&lt;li&gt;Role-based&lt;/li&gt;
&lt;li&gt;Rebuildable&lt;/li&gt;
&lt;li&gt;Evolvable&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The next phase is layering configuration management and eventually Kubernetes patterns on top.&lt;/p&gt;
&lt;p&gt;But this step mattered.&lt;/p&gt;
&lt;p&gt;Because platform engineering is not about scale first.&lt;/p&gt;
&lt;p&gt;It is about repeatability first.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;why-this-matters-beyond-my-basement&quot;&gt;Why This Matters Beyond My Basement&lt;/h2&gt;
&lt;p&gt;Declarative infrastructure is not exclusive to AWS or Azure.&lt;/p&gt;
&lt;p&gt;It is a pattern of thinking.&lt;/p&gt;
&lt;p&gt;When infrastructure intent is encoded:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Failure becomes recoverable&lt;/li&gt;
&lt;li&gt;Change becomes deliberate&lt;/li&gt;
&lt;li&gt;Systems become testable&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I did not automate VM creation.&lt;/p&gt;
&lt;p&gt;I automated forgetting how to create VMs.&lt;/p&gt;
&lt;p&gt;And that is the real shift.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-roadmap&quot;&gt;The Roadmap&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Phase 1:&lt;/strong&gt; Declarative VM provisioning ✓&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Phase 2:&lt;/strong&gt; Role abstraction and resource profiling ✓&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Still to come:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Phase 3:&lt;/strong&gt; Configuration layering with Ansible: roles declared in Terraform drive Ansible inventory, turning declared intent into applied configuration&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Phase 4:&lt;/strong&gt; Kubernetes integration: a k3s cluster bootstrapped from Ansible, control plane and workers resolved from the same role structure defined in Terraform&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Phase 5:&lt;/strong&gt; Observability and policy enforcement: Prometheus/Grafana stack, resource budgets, drift detection&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The foundation is solid.&lt;/p&gt;
&lt;p&gt;The work continues.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;The moment you can destroy and recreate without hesitation, your relationship with your platform changes.&lt;/p&gt;
&lt;p&gt;Mine did.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;What changed for you the first time you could rebuild your infrastructure from a declarative config? Tell me in the comments.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Self-Hosted GitLab CI/CD: Building My Own Deployment Platform</title>
    <link href="https://spinstate.dev/notes/selfhosting-gitlab/" />
    <updated>2026-01-28T00:00:00Z</updated>
    <id>https://spinstate.dev/notes/selfhosting-gitlab/</id>
    <summary>How I set up GitLab CE with automated pipelines on my self-hosted platform, and what I learned the hard way about SSL termination, reverse proxies, and infrastructure design.</summary>
    <content type="html">&lt;p&gt;When I decided to build my own CI/CD infrastructure, the easy path would have been GitHub Actions or GitLab.com&#39;s free tier. Instead, I went self-hosted. Here&#39;s why:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Enterprise relevance.&lt;/strong&gt; Many organisations choose not put their code on the public internet. Financial services, healthcare, government, they all run internal GitLab instances. I wanted hands-on experience with the patterns I&#39;d encounter professionally, not just the managed SaaS version.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No metering anxiety.&lt;/strong&gt; I run commits 50+ times a day when iterating on a problem. With a self-hosted setup, I never think about CI minutes, runner limits, or surprise bills. The pipeline runs when I push. Every time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hardware I already own.&lt;/strong&gt; I have an enterprise R730 server running Proxmox on my self-hosted platform. It has more headroom than I&#39;ll ever need. Why pay for cloud compute when the capacity is sitting in my home office?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Isolation for peace of mind.&lt;/strong&gt; I experiment a lot. VMs get broken, rebuilt, wiped. By keeping GitLab on its own isolated VM, I know that even if I catastrophically break my other environments, my codebase survives. Belt-and-braces: nightly backups mean I&#39;d never lose more than a day&#39;s work anyway.&lt;/p&gt;
&lt;hr&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Mirror enterprise patterns with self-hosted GitLab.&lt;/li&gt;
&lt;li&gt;Keep SSL termination at Traefik; run GitLab over HTTP internally.&lt;/li&gt;
&lt;li&gt;Isolate runners to protect GitLab responsiveness.&lt;/li&gt;
&lt;li&gt;Read error messages literally; they usually name the root cause.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;the-architecture&quot;&gt;The Architecture&lt;/h2&gt;
&lt;pre class=&quot;language-text&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;┌─────────────────────────────────────────────────────────────────────┐
│                         Proxmox Host (R730)                         │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────────────────┐  │
│  │  GitLab VM  │    │  Runner VM  │    │  Other VMs              │  │
│  │  (isolated) │    │   (DinD)    │    │ (experiments, can break)│  │
│  └──────┬──────┘    └──────┬──────┘    └─────────────────────────┘  │
│         │                  │                                        │
│         └────────┬─────────┘                                        │
│                  ▼                                                  │
│         ┌───────────────┐                                           │
│         │    Traefik    │  ← SSL termination + routing              │
│         └───────┬───────┘                                           │
│                 │                                                   │
└─────────────────┼───────────────────────────────────────────────────┘
                  ▼
          https://gitlab.example.internal&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Tip: Scroll horizontally on smaller screens to see the full diagram.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;key-design-decisions&quot;&gt;Key Design Decisions&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;GitLab on a dedicated VM:&lt;/strong&gt; Isolation from experimental workloads that might break.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Runners on a separate VM:&lt;/strong&gt; Resource isolation; GitLab stays responsive during heavy builds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Docker-in-Docker executor:&lt;/strong&gt; Container-based builds with clean environments each run.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Traefik for SSL termination:&lt;/strong&gt; Already part of my infrastructure; no need to duplicate certificate management.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Nightly VM backups:&lt;/strong&gt; Never lose more than a day; peace of mind for experimentation.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-troubleshooting-journey&quot;&gt;The Troubleshooting Journey&lt;/h2&gt;
&lt;p&gt;Setting this up wasn&#39;t a smooth path. Here&#39;s where I went wrong, and what I learned.&lt;/p&gt;
&lt;h3 id=&quot;problem-1-the-lets-encrypt-trap&quot;&gt;Problem 1: The Let&#39;s Encrypt Trap&lt;/h3&gt;
&lt;p&gt;I started with GitLab&#39;s official installation for Ubuntu:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;sudo&lt;/span&gt; &lt;span class=&quot;token assign-left variable&quot;&gt;EXTERNAL_URL&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;https://gitlab.example.internal&quot;&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;apt-get&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;install&lt;/span&gt; gitlab-ce&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After correcting my external URL and running &lt;code&gt;gitlab-ctl reconfigure&lt;/code&gt;, I hit this:&lt;/p&gt;
&lt;pre class=&quot;language-text&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;FATAL: RuntimeError: letsencrypt_certificate[gitlab.example.internal] had an error
DNS problem: NXDOMAIN looking up A for gitlab.example.internal&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The realisation:&lt;/strong&gt; GitLab saw my HTTPS URL and automatically tried to provision a Let&#39;s Encrypt certificate. But this is local-only infrastructure. Let&#39;s Encrypt requires public DNS validation, which doesn&#39;t apply to my self-hosted platform.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Key learning:&lt;/strong&gt; When you specify &lt;code&gt;https://&lt;/code&gt; in &lt;code&gt;external_url&lt;/code&gt;, GitLab assumes you want it to handle SSL via Let&#39;s Encrypt unless you explicitly tell it otherwise.&lt;/p&gt;
&lt;h3 id=&quot;problem-2-fighting-my-own-infrastructure&quot;&gt;Problem 2: Fighting My Own Infrastructure&lt;/h3&gt;
&lt;p&gt;My initial instinct was to make GitLab handle SSL directly. But I was missing the bigger picture: I already have a perfectly good SSL termination point, Traefik.&lt;/p&gt;
&lt;p&gt;My self-hosted platform already includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Traefik reverse proxy managing all external traffic&lt;/li&gt;
&lt;li&gt;Automated Let&#39;s Encrypt certificate provisioning&lt;/li&gt;
&lt;li&gt;Wildcard certificates for &lt;code&gt;*.spinstate.dev&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Trying to make GitLab handle its own SSL was duplicating functionality and fighting against my existing architecture.&lt;/p&gt;
&lt;h3 id=&quot;the-solution-separation-of-concerns&quot;&gt;The Solution: Separation of Concerns&lt;/h3&gt;
&lt;p&gt;The correct approach separates responsibilities:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Traefik handles:&lt;/strong&gt; HTTPS, SSL termination, certificate management, routing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GitLab handles:&lt;/strong&gt; Application logic, Git operations, internal HTTP only&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;GitLab configuration&lt;/strong&gt; (&lt;code&gt;/etc/gitlab/gitlab.rb&lt;/code&gt;):&lt;/p&gt;
&lt;pre class=&quot;language-ruby&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-ruby&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;# External URL is what users see&lt;/span&gt;
external_url &lt;span class=&quot;token string-literal&quot;&gt;&lt;span class=&quot;token string&quot;&gt;&#39;http://gitlab.example.internal&#39;&lt;/span&gt;&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;# GitLab listens on plain HTTP internally&lt;/span&gt;
nginx&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string-literal&quot;&gt;&lt;span class=&quot;token string&quot;&gt;&#39;listen_port&#39;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;80&lt;/span&gt;
nginx&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string-literal&quot;&gt;&lt;span class=&quot;token string&quot;&gt;&#39;listen_https&#39;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;false&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;# Disable Let&#39;s Encrypt completely&lt;/span&gt;
letsencrypt&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string-literal&quot;&gt;&lt;span class=&quot;token string&quot;&gt;&#39;enable&#39;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;false&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;# Tell GitLab it&#39;s behind an HTTPS proxy&lt;/span&gt;
nginx&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string-literal&quot;&gt;&lt;span class=&quot;token string&quot;&gt;&#39;proxy_set_headers&#39;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token string-literal&quot;&gt;&lt;span class=&quot;token string&quot;&gt;&quot;X-Forwarded-Proto&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&gt;&lt;/span&gt; &lt;span class=&quot;token string-literal&quot;&gt;&lt;span class=&quot;token string&quot;&gt;&quot;https&quot;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token string-literal&quot;&gt;&lt;span class=&quot;token string&quot;&gt;&quot;X-Forwarded-Ssl&quot;&lt;/span&gt;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&gt;&lt;/span&gt; &lt;span class=&quot;token string-literal&quot;&gt;&lt;span class=&quot;token string&quot;&gt;&quot;on&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Traefik configuration:&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;http&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;routers&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;gitlab&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;token key atrule&quot;&gt;rule&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Host(`gitlab.example.internal`)&quot;&lt;/span&gt;
      &lt;span class=&quot;token key atrule&quot;&gt;service&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; gitlab&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;service
      &lt;span class=&quot;token key atrule&quot;&gt;entryPoints&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; websecure
      &lt;span class=&quot;token key atrule&quot;&gt;tls&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;token key atrule&quot;&gt;certResolver&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; letsencrypt

  &lt;span class=&quot;token key atrule&quot;&gt;services&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;gitlab-service&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;token key atrule&quot;&gt;loadBalancer&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;token key atrule&quot;&gt;servers&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
          &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;http://X.X.X.X:80&quot;&lt;/span&gt;  &lt;span class=&quot;token comment&quot;&gt;# HTTP to GitLab obsfucated internal&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;problem-3-protocol-mismatch&quot;&gt;Problem 3: Protocol Mismatch&lt;/h3&gt;
&lt;p&gt;Even after the above, I had one more mistake. My initial Traefik config had:&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;https://X.X.X.X:80&quot;&lt;/span&gt;  &lt;span class=&quot;token comment&quot;&gt;# WRONG&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I was telling Traefik to connect via HTTPS to a service listening on HTTP. Small typo, confusing errors.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fixed:&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;http://X.X.X.X:80&quot;&lt;/span&gt;  &lt;span class=&quot;token comment&quot;&gt;# Correct&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-request-flow&quot;&gt;The Request Flow&lt;/h3&gt;
&lt;pre class=&quot;language-text&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;User Browser
    ↓ HTTPS
gitlab.example.internal:443
    ↓
Traefik (SSL termination)
    ↓ HTTP
GitLab nginx (port 80)
    ↓
GitLab application&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-this-enables&quot;&gt;What This Enables&lt;/h2&gt;
&lt;p&gt;The payoff for this setup is simple but significant:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I just &lt;code&gt;git push&lt;/code&gt;, and it&#39;s deployed.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;No more SSH-ing into servers. No more remembering which script to run, or from which directory. I push to main, the pipeline runs, and my sites update. That&#39;s it.&lt;/p&gt;
&lt;p&gt;Currently deploying through this pipeline:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;spinstate.dev:&lt;/strong&gt; my portfolio site&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A community campaign website:&lt;/strong&gt; pro bono project&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Both deploy to &lt;code&gt;nginx:alpine&lt;/code&gt; containers. Nothing fancy. Does the job.&lt;/p&gt;
&lt;p&gt;The real value isn&#39;t the complexity of what I&#39;m deploying. It&#39;s that I now understand the fundamentals of CI/CD from the infrastructure up, not just the YAML, as fun as that is.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;costs-and-tradeoffs&quot;&gt;Costs and Tradeoffs&lt;/h2&gt;
&lt;p&gt;Self-hosting buys control and realism, but it isn&#39;t free. You take on patching, monitoring, backups, storage growth, and the occasional weekend fix. And if your lab goes down, your pipelines do too. For me, the trade is worth it because the learning and autonomy outweigh the extra operational overhead.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Understand your infrastructure layers.&lt;/strong&gt; Don&#39;t duplicate functionality. If you already have SSL termination, use it.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Read error messages carefully.&lt;/strong&gt; The Let&#39;s Encrypt error explicitly mentioned DNS validation failure. That should have immediately signalled the mismatch with local-only infrastructure.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Match protocols correctly.&lt;/strong&gt; When configuring reverse proxies, the upstream URL protocol must match what the backend actually listens on.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Use proxy headers appropriately.&lt;/strong&gt; &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; and &lt;code&gt;X-Forwarded-Ssl&lt;/code&gt; are crucial for applications to know they&#39;re behind an HTTPS proxy.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Consult the community.&lt;/strong&gt; The GitLab forums had threads on Traefik integration. The problem had been solved before.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2 id=&quot;resources&quot;&gt;Resources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.gitlab.com/omnibus/settings/configuration.html&quot;&gt;GitLab Omnibus configuration reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.gitlab.com/omnibus/settings/nginx.html&quot;&gt;GitLab HTTPS configuration and reverse proxies&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doc.traefik.io/traefik/routing/providers/file/&quot;&gt;Traefik dynamic configuration (routers/services)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://letsencrypt.org/docs/challenge-types/#dns-01-challenge&quot;&gt;Let&#39;s Encrypt DNS validation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What&#39;s Next&lt;/h2&gt;
&lt;p&gt;This setup works, but it&#39;s not finished. In &lt;strong&gt;Part 2&lt;/strong&gt;, I&#39;ll cover the security side, specifically, secrets management. My initial approach had gaps that I&#39;ve since corrected, and I&#39;ll walk through:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What I got wrong with credential handling&lt;/li&gt;
&lt;li&gt;GitLab CI/CD variables and environment scoping&lt;/li&gt;
&lt;li&gt;The path toward proper secrets management&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Building your own CI/CD teaches you things that clicking &amp;quot;Enable GitHub Actions&amp;quot; never will. The troubleshooting was frustrating in the moment, but every error message was a lesson in how these systems actually work.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;This post is part of my platform engineering portfolio. See more at &lt;a href=&quot;https://www.spinstate.dev&quot;&gt;spinstate.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;What&#39;s the hardest lesson you&#39;ve learned building your own CI/CD? Share it in the comments.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Designing Business Operations That Don&#39;t Rely on Heroics</title>
    <link href="https://spinstate.dev/notes/heroics/" />
    <updated>2026-01-10T00:00:00Z</updated>
    <id>https://spinstate.dev/notes/heroics/</id>
    <summary>Why MSP environments reward restoration over prevention, and how to design operations that fix root causes without heroics.</summary>
    <content type="html">&lt;p&gt;Root causes don&#39;t persist because people don&#39;t care.
They persist because the system rewards restoration, not prevention.&lt;/p&gt;
&lt;p&gt;If you have ever worked in an MSP-led IT environment, this pattern will feel familiar.&lt;/p&gt;
&lt;p&gt;The same incidents recur.
The same fixes are applied.
Service is restored, everyone moves on, and nothing really changes.&lt;/p&gt;
&lt;p&gt;For a long time, I thought this meant someone was not trying hard enough.
In reality, it usually means the system is working exactly as designed.&lt;/p&gt;
&lt;p&gt;This post is about why root causes so often go unaddressed in MSP environments, and how an IT Operations Manager can fix that without becoming a hero, a bottleneck, or a burnout case.&lt;/p&gt;
&lt;h2 id=&quot;the-myth-of-heroic-it&quot;&gt;The myth of heroic IT&lt;/h2&gt;
&lt;p&gt;Most organisations do not explicitly ask for heroic behaviour. It emerges quietly.&lt;/p&gt;
&lt;p&gt;An incident happens.
Someone stays late to fix it.
The business is grateful.
Normal service resumes.&lt;/p&gt;
&lt;p&gt;The next time it happens, the same person is expected to step in again. Over time, they become the safety net. The organisation feels stable, but only because someone is absorbing risk on its behalf.&lt;/p&gt;
&lt;p&gt;Heroic IT feels productive. It feels necessary. It is also one of the most effective ways to prevent an organisation from learning.&lt;/p&gt;
&lt;h2 id=&quot;why-msp-environments-struggle-with-root-causes&quot;&gt;Why MSP environments struggle with root causes&lt;/h2&gt;
&lt;p&gt;This is not about bad suppliers or lazy engineers. It is about incentives.&lt;/p&gt;
&lt;h3 id=&quot;slas-reward-speed-not-durability&quot;&gt;SLAs reward speed, not durability&lt;/h3&gt;
&lt;p&gt;Most MSP contracts measure things like response time, resolution time, and availability. Once service is restored and the ticket is closed, the work is considered complete.&lt;/p&gt;
&lt;p&gt;A permanent fix that prevents future incidents is often slower, riskier, and outside scope.&lt;/p&gt;
&lt;p&gt;From the MSP&#39;s point of view, stopping at restoration is commercially rational.&lt;/p&gt;
&lt;h3 id=&quot;root-cause-work-sits-in-a-grey-zone&quot;&gt;Root cause work sits in a grey zone&lt;/h3&gt;
&lt;p&gt;True root cause correction usually involves change. Configuration standardisation. Documentation. Architectural cleanup. Dependency mapping.&lt;/p&gt;
&lt;p&gt;That work is neither pure support nor clearly defined project delivery.&lt;/p&gt;
&lt;p&gt;If it is not explicitly funded or prioritised, it becomes optional. Optional work rarely survives a busy service desk.&lt;/p&gt;
&lt;h3 id=&quot;improvement-work-is-invisible&quot;&gt;Improvement work is invisible&lt;/h3&gt;
&lt;p&gt;Incident resolution is visible. A ticket closes. A dashboard turns green.&lt;/p&gt;
&lt;p&gt;Preventative work is quiet. When it succeeds, nothing happens. No outage. No alert. No thank you.&lt;/p&gt;
&lt;p&gt;In environments driven by urgency, invisible work loses.&lt;/p&gt;
&lt;h3 id=&quot;everyone-assumes-someone-else-owns-prevention&quot;&gt;Everyone assumes someone else owns prevention&lt;/h3&gt;
&lt;p&gt;The business assumes IT has it covered.
IT assumes the MSP will fix it.
The MSP assumes the issue is accepted as part of the environment.&lt;/p&gt;
&lt;p&gt;Nobody is being negligent. Ownership is simply unclear.&lt;/p&gt;
&lt;h2 id=&quot;the-hero-trap&quot;&gt;The hero trap&lt;/h2&gt;
&lt;p&gt;This is where well meaning IT managers get stuck.&lt;/p&gt;
&lt;p&gt;They know the root cause.
They know how to fix it.
They know the business will not fund it easily.&lt;/p&gt;
&lt;p&gt;So they squeeze it in. Late nights. Quiet changes. Personal effort.&lt;/p&gt;
&lt;p&gt;The system never feels the pain, so it never changes.&lt;/p&gt;
&lt;p&gt;Over time, the manager becomes indispensable, exhausted, and frustrated. The organisation becomes dependent without realising it.&lt;/p&gt;
&lt;p&gt;This is not leadership. It is risk concentration.&lt;/p&gt;
&lt;h2 id=&quot;the-shift-from-effort-to-structure&quot;&gt;The shift from effort to structure&lt;/h2&gt;
&lt;p&gt;Solving this does not require more energy. It requires better design.&lt;/p&gt;
&lt;h3 id=&quot;separate-restore-from-improve&quot;&gt;Separate restore from improve&lt;/h3&gt;
&lt;p&gt;Incident management exists to restore service. That is its job.&lt;/p&gt;
&lt;p&gt;Problem management exists to remove causes. That must be treated as planned, deliberate work.&lt;/p&gt;
&lt;p&gt;When improvement is expected to happen inside BAU support, it will always lose.&lt;/p&gt;
&lt;h3 id=&quot;fund-prevention-explicitly&quot;&gt;Fund prevention explicitly&lt;/h3&gt;
&lt;p&gt;Even a small allocation changes behaviour.&lt;/p&gt;
&lt;p&gt;Five to ten percent of MSP capacity.
A modest monthly remediation budget.
A rolling backlog of known problems.&lt;/p&gt;
&lt;p&gt;Once prevention is paid for, it becomes legitimate.&lt;/p&gt;
&lt;h3 id=&quot;make-recurrence-visible&quot;&gt;Make recurrence visible&lt;/h3&gt;
&lt;p&gt;Do not obsess over single incidents. Track patterns.&lt;/p&gt;
&lt;p&gt;What breaks repeatedly.
How often.
With what impact.&lt;/p&gt;
&lt;p&gt;Frequency multiplied by impact tells you where to focus. It also gives you an evidence base that does not rely on anecdotes or emotion.&lt;/p&gt;
&lt;h3 id=&quot;force-trade-offs-into-the-open&quot;&gt;Force trade offs into the open&lt;/h3&gt;
&lt;p&gt;When a permanent fix is declined, record it.&lt;/p&gt;
&lt;p&gt;Document the risk.
Note the expected recurrence.
Capture who accepted it.&lt;/p&gt;
&lt;p&gt;This is not about blame. It is about clarity.&lt;/p&gt;
&lt;p&gt;Once risk is explicit, it stops living in someone&#39;s head.&lt;/p&gt;
&lt;h3 id=&quot;use-governance-instead-of-heroics&quot;&gt;Use governance instead of heroics&lt;/h3&gt;
&lt;p&gt;Service reviews should not just report what happened. They should decide what happens next.&lt;/p&gt;
&lt;p&gt;Which recurring issue are we eliminating this month.
Which risks are we consciously accepting.
What work are we choosing not to do, and why.&lt;/p&gt;
&lt;p&gt;Cadence beats effort every time.&lt;/p&gt;
&lt;h2 id=&quot;what-changes-when-you-do-this&quot;&gt;What changes when you do this&lt;/h2&gt;
&lt;p&gt;Things get quieter.&lt;/p&gt;
&lt;p&gt;Incidents reduce.
Escalations become rarer.
The MSP starts suggesting improvements rather than reacting to noise.&lt;/p&gt;
&lt;p&gt;IT feels boring in the best possible way.&lt;/p&gt;
&lt;p&gt;Boring IT is predictable, resilient, and trusted. It does not rely on individuals stepping in at the last minute. It improves because the system makes improvement inevitable.&lt;/p&gt;
&lt;h2 id=&quot;the-kind-of-it-worth-running&quot;&gt;The kind of IT worth running&lt;/h2&gt;
&lt;p&gt;The goal is not perfect systems.&lt;/p&gt;
&lt;p&gt;It is systems that get better over time without burning people out.&lt;/p&gt;
&lt;p&gt;If your IT operation only works because someone is being heroic, it is fragile by definition.&lt;/p&gt;
&lt;p&gt;Design the incentives.
Create the feedback loops.
Make the trade offs visible.&lt;/p&gt;
&lt;p&gt;Do that, and root causes stop being a personal burden and start becoming an organisational responsibility.&lt;/p&gt;
&lt;p&gt;That is where calm, sustainable IT actually begins.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Have you worked somewhere that rewarded heroics over prevention? How did you shift the incentives? I&#39;d like to hear about it in the comments.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Year-end notes: self-hosted platform resolutions and the backlog I am finally tackling</title>
    <link href="https://spinstate.dev/notes/new-year-homelab-backlog/" />
    <updated>2025-12-28T00:00:00Z</updated>
    <id>https://spinstate.dev/notes/new-year-homelab-backlog/</id>
    <summary>A late December reset: observability across the stack, agentic workflows, self-healing, and the certifications I am chasing in the new year.</summary>
    <content type="html">&lt;p&gt;Late December always brings clarity. I can see the gaps in my platform backlog, and I finally want to finish the work that turns the stack into a real platform, not just a collection of services.&lt;/p&gt;
&lt;h2 id=&quot;observability-across-the-stack&quot;&gt;Observability across the stack&lt;/h2&gt;
&lt;p&gt;Grafana, Loki, and Prometheus are next. I want full coverage across the stack so I can answer questions quickly and trace issues end to end. I am done with piecemeal logs and dashboards that only cover a single host.&lt;/p&gt;
&lt;h2 id=&quot;agentic-workflows-on-real-events&quot;&gt;Agentic workflows on real events&lt;/h2&gt;
&lt;p&gt;I am building automated AI workflows that trigger on events like journal updates and platform alerts. The goal is less noise, faster diagnosis, and a clean handoff when something needs a human.&lt;/p&gt;
&lt;h2 id=&quot;alerts-and-self-healing&quot;&gt;Alerts and self-healing&lt;/h2&gt;
&lt;p&gt;Next year I want to go deeper on alert tuning and self-healing patterns. It is not enough to detect failures. The platform should recover when it can, and surface the right signal when it cannot.&lt;/p&gt;
&lt;h2 id=&quot;certifications-to-focus-on&quot;&gt;Certifications to focus on&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;AZ-305&lt;/li&gt;
&lt;li&gt;CKA&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is my new year reset. Smaller list, higher quality, real systems work. That is the goal.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;What&#39;s the one platform backlog item you&#39;re finally going to tackle this year? Tell me in the comments.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>CI/CD runners on GitLab CE: a small win worth celebrating</title>
    <link href="https://spinstate.dev/notes/gitlab-cicd-runners/" />
    <updated>2025-12-10T00:00:00Z</updated>
    <id>https://spinstate.dev/notes/gitlab-cicd-runners/</id>
    <summary>I stood up my own GitLab CE runners and automated deployments without leaning on an off the shelf service.</summary>
    <content type="html">&lt;p&gt;December brought a quiet but meaningful win. I finally stood up CI/CD runners in GitLab CE (yeah, Gitea didn&#39;t scratch my itch!) and integrated them into my deployment flow. Releases now move through a predictable pipeline instead of being kicked off by a script I have to remember to run by hand.&lt;/p&gt;
&lt;p&gt;I deliberately avoided an off-the-shelf hosted service. I wanted to understand the moving parts, own the configuration end to end, and build something that fits my platform rather than bending the platform to fit a tool. It took longer, but the result feels solid.&lt;/p&gt;
&lt;p&gt;Pipelines run cleanly. Deployments are consistent. Failures surface early and clearly. When a build breaks, tests fail and I know about it immediately. When something unexpected happens further down the line, getting back to a known good state is fast and repeatable.&lt;/p&gt;
&lt;p&gt;The day-to-day interaction is almost trivial now:&lt;/p&gt;
&lt;p&gt;&#39;git commit -m &amp;quot;commit note&amp;quot; &amp;amp;&amp;amp; git push origin main&#39;&lt;/p&gt;
&lt;p&gt;Behind that simplicity sits a set of guardrails that remove manual steps without removing intent or control. Automation replaces repetition, not judgement, and that’s where the confidence comes from.&lt;/p&gt;
&lt;p&gt;On paper, this is a small change. In practice, it’s a shift in how the platform behaves: less manual work, less reliance on ad-hoc scripting, and a clearer, calmer path from commit to release. Small wins like this compound, and this one felt worth celebrating.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;What&#39;s a small automation win that changed how your platform behaves day to day? Let me know in the comments.&lt;/p&gt;
</content>
  </entry>
</feed>