<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: david</title>
    <description>The latest articles on DEV Community by david (@dwoitzik).</description>
    <link>https://dev.to/dwoitzik</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3933869%2F1fb8aa5b-2239-46a7-bf78-b5352809883c.png</url>
      <title>DEV Community: david</title>
      <link>https://dev.to/dwoitzik</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dwoitzik"/>
    <language>en</language>
    <item>
      <title>Gitea Actions on k3s: Self-Hosted CI Without GitHub</title>
      <dc:creator>david</dc:creator>
      <pubDate>Thu, 12 Nov 2026 16:44:49 +0000</pubDate>
      <link>https://dev.to/dwoitzik/gitea-actions-on-k3s-self-hosted-ci-without-github-4mgo</link>
      <guid>https://dev.to/dwoitzik/gitea-actions-on-k3s-self-hosted-ci-without-github-4mgo</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/gitea-actions-runner-k3s/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). As an Amazon Associate I earn from qualifying purchases. Buying through them costs you nothing extra.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;GitHub Actions is free for public repos. For private repos, you get 2,000 minutes/month on the free tier. A homelab infrastructure repo that runs Terraform plan, Ansible lint, and container builds on every PR burns through 2,000 minutes in about a week. Gitea Actions runs the same YAML workflows on your own hardware, and the &lt;a href="https://dev.to/go/4bv3yF1"&gt;BMAX Mini PC*&lt;/a&gt; with 64GB RAM has more than enough for a Gitea instance and a handful of runners.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/homelab-infrastructure" rel="noopener noreferrer"&gt;View the complete homelab infrastructure source on GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Gitea Actions: The GitHub Actions Clone
&lt;/h2&gt;

&lt;p&gt;Gitea added Actions in 1.19 as an experimental feature and has enabled it by default since 1.21. It's a CI system that reads workflow files in the same YAML format as GitHub Actions. The key differences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Workflows go in &lt;code&gt;.gitea/workflows/&lt;/code&gt;; Gitea also reads &lt;code&gt;.github/workflows/&lt;/code&gt; when that's all a repo has&lt;/li&gt;
&lt;li&gt;The runner is a separate binary (&lt;code&gt;act_runner&lt;/code&gt;) that polls Gitea for jobs&lt;/li&gt;
&lt;li&gt;Logs and artifacts go to Gitea's own storage (local disk by default, configurable to MinIO/S3)&lt;/li&gt;
&lt;li&gt;Secrets and variables are managed in the Gitea UI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The YAML syntax is the same, and actions like &lt;code&gt;actions/checkout@v4&lt;/code&gt; are pulled from github.com by default. Most GitHub Actions workflows work in Gitea with minimal changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying Gitea on k3s
&lt;/h2&gt;

&lt;p&gt;Gitea runs as a StatefulSet with PostgreSQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;StatefulSet&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;serviceName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea/gitea:1.22&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;
            &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GITEA__DATABASE__DB_TYPE&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"postgres"&lt;/span&gt;
            &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GITEA__DATABASE__HOST&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"gitea-db.gitea.svc:5432"&lt;/span&gt;
            &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GITEA__DATABASE__NAME&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"gitea"&lt;/span&gt;
          &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;data&lt;/span&gt;
              &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/data&lt;/span&gt;
  &lt;span class="na"&gt;volumeClaimTemplates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;data&lt;/span&gt;
      &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ReadWriteOnce"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10Gi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Action Runner
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;act_runner&lt;/code&gt; binary runs as a Deployment, polling Gitea for jobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea-runner&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea-runner&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea-runner&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;runner&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea/act_runner:latest&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GITEA_INSTANCE_URL&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"http://gitea.gitea.svc:3000"&lt;/span&gt;
            &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GITEA_RUNNER_REGISTRATION_TOKEN&lt;/span&gt;
              &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;secretKeyRef&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea-runner-token&lt;/span&gt;
                  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;token&lt;/span&gt;
            &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GITEA_RUNNER_NAME&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"k3s-runner"&lt;/span&gt;
            &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GITEA_RUNNER_LABELS&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ubuntu-latest:docker://node:20-bookworm"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runner registration token is generated in the Gitea UI under Site Administration → Actions → Runners → Create new Runner.&lt;/p&gt;

&lt;p&gt;Two caveats with this manifest. With &lt;code&gt;docker://&lt;/code&gt; labels, &lt;code&gt;act_runner&lt;/code&gt; starts each job in a container, so it needs a Docker daemon; on k3s that means a Docker-in-Docker sidecar or the &lt;code&gt;act_runner&lt;/code&gt; dind image, which runs privileged. And &lt;code&gt;act_runner&lt;/code&gt; stores its registration in a &lt;code&gt;.runner&lt;/code&gt; file, so give each replica persistent storage (a StatefulSet fits better than a Deployment here), or every restarted pod registers as a new runner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workflow Migration
&lt;/h2&gt;

&lt;p&gt;A GitHub Actions workflow migrates to Gitea by moving the file, or by leaving it where it is if the repo has no &lt;code&gt;.gitea/workflows/&lt;/code&gt; directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.github/workflows/ci.yml  →  .gitea/workflows/ci.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The YAML content stays the same. Gitea provides the &lt;code&gt;github&lt;/code&gt; context for compatibility, so &lt;code&gt;github.event_name&lt;/code&gt;, &lt;code&gt;github.repository&lt;/code&gt; and &lt;code&gt;github.sha&lt;/code&gt; keep working; &lt;code&gt;gitea.*&lt;/code&gt; is available as an alias.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker-in-Docker
&lt;/h2&gt;

&lt;p&gt;Many CI workflows need Docker (building images, running containers). The Gitea runner supports this either by running job containers privileged or by mounting the Docker socket into them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .gitea/workflows/docker-build.yml
&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build and Push&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;push&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker build -t myapp:${{ github.sha }} .&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Push&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin&lt;/span&gt;
          &lt;span class="s"&gt;docker push myapp:${{ github.sha }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runner label &lt;code&gt;ubuntu-latest:docker://node:20-bookworm&lt;/code&gt; tells the runner to execute jobs inside a &lt;code&gt;node:20-bookworm&lt;/code&gt; container, which has no Docker CLI. For image builds, point the label at an image that ships one, such as catthehacker's act image, and enable &lt;code&gt;container.privileged: true&lt;/code&gt; (or a socket mount via &lt;code&gt;container.options&lt;/code&gt;) in the runner's &lt;code&gt;config.yaml&lt;/code&gt;. Privileged mode is not a label setting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;GITEA_RUNNER_LABELS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-latest"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Resource Limits
&lt;/h2&gt;

&lt;p&gt;On a homelab &lt;a href="https://dev.to/go/4bv3yF1"&gt;BMAX Mini PC*&lt;/a&gt;, the Gitea runner shouldn't eat all the resources. Set resource limits on the runner Deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"500m"&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"512Mi"&lt;/span&gt;
  &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"2"&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"2Gi"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two runners capped at 2 GiB each is 4 GiB at most, negligible on a 64GB machine. The runners share CPU with the other k3s workloads: the scheduler reserves only the 500m request, and &lt;code&gt;cpu: "2"&lt;/code&gt; is a throttling ceiling, not a reservation. Note that with Docker-in-Docker the job containers run inside the dind sidecar, so set limits on that container too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trade-off
&lt;/h2&gt;

&lt;p&gt;Gitea Actions is not as mature as GitHub Actions. Matrix builds and actions from the GitHub marketplace work, but some workflow syntax is ignored or only partly supported: &lt;code&gt;jobs.&amp;lt;job_id&amp;gt;.environment&lt;/code&gt;, problem matchers, error annotations, complex &lt;code&gt;runs-on&lt;/code&gt; expressions, and GitHub-only &lt;code&gt;permissions&lt;/code&gt; scopes such as &lt;code&gt;id-token&lt;/code&gt;. For a homelab infrastructure repo that runs Terraform, Ansible, and container builds, those gaps don't matter. Terraform changes still go through &lt;a href="https://dev.to/blog/mikrotik-terraform-gitops-firewall-management/"&gt;Atlantis&lt;/a&gt; for the actual apply, so Gitea Actions only needs to handle lint and plan. The YAML syntax is the same, the runner runs on your hardware, and there's no minute limit.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>gitea</category>
      <category>cicd</category>
      <category>homelab</category>
    </item>
    <item>
      <title>Raspberry Pi 5 Alternatives Compared 2026</title>
      <dc:creator>david</dc:creator>
      <pubDate>Tue, 10 Nov 2026 15:38:55 +0000</pubDate>
      <link>https://dev.to/dwoitzik/raspberry-pi-5-alternatives-compared-2026-3g52</link>
      <guid>https://dev.to/dwoitzik/raspberry-pi-5-alternatives-compared-2026-3g52</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/raspberry-pi-5-alternatives-2026/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). As an Amazon Associate I earn from qualifying purchases. Buying through them costs you nothing extra.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every time Raspberry Pi stock tightens or prices creep up, the same question comes back: is there a better SBC for homelab edge work in 2026? I run &lt;a href="https://dev.to/blog/homelab-hardware-tour-physical-rack/"&gt;Raspberry Pi 4B units for DNS and Keepalived&lt;/a&gt; in my own rack, hardened per my &lt;a href="https://dev.to/blog/raspberry-pi-edge-hardening-watchdog-fail2ban-nftables/"&gt;watchdog/fail2ban/nftables setup&lt;/a&gt;, and I'd recommend the Pi 5 to anyone building that same role today. This is the comparison I'd want before spending money on an alternative: what the competing boards offer, where they win, and where the Pi's ecosystem still makes it the safer default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/homelab-infrastructure" rel="noopener noreferrer"&gt;View the complete homelab infrastructure source on GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What's hands-on here and what isn't:&lt;/strong&gt; I run two Raspberry Pi 4Bs daily. I don't own a Pi 5, a Radxa Rock 5B, or an Orange Pi 5 Plus. Everything said about those three boards is built from their published specifications and vendor documentation, not a personal test.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  TL;DR: The Picks
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;SoC&lt;/th&gt;
&lt;th&gt;RAM options&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://dev.to/go/4wETNfv"&gt;Raspberry Pi 5 (8GB) Starter Kit*&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Broadcom BCM2712 (4x Cortex-A76)&lt;/td&gt;
&lt;td&gt;1/2/4/8/16GB&lt;/td&gt;
&lt;td&gt;Best Overall / Ecosystem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Radxa Rock 5B&lt;/td&gt;
&lt;td&gt;Rockchip RK3588 (4x A76 + 4x A55)&lt;/td&gt;
&lt;td&gt;4/8/16GB&lt;/td&gt;
&lt;td&gt;Best Raw Performance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Orange Pi 5 Plus&lt;/td&gt;
&lt;td&gt;Rockchip RK3588 (4x A76 + 4x A55)&lt;/td&gt;
&lt;td&gt;4/8/16/32GB&lt;/td&gt;
&lt;td&gt;Best Specs-per-Euro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://dev.to/go/4gGnuZ3"&gt;Beelink S12 Pro mini PC*&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Intel N100&lt;/td&gt;
&lt;td&gt;16GB DDR4 (SO-DIMM)&lt;/td&gt;
&lt;td&gt;Best "Just Skip SBCs" Option&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;SBC prices have moved repeatedly since 2025, mostly because of memory costs, so I've left them out. Check the current listing for the RAM tier you actually need.&lt;/p&gt;

&lt;p&gt;The short version: the &lt;strong&gt;Raspberry Pi 5&lt;/strong&gt; remains the right default for anything that needs to work with the widest software and community support: DNS, Keepalived, Home Assistant, PiKVM-style appliances. If the workload is CPU- or RAM-bound (local inference, heavier containerized services, multiple VMs via KVM), the &lt;strong&gt;RK3588 boards&lt;/strong&gt; (Rock 5B or Orange Pi 5 Plus) offer more silicon for the money. And if the form factor doesn't need to be SBC-sized, a &lt;strong&gt;budget N100 mini PC&lt;/strong&gt; often beats all of them on day-to-day reliability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Comparison Exists at All
&lt;/h2&gt;

&lt;p&gt;The Pi 5 is a good board. People search for "alternatives" because of availability and price, not a spec deficiency. Raspberry Pi's retail pricing has crept up since the Pi 4 era, and RK3588 boards from Radxa, Orange Pi, and others outperform it on paper for similar money. Whether that advantage survives real homelab use is the question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Selection Criteria for Homelab Edge Use
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Software and driver maturity.&lt;/strong&gt; This is where the Pi wins before specs enter the conversation. Raspberry Pi OS, a kernel Raspberry Pi maintains itself, and a decade of community documentation mean almost everything works, including obscure GPIO/HAT hardware. RK3588 boards run mainline Linux reasonably well in 2026, but VPU/NPU acceleration, some peripheral drivers, and firmware still lag behind. Vendor kernel forks and less-tested distro images are common.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Actual (not marketed) idle power draw.&lt;/strong&gt; For anything running 24/7 on a homelab shelf, idle wattage matters more than peak benchmark numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long-term software support.&lt;/strong&gt; Raspberry Pi states the Pi 5 will stay in production until at least January 2036. Smaller board vendors support their boards too, but for shorter and less predictable periods. That matters if you don't want to re-flash and re-provision in two years.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Networking.&lt;/strong&gt; The Pi 5 has a single 1GbE port. Both RK3588 boards here ship 2.5GbE, which matters if the device does anything network-intensive (routing, NAS-adjacent duties).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price at the RAM tier you need.&lt;/strong&gt; SBC pricing is not linear across RAM tiers, and the advertised starting price of any board is the smallest variant, not the 16GB one you'd want for anything beyond DNS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Boards
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Raspberry Pi 5 (8GB): Best Overall
&lt;/h3&gt;

&lt;p&gt;Broadcom BCM2712, 4x Cortex-A76 @ 2.4GHz, LPDDR4X in 1/2/4/8/16GB variants, single 1GbE, and a PCIe 2.0 x1 interface that takes an M.2 HAT for NVMe boot. The &lt;a href="https://dev.to/go/4wETNfv"&gt;db-tronic starter kit I've linked throughout this site*&lt;/a&gt; bundles the 8GB board with a case, fan, official power supply, and SD card, which saves a separate shopping list if you don't already have those parts lying around.&lt;/p&gt;

&lt;p&gt;The Pi 5's real advantage over its predecessor and most alternatives isn't raw compute. It's the PCIe lane and the official HAT ecosystem around it, which finally make NVMe boot a well-documented, first-class option instead of a community hack. For homelab edge roles (DNS, Keepalived, Home Assistant, lightweight k3s agents) the Pi 5 has enough CPU headroom, and the software maturity means you spend your time on the service instead of debugging why a USB Wi-Fi dongle or camera module doesn't work on one particular board revision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; unmatched software/community support, official case and HAT ecosystem, production commitment to 2036, PoE+ via HAT&lt;br&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; single 1GbE only, NVMe needs a separate HAT, price has crept up since the Pi 4 era&lt;/p&gt;

&lt;h3&gt;
  
  
  Radxa Rock 5B: Best Raw Performance
&lt;/h3&gt;

&lt;p&gt;Rockchip RK3588 (4x Cortex-A76 + 4x Cortex-A55), 4/8/16GB LPDDR4X, one 2.5GbE port, and an on-board M.2 M-key slot (PCIe 3.0 x4) for NVMe, so no HAT needed.&lt;/p&gt;

&lt;p&gt;On paper this is a much more powerful board than the Pi 5. Eight cores, the RK3588's hardware video codecs, and its 6 TOPS NPU give it an edge for anything that can use that hardware (video transcoding, some ML inference), and the on-board M.2 slot is a real convenience. The tradeoff is software maturity: mainline kernel support has improved a lot, but you're more likely to hit a driver gap or need a vendor image for full hardware support. NPU acceleration in particular is still the least mature part of the RK3588 software stack across every board, not just this one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; more compute headroom than the Pi 5, on-board NVMe slot with four PCIe 3.0 lanes, 2.5GbE&lt;br&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; smaller community than Pi, less mature driver/firmware stack, shorter and less predictable support window, fewer case/accessory options&lt;/p&gt;

&lt;h3&gt;
  
  
  Orange Pi 5 Plus: Best Specs-per-Euro
&lt;/h3&gt;

&lt;p&gt;Same RK3588 SoC as the Rock 5B, 4/8/16/32GB RAM, dual 2.5GbE, on-board M.2 M-key slot (PCIe 3.0 x4).&lt;/p&gt;

&lt;p&gt;Orange Pi has historically undercut Radxa on price for the same silicon, and the 32GB configuration is unusual in this class. It's useful if the plan is running several lightweight VMs via KVM rather than bare containers. Orange Pi's documentation and image quality have historically been rougher than Radxa's, which are in turn rougher than Raspberry Pi's. If you hit a hardware quirk, expect more time in forum threads and less in polished documentation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; most RAM headroom in this comparison, dual 2.5GbE, on-board NVMe&lt;br&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; documentation and image quality behind Radxa and Pi, smallest community of the three, needs an active cooler under sustained load&lt;/p&gt;

&lt;h3&gt;
  
  
  The Honest Alternative: Skip the SBC Entirely
&lt;/h3&gt;

&lt;p&gt;For a homelab specifically (as opposed to embedded or GPIO-driven projects, where SBCs have no real substitute), the comparison that matters most often isn't Pi vs. RK3588 board. It's SBC vs. a budget x86 mini PC. A &lt;a href="https://dev.to/go/4gGnuZ3"&gt;Beelink S12 Pro*&lt;/a&gt; with an Intel N100, 16GB DDR4, and a 500GB SSD runs full x86 Linux with zero driver-maturity questions. It costs more than a bare board, so compare it against the full price of an RK3588 board plus case, power supply, and NVMe drive. I cover it in more depth in the &lt;a href="https://dev.to/blog/best-mini-pc-homelab-2026/"&gt;full mini PC comparison&lt;/a&gt;. If the workload doesn't need GPIO, camera interfaces, or the smallest possible footprint, the N100 box removes the whole "will this peripheral work on this SBC" category of problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Pi 5 (8GB)&lt;/th&gt;
&lt;th&gt;Rock 5B (16GB)&lt;/th&gt;
&lt;th&gt;Orange Pi 5 Plus (16GB)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;4x A76&lt;/td&gt;
&lt;td&gt;4x A76 + 4x A55&lt;/td&gt;
&lt;td&gt;4x A76 + 4x A55&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NPU&lt;/td&gt;
&lt;td&gt;None (optional AI HAT+ via PCIe)&lt;/td&gt;
&lt;td&gt;6 TOPS&lt;/td&gt;
&lt;td&gt;6 TOPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Networking&lt;/td&gt;
&lt;td&gt;1x GbE&lt;/td&gt;
&lt;td&gt;1x 2.5GbE&lt;/td&gt;
&lt;td&gt;2x 2.5GbE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;microSD / NVMe via HAT (PCIe 2.0 x1)&lt;/td&gt;
&lt;td&gt;microSD / on-board M.2 (PCIe 3.0 x4)&lt;/td&gt;
&lt;td&gt;microSD / on-board M.2 (PCIe 3.0 x4)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Software maturity&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Good, improving&lt;/td&gt;
&lt;td&gt;Fair, improving&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Community/support&lt;/td&gt;
&lt;td&gt;Largest by far&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;td&gt;Smaller&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Who Should Buy Which
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Buy the Raspberry Pi 5 if:&lt;/strong&gt; you want the widest hardware/software compatibility, you're running GPIO or camera peripherals, or the workload is light (DNS, Keepalived, Home Assistant) and driver headaches aren't worth risking for compute you don't need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buy the Rock 5B or Orange Pi 5 Plus if:&lt;/strong&gt; the workload is CPU- or RAM-bound (multiple KVM VMs, video transcoding, light ML inference) and you're comfortable working through a smaller community's documentation when something doesn't work out of the box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buy an N100 mini PC instead if:&lt;/strong&gt; the project doesn't need GPIO or the smallest physical footprint. For pure homelab service hosting, x86 driver maturity beats every SBC option here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is the Rock 5B actually faster than the Raspberry Pi 5?
&lt;/h3&gt;

&lt;p&gt;In multi-threaded benchmarks and anything that can use the RK3588's NPU or video codecs, yes, by a clear margin. In homelab service hosting (reverse proxies, DNS, lightweight containers) the difference is far less noticeable than the spec sheet suggests, because those workloads rarely saturate either board's CPU.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I buy an RK3588 board for k3s?
&lt;/h3&gt;

&lt;p&gt;For a worker node running a handful of lightweight pods, either family works. The Pi 5 now goes up to 16GB, which covers most edge-node use. If you need more than that, the 32GB Orange Pi 5 Plus is the only option here. Budget extra time for kernel/driver troubleshooting on either RK3588 board if you hit an edge case.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the Raspberry Pi 5 support NVMe boot?
&lt;/h3&gt;

&lt;p&gt;Yes, via the official M.2 HAT+ (or third-party equivalents) on the PCIe 2.0 x1 interface. It's a separate purchase, unlike the Rock 5B and Orange Pi 5 Plus, which have the M.2 slot on-board.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about power draw?
&lt;/h3&gt;

&lt;p&gt;All three boards idle in the low single-digit watts in published measurements. The differences are a few watts at most, not a category difference, and not enough on their own to decide anything for a homelab running a handful of these devices.&lt;/p&gt;

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

&lt;p&gt;The Raspberry Pi 5 is no longer the best SBC on paper. The RK3588 boards from Radxa and Orange Pi out-spec it for similar money. What the Pi still wins decisively is the thing that costs you time in a homelab: software and driver maturity. For DNS, Keepalived, and similar edge roles, I'm staying on the Raspberry Pi platform, and the Pi 5 is what I'd buy to replace my 4Bs. For CPU- or RAM-bound work where you'll trade some driver risk for more silicon, the Rock 5B or Orange Pi 5 Plus is worth it. And for pure service hosting with no GPIO requirement, a budget x86 mini PC often beats every SBC in this comparison on total reliability per euro.&lt;/p&gt;

</description>
      <category>hardware</category>
      <category>homelab</category>
      <category>review</category>
    </item>
    <item>
      <title>Unable to mkstemp /tmp/apt.sig: How /tmp 0755 Broke apt</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 09 Nov 2026 15:42:24 +0000</pubDate>
      <link>https://dev.to/dwoitzik/unable-to-mkstemp-tmpaptsig-how-tmp-0755-broke-apt-2a45</link>
      <guid>https://dev.to/dwoitzik/unable-to-mkstemp-tmpaptsig-how-tmp-0755-broke-apt-2a45</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/tmp-permissions-broke-apt-unattended-upgrades/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). As an Amazon Associate I earn from qualifying purchases. Buying through them costs you nothing extra.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For about two months, apt on my Proxmox host could not refresh a single repository. &lt;code&gt;/tmp&lt;/code&gt; had drifted from mode 1777 to 0755, apt's unprivileged &lt;code&gt;_apt&lt;/code&gt; sandbox user could no longer create its signature temp files, and every &lt;code&gt;apt-get update&lt;/code&gt; quietly fell back to stale index files. The fix was one &lt;code&gt;chmod&lt;/code&gt;, plus a watchdog check so the next drift raises an alert instead of waiting for me to trip over it.&lt;/p&gt;

&lt;p&gt;A week later I found a second version of the same problem on the Raspberry Pi nodes: &lt;code&gt;unattended-upgrades&lt;/code&gt; ran every day, logged success, and left 69 updates untouched, including Docker Engine and containerd. Both failures share a shape. The update machinery was running, and nothing was getting updated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/homelab-infrastructure" rel="noopener noreferrer"&gt;View the complete homelab infrastructure source on GitHub 🐙&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How it surfaced: not through an alert
&lt;/h2&gt;

&lt;p&gt;No alert caught the &lt;code&gt;/tmp&lt;/code&gt; problem. I was rolling out a change to the host watchdog on &lt;code&gt;pve-mgmt-01&lt;/code&gt;, the single Proxmox box everything else in the homelab runs on, and the Ansible playbook died on its very first task:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failed to update apt cache: unknown reason
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Unknown reason" is the Ansible apt module passing on that apt did not exit cleanly, without saying why. Running &lt;code&gt;apt-get update&lt;/code&gt; by hand on the host showed the real error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unable to mkstemp /tmp/apt.sig.&amp;lt;XXX&amp;gt; - GetTempFile (13: Permission denied)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;followed by the warning that matters most and gets read least:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The repository is not updated and the previous index files will be used
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That warning came up for every repository. &lt;code&gt;apt-get update&lt;/code&gt; still completed and printed only &lt;code&gt;W:&lt;/code&gt; lines, so a quick glance looked like a noisy but successful run. In reality the host had not accepted a fresh package index since the problem began, and any update or install went against outdated metadata.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause: /tmp at 0755, and who apt runs as
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;stat&lt;/code&gt; on &lt;code&gt;/tmp&lt;/code&gt; showed the problem immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; %A /tmp
drwxr-xr-x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is &lt;code&gt;root:root 0755&lt;/code&gt;. A normal &lt;code&gt;/tmp&lt;/code&gt; is &lt;code&gt;drwxrwxrwt&lt;/code&gt;, mode 1777: world-writable, with the sticky bit so users can only delete their own files.&lt;/p&gt;

&lt;p&gt;The reason this only broke apt is privilege separation. apt does not do its downloading and signature checking as root. It drops to a sandbox user, &lt;code&gt;_apt&lt;/code&gt;, configured by &lt;code&gt;APT::Sandbox::User&lt;/code&gt;, and that user writes temporary signature files to &lt;code&gt;/tmp&lt;/code&gt; while verifying repository metadata. With &lt;code&gt;/tmp&lt;/code&gt; at 0755, only root can create files there. &lt;code&gt;_apt&lt;/code&gt; gets &lt;code&gt;Permission denied&lt;/code&gt;, signature verification fails for every source, and apt keeps the old indexes.&lt;/p&gt;

&lt;p&gt;Root, meanwhile, can still write to &lt;code&gt;/tmp&lt;/code&gt; without complaint. Every root-run cron job, every script, every Proxmox service I touched kept working. Nothing else on the host needed a non-root user to write a temp file, so nothing else complained. That is why this went unnoticed for two months.&lt;/p&gt;

&lt;h2&gt;
  
  
  How long, and why I can't say what did it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/tmp&lt;/code&gt; on this host is a tmpfs from the stock systemd unit &lt;code&gt;tmp.mount&lt;/code&gt;, with these options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;mode=1777,strictatime,nosuid,nodev,size=50%,nr_inodes=1m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The running mount matched the unit on &lt;code&gt;size&lt;/code&gt; and &lt;code&gt;nr_inodes&lt;/code&gt;, but &lt;code&gt;mode&lt;/code&gt; and &lt;code&gt;strictatime&lt;/code&gt; were missing from its effective options. The mount had been active since &lt;code&gt;2026-08-23 12:29:28 CEST&lt;/code&gt;, which puts the broken state at roughly two months by the time I found it on October 16.&lt;/p&gt;

&lt;p&gt;I could not reconstruct the trigger. Nothing in the repository and nothing on the host (scripts, cron, shell history) runs &lt;code&gt;chmod&lt;/code&gt; on &lt;code&gt;/tmp&lt;/code&gt;. The most plausible explanation is a single manual &lt;code&gt;chmod 0755&lt;/code&gt;, or a &lt;code&gt;remount&lt;/code&gt; issued without &lt;code&gt;mode=&lt;/code&gt;. I'd rather write "unknown" in the incident record than invent a cause that fits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The live fix was one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod &lt;/span&gt;1777 /tmp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, &lt;code&gt;stat -c %A /tmp&lt;/code&gt; showed &lt;code&gt;drwxrwxrwt&lt;/code&gt; again, and &lt;code&gt;apt-get update&lt;/code&gt; fetched 898 kB with no signature errors.&lt;/p&gt;

&lt;p&gt;Persistence needed no change. &lt;code&gt;tmp.mount&lt;/code&gt; sets &lt;code&gt;mode=1777&lt;/code&gt; on every boot, so a reboot would have repaired it as well. The drift was a runtime state, not a broken boot configuration. That is also the uncomfortable part: if I don't know what changed the mode, I can't stop it from happening again. I can only make sure I hear about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detection: one more check in the host watchdog
&lt;/h2&gt;

&lt;p&gt;The Proxmox host already runs a watchdog script every five minutes that checks thin-pool usage, CPU temperature, load, stale snapshots and NVMe kernel errors, and sends everything it finds through ntfy with escalating backoff. Adding &lt;code&gt;/tmp&lt;/code&gt; there costs seven lines in &lt;code&gt;ansible/roles/pve_power/templates/pve-watchdog.sh.j2&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /tmp must be world-writable + sticky (1777). A chmod to 0755 broke apt's&lt;/span&gt;
&lt;span class="c"&gt;# _apt sandbox user for ~2 months (mkstemp: permission denied) with no other&lt;/span&gt;
&lt;span class="c"&gt;# symptom; tmp.mount owns the mode at boot, this catches runtime drift.&lt;/span&gt;
&lt;span class="nv"&gt;TMP_MODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; %a /tmp 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP_MODE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"1777"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;ALERT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ALERT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/tmp mode is &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TMP_MODE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (want 1777). "&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It appends to the same &lt;code&gt;ALERT&lt;/code&gt; string as every other check, so it inherits the existing ntfy topic, the maintenance-mode suppression and the backoff without any new plumbing. The check detects a repeat. It does not prevent one, and the incident record says so in those words.&lt;/p&gt;

&lt;h2&gt;
  
  
  A week later: unattended-upgrades running, nothing upgrading
&lt;/h2&gt;

&lt;p&gt;On October 22 the pending-update counts across the hosts told two different stories. The Debian hosts looked fine: 0 to 6 pending upgrades on the k3s nodes, the NFS server and Atlantis. The two Raspberry Pi 4B nodes that run &lt;a href="https://dev.to/blog/unbound-adguard-keepalived-homelab/"&gt;AdGuard and Unbound behind keepalived&lt;/a&gt; had 69 pending updates, and that number wasn't moving.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;unattended-upgrades&lt;/code&gt; was installed and enabled there too, and its log showed it running daily. The log line that explained everything was the one listing what it was allowed to touch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INFO Allowed origins are: origin=Debian,codename=trixie,label=Debian,
     origin=Debian,codename=trixie,label=Debian-Security,
     origin=Debian,codename=trixie-security,label=Debian-Security
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only Debian. The Pi nodes also carry repositories from the Raspberry Pi Foundation, Docker CE, Tailscale and AZlux, and packages from those were never candidates. The backlog included &lt;code&gt;docker-ce&lt;/code&gt; (29.5.0 installed, 29.9.2 available), &lt;code&gt;containerd.io&lt;/code&gt; (2.2.3 installed, 2.4.2 available), &lt;code&gt;libc6&lt;/code&gt;, &lt;code&gt;openssl&lt;/code&gt; and the Raspberry Pi firmware packages. The installed containerd was affected by five published CVEs, the worst rated 9.9 critical, all fixed in the 2.4.2 that sat in the repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Matching third-party origins is fiddly
&lt;/h2&gt;

&lt;p&gt;The fix lives in the &lt;code&gt;common&lt;/code&gt; Ansible role, which runs on every managed host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Let unattended-upgrades cover the third-party repositories&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.blockinfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/apt/apt.conf.d/50unattended-upgrades&lt;/span&gt;
    &lt;span class="na"&gt;marker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{mark}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ANSIBLE&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;MANAGED&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ORIGINS"&lt;/span&gt;
    &lt;span class="na"&gt;block&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;Unattended-Upgrade::Origins-Pattern {&lt;/span&gt;
          &lt;span class="s"&gt;"origin=Debian,codename=${distro_codename},label=Debian";&lt;/span&gt;
          &lt;span class="s"&gt;"origin=Debian,codename=${distro_codename}-security,label=Debian-Security";&lt;/span&gt;
          &lt;span class="s"&gt;"origin=Debian,codename=${distro_codename}-updates,label=Debian";&lt;/span&gt;
          &lt;span class="s"&gt;"origin=Raspberry Pi Foundation,codename=${distro_codename},label=Raspberry Pi Foundation,c=main";&lt;/span&gt;
          &lt;span class="s"&gt;"origin=Docker,label=Docker CE,c=stable";&lt;/span&gt;
          &lt;span class="s"&gt;"origin=Tailscale,codename=bookworm,label=Tailscale,c=main";&lt;/span&gt;
      &lt;span class="s"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each third-party line uses different fields because the repositories publish different metadata. Docker CE has no codename at all, so a &lt;code&gt;codename=&lt;/code&gt; match finds nothing. Tailscale has no suite but does publish codename &lt;code&gt;bookworm&lt;/code&gt;, even on these trixie hosts. My first two attempts failed on exactly this: matching Docker on &lt;code&gt;codename=&lt;/code&gt; matched nothing, and matching it on &lt;code&gt;n=&lt;/code&gt; matched nothing either, because &lt;code&gt;n=&lt;/code&gt; is the codename field, not the suite.&lt;/p&gt;

&lt;p&gt;What worked was not guessing. &lt;code&gt;apt-get indextargets&lt;/code&gt; prints origin, label, suite, codename and component for every index the host knows, so the right field combination for each repository can be read off directly. &lt;code&gt;unattended-upgrade --dry-run --debug&lt;/code&gt; then shows the allowed origins and the package list they produce, which confirms the pattern before it runs for real.&lt;/p&gt;

&lt;p&gt;Applied live, unattended-upgrades installed 74 packages on both Pi nodes: Docker 29.5.0 to 29.9.2, containerd 2.2.3 to 2.4.2, Tailscale 1.98.4 to 1.104.2, kernel 6.18.29 to 6.18.50, and all containers came back up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-offs I accepted
&lt;/h2&gt;

&lt;p&gt;Letting unattended-upgrades touch Docker has a cost. A Docker Engine or containerd update restarts the daemon, which stops every container on that node, including AdGuard and Unbound. The keepalived VIP moves to the other Pi, so DNS stays up, but the containers on the updating node still restart. I accept that over a container runtime that sits behind on published security fixes.&lt;/p&gt;

&lt;p&gt;Kernel and firmware updates still need a reboot. unattended-upgrades installs them and writes &lt;code&gt;/var/run/reboot-required&lt;/code&gt;, but &lt;code&gt;Automatic-Reboot&lt;/code&gt; stays off. An unattended reboot of the DNS and proxy nodes is not something I want, so reboots remain a deliberate, announced step. That fits the split I described in &lt;a href="https://dev.to/blog/operating-model-auto-update-human/"&gt;what auto-updates and what needs a human&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The k3s cluster is not covered by any of this. k3s ships its own containerd inside the binary, so the packaged &lt;code&gt;containerd.io&lt;/code&gt; version says nothing about it. Its containerd is fixed only by a k3s upgrade, which is a separate job with a larger blast radius than a package update.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I check now
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;apt warnings are failures.&lt;/strong&gt; &lt;code&gt;The repository is not updated and the previous index files will be used&lt;/code&gt; means the update did not happen, whatever the exit status says. A &lt;code&gt;W:&lt;/code&gt; line from &lt;code&gt;apt-get update&lt;/code&gt; deserves the same attention as an &lt;code&gt;E:&lt;/code&gt; line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test as the user that does the work.&lt;/strong&gt; Root writing to &lt;code&gt;/tmp&lt;/code&gt; proved nothing. The failing write came from &lt;code&gt;_apt&lt;/code&gt;. When something only breaks for an unprivileged service user, a root shell will tell you everything is fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"unattended-upgrades ran" is not "the host is patched".&lt;/strong&gt; The log line to read is &lt;code&gt;Allowed origins are:&lt;/code&gt;, and the number to watch is pending updates per host over time. A count that doesn't go down is the alert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detection when the cause is unknown.&lt;/strong&gt; I couldn't find what changed &lt;code&gt;/tmp&lt;/code&gt;, so the honest fix is a check that pages me within five minutes of a repeat, not a guess at a root cause.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the same lesson as the &lt;a href="https://dev.to/blog/raspberry-pi-edge-hardening-watchdog-fail2ban-nftables/"&gt;Pi edge hardening work&lt;/a&gt;: an unattended box needs a check for each way it can fail quietly, because nobody is watching its console.&lt;/p&gt;

&lt;p&gt;If file modes, the sticky bit and what &lt;code&gt;stat -c %a&lt;/code&gt; actually prints are still a bit hazy, &lt;a href="https://dev.to/go/4gEAhv9"&gt;The Linux Command Line*&lt;/a&gt; covers permissions and special modes more clearly than most man pages.&lt;/p&gt;



</description>
      <category>proxmox</category>
      <category>homelab</category>
      <category>debugging</category>
      <category>ansible</category>
    </item>
    <item>
      <title>Prometheus Sizing for Homelab: RAM, Retention, and a 2Gi Limit</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 09 Nov 2026 15:41:48 +0000</pubDate>
      <link>https://dev.to/dwoitzik/prometheus-sizing-for-homelab-ram-retention-and-a-2gi-limit-50jk</link>
      <guid>https://dev.to/dwoitzik/prometheus-sizing-for-homelab-ram-retention-and-a-2gi-limit-50jk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/prometheus-sizing-homelab-ram/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). As an Amazon Associate I earn from qualifying purchases. Buying through them costs you nothing extra.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My Prometheus ran for months with no resource requests or limits. Nobody noticed until it landed on &lt;code&gt;vm-srv-k3s-13&lt;/code&gt;, at the time the smallest node in the cluster with 4GB, and used about 1.4Gi there. Together with everything else scheduled on that node it pushed memory to 100%, and containerd kill timeouts started restarting unrelated pods. Three weeks later the TSDB was filling its NFS volume. This post covers the values that fixed both, what the live numbers look like now, and one restart problem that looked like memory but wasn't. It's the same &lt;a href="https://dev.to/blog/kube-prometheus-loki-grafana-k3s/"&gt;kube-prometheus-stack&lt;/a&gt; deployment behind the cluster's alerting, so when Prometheus falls over, the alert pipeline goes with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/homelab-infrastructure" rel="noopener noreferrer"&gt;View the complete homelab infrastructure source on GitHub 🐙&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Drives Prometheus Memory
&lt;/h2&gt;

&lt;p&gt;Prometheus keeps the most recent data (roughly the last two to three hours) in an in-memory head block and writes older data to disk as compacted blocks. Memory follows from that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Active series count&lt;/strong&gt; is the biggest factor. Every unique label combination is a series, and each one in the head block costs memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scrape interval&lt;/strong&gt; doesn't add series, but it adds samples, which means more head chunks per series and more data to compact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retention&lt;/strong&gt; is mostly a disk cost. Older blocks are memory-mapped and paged in when queried, so long-range queries over long retention still cost RAM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recording rules&lt;/strong&gt; produce new series of their own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Target count is a poor predictor. My Prometheus scrapes 39 targets across 25 jobs and holds about 204,000 head series. Almost all of that comes from two jobs: the kubelet (cAdvisor container metrics) returns about 97,000 samples per scrape and the API server about 52,000. The largest single metrics are API server latency histograms like &lt;code&gt;apiserver_request_duration_seconds_bucket&lt;/code&gt;. One detail I didn't expect: the API server still exports &lt;code&gt;etcd_request_duration_seconds_bucket&lt;/code&gt; (about 22,000 series), even though this cluster's datastore is SQLite through kine, not etcd.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scrape Interval: 60s Globally
&lt;/h2&gt;

&lt;p&gt;The chart default is 30 seconds. I set it to 60:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheusSpec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Scrape every 60s instead of chart default 30s: halves scrape&lt;/span&gt;
    &lt;span class="c1"&gt;# load on the control-plane node and TSDB retention growth; 15s&lt;/span&gt;
    &lt;span class="c1"&gt;# granularity is far below any alert window we configure.&lt;/span&gt;
    &lt;span class="na"&gt;scrapeInterval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;60s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That halves the samples written per series compared to the default. It applies to the chart's own ServiceMonitors (kubelet, API server, node-exporter, kube-state-metrics) unless a ServiceMonitor sets its own &lt;code&gt;interval&lt;/code&gt;. The trade-off is that &lt;code&gt;rate()&lt;/code&gt; windows need to be at least a few minutes wide to hold enough samples. With 60-second scrapes, a &lt;code&gt;[1m]&lt;/code&gt; range often contains a single sample and returns nothing. My alerts all look back several minutes or longer, so that cost is zero in practice.&lt;/p&gt;

&lt;p&gt;Extra jobs go under &lt;code&gt;prometheus.prometheusSpec.additionalScrapeConfigs&lt;/code&gt;, not at the top level of the values file. They inherit the global interval unless they set &lt;code&gt;scrape_interval&lt;/code&gt; themselves. Mine cover node-exporters outside the cluster (the Raspberry Pis, the Docker LXC, the Proxmox host), the Proxmox and SNMP exporters, and a few blackbox probes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheusSpec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;additionalScrapeConfigs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;node-exporter-rpi'&lt;/span&gt;
        &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;10.0.20.2:9100'&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;10.0.20.3:9100'&lt;/span&gt;
            &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rpi_nodes'&lt;/span&gt;
        &lt;span class="na"&gt;relabel_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;__address__&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
            &lt;span class="na"&gt;target_label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;instance&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are small. The out-of-cluster node-exporters each return a few thousand samples per scrape, an order of magnitude below the kubelet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retention: 30 Days, Capped at 12GiB
&lt;/h2&gt;

&lt;p&gt;The TSDB lives on the &lt;code&gt;nfs-client&lt;/code&gt; storage class, not on the Proxmox host's local NVMe. It ran with &lt;code&gt;retention: 30d&lt;/code&gt; and nothing else until it started filling the NFS volume. The fix was a size cap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheusSpec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;retention&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30d&lt;/span&gt;
    &lt;span class="na"&gt;retentionSize&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;12GiB&lt;/span&gt;
    &lt;span class="na"&gt;storageSpec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;volumeClaimTemplate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nfs-client&lt;/span&gt;
          &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ReadWriteOnce"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20Gi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;retentionSize&lt;/code&gt; sits on top of the time-based retention. Persisted blocks, the WAL, and memory-mapped head chunks all count toward it, and when the total goes over, Prometheus deletes the oldest blocks regardless of &lt;code&gt;retention&lt;/code&gt;. Deletion happens block by block, so usage can sit a bit above the cap. Leave headroom on the volume. Also note that &lt;code&gt;nfs-subdir-external-provisioner&lt;/code&gt; does not enforce the PVC size; the &lt;code&gt;20Gi&lt;/code&gt; request is a label, and the real limit is the free space on the NFS export.&lt;/p&gt;

&lt;p&gt;In practice the size cap is now the limit that matters. Over the last 30 days, size-based retention deleted blocks nine times and time-based retention deleted none. The oldest sample in the TSDB is 21 days old, not 30. Blocks take about 10.9GB and the WAL about 0.2GB. If I wanted the full 30 days back, the lever would be fewer series (dropping unused kubelet and API server histogram buckets with &lt;code&gt;metricRelabelings&lt;/code&gt;), not a bigger cap.&lt;/p&gt;

&lt;p&gt;WAL compression is not set in my values. It has been on by default since Prometheus 2.20, and this cluster runs Prometheus 3.12. If you do set it explicitly, &lt;code&gt;walCompression&lt;/code&gt; in the Prometheus Operator CRD is a boolean (&lt;code&gt;walCompression: true&lt;/code&gt;), not a string like &lt;code&gt;enabled&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources and Placement
&lt;/h2&gt;

&lt;p&gt;After the k3s-13 incident, Prometheus got explicit requests and limits so the scheduler accounts for its real footprint, plus a soft preference away from both the control-plane node and the smallest worker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheusSpec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;affinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;nodeAffinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;preferredDuringSchedulingIgnoredDuringExecution&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;
            &lt;span class="na"&gt;preference&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;matchExpressions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes.io/hostname&lt;/span&gt;
                  &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NotIn&lt;/span&gt;
                  &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;vm-srv-k3s-11&lt;/span&gt;
                    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;vm-srv-k3s-13&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;100m&lt;/span&gt;
        &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Gi&lt;/span&gt;
      &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1000m&lt;/span&gt;
        &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2Gi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The current numbers, read from Prometheus itself on 2026-11-04:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scrape targets&lt;/td&gt;
&lt;td&gt;39 (25 jobs)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Head series&lt;/td&gt;
&lt;td&gt;~204,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Samples appended&lt;/td&gt;
&lt;td&gt;~3,800/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resident memory (process)&lt;/td&gt;
&lt;td&gt;~1.1GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Container working set&lt;/td&gt;
&lt;td&gt;~1.7GB now, ~1.9GB peak over 14 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory limit&lt;/td&gt;
&lt;td&gt;2Gi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TSDB on disk&lt;/td&gt;
&lt;td&gt;~10.9GB blocks + ~0.2GB WAL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oldest data&lt;/td&gt;
&lt;td&gt;21 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The working set includes page cache from memory-mapped blocks, which the kernel can reclaim, so it overstates what Prometheus strictly needs. Still, a 14-day peak at roughly 88% of the limit is not much headroom. If series count grows, the 2Gi limit is the next thing to revisit, together with trimming the kubelet and API server histograms.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Restarts That Weren't Memory
&lt;/h2&gt;

&lt;p&gt;In early October Prometheus had restarted 46 times, and with a 2Gi limit fresh in place, OOM kills were the obvious suspect. None of them were. The container's &lt;code&gt;lastState&lt;/code&gt; showed &lt;code&gt;reason=Completed&lt;/code&gt;, exit code 0: kubelet had sent SIGTERM after a failed liveness probe, and Prometheus shut down cleanly. The chart-default probe allows 3 seconds to answer &lt;code&gt;/-/healthy&lt;/code&gt; and restarts after about 30 seconds of failures. During TSDB compaction stalls, and while the Proxmox host (a single &lt;a href="https://dev.to/go/4bv3yF1"&gt;BMAX Mini PC*&lt;/a&gt;) sat at a load of 38 to 56 during a backup window, Prometheus couldn't answer in time.&lt;/p&gt;

&lt;p&gt;The fix was a more tolerant probe. The first attempt set &lt;code&gt;prometheusSpec.livenessProbe&lt;/code&gt;, which looks right and does nothing: the Prometheus CRD has no such field, structural schema pruning dropped it on apply, and ArgoCD still reported Synced. Probe overrides have to go into the container override list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheusSpec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
        &lt;span class="na"&gt;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;
          &lt;span class="na"&gt;timeoutSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
          &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12&lt;/span&gt;
        &lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;
          &lt;span class="na"&gt;timeoutSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
          &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives three minutes of tolerance before a restart. The Prometheus container has restarted once since. Before raising a memory limit because of restarts, check the exit code. OOMKilled with exit 137 is memory; Completed with exit 0 is a probe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recording Rules
&lt;/h2&gt;

&lt;p&gt;The chart ships 48 rule groups with 259 rules in my setup, recording and alerting combined. Each recording rule produces series of its own, evaluated continuously whether or not anything reads them. I haven't disabled any rule groups wholesale. What I did turn off are the chart components that can never work on k3s: &lt;code&gt;kubeControllerManager&lt;/code&gt;, &lt;code&gt;kubeScheduler&lt;/code&gt;, &lt;code&gt;kubeProxy&lt;/code&gt;, and &lt;code&gt;kubeEtcd&lt;/code&gt;. k3s runs those inside the single server binary and doesn't expose them as scrapable endpoints, and this cluster has no etcd at all. Disabling them removed their ServiceMonitors and the alerts that fired permanently because of them.&lt;/p&gt;

&lt;p&gt;If you want to trim further, the &lt;code&gt;defaultRules.rules&lt;/code&gt; map in the chart toggles individual groups. Key names change between chart versions, so read your chart's &lt;code&gt;values.yaml&lt;/code&gt; first. Be careful: the bundled Grafana dashboards query many of the recorded series, and several of those groups are alerting rules, not recording rules. Turning one off removes alerts, not just series.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Set requests and limits on Prometheus from day one, and pick its node on purpose; mine took down a 4GB worker before anyone gave it a budget. Cap the TSDB with &lt;code&gt;retentionSize&lt;/code&gt;, because on a small NFS volume the size limit will win over the time limit. And when Prometheus restarts, look at the exit code before blaming memory. None of this is visible one layer down: &lt;a href="https://dev.to/blog/overcommit-guard-python-host-freeze/"&gt;the two-tier overcommit guard&lt;/a&gt; checks Proxmox memory allocations per VM, and it can't see one pod eating most of a VM's RAM. Inside the cluster, requests and limits are that guard.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>prometheus</category>
      <category>monitoring</category>
      <category>homelab</category>
    </item>
    <item>
      <title>CloudNativePG on k3s: PostgreSQL That Survives Node Failures</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 02 Nov 2026 15:18:25 +0000</pubDate>
      <link>https://dev.to/dwoitzik/cloudnativepg-on-k3s-postgresql-that-survives-node-failures-4fag</link>
      <guid>https://dev.to/dwoitzik/cloudnativepg-on-k3s-postgresql-that-survives-node-failures-4fag</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/cnpg-postgresql-operator-k3s/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Nextcloud's PostgreSQL database ran as a StatefulSet with a Longhorn PVC. When the Proxmox host rebooted for a kernel update, Longhorn volumes took 90 seconds to reconstruct. During that window, Nextcloud returned 500 errors — the same &lt;a href="https://dev.to/blog/longhorn-to-nfs-distributed-storage/"&gt;Longhorn Multi-Attach problem&lt;/a&gt; that pushed everything else off distributed storage on this single-host cluster. The fix: CloudNativePG (CNPG), a Kubernetes operator that manages PostgreSQL streaming replication, automatic failover, and backups — without depending on a distributed storage layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/homelab-infrastructure" rel="noopener noreferrer"&gt;View the complete homelab infrastructure source on GitHub 🐙&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Just StatefulSets?
&lt;/h2&gt;

&lt;p&gt;A StatefulSet with a PersistentVolumeClaim works until the node dies. In a single-node homelab (one &lt;a href="https://dev.to/go/4bv3yF1"&gt;BMAX Mini PC*&lt;/a&gt; running Proxmox with k3s VMs), "node dies" means "Proxmox host reboots." Longhorn reconstructs the volume from replicas, but the reconstruction takes time. During that time, the PostgreSQL pod is in &lt;code&gt;ContainerCreating&lt;/code&gt; or &lt;code&gt;CrashLoopBackOff&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;CNPG solves this differently: it manages PostgreSQL replication at the database level, not the storage level. A primary and a replica run on different nodes. If the primary's node dies, CNPG promotes the replica to primary. No storage reconstruction needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing CNPG
&lt;/h2&gt;

&lt;p&gt;The CNPG operator is deployed with a Helm chart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add cnpg https://cloudnative-pg.github.io/charts
helm &lt;span class="nb"&gt;install &lt;/span&gt;cnpg cnpg/cloudnative-pg &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; cnpg-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-namespace&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The operator runs in the &lt;code&gt;cnpg-system&lt;/code&gt; namespace and watches for &lt;code&gt;Cluster&lt;/code&gt; CRDs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cluster Definition
&lt;/h2&gt;

&lt;p&gt;A CNPG PostgreSQL cluster with streaming replication:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql.cnpg.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cluster&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nextcloud-db&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nextcloud&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;instances&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;postgresql&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;max_connections&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;200"&lt;/span&gt;
      &lt;span class="na"&gt;shared_buffers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;256MB"&lt;/span&gt;
    &lt;span class="na"&gt;pg_hba&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;host all all 10.0.0.0/8 md5&lt;/span&gt;
  &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10Gi&lt;/span&gt;
    &lt;span class="na"&gt;storageClass&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local-path&lt;/span&gt;
  &lt;span class="na"&gt;backup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;barmanObjectStore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;destinationPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3://backups/nextcloud-db"&lt;/span&gt;
      &lt;span class="na"&gt;endpointURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://minio.minio.svc:9000"&lt;/span&gt;
      &lt;span class="na"&gt;s3Credentials&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;accessKeyId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;minio-credentials&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ACCESS_KEY_ID&lt;/span&gt;
        &lt;span class="na"&gt;secretAccessKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;minio-credentials&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SECRET_ACCESS_KEY&lt;/span&gt;
    &lt;span class="na"&gt;retentionPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;30d"&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;512Mi"&lt;/span&gt;
      &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;250m"&lt;/span&gt;
    &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1Gi"&lt;/span&gt;
      &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;instances: 2&lt;/code&gt; means one primary and one replica. CNPG handles streaming replication automatically. The &lt;code&gt;pg_hba&lt;/code&gt; entry allows connections from the 10.0.0.0/8 subnet (the homelab VLAN range).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Backup Sidecar
&lt;/h2&gt;

&lt;p&gt;CNPG uses Barman for backups. The &lt;code&gt;barmanObjectStore&lt;/code&gt; section points to a MinIO instance running in the cluster, kept deliberately separate from the Garage bucket Velero uses. Backups run daily by default. The &lt;code&gt;retentionPolicy: "30d"&lt;/code&gt; keeps 30 days of backups.&lt;/p&gt;

&lt;p&gt;To trigger a manual backup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl cnpg backup nextcloud-db &lt;span class="nt"&gt;-n&lt;/span&gt; nextcloud
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To restore:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl cnpg restore nextcloud-db-backup-20261101 &lt;span class="nt"&gt;-n&lt;/span&gt; nextcloud
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Failover in Practice
&lt;/h2&gt;

&lt;p&gt;When the primary node fails:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;CNPG detects the primary is unreachable (timeout: 30 seconds)&lt;/li&gt;
&lt;li&gt;CNPG promotes the replica to primary&lt;/li&gt;
&lt;li&gt;CNPG creates a new pod for the old primary (as a replica)&lt;/li&gt;
&lt;li&gt;The new primary accepts writes&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The failover takes 30-60 seconds. During that window, PostgreSQL connections are dropped. Application-level retries (Nextcloud has them) handle the gap transparently.&lt;/p&gt;

&lt;p&gt;The CNPG operator logs the failover event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;promoted instance nextcloud-db-1 to primary
created new replica pod nextcloud-db-0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Monitoring CNPG
&lt;/h2&gt;

&lt;p&gt;CNPG exposes Prometheus metrics through a ServiceMonitor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="n"&gt;cnpg_pg_stat_activity_count&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;datname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"nextcloud"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"active"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;
&lt;span class="n"&gt;cnpg_replication_lag_seconds&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"nextcloud-db-1"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="mf"&gt;0.003&lt;/span&gt;
&lt;span class="n"&gt;cnpg_backups_total&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grafana dashboard: alert if &lt;code&gt;replication_lag_seconds&lt;/code&gt; exceeds 5 seconds. That means the replica is falling behind, and a failover would lose data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trade-off
&lt;/h2&gt;

&lt;p&gt;CNPG adds complexity: an operator, Barman for backups, and streaming replication configuration. The benefit is PostgreSQL that survives node failures without depending on Longhorn's storage reconstruction. For a single-node homelab, this is the right trade-off — the &lt;a href="https://dev.to/go/4bv3yF1"&gt;BMAX Mini PC*&lt;/a&gt; has enough RAM for two PostgreSQL instances, and the failover is faster than waiting for Longhorn to rebuild.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>postgres</category>
      <category>cloudnativepg</category>
      <category>homelab</category>
    </item>
    <item>
      <title>Traefik Rate Limiting That Actually Works on k3s</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 02 Nov 2026 15:12:39 +0000</pubDate>
      <link>https://dev.to/dwoitzik/traefik-rate-limiting-that-actually-works-on-k3s-10io</link>
      <guid>https://dev.to/dwoitzik/traefik-rate-limiting-that-actually-works-on-k3s-10io</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/traefik-rate-limit-middleware-k3s/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Prometheus scraped &lt;a href="https://dev.to/blog/kube-prometheus-loki-grafana-k3s/"&gt;Grafana&lt;/a&gt; 30 times in 10 seconds. Grafana's API returned 429s. The dashboard went blank. The root cause: a misconfigured Prometheus service monitor with a 1-second scrape interval — the kind of runaway scrape config that also drives Prometheus's own RAM usage. The fix: Traefik's RateLimit middleware, correctly configured to protect against scrape storms without blocking normal dashboard access. This runs on a &lt;a href="https://dev.to/go/4bv3yF1"&gt;BMAX Mini PC*&lt;/a&gt; with 20+ services behind Traefik — one misconfigured scrape can cascade into a cluster-wide outage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/homelab-infrastructure" rel="noopener noreferrer"&gt;View the complete homelab infrastructure source on GitHub 🐙&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Scrape Storm
&lt;/h2&gt;

&lt;p&gt;Grafana runs on k3s behind Traefik. The Prometheus Operator's ServiceMonitor for Grafana had a &lt;code&gt;scrapeInterval: 1s&lt;/code&gt; — a copy-paste error from a debug session. Prometheus fired 30 requests per second at Grafana's &lt;code&gt;/api/datasources&lt;/code&gt; endpoint. Grafana's Go runtime couldn't handle the concurrent connections, started returning 503s, and the dashboard went blank.&lt;/p&gt;

&lt;p&gt;Without rate limiting, any misconfigured scraper — or any user refreshing aggressively — can DoS a service behind Traefik.&lt;/p&gt;

&lt;h2&gt;
  
  
  The RateLimit Middleware
&lt;/h2&gt;

&lt;p&gt;Traefik's RateLimit middleware uses a token bucket algorithm. It's configured per-entrypoint or per-route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Middleware&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rate-limit&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rateLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;average&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;
    &lt;span class="na"&gt;burst&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;
    &lt;span class="na"&gt;period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1s&lt;/span&gt;
    &lt;span class="na"&gt;sourceCriterion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;ipStrategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;average: 100&lt;/code&gt; — 100 requests per second per source IP&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;burst: 200&lt;/code&gt; — allow bursts up to 200 requests&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;period: 1s&lt;/code&gt; — the averaging window&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sourceCriterion.ipStrategy.depth: 1&lt;/code&gt; — trust the first X-Forwarded-For header (correct behind one proxy)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;depth: 1&lt;/code&gt; is critical. Without it, Traefik uses the raw TCP connection IP, which is always the Traefik pod's IP in a Kubernetes cluster. Every client gets the same rate limit bucket. With &lt;code&gt;depth: 1&lt;/code&gt;, Traefik reads the real client IP from X-Forwarded-For.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applying It Per Service
&lt;/h2&gt;

&lt;p&gt;Not every service needs the same rate limit. Grafana's dashboard needs a higher burst than the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Middleware&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana-rate-limit&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rateLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;average&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;
    &lt;span class="na"&gt;burst&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;
    &lt;span class="na"&gt;period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1s&lt;/span&gt;
    &lt;span class="na"&gt;sourceCriterion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;ipStrategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IngressRoute&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;entryPoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;websecure&lt;/span&gt;
  &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Host(`grafana.lab.woitzik.dev`)&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Rule&lt;/span&gt;
      &lt;span class="na"&gt;middlewares&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana-rate-limit&lt;/span&gt;
          &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring&lt;/span&gt;
      &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3000&lt;/span&gt;
  &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lab-wildcard-tls&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Whitelisting Internal Traffic
&lt;/h2&gt;

&lt;p&gt;Prometheus, ArgoCD, and other internal services shouldn't be rate-limited. Traefik's RateLimit middleware supports &lt;code&gt;requestHost&lt;/code&gt; and &lt;code&gt;requestHeaders&lt;/code&gt; as source criteria, but the cleanest approach is a separate middleware for internal services:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Middleware&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;internal-rate-limit&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rateLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;average&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;500&lt;/span&gt;
    &lt;span class="na"&gt;burst&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1000&lt;/span&gt;
    &lt;span class="na"&gt;period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1s&lt;/span&gt;
    &lt;span class="na"&gt;sourceCriterion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;requestHost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Internal services use &lt;code&gt;requestHost&lt;/code&gt; (the Host header) as the source instead of IP. Since internal services all share the same cluster IP, IP-based rate limiting would bucket them all together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 429 Response
&lt;/h2&gt;

&lt;p&gt;When a rate limit is hit, Traefik returns a &lt;code&gt;429 Too Many Requests&lt;/code&gt; with a &lt;code&gt;Retry-After&lt;/code&gt; header. The client should respect this. Most HTTP clients don't. The Grafana dashboard showed a blank page because the browser retried immediately. Adding a client-side backoff in the Prometheus scraper config fixed the retry behavior:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;scrapeConfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;scrapeInterval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
    &lt;span class="na"&gt;scrapeTimeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
    &lt;span class="na"&gt;metricsPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/metrics&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real fix was the scrape interval. The rate limit was the safety net.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring Rate Limits
&lt;/h2&gt;

&lt;p&gt;Traefik exposes rate limit metrics in Prometheus format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="n"&gt;traefikmiddleware_ratelimit_requests_total&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;middleware&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"monitoring-grafana-rate-limit"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;
&lt;span class="n"&gt;traefikmiddleware_ratelimit_rejected_total&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;middleware&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"monitoring-grafana-rate-limit"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grafana alert: if &lt;code&gt;rejected_total&lt;/code&gt; increases by more than 10 per minute, something is hammering the service. This is the early warning for scrape storms, bot attacks, or misconfigured clients.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>traefik</category>
      <category>security</category>
      <category>homelab</category>
    </item>
    <item>
      <title>Vault Configuration Under Terraform: Staged Migration Without Downtime</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:33:03 +0000</pubDate>
      <link>https://dev.to/dwoitzik/vault-configuration-under-terraform-staged-migration-without-downtime-2k7b</link>
      <guid>https://dev.to/dwoitzik/vault-configuration-under-terraform-staged-migration-without-downtime-2k7b</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/vault-terraform-config-staged-migration/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Vault is the trust root for every other secret on the cluster. 25+ services depend on External Secrets Operator syncing from Vault's KV v2 engine — all running on a single &lt;a href="https://dev.to/go/4bv3yF1"&gt;BMAX Mini PC*&lt;/a&gt;. &lt;a href="https://dev.to/blog/k3s-authelia-proxmox-homelab/"&gt;Authelia&lt;/a&gt;, databases, ArgoCD notifications, Velero credentials — all of them pull from &lt;code&gt;secret/&amp;lt;app&amp;gt;/*&lt;/code&gt; paths. Unsealing this Vault after a restart is a separate problem, solved by &lt;a href="https://dev.to/blog/vault-auto-unseal-polling-sidecar/"&gt;the polling sidecar pattern&lt;/a&gt; — this article only covers what's configured once Vault is already up.&lt;/p&gt;

&lt;p&gt;Vault's configuration — policies, Kubernetes auth method, roles — was managed through &lt;code&gt;vault login&lt;/code&gt; and CLI commands. No version control, no audit trail, no reproducibility. If someone changed a policy by hand, nobody else knew.&lt;/p&gt;

&lt;p&gt;The goal: bring Vault's configuration (not secret values) under Terraform. Staged, low-risk, each stage independently reversible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/homelab-infrastructure" rel="noopener noreferrer"&gt;View the complete homelab infrastructure source on GitHub 🐙&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Scope Boundary
&lt;/h2&gt;

&lt;p&gt;The hardest part of Terraforming Vault is knowing what NOT to Terraform:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;In scope (configuration)&lt;/th&gt;
&lt;th&gt;Out of scope (stays manual)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vault policies&lt;/td&gt;
&lt;td&gt;Actual secret values (&lt;code&gt;secret/&amp;lt;app&amp;gt;/*&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth methods (Kubernetes auth)&lt;/td&gt;
&lt;td&gt;Unseal keys, root tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets engine mount config&lt;/td&gt;
&lt;td&gt;Vault server config (Helm, Raft)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth roles and bindings&lt;/td&gt;
&lt;td&gt;TLS (currently disabled)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Secret values stay in Ansible Vault.&lt;/strong&gt; Terraform manages configuration, never secret data. The Ansible Vault file (&lt;code&gt;ansible/group_vars/all/vault.yml&lt;/code&gt;) is the durable source. ESO syncs from Vault to Kubernetes Secrets. If Terraform state is lost, you re-import config — no secrets leak.&lt;/p&gt;

&lt;p&gt;This is the critical principle: &lt;strong&gt;Terraform state describes access rules, not the secrets themselves.&lt;/strong&gt; Losing state means re-importing policies and roles, not exposing credentials.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Six Stages
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Stage 1: Policies (lowest risk)
&lt;/h3&gt;

&lt;p&gt;Import existing Vault policies into Terraform. Policies are purely declarative — they describe access rules but don't change anything about Vault's operation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"vault_policy"&lt;/span&gt; &lt;span class="s2"&gt;"eso"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eso"&lt;/span&gt;
  &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"${path.module}/policies/eso.hcl"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;.hcl&lt;/code&gt; file per policy under &lt;code&gt;policies/&lt;/code&gt;. Extracted from the live Vault instance (&lt;code&gt;vault policy read &amp;lt;name&amp;gt;&lt;/code&gt;), committed to git.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Risk&lt;/strong&gt;: Zero. Policies are read-only from Vault's perspective. No restart, no unseal, no client disruption.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verification&lt;/strong&gt;: &lt;code&gt;terraform plan&lt;/code&gt; shows only &lt;code&gt;import&lt;/code&gt; blocks matching live state. &lt;code&gt;vault policy list&lt;/code&gt; unchanged after apply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rollback&lt;/strong&gt;: &lt;code&gt;terraform state rm vault_policy.&amp;lt;name&amp;gt;&lt;/code&gt;. Vault policies are stateless — removing from TF doesn't delete them from Vault.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 2: KV v2 Secrets Engine Mount
&lt;/h3&gt;

&lt;p&gt;Import the &lt;code&gt;secret/&lt;/code&gt; KV v2 mount configuration. This configures the engine's existence and settings (version, max versions) but NOT the secret values.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"vault_mount"&lt;/span&gt; &lt;span class="s2"&gt;"secret"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"secret"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"kv"&lt;/span&gt;
  &lt;span class="nx"&gt;options&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"KV v2 secrets engine — application secrets"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Risk&lt;/strong&gt;: Low. If the mount already exists with the same config, &lt;code&gt;terraform plan&lt;/code&gt; shows no changes. If there's a config diff, review carefully before applying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verification&lt;/strong&gt;: &lt;code&gt;vault read sys/mounts/secret/&lt;/code&gt; output matches the HCL config. &lt;code&gt;vault kv list secret/&lt;/code&gt; still works — no disruption.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 3: Kubernetes Auth Method
&lt;/h3&gt;

&lt;p&gt;Import the Kubernetes auth method — the mechanism by which Vault trusts the k8s API server to authenticate pods.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"vault_auth_backend"&lt;/span&gt; &lt;span class="s2"&gt;"kubernetes"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"kubernetes"&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"kubernetes"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"vault_kubernetes_auth_backend_config"&lt;/span&gt; &lt;span class="s2"&gt;"config"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt;            &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vault_auth_backend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kubernetes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;
  &lt;span class="nx"&gt;kubernetes_host&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://kubernetes.default.svc"&lt;/span&gt;
  &lt;span class="nx"&gt;kubernetes_ca_cert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Risk&lt;/strong&gt;: Medium. Auth method misconfiguration breaks every ESO sync. &lt;strong&gt;Do not apply a diff here blindly&lt;/strong&gt; — if &lt;code&gt;terraform plan&lt;/code&gt; shows changes (not just imports), stop and investigate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verification&lt;/strong&gt;: Trigger one ESO sync (&lt;code&gt;kubectl annotate externalsecret -n &amp;lt;ns&amp;gt; &amp;lt;name&amp;gt; force-sync=$(date)&lt;/code&gt;). Check 3-5 apps that depend on Vault secrets — no restarts, no errors.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 4: Kubernetes Auth Roles
&lt;/h3&gt;

&lt;p&gt;Import the roles that map Kubernetes ServiceAccounts to Vault policies. This is the binding between "which pod" and "what it can access."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"vault_kubernetes_auth_backend_role"&lt;/span&gt; &lt;span class="s2"&gt;"eso"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt;                          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vault_auth_backend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kubernetes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;
  &lt;span class="nx"&gt;role_name&lt;/span&gt;                        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eso"&lt;/span&gt;
  &lt;span class="nx"&gt;bound_service_account_names&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"external-secrets"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;bound_service_account_namespaces&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"external-secrets"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;token_policies&lt;/span&gt;                   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"eso"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;token_ttl&lt;/span&gt;                        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Risk&lt;/strong&gt;: Medium for NEW roles (changes what pods can access). Low for importing existing ones. Least-privilege check: verify each role's &lt;code&gt;bound_service_account_names&lt;/code&gt; + &lt;code&gt;token_policies&lt;/code&gt; against the live config.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 5: Additional Auth Methods (deferred)
&lt;/h3&gt;

&lt;p&gt;Userpass, OIDC, or other auth methods beyond Kubernetes. Currently only Kubernetes auth is in use (all Vault access goes through ESO). If no other auth methods exist, skip entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 6: App Policies (pure additive)
&lt;/h3&gt;

&lt;p&gt;Import the per-app policies — one per ESO-managed secret path. Each policy controls exactly which secret path an app's role can read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Risk&lt;/strong&gt;: Lowest. Policies are additive, don't affect existing access until a role references them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Just &lt;code&gt;vault write&lt;/code&gt; Everything?
&lt;/h2&gt;

&lt;p&gt;The alternative is simpler: keep using CLI commands, document them in a runbook, hope nobody drifts.&lt;/p&gt;

&lt;p&gt;The problem with that approach:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No audit trail.&lt;/strong&gt; Who changed the &lt;code&gt;authelia&lt;/code&gt; policy last week? &lt;code&gt;vault audit&lt;/code&gt; logs secret access, not policy changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No reproducibility.&lt;/strong&gt; Recreating a Vault configuration from documentation is error-prone. Terraform state is the single source of truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No drift detection.&lt;/strong&gt; &lt;code&gt;terraform plan&lt;/code&gt; shows you exactly what changed. Without it, drift accumulates silently until something breaks at 3 AM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No rollback.&lt;/strong&gt; With CLI, undo means "remember what it was before" and &lt;code&gt;vault write&lt;/code&gt; it back. With Terraform, &lt;code&gt;terraform apply&lt;/code&gt; reverts to the last known-good state.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The staged approach means you can stop at any stage. If Stage 3 (auth method) makes you nervous, don't apply it — Stages 1 and 2 are already valuable and carry zero risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Maintenance Window
&lt;/h2&gt;

&lt;p&gt;Each stage takes 5-20 minutes. All stages are independently reversible. Total: ~1 hour.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Policies&lt;/td&gt;
&lt;td&gt;15 min&lt;/td&gt;
&lt;td&gt;Zero — read-only import&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. KV mount&lt;/td&gt;
&lt;td&gt;5 min&lt;/td&gt;
&lt;td&gt;Zero — config import&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. K8s auth&lt;/td&gt;
&lt;td&gt;10 min&lt;/td&gt;
&lt;td&gt;Zero if import-only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. Auth roles&lt;/td&gt;
&lt;td&gt;15 min&lt;/td&gt;
&lt;td&gt;Zero if import-only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6. App policies&lt;/td&gt;
&lt;td&gt;20 min&lt;/td&gt;
&lt;td&gt;Zero — additive&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;If any stage shows unexpected drift&lt;/strong&gt;: STOP, investigate, do not apply. The diff tells you exactly what's different. Fix in git or in Vault manually, re-plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Stays Manual Longest
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;th&gt;When to revisit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Secret values&lt;/td&gt;
&lt;td&gt;Ansible Vault is the durable source&lt;/td&gt;
&lt;td&gt;Never — this is the correct pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vault server config&lt;/td&gt;
&lt;td&gt;Managed by ArgoCD + Helm&lt;/td&gt;
&lt;td&gt;When Vault moves to non-ArgoCD install&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unseal keys / root tokens&lt;/td&gt;
&lt;td&gt;Security-sensitive, manual by design&lt;/td&gt;
&lt;td&gt;Never&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vault upgrades&lt;/td&gt;
&lt;td&gt;Renovate handles via PR&lt;/td&gt;
&lt;td&gt;Ongoing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;Bringing Vault config under Terraform isn't about Terraform being better than CLI — it's about configuration being version-controlled, auditable, and reproducible. The staged approach means zero risk at each step: if a stage makes you nervous, don't apply it. The previous stages are already valuable. The hardest part isn't the implementation. It's drawing the scope boundary — knowing that secrets stay in Ansible Vault and Terraform only touches the access rules around them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Further reading:&lt;/strong&gt; For understanding the security model behind Vault's policy system, &lt;a href="https://dev.to/go/zero-trust-networks-book"&gt;Zero Trust Networks by Evan Gilman and Doug Barth*&lt;/a&gt; covers the "never trust, always verify" principles that Vault's Kubernetes auth method implements. The staged migration pattern — import first, modify later — is a core operations concept covered in &lt;a href="https://dev.to/go/4f4RHQf"&gt;The Phoenix Project by Gene Kim*&lt;/a&gt; under the context of change management and reducing deployment risk.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>vault</category>
      <category>terraform</category>
      <category>security</category>
    </item>
    <item>
      <title>Velero Backs Up to the Cluster It's Backing Up: Breaking the Backup Circularity</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 19 Oct 2026 12:38:54 +0000</pubDate>
      <link>https://dev.to/dwoitzik/velero-backs-up-to-the-cluster-its-backing-up-breaking-the-backup-circularity-4f35</link>
      <guid>https://dev.to/dwoitzik/velero-backs-up-to-the-cluster-its-backing-up-breaking-the-backup-circularity-4f35</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/velero-backup-circularity-garage-r2-offsite/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The circularity: &lt;a href="https://dev.to/blog/velero-garage-k3s-backup/"&gt;Velero&lt;/a&gt; backs up into Garage S3, which runs inside the Kubernetes cluster it's backing up. Cluster lost → Garage PVC lost → Velero backup bucket gone → nothing to restore from.&lt;/p&gt;

&lt;p&gt;This is the backup equivalent of storing your offsite backup key in the safe you're backing up. The offsite target exists in config but isn't actually reachable — and you don't realize the gap until you need it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/homelab-infrastructure" rel="noopener noreferrer"&gt;View the complete homelab infrastructure source on GitHub 🐙&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two-Problem Stack
&lt;/h2&gt;

&lt;p&gt;Two issues compound into structural fragility:&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 1: Backup Circularity
&lt;/h3&gt;

&lt;p&gt;Velero's backup storage location points to Garage, an S3-compatible object store running as a StatefulSet inside the cluster. Every Velero backup — daily snapshots of Vault, Authelia, databases, ArgoCD configs — lands in a bucket that depends on the cluster surviving.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cluster lost → Garage PVC lost → Velero backup bucket gone → nothing to restore from
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PBS backs up the VM/LXC disks to a &lt;a href="https://dev.to/go/4vxwLpY"&gt;Seagate 2TB External HDD*&lt;/a&gt;, but PBS runs on the same physical host — a &lt;a href="https://dev.to/go/4bv3yF1"&gt;BMAX Mini PC*&lt;/a&gt;. Simultaneous loss of host + USB HDD means total unrecoverability at the VM layer. Velero was the last safety net — except it was circular.&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 2: Torn-State Hazard
&lt;/h3&gt;

&lt;p&gt;Two PVCs with different storage backends, different failure modes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;PVC&lt;/th&gt;
&lt;th&gt;Storage&lt;/th&gt;
&lt;th&gt;Location&lt;/th&gt;
&lt;th&gt;Failure mode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;garage-meta&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;local-path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;k3s node&lt;/td&gt;
&lt;td&gt;Node-pinned, survives if node survives&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;garage-data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;NFS (&lt;code&gt;nfs-client&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;NFS LXC&lt;/td&gt;
&lt;td&gt;Survives if NFS LXC survives&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On 2026-07-12, an unclean Garage shutdown (OOM kill) left &lt;code&gt;db.sqlite&lt;/code&gt; corrupted — &lt;a href="https://dev.to/blog/nfs-vs-local-path-sqlite-trap/"&gt;the same NFS/SQLite locking incompatibility&lt;/a&gt; that eventually forced every embedded database off NFS. The metadata — SQLite, bucket/key ownership, access grants — and the actual object data were on different volumes with different rollback fates. A partial restore or node loss would leave one without the other, with no clean way to reconcile them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Four Options
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Option A: Consolidate meta + data on one volume
&lt;/h3&gt;

&lt;p&gt;Move &lt;code&gt;garage-meta&lt;/code&gt; from &lt;code&gt;local-path&lt;/code&gt; to NFS (same backend as &lt;code&gt;garage-data&lt;/code&gt;). Both PVCs on the same storage class → same snapshot/restore fate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict&lt;/strong&gt;: Fixes torn-state but not circularity. NFS for SQLite is proven fragile — the 07-12 corruption happened on NFS-backed data. Moving data to &lt;code&gt;local-path&lt;/code&gt; requires hardware you don't have yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option B: External off-cluster target (Cloudflare R2)
&lt;/h3&gt;

&lt;p&gt;Activate the existing &lt;code&gt;r2-offsite&lt;/code&gt; backup storage location. Velero dual-targets: in-cluster Garage for speed + off-cluster Cloudflare R2 for bootstrap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cluster lost → Garage gone → Velero still has offsite copy on R2
Bootstrap: rebuild cluster → ArgoCD → Velero → restore from R2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cost&lt;/strong&gt;: ~$0.015/GB/month on R2 (egress free). At current backup size (~5-10Gi), negligible. &lt;strong&gt;Risk&lt;/strong&gt;: Near-zero — additive only, no existing backup path changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict&lt;/strong&gt;: Directly solves circularity. The existing R2 config is ready — needs credentials and a "go".&lt;/p&gt;

&lt;h3&gt;
  
  
  Option C: PBS as the primary recovery layer
&lt;/h3&gt;

&lt;p&gt;Lean into PBS as the from-scratch recovery path. PBS already backs up all VMs/LXCs (including the k3s VMs that carry Garage's data). Velero becomes a convenience layer for single-app restore, not the bootstrap path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict&lt;/strong&gt;: Already the real bootstrap path (DR Tier 1-4). Velero is Tier 5, only if Garage survived. PBS is the actual safety net. The circularity is a Velero-level problem, not a PBS-level one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option D: Combine B + C (what I chose)
&lt;/h3&gt;

&lt;p&gt;Activate R2 offsite for Velero (breaks circularity) AND acknowledge PBS as the primary VM-level recovery layer. Velero becomes the single-app/namespace convenience tool with a real offsite target.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it covers&lt;/th&gt;
&lt;th&gt;Recovery path&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PBS&lt;/td&gt;
&lt;td&gt;VM/LXC disks, full host recovery&lt;/td&gt;
&lt;td&gt;Restore VMs from PBS → cluster up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Velero + R2&lt;/td&gt;
&lt;td&gt;App-level config, secrets, databases&lt;/td&gt;
&lt;td&gt;ArgoCD → Velero → restore from R2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Garage rebuild&lt;/td&gt;
&lt;td&gt;If PVCs are lost entirely&lt;/td&gt;
&lt;td&gt;Terraform re-creates buckets, ESO re-seeds keys&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Verdict&lt;/strong&gt;: Covers both problems. R2 breaks circularity (zero risk, additive). PBS handles VM-level recovery (already there). Garage rebuild from scratch is the fallback if PVCs are lost — better than a partial restore that could leave metadata/data inconsistent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Recovery Model
&lt;/h2&gt;

&lt;p&gt;Three independent layers, each covering a different failure scenario:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Scenario                          → Recovery path
─────────────────────────────────────────────────────
Single app crash                  → Kubernetes restart (self-heals)
Single PVC lost                   → Velero restore from R2 offsite
Full cluster lost (host survives) → PBS restores k3s VMs → Velero from R2
Host + USB HDD lost               → Insurance (nothing saves this)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight: no single layer has to be perfect. Each layer covers the failure modes the layer below misses. Garage's torn-state problem becomes irrelevant when the recovery procedure is "rebuild from scratch" rather than "restore from backup."&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation (Zero Downtime)
&lt;/h2&gt;

&lt;p&gt;The R2 activation was purely additive — no existing backup path changed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create Cloudflare R2 bucket (free tier)&lt;/li&gt;
&lt;li&gt;Populate &lt;code&gt;r2-backuplocation.yml&lt;/code&gt; with real R2 credentials via External Secrets Operator&lt;/li&gt;
&lt;li&gt;Apply the &lt;code&gt;daily-offsite&lt;/code&gt; Velero Schedule (already committed)&lt;/li&gt;
&lt;li&gt;Run one test backup, verify with &lt;code&gt;velero backup describe&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Confirm &lt;code&gt;PodVolumeBackup&lt;/code&gt; entries present for PVCs&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The in-cluster Garage target stays. Velero now writes to both: Garage for fast local restores, R2 for bootstrap-from-scratch. If Garage is gone, R2 has everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Change
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Implement R2 sooner.&lt;/strong&gt; The config existed for weeks before activation. The circularity was a known risk — the "I'll do it later" mindset is what creates disaster recovery gaps.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Add a Velero backup age alert.&lt;/strong&gt; Alert when the most recent successful backup is older than 48 hours. If backups silently stop, you want to know before you need one.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test the full recovery path.&lt;/strong&gt; PBS restore of k3s VMs → cluster boot → ArgoCD reconciliation → Velero restore from R2. An untested recovery path is a theory, not a plan.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;Backup circularity is the most common structural flaw in self-hosted Kubernetes: the tool that protects you depends on the thing you're protecting. The fix is always the same — add an independent offsite layer. Cloudflare R2 costs nothing at homelab scale, and the R2 egress-free model means you're not penalized for actually using the restore. The hardest part isn't the implementation. It's admitting your current backup doesn't survive the scenario you built it for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Further reading:&lt;/strong&gt; For understanding the full recovery architecture, &lt;a href="https://dev.to/go/4aUtkCs"&gt;Designing Data-Intensive Applications by Martin Kleppmann*&lt;/a&gt; covers replication, backup strategies, and the failure modes that make torn-state hazards so dangerous. The Velero circularity is a specific case of the general problem Kleppmann describes: systems that depend on their own recovery path.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>backup</category>
      <category>velero</category>
      <category>homelab</category>
    </item>
    <item>
      <title>The DNS Drift That Wasn't DHCP: How Proxmox Cloud-Init Silently Injects Search Domains</title>
      <dc:creator>david</dc:creator>
      <pubDate>Fri, 16 Oct 2026 09:41:18 +0000</pubDate>
      <link>https://dev.to/dwoitzik/the-dns-drift-that-wasnt-dhcp-how-proxmox-cloud-init-silently-injects-search-domains-1496</link>
      <guid>https://dev.to/dwoitzik/the-dns-drift-that-wasnt-dhcp-how-proxmox-cloud-init-silently-injects-search-domains-1496</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/proxmox-cloudinit-dns-search-domain-drift/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every DNS lookup from every pod in my k3s cluster was appending &lt;code&gt;home.lan&lt;/code&gt; to the query. External lookups like &lt;code&gt;registry-1.docker.io&lt;/code&gt; became &lt;code&gt;registry-1.docker.io.home.lan&lt;/code&gt;, failed locally, recursed all the way out, and added ~341ms of latency to every image pull, every API call, every webhook — traffic that should have gone straight to Unbound for recursive resolution, not round-tripped through a phantom search domain. I assumed DHCP was the source, wrote a Terraform fix, and watched the plan show zero diff against live state.&lt;/p&gt;

&lt;p&gt;The real source was Proxmox's cloud-init generator — three layers above the DHCP server.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom
&lt;/h2&gt;

&lt;p&gt;Prometheus showed p99 DNS latency at 341ms for external queries. Local queries (services within the cluster) resolved in under 1ms. The gap was consistent across all three k3s nodes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="c"&gt;# PromQL showing the split&lt;/span&gt;
&lt;span class="nb"&gt;quantile_over_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dns_request_duration_seconds&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;!~&lt;/span&gt;&lt;span class="s2"&gt;".*cluster.local"&lt;/span&gt;&lt;span class="p"&gt;}[&lt;/span&gt;&lt;span class="mi"&gt;5m&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="c"&gt;# → ~341ms&lt;/span&gt;

&lt;span class="nb"&gt;quantile_over_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dns_request_duration_seconds&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;=~&lt;/span&gt;&lt;span class="s2"&gt;".*cluster.local"&lt;/span&gt;&lt;span class="p"&gt;}[&lt;/span&gt;&lt;span class="mi"&gt;5m&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="c"&gt;# → ~0.8ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kubelet's DNS policy appends the node's search domain to every pod's &lt;code&gt;/etc/resolv.conf&lt;/code&gt;. If the node has &lt;code&gt;search home.lan&lt;/code&gt;, every pod inherits it. With the default &lt;code&gt;ndots:5&lt;/code&gt;, any name with fewer than 5 dots gets the search domain appended first — meaning almost everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong Hypothesis: DHCP
&lt;/h2&gt;

&lt;p&gt;My first guess was the RouterOS DHCP server. The network was configured via Terraform, but I'd never explicitly set the domain field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Before fix — no domain attribute at all&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"routeros_ip_dhcp_server_network"&lt;/span&gt; &lt;span class="s2"&gt;"vlan_networks"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;homelab_vlans&lt;/span&gt;
  &lt;span class="nx"&gt;address&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.${each.value}.0/24"&lt;/span&gt;
  &lt;span class="nx"&gt;gateway&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.${each.value}.1"&lt;/span&gt;
  &lt;span class="nx"&gt;dns_server&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.20.5"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding &lt;code&gt;domain = ""&lt;/code&gt; should force Terraform to detect and correct any drift:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"routeros_ip_dhcp_server_network"&lt;/span&gt; &lt;span class="s2"&gt;"vlan_networks"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;homelab_vlans&lt;/span&gt;
  &lt;span class="nx"&gt;address&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.${each.value}.0/24"&lt;/span&gt;
  &lt;span class="nx"&gt;gateway&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.${each.value}.1"&lt;/span&gt;
  &lt;span class="nx"&gt;dns_server&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.20.5"&lt;/span&gt;
  &lt;span class="nx"&gt;domain&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;  &lt;span class="c1"&gt;# explicitly empty — should fix drift&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plan showed &lt;strong&gt;zero changes&lt;/strong&gt;. Live state already matched &lt;code&gt;domain = ""&lt;/code&gt;. DHCP was never the source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Live Verification
&lt;/h2&gt;

&lt;p&gt;Confirmed via the RouterOS REST API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Check all DHCP server config paths&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://router.lan/rest/ip/dhcp-server"&lt;/span&gt; | jq &lt;span class="s1"&gt;'.[].domain'&lt;/span&gt;
&lt;span class="c"&gt;# → all null&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://router.lan/rest/ip/dhcp-server/network"&lt;/span&gt; | jq &lt;span class="s1"&gt;'.[].domain'&lt;/span&gt;
&lt;span class="c"&gt;# → all null&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://router.lan/rest/ip/dns"&lt;/span&gt; | jq &lt;span class="s1"&gt;'.domain'&lt;/span&gt;
&lt;span class="c"&gt;# → null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The RouterOS DHCP server had no domain/search config anywhere. &lt;code&gt;domain = ""&lt;/code&gt; matched live state because the live state genuinely was empty. DHCP was never handing out &lt;code&gt;home.lan&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Source: Proxmox Cloud-Init
&lt;/h2&gt;

&lt;p&gt;On the k3s nodes themselves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;networkctl status &lt;span class="nt"&gt;--json&lt;/span&gt; | jq &lt;span class="s1"&gt;'.DNS'&lt;/span&gt;
&lt;span class="c"&gt;# → {"SearchDomains": ["home.lan"], "ConfigSource": "static"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;ConfigSource: static&lt;/strong&gt; — not DHCP. The search domain was baked into the node's netplan config, generated by Proxmox's cloud-init writer.&lt;/p&gt;

&lt;p&gt;When you create a VM in Proxmox with Terraform and leave &lt;code&gt;initialization.dns.domain&lt;/code&gt; unset, Proxmox falls back to the PVE node's own configured search domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On the Proxmox host&lt;/span&gt;
pvesh get /nodes/pve-mgmt-01/dns
&lt;span class="c"&gt;# → search: home.lan&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Proxmox's cloud-init generator reads the PVE node's DNS config and writes it into the VM's &lt;code&gt;/etc/netplan/50-cloud-init.yaml&lt;/code&gt;. Every k3s VM got &lt;code&gt;home.lan&lt;/code&gt; as its search domain — not from DHCP, but from the hypervisor itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Is a Trap
&lt;/h2&gt;

&lt;p&gt;The trap is the layering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Proxmox PVE node DNS config
    ↓ (cloud-init fallback)
VM netplan search domain
    ↓ (kubelet DNS policy)
Pod /etc/resolv.conf search domain
    ↓ (ndots:5)
Every DNS lookup appends .home.lan
    ↓
~341ms latency on every external query
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the debugging trap: &lt;code&gt;terraform plan&lt;/code&gt; showed zero diff on the DHCP resource because DHCP wasn't the source. A Terraform-native investigation would have concluded "no drift detected" and moved on. The real fix required:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Clearing the PVE node's default: &lt;code&gt;pvesh set /nodes/pve-mgmt-01/dns -search ""&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Regenerating cloud-init on each k3s VM&lt;/li&gt;
&lt;li&gt;Declaring &lt;code&gt;domain = ""&lt;/code&gt; in the Proxmox VM's &lt;code&gt;initialization.dns&lt;/code&gt; block&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1 and 2 are live changes to etcd-member VMs — no Terraform resource models this. Step 3 declares intent for future VMs but has no effect on already-provisioned ones (the &lt;code&gt;dns&lt;/code&gt; block is in &lt;code&gt;lifecycle.ignore_changes&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Terraform Fix (Future VMs)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# terraform/stacks/proxmox/vm.tf&lt;/span&gt;
&lt;span class="nx"&gt;initialization&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;dns&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;domain&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;  &lt;span class="c1"&gt;# explicitly unset — prevents PVE node fallback&lt;/span&gt;
    &lt;span class="nx"&gt;servers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"10.0.20.5"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This ensures new VMs don't inherit the PVE node's search domain. Existing VMs need the manual cloud-init regen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. DNS config has multiple sources.&lt;/strong&gt; DHCP is the obvious one, but cloud-init, node-level config, and kubelet policy all contribute. Check all layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;terraform plan&lt;/code&gt; zero-diff doesn't mean no drift.&lt;/strong&gt; It means the specific resource you checked matches state. The drift might be in a different layer entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;networkctl status --json&lt;/code&gt; is your friend.&lt;/strong&gt; The &lt;code&gt;ConfigSource&lt;/code&gt; field tells you whether the search domain came from DHCP, static config, or another source. If it says "static", stop looking at DHCP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Proxmox cloud-init is a hidden config source.&lt;/strong&gt; If you leave &lt;code&gt;initialization.dns.domain&lt;/code&gt; unset, Proxmox fills it from the PVE node. Always declare it explicitly. It's the same category of hidden-layer surprise as &lt;a href="https://dev.to/blog/staggered-vm-boot-load-average-147/"&gt;Proxmox's &lt;code&gt;onboot&lt;/code&gt; attribute silently not round-tripping through Terraform&lt;/a&gt; — the provider abstraction has gaps that only show up once you go looking for drift that isn't there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Further reading:&lt;/strong&gt; DNS debugging keeps coming back to the same fundamentals — search domains, ndots, resolution order. &lt;a href="https://dev.to/go/4f3kTar"&gt;Netzwerke: Verstehen, Einrichten, Administrieren*&lt;/a&gt; covers those fundamentals in more depth than this Proxmox-specific drift case gets into.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;DNS latency from inherited search domains is invisible in most monitoring setups. You'll see high p99 latency on external queries but no obvious cause. If your pods have &lt;code&gt;ndots:5&lt;/code&gt; (the default) and your nodes have a search domain you didn't explicitly set, you're paying the tax on every lookup.&lt;/em&gt;&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>dns</category>
      <category>proxmox</category>
      <category>debugging</category>
    </item>
    <item>
      <title>k3s vs Docker Swarm vs Nomad in 2026</title>
      <dc:creator>david</dc:creator>
      <pubDate>Fri, 16 Oct 2026 09:40:43 +0000</pubDate>
      <link>https://dev.to/dwoitzik/k3s-vs-docker-swarm-vs-nomad-in-2026-4c86</link>
      <guid>https://dev.to/dwoitzik/k3s-vs-docker-swarm-vs-nomad-in-2026-4c86</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/k3s-vs-docker-swarm-vs-nomad/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Choosing between k3s, Docker Swarm, and Nomad stopped being a features question some time ago. It is now a &lt;strong&gt;project-trajectory&lt;/strong&gt; question, and the three projects are on genuinely different trajectories. k3s tracks upstream Kubernetes within weeks and ships three supported release lines at once. Docker Swarm still works but its vendor has redirected development toward Kubernetes, and the Docker Engine v29 API floor broke parts of it badly. Nomad is the technically lightest option and the only one of the three that is not open source at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR — The Picks
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Orchestrator&lt;/th&gt;
&lt;th&gt;Pick it if&lt;/th&gt;
&lt;th&gt;Main risk in 2026&lt;/th&gt;
&lt;th&gt;License&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;k3s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You want Kubernetes, or your tooling already assumes it&lt;/td&gt;
&lt;td&gt;You inherit the Kubernetes upgrade treadmill&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Docker Swarm&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You already run it and need cluster mode with no extra daemon&lt;/td&gt;
&lt;td&gt;Docker Engine v29 dropped client and plugin compatibility&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Nomad&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You want one scheduler for mixed container and non-container workloads&lt;/td&gt;
&lt;td&gt;BUSL source-available, not open source&lt;/td&gt;
&lt;td&gt;BUSL → MPL-2.0 after 4 years&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you are starting fresh in 2026, the honest default is k3s. Not because it is the most capable — Nomad is lighter and Docker Swarm is simpler to learn — but because it is the only one of the three with a release cadence you can plan against, and because the surrounding ecosystem has consolidated around Kubernetes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three facts that decide it
&lt;/h2&gt;

&lt;p&gt;Before features: these are the three things that actually constrain the decision, and all three are documented rather than opinion.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Docker Engine v29 broke Swarm compatibility
&lt;/h3&gt;

&lt;p&gt;Docker Engine v29 raised the &lt;strong&gt;minimum supported daemon API version to 1.44&lt;/strong&gt;, dropping all compatibility with clients built against Docker Engine older than v25. There is no fallback and no negotiation: tooling compiled against an older API receives a hard rejection.&lt;/p&gt;

&lt;p&gt;The damage is not evenly distributed, and this is the part that matters if you run Swarm. &lt;strong&gt;Legacy (V1) Docker volume plugins are rejected by the v29 daemon.&lt;/strong&gt; Because Swarm requires cluster-scoped persistent storage, Swarm operators hit this disproportionately. The failure mode is nasty: Docker keeps the volume metadata but cannot reach the plugin needed to mount the volume, so &lt;strong&gt;your data appears to be gone while it is still intact on the storage target&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;There is a second trap in the same release. Upgrading &lt;em&gt;some&lt;/em&gt; cluster nodes to v29 while others stay older — a staged rollout, the obvious upgrade strategy — causes &lt;strong&gt;encrypted overlay traffic to silently stop&lt;/strong&gt; between v29.0.0 and v29.2.0. Silent is the operative word. If you use encrypted overlay networks, you must move all swarm hosts to v29.1.2 or later in one step; rolling upgrades are not available to you.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Docker has redirected Swarm development toward Kubernetes
&lt;/h3&gt;

&lt;p&gt;Docker's own retired-products page is unusually direct about SwarmKit, the project that powers Swarm mode:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;While Swarm mode remains functional, development has slowed in favor of Kubernetes-based solutions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the vendor stating the trajectory. Swarm is not deprecated in the sense of being scheduled for removal — &lt;code&gt;docker swarm init&lt;/code&gt; still works and the mode is still documented as built into the Engine — but it is a maintenance posture, not an investment posture. Portainer, the most visible Swarm management platform, published an advisory on 2026-03-10 recommending caution with continuing to run Swarm and planning a gradual move to Kubernetes.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Nomad is not open source
&lt;/h3&gt;

&lt;p&gt;This one gets repeated wrong. Nomad Community Edition is licensed under the &lt;strong&gt;Business Source License&lt;/strong&gt;. The change license is MPL 2.0, but it applies &lt;em&gt;four years after the version is published&lt;/em&gt;. So a Nomad release from today becomes MPL 2.0 in 2030 — meaning that today, in 2026, if you need a genuinely open orchestrator, Nomad is not one.&lt;/p&gt;

&lt;p&gt;The licensing situation is now stable but newly complicated. Since April 2026 HashiCorp's self-managed products follow IBM's enterprise software lifecycle, following the IBM acquisition. Nomad's support windows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Base support ends&lt;/th&gt;
&lt;th&gt;Extended support ends&lt;/th&gt;
&lt;th&gt;Ongoing extended ends&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1.8.x (LTS)&lt;/td&gt;
&lt;td&gt;2026-04-30&lt;/td&gt;
&lt;td&gt;2026-04-30&lt;/td&gt;
&lt;td&gt;2026-04-30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.10.x (LTS)&lt;/td&gt;
&lt;td&gt;2027-04-30&lt;/td&gt;
&lt;td&gt;2027-04-30&lt;/td&gt;
&lt;td&gt;2027-04-30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.11.x&lt;/td&gt;
&lt;td&gt;2026-10-31&lt;/td&gt;
&lt;td&gt;2026-10-31&lt;/td&gt;
&lt;td&gt;2026-10-31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.0.x&lt;/td&gt;
&lt;td&gt;2028-04-30&lt;/td&gt;
&lt;td&gt;2029-04-30&lt;/td&gt;
&lt;td&gt;2032-04-30&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note that 1.8.x is already fully out of support as of this writing, and 1.11.x ends 2026-10-31 — within weeks. &lt;strong&gt;Nomad v1.10.x is the last LTS line.&lt;/strong&gt; From the 2.0.0 release onward, Nomad also drops semantic versioning for IBM's Version-Modification-Fix model, so tooling that parses Nomad version strings needs updating.&lt;/p&gt;

&lt;p&gt;One operational warning worth knowing before you plan a mixed estate: a Nomad Enterprise cluster &lt;strong&gt;cannot be downgraded&lt;/strong&gt;, and servers running open-source Nomad will &lt;em&gt;panic&lt;/em&gt; if joined to an Enterprise cluster, because the Raft entries for Enterprise features are not parseable by the open-source build. Test that boundary before you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  k3s — the default for a reason
&lt;/h2&gt;

&lt;p&gt;k3s is a conformant Kubernetes distribution packaged as a single binary. As of October 2026 it maintains &lt;strong&gt;three release lines concurrently&lt;/strong&gt;: v1.36.4+k3s1 is current, with v1.35.8 and v1.34.11 still receiving backports. Each build pins its whole dependency set — for v1.34.11+k3s1 that is Kubernetes v1.34.11, Kine v0.16.4, SQLite 3.53.4, etcd v3.6.14-k3s1, containerd v2.2.7-k3s1, runc v1.4.2, Flannel v0.28.4, metrics-server v0.9.0, Traefik v3.7.13, CoreDNS v1.14.7, helm-controller v0.16.27 and local-path-provisioner v0.0.37.&lt;/p&gt;

&lt;p&gt;That table matters more than it looks. When something breaks at 02:00 you can read which exact version of which component is running instead of discovering it. My own homelab runs k3s across several nodes, and that pinned-component view is what makes problems like &lt;a href="https://dev.to/blog/argocd-cache-staleness-shared-resource-warning/"&gt;ArgoCD cache staleness on a shared resource&lt;/a&gt; or &lt;a href="https://dev.to/blog/nfs-vs-local-path-sqlite-trap/"&gt;picking the wrong storage backend for cluster state&lt;/a&gt; diagnosable at all.&lt;/p&gt;

&lt;p&gt;The cost of k3s is that you inherit Kubernetes' upgrade treadmill. Upgrading three majors is not a hobby, and every Helm chart in your stack is a chance for the chart to be behind the API version you just moved to. One concrete example from the 1.34 line: the bundled Traefik chart moved to v40.x, which renamed the ingress-nginx migration provider from &lt;code&gt;kubernetesIngressNginx&lt;/code&gt; to &lt;code&gt;kubernetesIngressNGINX&lt;/code&gt;. That is a breaking change inside a patch-level chart bump.&lt;/p&gt;

&lt;p&gt;I have written about the parts that bite in practice — the &lt;a href="https://dev.to/blog/velero-garage-k3s-backup/"&gt;Velero and Garage S3 backup chain&lt;/a&gt; and &lt;a href="https://dev.to/blog/unbound-adguard-keepalived-homelab/"&gt;Unbound with AdGuard and keepalived&lt;/a&gt; are both k3s deployments that took real iteration to get right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker Swarm — simpler, and now on borrowed time
&lt;/h2&gt;

&lt;p&gt;Swarm's genuine advantage is that it is &lt;em&gt;already there&lt;/em&gt;. No second daemon, no new control plane, no new YAML dialect. If your Docker workflow is a &lt;code&gt;compose&lt;/code&gt; file and a handful of services, &lt;code&gt;docker stack deploy&lt;/code&gt; is a very small step up, and the service model with rolling updates and health checks is more capable than plain Compose.&lt;/p&gt;

&lt;p&gt;The case against it in 2026 is the two items above: the v29 compatibility break with its silent overlay-traffic failure mode, and a vendor that has said in writing that development has slowed. Neither makes Swarm unusable tomorrow. Both mean every future upgrade is a risk you cannot get support for.&lt;/p&gt;

&lt;p&gt;If you are on Swarm, the sensible 2026 posture is: pin the Engine version deliberately, verify you are past v29.1.2, audit for V1 volume plugins &lt;strong&gt;before&lt;/strong&gt; you upgrade rather than after, and start introducing Kubernetes for new workloads so you are not migrating everything at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nomad — the best scheduler, the weakest license
&lt;/h2&gt;

&lt;p&gt;Setting licensing aside, Nomad is genuinely well-designed for its niche. It schedules not just containers but binaries, VMs and batch jobs through one job specification, it is built around a single deliberately small binary rather than a multi-component control plane, and its UI is easier to reason about than anything in the Kubernetes ecosystem. For a mixed estate — a few containers alongside a scheduled data job, an ETL step, a Windows task — that unification is the whole point and Kubernetes does not do it without extra machinery.&lt;/p&gt;

&lt;p&gt;Against that: BUSL rather than an OSI license, a support matrix reshuffled by the IBM acquisition, a semantic-versioning break at 2.0, and an ecosystem that assumes Kubernetes. If your team already lives in Kubernetes YAML, Nomad's job syntax is a second language to maintain for a saving you have to measure yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Head-to-Head
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;k3s&lt;/th&gt;
&lt;th&gt;Docker Swarm&lt;/th&gt;
&lt;th&gt;Nomad&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scheduling unit&lt;/td&gt;
&lt;td&gt;Pod&lt;/td&gt;
&lt;td&gt;Service (task)&lt;/td&gt;
&lt;td&gt;Job (group, task, service)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-container workloads&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes — binaries, VMs, batch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Control plane footprint&lt;/td&gt;
&lt;td&gt;Single binary per node&lt;/td&gt;
&lt;td&gt;Built into the Docker Engine, no extra daemon&lt;/td&gt;
&lt;td&gt;Single binary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;License&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;BUSL, becomes MPL-2.0 after 4 years&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026 release cadence&lt;/td&gt;
&lt;td&gt;Three concurrent K8s lines&lt;/td&gt;
&lt;td&gt;Slowed, vendor-prioritised elsewhere&lt;/td&gt;
&lt;td&gt;Spring/fall features, monthly as needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ecosystem direction&lt;/td&gt;
&lt;td&gt;Consolidating around it&lt;/td&gt;
&lt;td&gt;Maintenance&lt;/td&gt;
&lt;td&gt;Stable, narrower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upgrading is&lt;/td&gt;
&lt;td&gt;Hard (K8s treadmill)&lt;/td&gt;
&lt;td&gt;Easy&lt;/td&gt;
&lt;td&gt;Moderate (mind the VMF versioning break)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Which one should you run?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Run k3s&lt;/strong&gt; if your workloads are containers and you want the Kubernetes ecosystem — Helm, ArgoCD, Prometheus operators, the CNCF ecosystem at large. This is the default in 2026 and the choice I would make for anything new.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stay on Docker Swarm&lt;/strong&gt; only if you already run it in production and the migration cost is real. Treat it as a managed liability: pin versions, avoid the v29 trap, and plan the exit. Do not adopt it new in 2026.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run Nomad&lt;/strong&gt; if scheduling heterogeneous workload types on one control plane is worth more to you than an OSI license and a Kubernetes-shaped ecosystem. Just make that trade knowingly — you are choosing source-available software with a four-year conversion clock, not open source.&lt;/p&gt;

&lt;p&gt;If you genuinely cannot decide between two, run both on the same hardware for a month with one real workload. The theoretical comparison above is the least interesting part; how each behaves with your actual data on your actual disks is what decides it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is Docker Swarm being deprecated?
&lt;/h3&gt;

&lt;p&gt;Not formally in the sense of a scheduled removal — &lt;code&gt;docker swarm init&lt;/code&gt; still works and Swarm mode is still part of Docker Engine. But Docker's own documentation states that SwarmKit development "has slowed in favor of Kubernetes-based solutions," so treat it as maintenance-only. The practical risk arrived with Docker Engine v29, which broke client and plugin compatibility for Swarm disproportionately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Nomad open source?
&lt;/h3&gt;

&lt;p&gt;No. Nomad Community Edition is licensed under the Business Source License. Versions become MPL 2.0 four years after publication, so a release from 2026 becomes genuinely open source in 2030. If you require an OSI-approved license today, that rules Nomad out. k3s and Docker Swarm are both Apache-2.0.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is k3s just "Kubernetes but worse"?
&lt;/h3&gt;

&lt;p&gt;No. It is a conformant Kubernetes distribution — same API server, same controllers, same ecosystem — delivered as one binary with sensible defaults. The compromise is operational, not functional: you give up control over the component versions to get a maintainable install, which is why the pinned component table is worth reading before you debug anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I run Docker Swarm and Kubernetes on the same host?
&lt;/h3&gt;

&lt;p&gt;Technically, yes, but you should not. Both want to own the container runtime and the network stack, and the interaction between a Swarm overlay network and a CNI is a debugging experience you do not need to have. Run them on separate machines.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the safest way to upgrade a Swarm cluster to Docker v29?
&lt;/h3&gt;

&lt;p&gt;Get every host to v29.1.2 or later in a single step. Staged rollouts break encrypted overlay traffic silently in v29.0.0 through v29.2.0. Before you start, inventory your volume plugins — any V1 plugin will be rejected by the v29 daemon, and the resulting "missing" data is usually still on the storage target. Export your data first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Nomad support Kubernetes workloads?
&lt;/h3&gt;

&lt;p&gt;Not natively. Nomad is not a Kubernetes scheduler and does not run Kubernetes manifests. There is no compatibility path if you need the Kubernetes API; pick k3s instead. Nomad's own strength — scheduling binaries and batch jobs alongside containers — is precisely what Kubernetes does not offer.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>docker</category>
      <category>devops</category>
      <category>homelab</category>
    </item>
    <item>
      <title>How a YAML List vs Map Difference Crash-Looped My Kyverno Fix</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 12 Oct 2026 14:54:50 +0000</pubDate>
      <link>https://dev.to/dwoitzik/how-a-yaml-list-vs-map-difference-crash-looped-my-kyverno-fix-4ifl</link>
      <guid>https://dev.to/dwoitzik/how-a-yaml-list-vs-map-difference-crash-looped-my-kyverno-fix-4ifl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/kyverno-crash-loop-helm-extraargs-map/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Kyverno's cleanup-controller was restarting every few hours. The leader-election timeout was expiring under load — the same class of leader-election instability that &lt;a href="https://dev.to/blog/cpu-scheduling-etcd-proxmox-units/"&gt;CPU contention causes for etcd&lt;/a&gt; — the controller lost its lock, and the new leader took over, causing a brief gap in policy enforcement. The fix looked simple: increase &lt;code&gt;leaderElectionRetryPeriod&lt;/code&gt;. The fix crash-looped the very pod it was meant to stabilize.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cleanup-controller-7f8b9c4d6-xk2p4   0/1     CrashLoopBackOff   3 (42s ago)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pod started, immediately crashed, and Kubernetes restarted it. Three crashes in under two minutes. The Kyverno dashboard showed the cleanup controller as unavailable, and policy reports stopped updating.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Investigation
&lt;/h2&gt;

&lt;p&gt;The crash-loop meant the binary was rejecting its own flags. Checking the pod's logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: unknown flag: --0=--leaderElectionRetryPeriod=10s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The binary received &lt;code&gt;--0=--leaderElectionRetryPeriod=10s&lt;/code&gt; as a single flag. That's not a valid argument — it's a rendered artifact of wrong YAML structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Root Cause: List vs Map
&lt;/h2&gt;

&lt;p&gt;The Helm chart's &lt;code&gt;cleanupController.extraArgs&lt;/code&gt; expects a &lt;strong&gt;map&lt;/strong&gt;, not a list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# WRONG — list syntax, renders as --0=--leaderElectionRetryPeriod=10s&lt;/span&gt;
&lt;span class="na"&gt;cleanupController&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;extraArgs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--leaderElectionRetryPeriod=10s"&lt;/span&gt;

&lt;span class="c1"&gt;# CORRECT — map syntax, renders as --leaderElectionRetryPeriod=10s&lt;/span&gt;
&lt;span class="na"&gt;cleanupController&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;extraArgs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;leaderElectionRetryPeriod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10s"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Helm's &lt;code&gt;range&lt;/code&gt; function iterates over maps as &lt;code&gt;$key, $value&lt;/code&gt; pairs. When you pass a list, Helm treats each list item as a map entry with an integer index as the key. The rendered result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# What the binary actually receives:&lt;/span&gt;
&lt;span class="nt"&gt;--0&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;--leaderElectionRetryPeriod&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10s

&lt;span class="c"&gt;# What it expected:&lt;/span&gt;
&lt;span class="nt"&gt;--leaderElectionRetryPeriod&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The binary sees &lt;code&gt;--0&lt;/code&gt; as a flag name, doesn't recognize it, and exits with a non-zero code. Kubernetes restarts the pod. Crash-loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Is a Production Risk
&lt;/h2&gt;

&lt;p&gt;This wasn't a staging environment. The Kyverno cleanup-controller handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Policy report cleanup&lt;/strong&gt; — old reports pile up without it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Namespace selector caching&lt;/strong&gt; — stale cache entries cause policy enforcement delays&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leader election&lt;/strong&gt; — the restart cycle itself destabilizes the leader lock&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The irony: the fix for leader-election instability &lt;em&gt;caused&lt;/em&gt; a worse instability. The cleanup-controller restarted 3 times before I caught it — and during those restarts, policy reports accumulated without cleanup.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Catch This Before It Hits Production
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Dry-run the Helm template:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm template kyverno kyverno/kyverno &lt;span class="nt"&gt;-n&lt;/span&gt; kyverno &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; cleanupController.extraArgs.leaderElectionRetryPeriod&lt;span class="o"&gt;=&lt;/span&gt;10s &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A5&lt;/span&gt; cleanup-controller
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the rendered YAML shows &lt;code&gt;--0=...&lt;/code&gt; in the container args, the structure is wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Check the chart's values.yaml:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm show values kyverno/kyverno | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A10&lt;/span&gt; extraArgs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The chart documents whether &lt;code&gt;extraArgs&lt;/code&gt; is a map or list. Trust the chart, not your muscle memory from other Helm charts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Use Kyverno's policy reports to validate:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After deploying, check the cleanup controller's pod status immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; kyverno &lt;span class="nt"&gt;-l&lt;/span&gt; app.kubernetes.io/name&lt;span class="o"&gt;=&lt;/span&gt;kyverno-cleanup-controller
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it shows &lt;code&gt;CrashLoopBackOff&lt;/code&gt; within the first minute, the flags are wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kubernetes/system/kyverno/application.yml&lt;/span&gt;
&lt;span class="na"&gt;cleanupController&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;extraArgs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;leaderElectionRetryPeriod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10s"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line change. The pod started cleanly, the crash-loop stopped, and policy reports resumed normal cleanup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pattern
&lt;/h2&gt;

&lt;p&gt;This is a common Helm gotcha across many charts, not just Kyverno:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chart&lt;/th&gt;
&lt;th&gt;Flag field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Gotcha&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Kyverno&lt;/td&gt;
&lt;td&gt;&lt;code&gt;extraArgs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;map&lt;/td&gt;
&lt;td&gt;List syntax renders &lt;code&gt;--0=&lt;/code&gt; prefix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://dev.to/blog/cert-manager-wildcard-k3s-cloudflare/"&gt;Cert-Manager&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;extraArgs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;map&lt;/td&gt;
&lt;td&gt;Same pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prometheus&lt;/td&gt;
&lt;td&gt;&lt;code&gt;extraArgs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;map&lt;/td&gt;
&lt;td&gt;Same pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ArgoCD&lt;/td&gt;
&lt;td&gt;&lt;code&gt;extraArgs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;map&lt;/td&gt;
&lt;td&gt;Same pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fix is always the same: if the chart expects a map, use map syntax. If you're unsure, &lt;code&gt;helm template&lt;/code&gt; will show you exactly what the binary receives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Further reading:&lt;/strong&gt; For a deeper dive into distributed systems failure modes, &lt;a href="https://dev.to/go/4aUtkCs"&gt;Designing Data-Intensive Applications by Martin Kleppmann*&lt;/a&gt; explains why leader-election timeouts behave the way they do under I/O pressure.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Kyverno policies are only effective when the controller is running. A crash-looping cleanup controller means policy reports accumulate without cleanup, and namespace selector caches go stale. Pin your Helm values correctly, and dry-run before deploying to production.&lt;/em&gt;&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Three Fixes Were Merged. None of Them Had Taken Effect.</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 12 Oct 2026 14:54:14 +0000</pubDate>
      <link>https://dev.to/dwoitzik/three-fixes-were-merged-none-of-them-had-taken-effect-pb6</link>
      <guid>https://dev.to/dwoitzik/three-fixes-were-merged-none-of-them-had-taken-effect-pb6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/gitops-merged-not-applied-argocd-drift/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;GitOps makes a specific promise: the cluster matches git. When a change is merged, it deploys. When a resource is deleted from git, it's deleted from the cluster. Merge = deploy is the core guarantee the entire workflow rests on.&lt;/p&gt;

&lt;p&gt;It's not always true. This is a story about finding three cases where it wasn't - two discovered during a drift audit, one that would have been inert even if ArgoCD had synced it correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/homelab-infrastructure" rel="noopener noreferrer"&gt;View the complete homelab infrastructure source on GitHub 🐙&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Gap Opens: ArgoCD's App-of-Apps Bootstrap Problem
&lt;/h2&gt;

&lt;p&gt;The cluster uses ArgoCD's app-of-apps pattern: a root Application watches a directory, creates child Applications for each manifest it finds, and those child Applications sync the actual workloads. The root Application uses a &lt;code&gt;directory.include&lt;/code&gt; glob to decide which files to track.&lt;/p&gt;

&lt;p&gt;The problem: that glob is set once, at bootstrap time, and files added to the directory later are only tracked if they match the existing glob. A file that doesn't match is invisible to ArgoCD - it won't create an Application for it, won't sync it, and won't alert about it. From ArgoCD's perspective, the file doesn't exist.&lt;/p&gt;

&lt;p&gt;The monitoring stack had this problem. Two files - &lt;code&gt;application.yml&lt;/code&gt; and &lt;code&gt;manifests-application.yml&lt;/code&gt; - had been added to the &lt;code&gt;kubernetes/system/monitoring/&lt;/code&gt; directory but were missing from the parent Application's &lt;code&gt;directory.include&lt;/code&gt; glob:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# monitoring-bootstrap/application.yml (the root)&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alertmanager-config.yml"&lt;/span&gt;  &lt;span class="c1"&gt;# ← only this file tracked&lt;/span&gt;
      &lt;span class="c1"&gt;# application.yml and manifests-application.yml: invisible&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fix for a broken &lt;code&gt;configSecret&lt;/code&gt; reference had been merged to &lt;code&gt;application.yml&lt;/code&gt;. It was valid YAML, it would have worked - but ArgoCD never picked it up. The live cluster was still running the broken version from initial bootstrap, indefinitely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the Gap: What the Drift Audit Actually Checks
&lt;/h2&gt;

&lt;p&gt;The standard ArgoCD health check (&lt;code&gt;kubectl get applications -n argocd&lt;/code&gt;) doesn't catch this. All Applications show &lt;code&gt;Synced/Healthy&lt;/code&gt;. The bootstrapped-but-untracked files don't generate an Application at all, so there's nothing to show as out-of-sync.&lt;/p&gt;

&lt;p&gt;The audit that found it was manual: compare the contents of each &lt;code&gt;kubernetes/system/*/&lt;/code&gt; directory against the tracked files in the corresponding bootstrap Application, and flag any file not in the glob:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# For each subdirectory, list tracked files vs actual files&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="nb"&gt;dir &lt;/span&gt;&lt;span class="k"&gt;in &lt;/span&gt;kubernetes/system/&lt;span class="k"&gt;*&lt;/span&gt;/&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== &lt;/span&gt;&lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="s2"&gt; ==="&lt;/span&gt;
  &lt;span class="c"&gt;# What ArgoCD tracks (from the bootstrap Application's directory.include)&lt;/span&gt;
  &lt;span class="c"&gt;# vs what's actually in the directory&lt;/span&gt;
  &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.yml 2&amp;gt;/dev/null
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The audit found approximately 14 directories with the same untracked-after-bootstrap gap - no current drift in most of them, but nothing preventing drift from accumulating silently if files were added there in the future.&lt;/p&gt;

&lt;p&gt;This is the same class of gap that a GitHub Actions Terraform-plan-to-Slack alert is designed to close on the Terraform side — a green pipeline or a &lt;code&gt;Synced&lt;/code&gt; badge tells you the command ran, not that the running state matches intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Live Finding: Traefik allowCrossNamespace
&lt;/h2&gt;

&lt;p&gt;Traefik's &lt;code&gt;allowCrossNamespace&lt;/code&gt; fix had been merged to git on 2026-07-04. The change allowed IngressRoutes in one namespace to reference Services in another - without it, &lt;code&gt;argo.woitzik.dev&lt;/code&gt;, &lt;code&gt;monitoring.woitzik.dev&lt;/code&gt;, and &lt;code&gt;status.woitzik.dev&lt;/code&gt; returned bare 404s.&lt;/p&gt;

&lt;p&gt;The fix was in git. ArgoCD showed the Traefik Application as &lt;code&gt;Synced&lt;/code&gt;. But the endpoints were still 404ing.&lt;/p&gt;

&lt;p&gt;The cause: the Traefik Application's last successful sync was before the fix merged. ArgoCD's sync status reflects the last successful reconciliation, not a continuous comparison. If the sync was clean before the change and nothing triggered a re-sync after (no new commit to the watched path, no manual refresh), the Application can show &lt;code&gt;Synced&lt;/code&gt; while being hours or days behind.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Force ArgoCD to re-compare against current git HEAD&lt;/span&gt;
argocd app &lt;span class="nb"&gt;sync &lt;/span&gt;traefik &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;span class="c"&gt;# or via annotation:&lt;/span&gt;
kubectl annotate application traefik &lt;span class="nt"&gt;-n&lt;/span&gt; argocd &lt;span class="se"&gt;\&lt;/span&gt;
  argocd.argoproj.io/refresh&lt;span class="o"&gt;=&lt;/span&gt;hard &lt;span class="nt"&gt;--overwrite&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After manual sync, all three endpoints came back to 302 within seconds of the rollout completing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Second Finding: Velero's Fix That Couldn't Have Worked
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/blog/velero-garage-k3s-backup/"&gt;Velero&lt;/a&gt; had a merged fix for repository maintenance frequency - too-frequent Kopia maintenance jobs were running during backup windows and causing timeouts. The fix set a custom maintenance frequency via Helm values.&lt;/p&gt;

&lt;p&gt;The fix was synced correctly. It also did nothing, for two independent reasons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason 1: Wrong Helm values structure.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;extraArgs&lt;/code&gt; key was placed at the top level of the Helm values instead of nested under &lt;code&gt;configuration&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# What was merged (broken)&lt;/span&gt;
&lt;span class="na"&gt;velero&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;extraArgs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--default-repo-maintain-frequency=168h"&lt;/span&gt;

&lt;span class="c1"&gt;# What Helm actually needs&lt;/span&gt;
&lt;span class="na"&gt;velero&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;configuration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;extraArgs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--default-repo-maintain-frequency=168h"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Helm silently drops unknown top-level keys. No error, no warning - the extra args simply weren't passed to the binary. Velero started with its defaults.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason 2: Wrong flag name.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The flag &lt;code&gt;--default-repo-maintenance-frequency&lt;/code&gt; doesn't exist in Velero. The real flag is &lt;code&gt;--default-repo-maintain-frequency&lt;/code&gt; (no "ance"). Even if the values structure had been correct, the binary would have printed an unknown flag error and ignored it.&lt;/p&gt;

&lt;p&gt;Two independently-wrong things, each of which would have prevented the fix from taking effect on its own. Caught during the drift audit by checking the running Velero container's actual args:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pod &lt;span class="nt"&gt;-n&lt;/span&gt; velero &lt;span class="nt"&gt;-l&lt;/span&gt; app.kubernetes.io/name&lt;span class="o"&gt;=&lt;/span&gt;velero &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'{.items[0].spec.containers[0].args}'&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;','&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt;
&lt;span class="c"&gt;# → no --default-repo-maintain-frequency flag present&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The correct fix, both issues resolved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;velero&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;configuration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;extraArgs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--default-repo-maintain-frequency=168h"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Three-Part Pattern
&lt;/h2&gt;

&lt;p&gt;All three failures share the same shape:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Traefik allowCrossNamespace&lt;/th&gt;
&lt;th&gt;Velero maintenance frequency&lt;/th&gt;
&lt;th&gt;Monitoring bootstrap files&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Git state&lt;/td&gt;
&lt;td&gt;Correct&lt;/td&gt;
&lt;td&gt;Correct&lt;/td&gt;
&lt;td&gt;Correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live state&lt;/td&gt;
&lt;td&gt;Unsynced&lt;/td&gt;
&lt;td&gt;Synced but inert&lt;/td&gt;
&lt;td&gt;Never tracked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ArgoCD showed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Synced/Healthy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Synced/Healthy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No Application (invisible)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Found by&lt;/td&gt;
&lt;td&gt;Manual sync + endpoint check&lt;/td&gt;
&lt;td&gt;Checking running container args&lt;/td&gt;
&lt;td&gt;Directory vs. glob comparison&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In each case, the developer closed the PR with the reasonable belief that the fix was live. In each case, it wasn't. The merge was the easy part; the verification that the change actually took effect in the running cluster is the part that was skipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Verification Step That Prevents It
&lt;/h2&gt;

&lt;p&gt;For any change where "merged" isn't sufficient proof of "deployed," there's one check that works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# For a Helm values change: verify the flag is in the running container&lt;/span&gt;
kubectl get pod &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;namespace&amp;gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &amp;lt;label-selector&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.items[0].spec.containers[0].args}'&lt;/span&gt;

&lt;span class="c"&gt;# For a Kubernetes manifest change: verify the field is live&lt;/span&gt;
kubectl get &amp;lt;resource&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;namespace&amp;gt; &amp;lt;name&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.spec.&amp;lt;changed-field&amp;gt;}'&lt;/span&gt;

&lt;span class="c"&gt;# For an ArgoCD Application bootstrap: verify the file is tracked&lt;/span&gt;
argocd app get &amp;lt;bootstrap-app&amp;gt; &lt;span class="nt"&gt;--output&lt;/span&gt; json | &lt;span class="se"&gt;\&lt;/span&gt;
  jq &lt;span class="s1"&gt;'.spec.source.directory.include'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of these are expensive. They're all one-liners. The cost of skipping them - in this case, Traefik returning 404 for three production subdomains, Velero running backup maintenance at the wrong frequency indefinitely, and monitoring fixes sitting inert - was substantially higher.&lt;/p&gt;




&lt;p&gt;The same GitOps verification gap exists in Azure DevOps pipelines and GitHub Actions that deploy Terraform or Helm. A pipeline that reports "succeeded" proves that the deployment command ran without error - it doesn't prove that the cluster's running state matches the manifest. Checking the running state directly, after deployment, is the only check that closes the gap.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/go/4f4RHQf"&gt;The Phoenix Project*&lt;/a&gt; is still the sharpest account I know of exactly this gap between "the change was made" and "the change took effect" - fiction, but painfully accurate about how it actually happens.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>gitops</category>
      <category>homelab</category>
    </item>
  </channel>
</rss>
