<?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: 우병수</title>
    <description>The latest articles on DEV Community by 우병수 (@ericwoooo_kr).</description>
    <link>https://dev.to/ericwoooo_kr</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%2F3893397%2Fcc10e5dc-580b-44d5-b2e3-d0b9b7b4f547.png</url>
      <title>DEV Community: 우병수</title>
      <link>https://dev.to/ericwoooo_kr</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ericwoooo_kr"/>
    <language>en</language>
    <item>
      <title>Modularized Workflow Toggles in GitHub Actions: Cutting CI/CD Run Time Without Losing Control</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Mon, 13 Jul 2026 08:10:39 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/modularized-workflow-toggles-in-github-actions-cutting-cicd-run-time-without-losing-control-m4</link>
      <guid>https://dev.to/ericwoooo_kr/modularized-workflow-toggles-in-github-actions-cutting-cicd-run-time-without-losing-control-m4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The most expensive CI failure isn't a flaky test — it's running the full pipeline on a commit that changed two lines in &lt;code&gt;README. md&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~21 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Problem: Every Push Runs Everything, and It's Killing Your Feedback Loop&lt;/li&gt;
&lt;li&gt;The Three Toggle Mechanisms Worth Understanding&lt;/li&gt;
&lt;li&gt;Building the Toggle Layer: A Real Orchestrator Workflow&lt;/li&gt;
&lt;li&gt;Reusable Workflows as the Toggle Target: Structure and Secrets Passing&lt;/li&gt;
&lt;li&gt;Matrix Toggles: Scoping Environments and Platforms Dynamically&lt;/li&gt;
&lt;li&gt;Self-Hosted Runner Considerations When Using Toggles&lt;/li&gt;
&lt;li&gt;When This Pattern Breaks Down (and What to Use Instead)&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Problem: Every Push Runs Everything, and It's Killing Your Feedback Loop
&lt;/h2&gt;

&lt;p&gt;The most expensive CI failure isn't a flaky test — it's running the full pipeline on a commit that changed two lines in &lt;code&gt;README.md&lt;/code&gt;. A typical monolithic &lt;code&gt;.github/workflows/ci.yml&lt;/code&gt; queues lint, unit tests, integration tests, Docker build, push to registry, deploy to staging, and a Slack notification for every single push to every branch. That setup makes sense for exactly one scenario: your first week before you have enough complexity to care. After that, it's billing you for work that produces zero signal.&lt;/p&gt;

&lt;p&gt;The operator cost is concrete. GitHub-hosted runners bill per minute, and a full pipeline that takes 18 minutes on a docs-only commit is 18 minutes of pure waste — multiplied by every engineer pushing WIP commits. On a self-hosted runner, the billing is electricity and thermal load on hardware you own. My local runner sits on the same workstation running Ollama inference; a needless Docker multi-stage build competes with whatever model is warming up. Idle CPU cycles aren't free when the machine has a day job. The waste isn't abstract — it shows up in slower feedback, higher costs, and runners that are busy when you actually need them.&lt;/p&gt;

&lt;p&gt;The fix isn't splitting into a dozen separate workflow files and maintaining them all independently. That trades one problem for another — diverging logic, copy-pasted steps, and no single place to audit what actually runs. The better model is &lt;strong&gt;workflow toggles&lt;/strong&gt;: a combination of &lt;code&gt;workflow_dispatch&lt;/code&gt; inputs, path-based conditionals, reusable workflows called with &lt;code&gt;workflow_call&lt;/code&gt;, and matrix &lt;code&gt;include&lt;/code&gt;/&lt;code&gt;exclude&lt;/code&gt; blocks that let you dial exactly what executes based on who triggered the run, what changed, and what the target environment is. A push to a feature branch with only &lt;code&gt;docs/**&lt;/code&gt; changes should run spell-check and nothing else. A manual dispatch with &lt;code&gt;deploy: true&lt;/code&gt; should run the full chain. The same YAML, scoped by context.&lt;/p&gt;

&lt;p&gt;This kind of conditional orchestration is one layer of a broader automation stack. If you're thinking about where CI toggles fit alongside event-driven pipelines, webhook triggers, and cross-system workflows, the guide on &lt;a href="https://techdigestor.com/ultimate-productivity-guide-2026/" rel="noopener noreferrer"&gt;Workflow Automation in 2026: n8n, Zapier, and Self-Hosted Pipelines&lt;/a&gt; covers the wider space and is worth reading alongside this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Toggle Mechanisms Worth Understanding
&lt;/h2&gt;

&lt;p&gt;The mechanism most developers reach for first — &lt;code&gt;workflow_dispatch&lt;/code&gt; boolean inputs — is also the one most often misused. It gives you explicit, human-readable control over which stages run, and it works equally well when called via the GitHub API for programmatic triggers. The YAML is straightforward, but the &lt;code&gt;if&lt;/code&gt; expression syntax trips people up because it's not quite standard boolean comparison:&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;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;run_deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Deploy&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;after&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;build"&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;boolean&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;target_env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Target&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;environment"&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;choice&lt;/span&gt;
        &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;staging&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;staging&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="c1"&gt;# inputs.run_deploy returns the string "true", not boolean true —&lt;/span&gt;
    &lt;span class="c1"&gt;# the == 'true' comparison is intentional, not a bug&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inputs.run_deploy == 'true'&lt;/span&gt;
    &lt;span class="na"&gt;steps&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;Deploy&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./scripts/deploy.sh ${{ inputs.target_env }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That string-vs-boolean distinction catches everyone once. The &lt;code&gt;type: boolean&lt;/code&gt; declaration makes the UI render a checkbox, but the value arrives in &lt;code&gt;if&lt;/code&gt; expressions as the string &lt;code&gt;'true'&lt;/code&gt; or &lt;code&gt;'false'&lt;/code&gt;. Use &lt;code&gt;== 'true'&lt;/code&gt;, not &lt;code&gt;== true&lt;/code&gt;. Also worth knowing: if you trigger the same workflow via &lt;code&gt;push&lt;/code&gt; (not &lt;code&gt;workflow_dispatch&lt;/code&gt;), &lt;code&gt;inputs.run_deploy&lt;/code&gt; evaluates to empty string, which means every &lt;code&gt;if: inputs.run_deploy == 'true'&lt;/code&gt; gate silently skips — that's usually what you want, but only if you intended it.&lt;/p&gt;

&lt;p&gt;Path filters solve a different problem: not "should this stage run" but "should this workflow run at all given what changed." If your monorepo has a &lt;code&gt;docs/&lt;/code&gt; directory and a &lt;code&gt;src/&lt;/code&gt; directory, there's no reason a markdown edit should trigger your full build and test matrix. The &lt;code&gt;paths&lt;/code&gt; filter handles this at the trigger level, before any job ever starts:&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;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;release/**"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&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;src/**"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;packages/**"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.github/workflows/ci.yml"&lt;/span&gt;  &lt;span class="c1"&gt;# workflow changes should always re-run&lt;/span&gt;
    &lt;span class="na"&gt;paths-ignore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# paths-ignore and paths are mutually exclusive — pick one per trigger&lt;/span&gt;
      &lt;span class="c1"&gt;# listed here only for documentation; remove if using paths above&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docs/**"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.md"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.gitignore"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha: &lt;code&gt;paths&lt;/code&gt; and &lt;code&gt;paths-ignore&lt;/code&gt; are mutually exclusive on the same trigger block. If you mix them, GitHub silently drops one. Use &lt;code&gt;paths&lt;/code&gt; with an allowlist or &lt;code&gt;paths-ignore&lt;/code&gt; with a denylist — not both. Also include the workflow file itself in your &lt;code&gt;paths&lt;/code&gt; allowlist, otherwise a change to the CI config won't trigger a run, which makes debugging infuriating.&lt;/p&gt;

&lt;p&gt;Reusable workflows via &lt;code&gt;workflow_call&lt;/code&gt; are the mechanism that actually enables modular composition at scale. The pattern is an orchestrator workflow that conditionally calls sub-workflows based on job-level &lt;code&gt;if&lt;/code&gt; conditions:&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;# .github/workflows/orchestrator.yml&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;run_integration_tests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;boolean&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/workflows/build.yml&lt;/span&gt;
    &lt;span class="c1"&gt;# no condition — build always runs&lt;/span&gt;

  &lt;span class="na"&gt;integration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
    &lt;span class="c1"&gt;# only call the expensive sub-workflow when explicitly requested&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inputs.run_integration_tests == 'true'&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/workflows/integration-tests.yml&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;node_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;20"&lt;/span&gt;
    &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inherit&lt;/span&gt;

  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;integration&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="c1"&gt;# needs: integration — but integration might be skipped.&lt;/span&gt;
    &lt;span class="c1"&gt;# result == 'skipped' must be handled or this job never runs.&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;always() &amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="s"&gt;needs.build.result == 'success' &amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="s"&gt;(needs.integration.result == 'success' || needs.integration.result == 'skipped')&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/workflows/deploy.yml&lt;/span&gt;
    &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inherit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;always()&lt;/code&gt; + skipped-result pattern in the deploy job is the part nobody documents. If a &lt;code&gt;needs&lt;/code&gt; dependency was skipped, GitHub marks the dependent job as skipped too — automatically, without running your &lt;code&gt;if&lt;/code&gt; condition. The only escape is wrapping the condition in &lt;code&gt;always()&lt;/code&gt; and explicitly allowing &lt;code&gt;'skipped'&lt;/code&gt; as an acceptable upstream result. These three mechanisms also interact in ways that compound quickly: a path filter can suppress the trigger entirely so &lt;code&gt;workflow_dispatch&lt;/code&gt; inputs never exist; a skipped upstream job breaks naive &lt;code&gt;needs&lt;/code&gt; chains; and an orchestrator calling a reusable workflow can't inspect the sub-workflow's internal job results, only its overall conclusion. Document which toggles are active and why at the top of every orchestrator workflow — a three-line comment block saves an hour of trace-reading two weeks later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the Toggle Layer: A Real Orchestrator Workflow
&lt;/h2&gt;

&lt;p&gt;The most counter-intuitive part of building a toggle layer isn't the boolean inputs — it's what happens downstream when a job gets skipped. GitHub Actions' default &lt;code&gt;needs:&lt;/code&gt; behavior treats a skipped upstream job the same as a failed one, which means your entire pipeline silently dies the moment you flip a toggle off. That gotcha alone has caused more broken pipelines than any misconfigured secret. Fix that first, build the rest around it.&lt;/p&gt;

&lt;p&gt;Here's the full orchestrator skeleton. Four boolean inputs, all defaulting to &lt;code&gt;false&lt;/code&gt; so a bare &lt;code&gt;workflow_dispatch&lt;/code&gt; trigger does nothing unless you explicitly opt in:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Orchestrator&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;run_lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Run&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;linting"&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;boolean&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;run_test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Run&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tests"&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;boolean&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;run_build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Run&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;build"&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;boolean&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;run_deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Run&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;deployment"&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;boolean&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# On push, always lint. On manual dispatch, only if toggled on.&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event_name == 'push' || inputs.run_lint&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/workflows/lint.yml&lt;/span&gt;
    &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inherit&lt;/span&gt;

  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Same dual-trigger pattern — push always runs, dispatch checks the toggle.&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event_name == 'push' || inputs.run_test&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lint&lt;/span&gt;
    &lt;span class="c1"&gt;# This is the critical fix: accept 'skipped' as a valid upstream state.&lt;/span&gt;
    &lt;span class="c1"&gt;# Without this, skipping lint kills test even when lint was intentionally off.&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;(github.event_name == 'push' || inputs.run_test) &amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="s"&gt;(needs.lint.result == 'success' || needs.lint.result == 'skipped')&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/workflows/test.yml&lt;/span&gt;
    &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inherit&lt;/span&gt;

  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;lint&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;(github.event_name == 'push' || inputs.run_build) &amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="s"&gt;(needs.lint.result == 'success' || needs.lint.result == 'skipped') &amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="s"&gt;(needs.test.result == 'success' || needs.test.result == 'skipped')&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/workflows/build.yml&lt;/span&gt;
    &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inherit&lt;/span&gt;

  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;(github.event_name == 'push' || inputs.run_deploy) &amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="s"&gt;(needs.build.result == 'success' || needs.build.result == 'skipped')&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/workflows/deploy.yml&lt;/span&gt;
    &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inherit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;github.event_name == 'push' || inputs.run_lint&lt;/code&gt; pattern does real work here. On a push to main, all four stages run unconditionally — the toggles are irrelevant. On a manual &lt;code&gt;workflow_dispatch&lt;/code&gt;, each stage is opt-in. One YAML file covers both the automated path and the surgical "just redeploy" path a developer needs at 11pm. The alternative — separate YAML files for manual vs automated — sounds clean until you have to keep them in sync across three months of changes.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;needs:&lt;/code&gt; chaining deserves more attention than the docs give it. When you write &lt;code&gt;needs: lint&lt;/code&gt; and lint gets skipped, GitHub marks the downstream job as skipped too, without evaluating its &lt;code&gt;if:&lt;/code&gt; condition at all — unless you've written that condition to handle the &lt;code&gt;skipped&lt;/code&gt; result explicitly. The exact pattern that actually works:&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;# This handles three valid upstream states: job ran and passed,&lt;/span&gt;
&lt;span class="c1"&gt;# job was skipped by toggle, or job doesn't exist in this event context.&lt;/span&gt;
&lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
  &lt;span class="s"&gt;needs.lint.result == 'success' || needs.lint.result == 'skipped'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do &lt;strong&gt;not&lt;/strong&gt; use &lt;code&gt;if: always()&lt;/code&gt; as a blanket fix here. &lt;code&gt;always()&lt;/code&gt; will run the downstream job even when the upstream genuinely failed, which destroys your failure signal. The &lt;code&gt;needs.X.result == 'skipped'&lt;/code&gt; check is surgical — it unblocks the toggle path while preserving real failure propagation. For the deploy job specifically, you want to be even stricter: if build failed (not skipped), deploy should never run regardless of toggles. The condition above handles that correctly because a real failure returns &lt;code&gt;'failure'&lt;/code&gt;, not &lt;code&gt;'skipped'&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reusable Workflows as the Toggle Target: Structure and Secrets Passing
&lt;/h2&gt;

&lt;p&gt;The part most teams get wrong first: they treat reusable workflows like shared bash scripts — useful, but dumb. The &lt;code&gt;workflow_call&lt;/code&gt; trigger makes them something closer to typed function signatures. When you define &lt;code&gt;inputs&lt;/code&gt; and &lt;code&gt;secrets&lt;/code&gt; blocks directly in the called workflow file, that file becomes self-documenting in a way that a raw composite action or a script-with-args pattern never quite achieves. The orchestrator that calls it is forced to satisfy a contract. That's the actual value — not code reuse, but enforced interface boundaries between your pipeline stages.&lt;/p&gt;

&lt;p&gt;Here's a minimal &lt;code&gt;deploy.yml&lt;/code&gt; that accepts an environment choice and inherits secrets from the caller:&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;# .github/workflows/deploy.yml&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_call&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Target&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;deployment&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;environment"&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;choice&lt;/span&gt;
        &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;staging&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="c1"&gt;# secrets: inherit is the shortcut — the called workflow sees everything&lt;/span&gt;
    &lt;span class="c1"&gt;# the caller has access to, without you explicitly naming each secret.&lt;/span&gt;
    &lt;span class="c1"&gt;# Use it during early development or when the secret set is stable and&lt;/span&gt;
    &lt;span class="c1"&gt;# well-understood. Switch to explicit mapping before the workflow is&lt;/span&gt;
    &lt;span class="c1"&gt;# called from more than one orchestrator with different secret scopes.&lt;/span&gt;
    &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="s"&gt;inherit&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.environment }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&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;Deploy&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./scripts/deploy.sh&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="c1"&gt;# secrets.DEPLOY_KEY is available because of secrets: inherit&lt;/span&gt;
          &lt;span class="na"&gt;DEPLOY_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DEPLOY_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;TARGET_ENV&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.environment }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;secrets: inherit&lt;/code&gt; shortcut is convenient but carries a real tradeoff: it implicitly grants the called workflow access to every secret visible in the calling context, including ones it has no business touching. If your orchestrator has &lt;code&gt;PROD_DATABASE_URL&lt;/code&gt; and &lt;code&gt;STRIPE_SECRET_KEY&lt;/code&gt; in scope and you call a build workflow that only needs &lt;code&gt;REGISTRY_TOKEN&lt;/code&gt;, you've silently over-permissioned it. Explicit secret mapping is more verbose but documents exactly what's being passed:&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;# in the orchestrator (caller):&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;call-deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/workflows/deploy.yml&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
    &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DEPLOY_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DEPLOY_KEY }}&lt;/span&gt;
      &lt;span class="c1"&gt;# Only DEPLOY_KEY crosses the boundary. Nothing else.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output mechanism is where reusable workflows stop feeling like isolated scripts and start behaving like pipeline stages with return values. A called workflow can surface data back to the orchestrator via &lt;code&gt;workflow_call.outputs&lt;/code&gt; mapped from a job's outputs. The pattern that actually matters in practice: a build workflow that pushes a container image emits the immutable digest, and the deploy workflow consumes it — so you're never deploying "latest" by accident, you're deploying a specific SHA-pinned artifact.&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;# in build.yml (called workflow)&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_call&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;image_digest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pushed&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;image&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;digest&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(sha256:...)"&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ jobs.build.outputs.digest }}&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;digest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.push.outputs.digest }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&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;Build and push&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;push&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/myorg/myapp:${{ github.sha }}&lt;/span&gt;
      &lt;span class="c1"&gt;# docker/build-push-action emits `digest` as a step output automatically&lt;/span&gt;

&lt;span class="c1"&gt;# in orchestrator.yml (caller consuming the output)&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/workflows/build.yml&lt;/span&gt;

  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/workflows/deploy.yml&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;staging&lt;/span&gt;
      &lt;span class="na"&gt;image_digest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ needs.build.outputs.image_digest }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nesting limitation is the sharp edge that trips up anyone who tries to build a genuinely modular pipeline from reusable workflows. As of current GitHub Actions behavior, a called workflow cannot itself call another reusable workflow. The chain is flat: orchestrator calls workflow A, and workflow A cannot then call workflow B as a reusable workflow — it would have to inline those steps or trigger them through a separate mechanism. The documentation uses language about composability that implies deeper nesting works, but it doesn't. The practical consequence is that your orchestrator ends up owning more coordination logic than feels clean, and "reusable" workflows max out at one level of abstraction. If you find yourself wanting to compose two called workflows together, the honest answer is to either inline the second one into the first, or use composite actions (which &lt;em&gt;can&lt;/em&gt; nest) for the leaf-level step logic and reserve reusable workflows for the job-level orchestration boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Matrix Toggles: Scoping Environments and Platforms Dynamically
&lt;/h2&gt;

&lt;p&gt;The most underused feature in GitHub Actions matrix configuration is treating the matrix itself as a runtime input rather than a static list baked into the workflow file. Instead of commenting out environments or maintaining a dozen nearly-identical workflow files, you can pass a JSON array through &lt;code&gt;workflow_dispatch&lt;/code&gt; and let the matrix expand dynamically at runtime. The entry point looks deceptively simple:&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;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;target_envs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;JSON&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;array&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;of&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;target&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;environments'&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;["staging"]'&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-22.04&lt;/span&gt;
    &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ fromJson(inputs.target_envs) }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&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;Deploy to ${{ matrix.env }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./scripts/deploy.sh --env ${{ matrix.env }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default of &lt;code&gt;'["staging"]'&lt;/code&gt; matters: it means a bare trigger with no inputs filled in still produces a valid single-vector matrix rather than blowing up the job. When you need to hit production and canary simultaneously, you pass &lt;code&gt;["production","canary"]&lt;/code&gt; through the dispatch UI or the API payload. No branch gymnastics, no hardcoded job duplicates. The &lt;code&gt;fromJson()&lt;/code&gt; call is doing the heavy lifting — it converts the string input into an actual array that the matrix engine can iterate over.&lt;/p&gt;

&lt;p&gt;Once you have dynamic environments, you'll want certain steps to fire only for specific ones. That's where &lt;code&gt;matrix.include&lt;/code&gt; and conditional step guards work together. A common pattern: smoke tests should only run against production because staging data is inconsistent and you don't want false alarms blocking the pipeline:&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;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-22.04&lt;/span&gt;
    &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ fromJson(inputs.target_envs) }}&lt;/span&gt;
        &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="c1"&gt;# Attach extra config only when env is production&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
            &lt;span class="na"&gt;run_smoke&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;staging&lt;/span&gt;
            &lt;span class="na"&gt;run_smoke&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;steps&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;Deploy&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./scripts/deploy.sh --env ${{ matrix.env }}&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;Smoke Tests&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;matrix.run_smoke == &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./scripts/smoke.sh --env ${{ matrix.env }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;matrix.include&lt;/code&gt; here acts as a lookup table — it attaches the &lt;code&gt;run_smoke&lt;/code&gt; flag to each matrix cell based on the environment value. The step-level &lt;code&gt;if&lt;/code&gt; condition then reads that flag. One gotcha: if you pass an environment that has no matching &lt;code&gt;include&lt;/code&gt; entry, the variable is undefined rather than false — so write your &lt;code&gt;if&lt;/code&gt; guard as &lt;code&gt;matrix.run_smoke == true&lt;/code&gt; rather than &lt;code&gt;!= false&lt;/code&gt;, or you'll get unexpected step execution when new environments get added to the input array without a corresponding include entry.&lt;/p&gt;

&lt;p&gt;Now the sharp edge: pass an empty array (&lt;code&gt;[]&lt;/code&gt;) and the job fails immediately with &lt;em&gt;"matrix must define at least one vector"&lt;/em&gt; — no graceful skip, just a red job. This happens more than you'd expect when upstream logic generates the input programmatically and produces an empty result set. The fix is a job-level guard that runs before the matrix ever expands:&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;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# Gate job: check if the matrix input is non-empty before expanding&lt;/span&gt;
  &lt;span class="na"&gt;matrix-guard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-22.04&lt;/span&gt;
    &lt;span class="na"&gt;outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;has_targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.check.outputs.has_targets }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;check&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;ENVS='${{ inputs.target_envs }}'&lt;/span&gt;
          &lt;span class="s"&gt;COUNT=$(echo "$ENVS" | jq 'length')&lt;/span&gt;
          &lt;span class="s"&gt;echo "has_targets=$([ "$COUNT" -gt 0 ] &amp;amp;&amp;amp; echo 'true' || echo 'false')" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;

  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;matrix-guard&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;needs.matrix-guard.outputs.has_targets == 'true'&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-22.04&lt;/span&gt;
    &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ fromJson(inputs.target_envs) }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&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;Deploy to ${{ matrix.env }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./scripts/deploy.sh --env ${{ matrix.env }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;matrix-guard&lt;/code&gt; job uses &lt;code&gt;jq length&lt;/code&gt; to count array elements and publishes a boolean output. The deploy job's &lt;code&gt;if&lt;/code&gt; condition reads that output via &lt;code&gt;needs.matrix-guard.outputs.has_targets&lt;/code&gt; and skips the entire job — including matrix expansion — when the array is empty. This means the workflow shows as skipped rather than failed, which is the correct behavior when "no environments selected" is a valid state, not an error condition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-Hosted Runner Considerations When Using Toggles
&lt;/h2&gt;

&lt;p&gt;The thermal angle gets overlooked almost entirely in CI documentation. On a self-hosted runner that shares hardware with other services — say, an Ollama inference server or an n8n Docker stack — a job that runs but does nothing still consumes queue slots, spins up the runner process, and generates heat from the CPU doing bookkeeping. A toggle that skips the job entirely at the workflow level means the runner never receives the job assignment. That's not a minor optimization; on a machine where the GPU is already warm from LLM inference, preventing an unnecessary CUDA context initialization from a build job genuinely matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tagging Runners by Capability, Not Just "Self-Hosted"
&lt;/h3&gt;

&lt;p&gt;The default advice — slap &lt;code&gt;runs-on: self-hosted&lt;/code&gt; on everything — causes real pain once you have more than one runner or more than one job type. The fix is capability labels. When you register a runner, you assign labels during setup or via the GitHub UI. A GPU-equipped machine gets &lt;code&gt;[self-hosted, gpu, linux]&lt;/code&gt;. A lightweight runner on a cheap VPS gets &lt;code&gt;[self-hosted, linux]&lt;/code&gt;. Then your workflow targets labels with intent:&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;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;docs-lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Docs linting has zero reason to touch the GPU runner.&lt;/span&gt;
    &lt;span class="c1"&gt;# This label set will never match the gpu-tagged machine.&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;self-hosted&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;linux&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.run_docs_lint == 'true' }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx markdownlint-cli2 "**/*.md"&lt;/span&gt;

  &lt;span class="na"&gt;model-eval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Only dispatched to the machine that actually has CUDA available.&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;self-hosted&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;gpu&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;linux&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.run_model_eval == 'true' }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python eval/run_benchmarks.py&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A docs-lint job will never queue on your GPU runner because GitHub Actions requires all specified labels to match. This is the label-as-capability-gate pattern — you're not just routing, you're enforcing resource access at the dispatch layer. Combined with a toggle that skips &lt;code&gt;model-eval&lt;/code&gt; entirely when you're pushing a docs change, the GPU runner stays free for the work that actually needs it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Concurrency Controls on Top of Toggles
&lt;/h3&gt;

&lt;p&gt;Toggles prevent unneeded &lt;em&gt;types&lt;/em&gt; of jobs from running. Concurrency controls prevent the same job from stacking when someone triggers &lt;code&gt;workflow_dispatch&lt;/code&gt; twice in a row, or when a push and a manual dispatch land close together. These two concerns are orthogonal and both need to be in the config. The group key should encode both the branch ref and the specific toggle inputs so that a deploy-enabled dispatch doesn't cancel a non-deploy dispatch on the same branch:&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;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;self-hosted&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;linux&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.run_deploy == 'true' }}&lt;/span&gt;
    &lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# Group key ties together: the workflow name, the branch, and whether deploy is on.&lt;/span&gt;
      &lt;span class="c1"&gt;# Two deploy runs on main will cancel-and-replace each other.&lt;/span&gt;
      &lt;span class="c1"&gt;# A deploy run and a lint-only run are in different groups and don't interfere.&lt;/span&gt;
      &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.workflow }}-${{ github.ref }}-deploy-${{ inputs.run_deploy }}&lt;/span&gt;
      &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./scripts/deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;inputs.run_deploy&lt;/code&gt; in the group key, a lint-only run (where &lt;code&gt;run_deploy&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;) and a full deploy run can cancel each other out — which is not what you want. The group key needs to be specific enough that only genuinely redundant runs collapse.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Idle Timeout Phantom Offline Problem
&lt;/h3&gt;

&lt;p&gt;This one doesn't appear in the GitHub Actions runner docs prominently, but you'll hit it within a week of running toggles on a self-hosted setup managed by PM2 or systemd. When a skipped job ends immediately — because every step is gated by an &lt;code&gt;if&lt;/code&gt; condition that evaluates false — the runner completes the job in seconds and goes idle. If your runner's idle timeout is shorter than the interval between jobs, the GitHub Actions service marks the runner as offline. You then see phantom "offline" states in the Settings → Actions → Runners panel, and queued jobs fail to dispatch.&lt;/p&gt;

&lt;p&gt;Two practical fixes. First, use the &lt;code&gt;ACTIONS_RUNNER_HOOK_JOB_COMPLETED&lt;/code&gt; environment variable to fire a keepalive or logging script at the end of every job — this resets the runner's internal idle clock. Second, and simpler: bump the idle timeout in the runner's &lt;code&gt;.env&lt;/code&gt; file before starting the service:&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;# In the runner's root directory: .env (create if not present)&lt;/span&gt;
&lt;span class="c"&gt;# Default idle timeout is 50 seconds in older runner versions.&lt;/span&gt;
&lt;span class="c"&gt;# Bump it to avoid phantom offline during toggle-heavy workflows.&lt;/span&gt;
&lt;span class="nv"&gt;RUNNER_IDLE_TIMEOUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;300

&lt;span class="c"&gt;# If using the job-completed hook instead, point it at a no-op keepalive:&lt;/span&gt;
&lt;span class="nv"&gt;ACTIONS_RUNNER_HOOK_JOB_COMPLETED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/opt/actions-runner/hooks/keepalive.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# /opt/actions-runner/hooks/keepalive.sh&lt;/span&gt;
&lt;span class="c"&gt;# Called by the runner after every job completes.&lt;/span&gt;
&lt;span class="c"&gt;# Exists purely to produce a log line and reset the idle clock.&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y-%m-%dT%H:%M:%SZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] job completed hook fired, runner staying warm"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hook approach is more surgical — it doesn't change idle behavior globally, just ensures the runner signals activity after every job regardless of how fast it finished. On a PM2-managed runner, make sure the hook script is executable and the PM2 process has the env variable set in its ecosystem config, not just in the shell where you ran &lt;code&gt;pm2 start&lt;/code&gt;. Environment variables set in the shell are not inherited by PM2 processes started at boot unless they're declared in &lt;code&gt;ecosystem.config.js&lt;/code&gt; or the &lt;code&gt;.env&lt;/code&gt; file the runner process reads on startup.&lt;/p&gt;

&lt;h2&gt;
  
  
  When This Pattern Breaks Down (and What to Use Instead)
&lt;/h2&gt;

&lt;p&gt;The silent green run is the most dangerous failure mode in toggle-based workflows. A new contributor runs &lt;code&gt;gh workflow run ci.yml&lt;/code&gt; accepting all defaults, every input defaults to &lt;code&gt;false&lt;/code&gt;, the workflow completes in 8 seconds with a green checkmark, and nothing actually ran. The fix is a required summary job that always executes and explicitly asserts that at least one stage was enabled — treat it like a guard clause, not an afterthought:&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;sanity-check&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;lint&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
  &lt;span class="na"&gt;steps&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;Assert at least one stage ran&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;# Fails the workflow if every toggle was false&lt;/span&gt;
        &lt;span class="s"&gt;if [[ "${{ inputs.run_lint }}" == "false" &amp;amp;&amp;amp; \&lt;/span&gt;
              &lt;span class="s"&gt;"${{ inputs.run_test }}" == "false" &amp;amp;&amp;amp; \&lt;/span&gt;
              &lt;span class="s"&gt;"${{ inputs.run_build }}" == "false" &amp;amp;&amp;amp; \&lt;/span&gt;
              &lt;span class="s"&gt;"${{ inputs.run_deploy }}" == "false" ]]; then&lt;/span&gt;
          &lt;span class="s"&gt;echo "::error::All stages disabled — this run did nothing. Enable at least one toggle."&lt;/span&gt;
          &lt;span class="s"&gt;exit 1&lt;/span&gt;
        &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For monorepos, toggles are often the wrong tool entirely. If what you actually need is "run the backend job only when files under &lt;code&gt;services/api/&lt;/code&gt; changed," you're modeling a path-scoping problem as an input problem. Native &lt;code&gt;paths:&lt;/code&gt; triggers in GitHub Actions apply at the workflow level, not the job level — you can't say "trigger this job but not that one based on which files changed." The third-party action &lt;code&gt;dorny/paths-filter&lt;/code&gt; fills that gap by outputting per-path boolean signals you can consume in job &lt;code&gt;if:&lt;/code&gt; conditions. The tradeoff is a real one: you're adding a dependency on a third-party action with its own release cadence, and you're coupling your job graph to a &lt;code&gt;paths-filter&lt;/code&gt; step that must run first. Worth it for a true monorepo with three or more distinct service roots; not worth it if your "monorepo" is two packages sharing a lockfile.&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;# dorny/paths-filter approach — jobs respond to actual file changes&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dorny/paths-filter@v3&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;changes&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;api:&lt;/span&gt;
        &lt;span class="s"&gt;- 'services/api/**'&lt;/span&gt;
      &lt;span class="s"&gt;frontend:&lt;/span&gt;
        &lt;span class="s"&gt;- 'apps/web/**'&lt;/span&gt;

&lt;span class="c1"&gt;# Then in a downstream job:&lt;/span&gt;
&lt;span class="na"&gt;build-api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;changes&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;needs.changes.outputs.api == 'true'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A more subtle signal that the pattern is collapsing: you've written a script to decide which toggle values to pass to the workflow. The moment decision logic lives inside a shell script or composite action that computes inputs for another workflow, the workflow is no longer the source of truth — it's just an executor with a confusing interface. That logic belongs in an orchestrator. On my setup, this lands in n8n: a webhook receives the trigger event, a Function node evaluates branch name, changed paths, PR labels, and environment targets, then calls the GitHub API to dispatch the workflow with explicit inputs already resolved. The workflow itself stays dumb — it receives concrete boolean inputs and executes exactly what it's told. A small Node.js script via the Octokit REST client works equally well if n8n feels heavy for the use case.&lt;/p&gt;

&lt;p&gt;There's also a hard architectural ceiling: &lt;code&gt;workflow_dispatch&lt;/code&gt; inputs are capped at 10 per workflow in the current GitHub Actions spec. If you're at 8 toggles and considering adding more, that's not a configuration problem — it's a signal the workflow has accumulated too many responsibilities. The right move at that boundary is decomposition: split into multiple focused workflows (&lt;code&gt;ci-quality.yml&lt;/code&gt;, &lt;code&gt;ci-build.yml&lt;/code&gt;, &lt;code&gt;ci-deploy.yml&lt;/code&gt;) called via &lt;code&gt;workflow_call&lt;/code&gt;, each with their own narrow input surface. The toggle pattern works well in the range of 3–6 inputs for a single workflow; beyond that, you're managing complexity with more complexity.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/modularized-workflow-toggles-in-github-actions-cutting-ci-cd-run-time-without-losing-control/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>tools</category>
      <category>career</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Self-Hosted Log Fingerprinting Pipelines: Catching Anomalies Without Sending Logs to the Cloud</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Fri, 10 Jul 2026 08:10:41 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/self-hosted-log-fingerprinting-pipelines-catching-anomalies-without-sending-logs-to-the-cloud-52l5</link>
      <guid>https://dev.to/ericwoooo_kr/self-hosted-log-fingerprinting-pipelines-catching-anomalies-without-sending-logs-to-the-cloud-52l5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Threshold-based alerting has a fundamental blind spot: it measures magnitude, not behavior.  A sudden flood of HTTP 200s looks healthy to every rule you've probably written — no error rate spike, no latency breach, nothing trips.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~24 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Problem: Your Logs Are Noisy and Your Alerting Is Dumb&lt;/li&gt;
&lt;li&gt;Tool Stack and Why Each Piece Earns Its Place&lt;/li&gt;
&lt;li&gt;Building the Pipeline: From Raw Log Line to Stored Fingerprint&lt;/li&gt;
&lt;li&gt;Storing and Comparing Fingerprints in Qdrant&lt;/li&gt;
&lt;li&gt;Wiring the Alert Path in n8n&lt;/li&gt;
&lt;li&gt;Gotchas That Will Cost You an Afternoon&lt;/li&gt;
&lt;li&gt;When This Architecture Is and Isn't the Right Call&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Problem: Your Logs Are Noisy and Your Alerting Is Dumb
&lt;/h2&gt;

&lt;p&gt;Threshold-based alerting has a fundamental blind spot: it measures magnitude, not behavior. A sudden flood of HTTP 200s looks healthy to every rule you've probably written — no error rate spike, no latency breach, nothing trips. But if those 200s are all hitting &lt;code&gt;/api/export&lt;/code&gt; from twenty IPs that appeared six hours ago, you have a data exfiltration pattern that sailed right past your PagerDuty rules. The same failure mode shows up in auth logs, queue workers, batch jobs — anywhere the &lt;em&gt;shape&lt;/em&gt; of traffic matters more than the raw count. Threshold alerting asks "how many?" when the real question is "does this look like something we've seen before?"&lt;/p&gt;

&lt;p&gt;The SaaS log aggregation pitch is compelling until you work out the economics at volume. Shipping gigabytes of application logs to Datadog or Splunk means paying ingestion costs that scale linearly with your verbosity, and those platforms reward you for logging &lt;em&gt;less&lt;/em&gt; — which is exactly backwards from good observability practice. Beyond cost, there's the data residency question. Your logs contain internal hostnames, user IDs, API response bodies, stack traces with file paths, and whatever else your developers decided to dump into structured fields. That data leaving the building is a compliance surface you may not have formally assessed. The "just send everything to the cloud" approach trades operational convenience for a data exposure posture that's genuinely hard to audit.&lt;/p&gt;

&lt;p&gt;Log fingerprinting takes a different angle entirely. Instead of writing regex patterns for every known error format — a game you will lose as soon as a library version changes its error messages — you cluster structurally similar log lines together and track the cluster space over time. A new cluster appearing is interesting. A known cluster whose volume doubles in ten minutes is interesting. A cluster that disappears entirely might mean a service stopped doing something it should be doing. None of this requires you to have predicted the specific log message in advance. The clusters emerge from the data, and deviations from the established cluster topology are what trigger alerts. This is why it beats regex whack-a-mole: you're not pattern-matching strings, you're modeling normal log behavior and flagging structural drift.&lt;/p&gt;

&lt;p&gt;The pipeline that makes this work locally has four sequential stages. Raw log lines come in and get parsed into structured fields — timestamp, level, service, message body. The message body gets embedded into a dense vector using something like &lt;strong&gt;bge-m3&lt;/strong&gt;, which handles the semantic similarity work that makes "connection refused" and "failed to connect" land near each other in vector space. Those vectors get clustered — HDBSCAN is a solid choice here because it doesn't require you to specify the number of clusters upfront and handles noise points well. Then each incoming batch of embedded vectors gets compared against the known cluster map: do these points fall into existing clusters, or are they carving out new territory? The alert layer only fires on that last question. The whole thing runs on local hardware, your logs never leave the machine, and the operational cost is VRAM and a Postgres instance, not a per-GB ingestion bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool Stack and Why Each Piece Earns Its Place
&lt;/h2&gt;

&lt;p&gt;The most common mistake in log fingerprinting pipelines is over-engineering the ingestion layer. Elasticsearch clusters are expensive to run, operationally heavy, and genuinely overkill for fingerprinting workloads where you're not doing full-text search across petabytes — you're chunking log lines, embedding them, and doing nearest-neighbor lookups. Grafana Loki costs a fraction of that operationally: it indexes labels, not content, which means disk usage stays sane and memory pressure stays low. Promtail's Docker log scraping drops into a compose file without custom configuration gymnastics — point it at the Docker socket, define your label mappings, done.&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;# promtail-config.yml — minimal Docker log scraping&lt;/span&gt;
&lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;http_listen_port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9080&lt;/span&gt;

&lt;span class="na"&gt;clients&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://loki:3100/loki/api/v1/push&lt;/span&gt;

&lt;span class="na"&gt;scrape_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;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker&lt;/span&gt;
    &lt;span class="na"&gt;docker_sd_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;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unix:///var/run/docker.sock&lt;/span&gt;
        &lt;span class="na"&gt;refresh_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
    &lt;span class="na"&gt;relabel_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# use container name as the service label for LogQL filtering&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;__meta_docker_container_name&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;service&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;__meta_docker_container_log_stream&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;stream&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the embedding side, bge-m3 via Ollama is the right call for logs specifically because logs are multilingual garbage — stack traces mixed with German error messages, base64 blobs, hex addresses, vendor-specific tokens. Most embedding models fall apart on that. bge-m3's 1024-dimensional output handles the noise better than smaller models, and on a 32GB VRAM box it loads alongside a quantized inference model without eviction. The VRAM footprint for bge-m3 in Ollama sits around 1.2–1.5 GB depending on quantization, which is pocket change if you're already running a Q4 Mixtral or similar. The critical thing: you're embedding log &lt;em&gt;templates&lt;/em&gt;, not raw lines — strip the timestamps and variable fields first, or your vector space becomes useless.&lt;/p&gt;

&lt;p&gt;Qdrant earns its place because the operational model matches exactly what log fingerprinting needs. Named collections per service means your web app logs and your database logs don't pollute each other's similarity space — a connection timeout in Postgres and a 502 in Nginx have different cluster geometries. The HNSW index gives you approximate nearest-neighbor at latency that doesn't bottleneck the pipeline; on a local Docker deployment, payload queries with a vector search come back well under 10ms for collections in the hundreds of thousands of points. One real gotcha: Qdrant's default &lt;code&gt;on_disk&lt;/code&gt; payload storage is off, so if you're storing full log line text as payload for retrieval, turn on &lt;code&gt;on_disk_payload: true&lt;/code&gt; or RAM usage grows faster than expected.&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;# Create a named collection for a specific service&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; PUT http://localhost:6333/collections/nginx_logs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "vectors": {
      "size": 1024,
      "distance": "Cosine"
    },
    "on_disk_payload": true,
    "hnsw_config": {
      "m": 16,
      "ef_construct": 100
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;n8n is where this stack avoids becoming a custom microservice project. The HTTP Request node hits the Ollama embedding API, another hits Qdrant's upsert endpoint, and the whole flow triggers either from a Loki webhook alert or a cron that polls LogQL for new log volume. No custom server code, no maintaining a Python service, no dependency hell — the glue layer is just JSON flowing between HTTP calls. The honest trade-off: n8n's execution model isn't designed for high-frequency streaming; if you're trying to fingerprint thousands of log lines per second in real time, you'll hit the webhook queue ceiling and need something like a dedicated consumer. For batch fingerprinting on a 5-minute cron or alert-driven triage, it handles the load without complaint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the Pipeline: From Raw Log Line to Stored Fingerprint
&lt;/h2&gt;

&lt;p&gt;The part most tutorials skip entirely: Loki ingestion without a sane label strategy produces a query surface so noisy that your downstream fingerprinting collapses into garbage clusters. Before touching embeddings, the labels you attach at scrape time determine whether your LogQL queries return coherent batches or mixed-signal soup. Get that wrong and every downstream fix is harder than it needs to be.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — Promtail Config and Label Strategy
&lt;/h3&gt;

&lt;p&gt;The minimal working Promtail config for Docker container logs uses the Docker socket to autodiscover containers, then applies relabeling to extract the labels that matter for fingerprinting. The three you actually need are &lt;code&gt;job&lt;/code&gt;, &lt;code&gt;container_name&lt;/code&gt;, and &lt;code&gt;level&lt;/code&gt;. Everything else is noise in Loki's index unless you have a specific query need for it.&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;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;http_listen_port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9080&lt;/span&gt;
  &lt;span class="na"&gt;grpc_listen_port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;

&lt;span class="na"&gt;positions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;filename&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/tmp/positions.yaml&lt;/span&gt;

&lt;span class="na"&gt;clients&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://loki:3100/loki/api/v1/push&lt;/span&gt;

&lt;span class="na"&gt;scrape_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;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker&lt;/span&gt;
    &lt;span class="na"&gt;docker_sd_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;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unix:///var/run/docker.sock&lt;/span&gt;
        &lt;span class="na"&gt;refresh_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
    &lt;span class="na"&gt;relabel_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# container_name becomes the primary identity label&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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;__meta_docker_container_name'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&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;container_name&lt;/span&gt;
      &lt;span class="c1"&gt;# job groups containers by logical service (set via Docker label)&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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;__meta_docker_container_label_com_docker_compose_service'&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;job&lt;/span&gt;
    &lt;span class="na"&gt;pipeline_stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="c1"&gt;# pull level out of structured JSON logs; adjust pattern for your format&lt;/span&gt;
          &lt;span class="na"&gt;expression&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.*"level":"(?P[a-zA-Z]+)".*'&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha that burned me early: Promtail's Docker SD requires the socket to be mounted read-only into the container. If you run Promtail rootless, file permission errors on &lt;code&gt;/var/run/docker.sock&lt;/code&gt; are silent — Promtail starts, reports no errors, and just never scrapes anything. Mount it explicitly with &lt;code&gt;--group-add $(stat -c '%g' /var/run/docker.sock)&lt;/code&gt; in your run command or add the GID to the Compose file. Also worth knowing: high-cardinality labels like &lt;code&gt;trace_id&lt;/code&gt; or &lt;code&gt;request_id&lt;/code&gt; will crater Loki's index performance fast. Keep the label set to those three unless you have a concrete reason to expand it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Pulling Log Batches via LogQL HTTP API
&lt;/h3&gt;

&lt;p&gt;Once logs are in Loki, the fingerprinting pipeline pulls batches on a cron cycle. The &lt;code&gt;query_range&lt;/code&gt; endpoint is what you want — not &lt;code&gt;query&lt;/code&gt;, which is for instant queries. The &lt;code&gt;limit&lt;/code&gt; parameter caps lines per response, and &lt;code&gt;start&lt;/code&gt;/&lt;code&gt;end&lt;/code&gt; accept nanosecond epoch timestamps.&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;# Pull last 5 minutes of app logs, 200 lines max&lt;/span&gt;
curl &lt;span class="s1"&gt;'http://loki:3100/loki/api/v1/query_range?query=\{job="app"\}&amp;amp;limit=200&amp;amp;start='&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'5 minutes ago'&lt;/span&gt; +%s%N&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s1"&gt;'&amp;amp;end='&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s%N&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s1"&gt;'&amp;amp;direction=forward'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is a JSON envelope where actual log lines live at &lt;code&gt;.data.result[*].values[*][1]&lt;/code&gt; — the second element of each &lt;code&gt;[timestamp, line]&lt;/code&gt; pair. In Node.js, extract with &lt;code&gt;results.flatMap(s =&amp;gt; s.values.map(v =&amp;gt; v[1]))&lt;/code&gt;. A common failure mode is hitting Loki's default &lt;code&gt;query_timeout&lt;/code&gt; of 1 minute when &lt;code&gt;limit&lt;/code&gt; is large and the label selector isn't selective enough. If your query spans multiple high-volume containers without a &lt;code&gt;level&lt;/code&gt; filter, add one: &lt;code&gt;{job="app", level="error"}&lt;/code&gt; cuts the result set dramatically and the fingerprints you actually care about are usually errors and warnings anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — Normalizing Log Lines Before Embedding
&lt;/h3&gt;

&lt;p&gt;This is the step that makes or breaks fingerprint quality. Two log lines that are semantically identical — same error, different request ID — must hash to the same embedding neighborhood. Without normalization, bge-m3 treats them as distinct because they literally differ in token content. The transform strips the high-entropy variable parts and leaves the structural template.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// normalize.js — run each raw line through this before embedding&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizeLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;
    &lt;span class="c1"&gt;// strip leading ISO timestamp (2024-01-15T12:34:56.789Z or similar)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;T&lt;/span&gt;&lt;span class="se"&gt;[\d&lt;/span&gt;&lt;span class="sr"&gt;:.&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+Z&lt;/span&gt;&lt;span class="se"&gt;?\s&lt;/span&gt;&lt;span class="sr"&gt;*/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// strip UUIDs (8-4-4-4-12 hex pattern)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{8}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{12}&lt;/span&gt;&lt;span class="sr"&gt;/gi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// strip IPv4 addresses (replace octets, keep structure visible)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// strip standalone numeric IDs (≥4 digits to avoid catching HTTP status codes)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b\d{4,}\b&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// collapse repeated whitespace&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;normalizeLine&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The threshold for numeric stripping matters: stripping all numbers kills useful signal like HTTP status codes (&lt;code&gt;404&lt;/code&gt;, &lt;code&gt;500&lt;/code&gt;), which are exactly the kind of variance you want your fingerprints to capture as distinct clusters. That's why the pattern only strips four-or-more-digit numbers. Adjust that threshold based on what your logs actually contain — if your app logs database row counts that vary but the surrounding message is identical, you may want to go lower. After this transform, structurally identical lines from different requests collapse to the same string, which means bge-m3 will place them in essentially the same embedding space rather than scattering them by entropy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — Batching to Ollama's Embeddings Endpoint
&lt;/h3&gt;

&lt;p&gt;bge-m3 via Ollama runs at the &lt;code&gt;/api/embeddings&lt;/code&gt; endpoint, but that endpoint is single-input — one string per call. Batching here means N parallel requests or sequential chunked requests, not a true batch API call. On my 32GB VRAM box (RTX 3090), sequential calls to bge-m3 run around 80–120ms each for typical log line lengths. For 200 lines that's 16–24 seconds sequential, which is fine for a 5-minute cron but would miss a 1-minute window. The practical fix is groups of 20 with &lt;code&gt;Promise.all&lt;/code&gt; — 10 parallel groups at ~100ms each gives you roughly 1 second of wall time for 200 embeddings.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// embed-batch.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;OLLAMA_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:11434/api/embeddings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BATCH_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;embedLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;OLLAMA_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;// bge-m3 is the model identifier as registered in Ollama&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bge-m3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// float32 array, 1024 dims for bge-m3&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;embedBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="c1"&gt;// chunk into groups of BATCH_SIZE to avoid hammering the GPU queue&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;BATCH_SIZE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;BATCH_SIZE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;embedLine&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// parallel within chunk, serial across chunks&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;embedBatch&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing the Ollama docs don't emphasize enough: if you fire more than ~30 concurrent requests to a single bge-m3 instance, you'll start seeing timeout errors from the HTTP layer before the GPU is even the bottleneck. The request queue fills up at the Go HTTP server level. Keeping parallel inflight requests to 20 gives the queue room to breathe while still saturating the GPU. The resulting embeddings are 1024-dimensional float32 vectors — each one about 4KB on disk. For 200 log lines per run that's under 1MB per cycle, which stores fine in Postgres with the &lt;code&gt;pgvector&lt;/code&gt; extension using a &lt;code&gt;vector(1024)&lt;/code&gt; column.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storing and Comparing Fingerprints in Qdrant
&lt;/h2&gt;

&lt;p&gt;Most people reach for Elasticsearch when they need to store and query log patterns. That's fine if you already have it running, but Qdrant gives you something Elasticsearch doesn't: the similarity search is the primary operation, not an afterthought bolted onto a text index. For fingerprint storage specifically, that distinction matters — you're not keyword-searching logs, you're asking "how novel is this line relative to everything I've seen before?"&lt;/p&gt;

&lt;h3&gt;
  
  
  Collection Setup
&lt;/h3&gt;

&lt;p&gt;One collection per major service is the right default. Cross-service similarity searches produce false negatives constantly — a database connection timeout and an HTTP gateway timeout can embed close together because the sentence structure is similar, even though they're completely unrelated failure modes. Keep them separate from day one; merging collections later is painful.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; PUT http://localhost:6333/collections/log_fingerprints &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "vectors": {
      "size": 1024,
      "distance": "Cosine"
    },
    "optimizers_config": {
      "memmap_threshold": 20000
    },
    "hnsw_config": {
      "m": 16,
      "ef_construct": 100
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;size: 1024&lt;/code&gt; matches bge-m3's output dimension. If you're using a different embedding model, verify the output dimension before creating the collection — Qdrant will reject upserts that don't match the declared size, and the error message just says "wrong vector size" without telling you what it expected vs. what it got. The &lt;code&gt;memmap_threshold&lt;/code&gt; config tells Qdrant to switch segments to memory-mapped files above 20k vectors, which keeps RAM usage from ballooning on a busy service log collection.&lt;/p&gt;

&lt;h3&gt;
  
  
  Upsert Pattern: Hash as Point ID
&lt;/h3&gt;

&lt;p&gt;The critical detail here is deterministic point IDs. If you let Qdrant generate random UUIDs, every re-occurrence of the same log template creates a new point, your collection bloats, and your novelty scores degrade because the same pattern now has dozens of near-duplicate neighbors. Instead, hash the normalized template and use that as the ID. Qdrant accepts unsigned 64-bit integers as point IDs, so truncate the SHA-256 to 8 bytes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;template_to_point_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Truncate SHA-256 to 64-bit unsigned int — collision risk is
&lt;/span&gt;    &lt;span class="c1"&gt;# acceptable here; false deduplication is less harmful than bloat
&lt;/span&gt;    &lt;span class="n"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;template&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;byteorder&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;big&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;upsert_fingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="n"&gt;point_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;template_to_point_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;template&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;points&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;point_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vector&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payload&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;template&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;template&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;first_seen&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;  &lt;span class="c1"&gt;# only meaningful on first insert
&lt;/span&gt;                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hit_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;                   &lt;span class="c1"&gt;# increment separately via set_payload
&lt;/span&gt;            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}],&lt;/span&gt;
        &lt;span class="c1"&gt;# upsert semantics: existing point is fully replaced, not merged
&lt;/span&gt;        &lt;span class="c1"&gt;# so update hit_count with a separate set_payload call
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payload stores the raw template and a &lt;code&gt;first_seen&lt;/code&gt; epoch timestamp. On a true upsert (the point already exists), Qdrant replaces the payload entirely — it does not merge. If you want to increment a hit counter without overwriting &lt;code&gt;first_seen&lt;/code&gt;, use the &lt;code&gt;set_payload&lt;/code&gt; endpoint after checking whether the point existed. It's an extra round-trip but it's the correct pattern; trying to handle this in one call leads to timestamp drift where recurring patterns appear newly seen every time they're updated.&lt;/p&gt;

&lt;h3&gt;
  
  
  Similarity Search for Novelty Detection
&lt;/h3&gt;

&lt;p&gt;The actual anomaly detection query is a top-k search with a score threshold applied client-side. Qdrant's &lt;code&gt;score_threshold&lt;/code&gt; parameter cuts off results below the value, but for novelty detection you want to inspect the scores yourself rather than rely on the cutoff — you need to know if zero results came back because the line is genuinely novel or because the collection is empty.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_novel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.88&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;query_vector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;with_payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# skip payload fetch — we only need scores here
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# empty collection or empty result: treat as novel
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="c1"&gt;# novel if every neighbor is below threshold
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 0.88 threshold is a starting point, not a derived constant. bge-m3 with Cosine similarity tends to score structurally similar log lines — same verb, different values — in the 0.91–0.97 range. Lines that share only domain vocabulary (both mention "connection") but differ in structure tend to fall into the 0.75–0.87 range. So 0.88 sits in a real gap for many log corpora, but your logs may have a different distribution. Run the search across a week of known-normal traffic, plot the score histogram, and pick the threshold at the valley between the high-similarity cluster and the low-similarity tail. That's more defensible than any fixed number.&lt;/p&gt;

&lt;h3&gt;
  
  
  Payload Filtering to Avoid Stale Fingerprints
&lt;/h3&gt;

&lt;p&gt;Without a time filter, a fingerprint from six months ago of a since-fixed bug will suppress anomaly detection for any log line that resembles it. The fix is scoping similarity searches to a recent window via payload filter. Qdrant evaluates the filter before computing similarity, so this doesn't add much overhead — it's not post-filtering.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_recent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;window_hours&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;window_hours&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;query_vector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;query_filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;must&lt;/span&gt;&lt;span class="sh"&gt;"&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;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;first_seen&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gte&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cutoff&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;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;with_payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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 non-obvious behavior: if your 24-hour window is narrow and the collection has low volume, you may get empty results for lines that genuinely appeared earlier today just because their &lt;code&gt;first_seen&lt;/code&gt; timestamp is slightly outside the window due to clock skew between your log ingestion service and the machine running the upserts. Store timestamps in UTC epoch seconds everywhere and sync clocks. NTP drift of a few seconds is harmless; container time misconfiguration causing hours of offset is not, and it's more common than you'd expect in air-gapped or intermittently connected setups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring the Alert Path in n8n
&lt;/h2&gt;

&lt;p&gt;The silent timeout is the first thing that will burn you. When the HTTP Request node in n8n calls Ollama for embeddings and the model is still warming up or the queue is deep, n8n drops the request without surfacing an error — it just moves on, and your embedding comes back empty. The fix is explicit: open the HTTP Request node options, find &lt;strong&gt;Timeout&lt;/strong&gt;, and set it to &lt;code&gt;10000&lt;/code&gt; ms minimum. The default is lower and undocumented in the UI. Miss this and you'll spend an hour wondering why your similarity scores are all zero.&lt;/p&gt;

&lt;p&gt;The overall wiring uses two entry points. A Schedule Trigger fires every 5 minutes, queries Loki via HTTP Request (LogQL, range query, last 5-minute window), then hands off to a sub-workflow that runs normalize → embed → compare. Keeping this as a sub-workflow rather than one giant flow means you can trigger the same logic from the second entry point: a Webhook node that receives Loki ruler alerts for immediate processing when a rule fires mid-window. Both paths converge on the same normalize-embed-compare sub-workflow, so there's no logic duplication.&lt;/p&gt;

&lt;p&gt;The Ollama embedding node config looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// HTTP Request node — Ollama embeddings
Method: POST
URL: http://ollama:11434/api/embeddings
Body (JSON):
{
  "model": "bge-m3",
  "prompt": "{{$json.normalized_line}}"
}
Options:
  Timeout: 10000          // must be explicit — silent drop otherwise
  Response Format: JSON   // Ollama returns { "embedding": [...] }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the embedding returns, an IF node checks the cosine similarity score produced by the compare step against your threshold — a value you'll tune per log source, but starting around &lt;code&gt;0.82&lt;/code&gt; is reasonable for normalized syslog-style lines. The branching is binary: high-score (known pattern) upserts the vector into Qdrant and exits cleanly; low-score (novel or anomalous) either posts to a Slack webhook with the raw line and score attached, or writes a row to a Postgres table for human review. I use the Postgres path for anything that fires during off-hours — it accumulates without paging anyone, and a morning query against the review table surfaces clusters of related anomalies better than a pile of Slack messages.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Postgres review table schema&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;log_anomalies&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;captured_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;service&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;normalized&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;raw_line&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;similarity&lt;/span&gt;  &lt;span class="n"&gt;FLOAT4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;reviewed&lt;/span&gt;    &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- morning triage query: cluster by normalized template&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;MIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;similarity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;captured_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;log_anomalies&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;reviewed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;normalized&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rate-limiting reality hits hard on any service generating more than 500 log lines per 5-minute window. &lt;code&gt;bge-m3&lt;/code&gt; on my 32GB box handles roughly 80–120 embed calls per minute before latency climbs enough to blow the cron budget. Three practical escapes: increase the cron interval to 15 minutes and accept the detection lag; sample the incoming batch (every Nth line after deduplication); or — the best option — pre-deduplicate by normalized template before the embed step. If ten log lines normalize to the same template, you only need one embedding. A Set node that builds a deduplicated map by template string before the HTTP Request loop cuts embed volume dramatically on noisy services and costs almost nothing in n8n processing time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas That Will Cost You an Afternoon
&lt;/h2&gt;

&lt;p&gt;The one that burns people most reliably: bge-m3 has a hard effective limit around 512 tokens, and a raw log line with an attached Java stack trace can hit that ceiling fast. The model won't throw an error — it silently truncates the input, which means your embedding represents partial content and your similarity scores become garbage for anything longer than a short event line. The fix is to embed the normalized template, not the raw line. Strip the variable parts (timestamps, hex addresses, UUIDs, numeric IDs), take the first ~300 characters of what's left, and embed that. You get a stable, compact representation of the log &lt;em&gt;shape&lt;/em&gt; rather than a noisy snapshot of one occurrence.&lt;/p&gt;

&lt;p&gt;Qdrant's HNSW index lives in memory by default. With a small collection this is invisible. Push past 500k vectors and you'll notice it the hard way — container restarts on your fingerprint store will stall for tens of seconds while the index reconstructs. Set &lt;code&gt;on_disk: true&lt;/code&gt; in the vector config at collection creation time, not after the fact (you'd have to recreate the collection anyway):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;PUT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/collections/log_fingerprints&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vectors"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"distance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cosine"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"on_disk"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;HNSW&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;graph&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;stays&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;disk;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;slower&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ANN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;but&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;restarts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;are&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;instant&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hnsw_config"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"on_disk"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The latency tradeoff is real — expect slightly slower nearest-neighbor queries — but for a fingerprinting pipeline that tolerates a few hundred milliseconds per lookup, it's the right call. The alternative is a Qdrant container that takes 40 seconds to become healthy every time you update it, which makes deployment windows painful.&lt;/p&gt;

&lt;p&gt;n8n's HTTP Request node does not stream responses, and it does not know your GPU is busy. If Ollama is mid-load serving another model when your embedding call arrives, that request can sit for 30+ seconds. n8n will time out and retry — and now you've sent the same log chunk to Qdrant twice with subtly different timing, which can create duplicate fingerprint entries if your upsert logic isn't idempotent. The fix isn't to increase the timeout (though you should). The fix is to generate a deterministic ID for each upsert — hash the normalized template string, use that as the Qdrant point ID. A SHA-256 of the first 300 characters of the template gives you a stable key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fingerprintId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;normalizedTemplate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Using the template, not the raw line — same shape always maps to the same ID&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;normalizedTemplate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Qdrant accepts string IDs; 16 hex chars is plenty for uniqueness&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Loki will silently lie to you about completeness. The default query limit is 5000 lines per request, and the API does not return a warning or a &lt;code&gt;has_more&lt;/code&gt; flag when it truncates — you just get 5000 lines and a clean response. If your ingestion window during a busy period produces 12,000 log events, your fingerprint pipeline is working with 42% of the data without knowing it. Paginate explicitly using the &lt;code&gt;start&lt;/code&gt; and &lt;code&gt;end&lt;/code&gt; epoch nanosecond parameters, and keep fetching until you get a response shorter than your &lt;code&gt;limit&lt;/code&gt; value:&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;# First page — 5000 line limit, 1-hour window&lt;/span&gt;
curl &lt;span class="nt"&gt;-G&lt;/span&gt; &lt;span class="s2"&gt;"http://loki:3100/loki/api/v1/query_range"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s1"&gt;'query={job="app-logs"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"start=1700000000000000000"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"end=1700003600000000000"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"limit=5000"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"direction=forward"&lt;/span&gt;

&lt;span class="c"&gt;# If you got exactly 5000 results, advance `start` to the last result's timestamp + 1ns and repeat&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "advance start to last timestamp + 1 nanosecond" detail matters — if you use the exact last timestamp, Loki's range is inclusive and you'll re-ingest the final event on every page boundary, creating duplicate fingerprint candidates that inflate your similarity counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  When This Architecture Is and Isn't the Right Call
&lt;/h2&gt;

&lt;p&gt;The architecture earns its keep in a specific, narrow band of use cases. Outside that band, it becomes a liability — either an underpowered toy or an architectural bottleneck. Getting honest about the boundaries before you build saves you from ripping it out six months later.&lt;/p&gt;

&lt;p&gt;The sweet spot is a home lab or small production deployment running somewhere between 5 and 20 Docker services. At that scale, you're generating enough log volume to make manual review painful, but not so much that the embedding step becomes a chokepoint. You get semantic anomaly detection — the kind that catches a novel failure pattern that substring matching would miss entirely — without routing your logs through a cloud ingestion service that charges per gigabyte and retains your data on someone else's infrastructure. If you're running Postgres, Nginx, a few API containers, and a handful of background workers, this pipeline handles that comfortably on modest hardware.&lt;/p&gt;

&lt;p&gt;The air-gap case is the other clean fit. Regulated environments, on-premise deployments, anything where logs are considered sensitive operational data — the entire stack runs locally. Ollama serves the model, bge-m3 handles embeddings, Qdrant or a local Postgres vector column stores the fingerprints, and nothing phones home. Compare that to shipping logs to Datadog or Elastic Cloud: you've now made your internal service topology, error messages, and timing patterns visible to a third-party SaaS. For compliance-conscious operators, the self-hosted pipeline isn't a cost optimization — it's a hard requirement.&lt;/p&gt;

&lt;p&gt;The failure mode to recognize early: high-cardinality, high-volume services. If any single service is emitting millions of log lines per minute, the embedding step will saturate before the rest of the pipeline can keep up. &lt;code&gt;bge-m3&lt;/code&gt; on a GPU can process batches quickly, but you're still talking about a sequential bottleneck unless you place a durable queue — Redis Streams, NATS JetStream, or similar — in front of the embedding workers and run multiple consumers. That's a non-trivial architectural addition. Without it, backpressure accumulates silently and you start missing anomalies during the exact spikes you most want to catch. If you're already operating at that volume, purpose-built log infrastructure with horizontal scaling baked in is the more honest choice.&lt;/p&gt;

&lt;p&gt;One combination that works surprisingly well in practice: if your team is already using local models for development assistance — the workflow covered in &lt;a href="https://techdigestor.com/best-ai-coding-tools-2026/" rel="noopener noreferrer"&gt;AI Coding Tools in 2026: Cloud Copilots vs Local Models&lt;/a&gt; — the same hardware running code completions during the day can run log fingerprinting on off-peak cycles. The 32GB VRAM box that serves a coding assistant doesn't sit idle overnight. You're amortizing the hardware cost across both workloads, and the operational mental model transfers directly: the same Ollama instance, the same embedding model, just pointed at log chunks instead of code context. That overlap makes the ops overhead of maintaining the pipeline much lower than it looks on paper.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/self-hosted-log-fingerprinting-pipelines-catching-anomalies-without-sending-logs-to-the-cloud/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>tools</category>
      <category>webdev</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Nginx Proxy Manager on Your Home Lab: Performance Tuning Beyond the Defaults</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Wed, 08 Jul 2026 08:11:09 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/nginx-proxy-manager-on-your-home-lab-performance-tuning-beyond-the-defaults-5d1j</link>
      <guid>https://dev.to/ericwoooo_kr/nginx-proxy-manager-on-your-home-lab-performance-tuning-beyond-the-defaults-5d1j</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Nginx Proxy Manager ships with one goal: get a reverse proxy with Let's Encrypt certs running in under ten minutes.  It delivers on that.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~20 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Why the Default NPM Config Leaves Performance on the Table&lt;/li&gt;
&lt;li&gt;Getting NPM Running the Right Way in Docker&lt;/li&gt;
&lt;li&gt;Worker and Buffer Tuning Inside the Generated nginx.conf&lt;/li&gt;
&lt;li&gt;Proxy Host Advanced Settings That Actually Matter&lt;/li&gt;
&lt;li&gt;SSL Performance: Let's Encrypt, Wildcard Certs, and HSTS Pitfalls&lt;/li&gt;
&lt;li&gt;Monitoring NPM and Catching Problems Before Users Do&lt;/li&gt;
&lt;li&gt;When NPM Is the Wrong Tool&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Why the Default NPM Config Leaves Performance on the Table
&lt;/h2&gt;

&lt;p&gt;Nginx Proxy Manager ships with one goal: get a reverse proxy with Let's Encrypt certs running in under ten minutes. It delivers on that. What it doesn't deliver is a config that holds up under anything resembling real traffic. The defaults reflect shared-hosting assumptions — conservative buffer sizes, short keepalive windows, worker counts that don't account for your actual CPU topology. On a machine you own, those aren't safe defaults, they're just wrong defaults.&lt;/p&gt;

&lt;p&gt;The symptoms are recognizable once you know what to look for. Upstream timeouts on long-running API responses — say, a local Ollama inference call or an OpenAI streaming response that takes 45 seconds — because &lt;code&gt;proxy_read_timeout&lt;/code&gt; ships at 60s and NPM's UI doesn't expose it per-host without a custom config block. 502s during n8n webhook bursts because the upstream queue fills faster than the default buffer can drain. And SSL handshake latency that eats 80–150ms on every short request because session resumption isn't configured and the TLS ticket key rotation is left at defaults. That last one is invisible in synthetic benchmarks but shows up immediately when you're proxying lots of small API calls.&lt;/p&gt;

&lt;p&gt;The underlying issue is that NPM wraps nginx in a management layer — which is genuinely useful — but it also abstracts away the knobs that matter. The generated &lt;code&gt;nginx.conf&lt;/code&gt; in a stock Docker deployment looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;worker_processes&lt;/span&gt; &lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;# "auto" resolves to 1 on a 1-vCPU container — fine for a VPS,&lt;/span&gt;
&lt;span class="c1"&gt;# wrong for a 16-core workstation running in Docker with --cpus not set&lt;/span&gt;
&lt;span class="k"&gt;worker_connections&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;# 1024 total across all workers; saturates fast under webhook fan-out&lt;/span&gt;

&lt;span class="k"&gt;http&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# no proxy_cache_path defined — caching is entirely disabled&lt;/span&gt;
  &lt;span class="c1"&gt;# keepalive_timeout 75s — upstream keepalives not configured at all&lt;/span&gt;
  &lt;span class="c1"&gt;# client_max_body_size 1m — will silently drop file uploads&lt;/span&gt;
  &lt;span class="c1"&gt;# gzip off — yes, off by default&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this article works through: the specific &lt;code&gt;/data/nginx/custom&lt;/code&gt; override files that NPM actually reads, per-proxy-host advanced config blocks that survive container restarts, how to set &lt;code&gt;proxy_read_timeout&lt;/code&gt; and &lt;code&gt;proxy_send_timeout&lt;/code&gt; high enough for LLM API responses without opening yourself up to connection exhaustion, and how to wire up upstream keepalives so n8n webhook throughput stops degrading under burst load. Everything here runs on a Docker Compose stack — NPM 2.x on top of nginx 1.25 — so the file paths and config injection points are concrete and reproducible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting NPM Running the Right Way in Docker
&lt;/h2&gt;

&lt;p&gt;The volume mount decision trips up more NPM deployments than any config mistake. When you bind-mount &lt;code&gt;/path/on/host/data&lt;/code&gt; directly on an ext4 filesystem and NPM starts hammering Let's Encrypt renewals plus proxy host writes simultaneously, you can hit inode exhaustion or contention-driven write stalls — especially on VPS images provisioned with small inode counts. Named volumes let Docker manage that I/O through its own storage driver layer, which sidesteps the problem entirely. The fix is one line in your compose file, and it costs nothing.&lt;/p&gt;

&lt;p&gt;Here's a compose file that avoids the common traps:&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;npm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jc21/nginx-proxy-manager:2.11.3&lt;/span&gt;   &lt;span class="c1"&gt;# pinned — not latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&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;80:80"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;81:81"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DB_SQLITE_FILE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/data/database.sqlite"&lt;/span&gt;
      &lt;span class="na"&gt;DISABLE_IPV6&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;           &lt;span class="c1"&gt;# drop this only if your LAN actually routes v6&lt;/span&gt;
      &lt;span class="na"&gt;X_FRAME_OPTIONS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sameorigin"&lt;/span&gt;  &lt;span class="c1"&gt;# default is DENY, which breaks iframe embeds&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm_data:/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm_letsencrypt:/etc/letsencrypt&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&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;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-sf"&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://localhost:81/api"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&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;timeout&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;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20s&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;npm_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;npm_letsencrypt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin the image. Between 2.10.x and 2.11.x, upstream NPM changed how it stores custom Nginx config snippets in SQLite, and containers that got auto-pulled to a new minor broke existing advanced proxy host configs silently — the proxy kept running from the old rendered config on disk, but any edit triggered a regeneration that dropped custom directives. You won't catch that until you touch a host entry. &lt;code&gt;2.11.3&lt;/code&gt; is the last version I've validated on my stack; check the &lt;a href="https://github.com/NginxProxyManager/nginx-proxy-manager/releases" rel="noopener noreferrer"&gt;release notes&lt;/a&gt; before bumping.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DISABLE_IPV6: "true"&lt;/code&gt; is worth explaining because the NPM docs barely surface it. If your Docker host has IPv6 disabled at the kernel level (&lt;code&gt;net.ipv6.conf.all.disable_ipv6 = 1&lt;/code&gt; in sysctl), Nginx will still try to bind &lt;code&gt;[::]:80&lt;/code&gt; and &lt;code&gt;[::]:443&lt;/code&gt; by default — and it will fail silently on startup in a way that leaves your plain HTTP proxy hosts unreachable while HTTPS ones work. The symptom looks like a routing problem, not a bind failure. Setting this env var removes the v6 listen directives from the generated config before Nginx ever starts. &lt;code&gt;X_FRAME_OPTIONS&lt;/code&gt; is a different category of gotcha: NPM sets &lt;code&gt;DENY&lt;/code&gt; by default as a security header, which is correct for most traffic, but if you're embedding Grafana, Homepage, or any self-hosted dashboard in an iframe through the proxy, every embed will be blocked. Override to &lt;code&gt;sameorigin&lt;/code&gt; or your specific upstream value rather than disabling it wholesale.&lt;/p&gt;

&lt;p&gt;Don't rely on Docker's built-in TCP healthcheck against port 81 to decide if NPM is ready for depends_on chains. The admin UI port binds before the proxy engine finishes loading its host configs and writing Nginx conf files. The correct check is against the API endpoint:&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;# Run this manually to confirm the proxy engine is actually up&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;npm curl &lt;span class="nt"&gt;-sf&lt;/span&gt; http://localhost:81/api

&lt;span class="c"&gt;# Expected output when healthy — any non-empty 200 response:&lt;/span&gt;
&lt;span class="c"&gt;# {"status":"OK"}&lt;/span&gt;

&lt;span class="c"&gt;# If this returns nothing or a connection refused,&lt;/span&gt;
&lt;span class="c"&gt;# Nginx hasn't finished initializing even if port 81 is open&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;-sf&lt;/code&gt; flag combination is important: &lt;code&gt;-s&lt;/code&gt; silences the progress output so only real failures produce noise, and &lt;code&gt;-f&lt;/code&gt; makes curl return a non-zero exit code on HTTP 4xx/5xx — Docker's healthcheck mechanism requires a non-zero exit to register as unhealthy. The &lt;code&gt;start_period: 20s&lt;/code&gt; in the compose healthcheck above gives NPM time to do its initial SQLite migration on first boot without immediately triggering restart loops on a slow host.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worker and Buffer Tuning Inside the Generated nginx.conf
&lt;/h2&gt;

&lt;p&gt;NPM regenerates &lt;code&gt;/etc/nginx/nginx.conf&lt;/code&gt; on every container restart and every time you save a proxy host — the file is not yours to own. Edit it directly and your changes vanish the next time the UI touches anything. The correct injection points are the &lt;strong&gt;Custom Nginx Configuration&lt;/strong&gt; textarea inside each proxy host's Advanced tab, or files dropped into &lt;code&gt;/data/nginx/custom/&lt;/code&gt; on the host. Files in that directory get included automatically; NPM ships with hooks for &lt;code&gt;http_top.conf&lt;/code&gt; and &lt;code&gt;events.conf&lt;/code&gt; that map to exactly the blocks you need to tune.&lt;/p&gt;

&lt;p&gt;Worker and connection limits belong in &lt;code&gt;/data/nginx/custom/http_top.conf&lt;/code&gt;. The generated config inherits &lt;code&gt;worker_processes 4&lt;/code&gt; regardless of your actual core count, and the default &lt;code&gt;worker_connections&lt;/code&gt; ceiling will cap you well before your NIC does. Drop this in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /data/nginx/custom/http_top.conf&lt;/span&gt;
&lt;span class="c1"&gt;# worker_processes goes outside http{} — NPM's include point handles placement&lt;/span&gt;
&lt;span class="k"&gt;worker_processes&lt;/span&gt; &lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;worker_rlimit_nofile&lt;/span&gt; &lt;span class="mi"&gt;65535&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;events&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;worker_connections&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;multi_accept&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;# accept all pending connections per epoll wakeup&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="s"&gt;epoll&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;# explicit on Linux; don't leave it to autodetect&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;worker_rlimit_nofile&lt;/code&gt; matters more than most operators realize. Each proxied connection consumes two file descriptors — one client-side, one upstream-side — so a 4096 &lt;code&gt;worker_connections&lt;/code&gt; ceiling with the default 1024 nofile limit will silently drop connections under load before nginx logs anything useful. Set the system ulimit too: add &lt;code&gt;LimitNOFILE=65535&lt;/code&gt; to the Docker service unit or your compose override.&lt;/p&gt;

&lt;p&gt;Buffer tuning is where NPM setups running Ollama or large API backends fall apart. The defaults assume small web responses. When proxying Ollama's &lt;code&gt;/api/chat&lt;/code&gt; streaming endpoint, nginx will start spilling response chunks to disk the moment they exceed the in-memory buffer allocation — and disk spill shows up as inexplicable latency spikes, not errors. Add this to the same custom file or to a specific proxy host's Advanced block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Prevents disk-spill on large/streaming API responses&lt;/span&gt;
&lt;span class="k"&gt;proxy_buffer_size&lt;/span&gt;      &lt;span class="mi"&gt;16k&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;# holds the response headers + first chunk&lt;/span&gt;
&lt;span class="k"&gt;proxy_buffers&lt;/span&gt;          &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="mi"&gt;16k&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# 8 × 16k = 128k total in-memory budget per request&lt;/span&gt;
&lt;span class="k"&gt;proxy_busy_buffers_size&lt;/span&gt; &lt;span class="mi"&gt;32k&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# max handed to client while rest is still being read&lt;/span&gt;
&lt;span class="k"&gt;proxy_request_buffering&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# critical for streaming — don't buffer the full request body&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For n8n webhook backends that get hit repeatedly by the same automation loop, the TCP handshake cost accumulates fast. Keepalive on the upstream block removes it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;n8n_backend&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;127.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5678&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;keepalive&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;# pool of 32 idle keepalive connections to the backend&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;# inside your proxy host's advanced config&lt;/span&gt;
    &lt;span class="kn"&gt;keepalive_timeout&lt;/span&gt;   &lt;span class="s"&gt;75s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;keepalive_requests&lt;/span&gt;  &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# before nginx forces a connection recycle&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gzip belongs in &lt;code&gt;http_top.conf&lt;/code&gt; globally rather than per-host, because you want it covering JSON API responses from every backend — not just the ones you remembered to configure. Level 4 is the practical ceiling on modern x86 hardware; beyond it, CPU cost grows faster than byte savings shrink, and you gain nothing on already-small payloads that the &lt;code&gt;gzip_min_length&lt;/code&gt; gate handles anyway:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;gzip&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;gzip_comp_level&lt;/span&gt;  &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;gzip_min_length&lt;/span&gt;  &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# don't bother compressing tiny responses&lt;/span&gt;
&lt;span class="k"&gt;gzip_proxied&lt;/span&gt;     &lt;span class="s"&gt;any&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;# compress responses to reverse-proxied clients too&lt;/span&gt;
&lt;span class="k"&gt;gzip_vary&lt;/span&gt;        &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;# tells CDNs the response varies by Accept-Encoding&lt;/span&gt;
&lt;span class="k"&gt;gzip_types&lt;/span&gt;
    &lt;span class="nc"&gt;text/plain&lt;/span&gt;
    &lt;span class="nc"&gt;text/css&lt;/span&gt;
    &lt;span class="nc"&gt;application/json&lt;/span&gt;
    &lt;span class="nc"&gt;application/javascript&lt;/span&gt;
    &lt;span class="nc"&gt;application/x-javascript&lt;/span&gt;
    &lt;span class="nc"&gt;text/xml&lt;/span&gt;
    &lt;span class="nc"&gt;application/xml&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha: &lt;code&gt;gzip_proxied any&lt;/code&gt; is off by default and the NPM UI gives no hint it exists. Without it, responses to clients that came through another proxy layer — common when NPM sits behind Cloudflare — won't be compressed even if the client sends &lt;code&gt;Accept-Encoding: gzip&lt;/code&gt;. The &lt;code&gt;gzip_vary on&lt;/code&gt; line is equally important if a CDN or Varnish layer sits in front; without it, a compressed response can get cached and served to a client that never declared gzip support.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proxy Host Advanced Settings That Actually Matter
&lt;/h2&gt;

&lt;p&gt;The most underused field in the NPM UI is the &lt;strong&gt;Custom Nginx Configuration&lt;/strong&gt; box on each proxy host's Advanced tab. Whatever you put there gets injected directly into the &lt;code&gt;location /&lt;/code&gt; block of the generated config — and that's where you need to be for anything beyond basic reverse proxying. The one I add to every AI inference endpoint without exception: &lt;code&gt;proxy_read_timeout 300s;&lt;/code&gt;. The default is 60 seconds. A locally-served LLM generating a 2,000-token response with a quantized 13B model on modest hardware will routinely take longer than that, and Nginx silently closes the connection and returns a 504 to the client mid-stream. No error in your app logs, just a truncated response and a confused user. Five seconds of config work eliminates the entire class of problem.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Custom Nginx Configuration (per proxy host, Advanced tab)&lt;/span&gt;
&lt;span class="c1"&gt;# Drop this in for any LLM or long-poll backend&lt;/span&gt;

&lt;span class="k"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="s"&gt;300s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;proxy_send_timeout&lt;/span&gt; &lt;span class="s"&gt;300s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;proxy_connect_timeout&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# keep short — see note on health checks below&lt;/span&gt;
&lt;span class="k"&gt;proxy_buffering&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;# required for SSE / streaming token output&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NPM's WebSocket toggle (the "Websockets Support" checkbox in the UI) does not emit &lt;code&gt;proxy_http_version 1.1;&lt;/code&gt;. That's the silent killer. Without it, Nginx defaults to HTTP/1.0 for upstream connections, which doesn't support persistent connections. You'll see intermittent WebSocket drops, failed upgrade handshakes on certain backends, or keepalive connections that close immediately. The fix is to ignore the checkbox entirely and write the full block yourself in the Advanced tab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Upgrade&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="s"&gt;'upgrade'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;proxy_cache_bypass&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rate limiting requires two separate config locations, which is the part NPM's UI makes awkward. The zone declaration — &lt;code&gt;limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;&lt;/code&gt; — must go in NPM's global custom config, which lives under Settings → Nginx. That config gets included at the &lt;code&gt;http {}&lt;/code&gt; block level. Then on each individual proxy host that should enforce the limit, the Advanced tab gets &lt;code&gt;limit_req zone=api burst=10 nodelay;&lt;/code&gt;. The &lt;code&gt;nodelay&lt;/code&gt; flag matters here: without it, requests arriving within the burst window get queued and delayed rather than rejected immediately, which can cause a pile-up if a runaway n8n workflow or a misbehaving API client starts hammering a local Ollama endpoint. With &lt;code&gt;nodelay&lt;/code&gt;, burst capacity is consumed instantly and anything over it gets a clean 503.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Settings → Nginx (global custom config, http block level)&lt;/span&gt;
&lt;span class="k"&gt;limit_req_zone&lt;/span&gt; &lt;span class="nv"&gt;$binary_remote_addr&lt;/span&gt; &lt;span class="s"&gt;zone=api:10m&lt;/span&gt; &lt;span class="s"&gt;rate=30r/m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;limit_req_zone&lt;/span&gt; &lt;span class="nv"&gt;$binary_remote_addr&lt;/span&gt; &lt;span class="s"&gt;zone=ui:10m&lt;/span&gt; &lt;span class="s"&gt;rate=120r/m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;# Per-host Advanced tab (enforcement point)&lt;/span&gt;
&lt;span class="k"&gt;limit_req&lt;/span&gt; &lt;span class="s"&gt;zone=api&lt;/span&gt; &lt;span class="s"&gt;burst=10&lt;/span&gt; &lt;span class="s"&gt;nodelay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;limit_req_status&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# return proper 429 instead of default 503&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The missing upstream health check is a real operational gap. The open-source NPM build has no equivalent of Nginx Plus's &lt;code&gt;health_check&lt;/code&gt; directive or HAProxy's backend polling — when a container goes down, NPM keeps routing traffic to the dead upstream until the connection attempt times out. The default &lt;code&gt;proxy_connect_timeout&lt;/code&gt; is 60 seconds, which means every request to a failed backend hangs for a full minute before the client gets an error. Setting &lt;code&gt;proxy_connect_timeout 5s;&lt;/code&gt; per host cuts that failure surface down immediately. Pair it with a short &lt;code&gt;proxy_read_timeout&lt;/code&gt; appropriate to the endpoint, and bad backends surface fast enough that monitoring (even a basic Uptime Kuma instance polling the proxied URL) will catch them before users notice a pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  SSL Performance: Let's Encrypt, Wildcard Certs, and HSTS Pitfalls
&lt;/h2&gt;

&lt;p&gt;The wildcard cert via DNS challenge is one of those setups that feels like extra work until the third time you add a new subdomain and realize you didn't touch Certbot at all. With NPM's built-in Let's Encrypt integration, you configure the DNS provider once — Cloudflare or Route53 both have first-class support — drop in an API token, and every subdomain you spin up afterward is covered automatically. More practically for home labs: the DNS-01 challenge never requires port 80 to be reachable. If your ISP quietly blocks inbound port 80 (common on residential connections), the HTTP-01 challenge fails silently and you end up debugging NPM logs instead of realizing the port is the problem. DNS-01 sidesteps that entirely. Renewal happens on a schedule against the DNS API, not against your server's open ports.&lt;/p&gt;

&lt;p&gt;OCSP stapling is disabled in NPM's default nginx template, and that's a consistent 100–200ms tax on every cold TLS handshake. The fix goes in NPM's "Advanced" custom config block for each proxy host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;ssl_stapling&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ssl_stapling_verify&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;# resolver needs to be reachable from the nginx process — not just from the host OS&lt;/span&gt;
&lt;span class="k"&gt;resolver&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="s"&gt;.1.1&lt;/span&gt; &lt;span class="s"&gt;valid=60s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;resolver_timeout&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;resolver&lt;/code&gt; directive is the one people skip and then wonder why stapling silently fails. Nginx needs to independently resolve the OCSP responder URL from within the container, and if you don't give it a working resolver, &lt;code&gt;ssl_stapling&lt;/code&gt; logs a warning and falls back to no stapling. You can verify stapling is active with &lt;code&gt;openssl s_client -connect your.domain:443 -status&lt;/code&gt; — look for &lt;code&gt;OCSP Response Status: successful&lt;/code&gt; in the output. If you see &lt;code&gt;no response sent&lt;/code&gt;, the resolver is the first thing to check.&lt;/p&gt;

&lt;p&gt;The HSTS max-age default in NPM is 63072000 seconds — exactly two years. That number is fine for a stable production host, but if you toggle HSTS on during initial setup and your cert or config has any issue requiring a fallback to plain HTTP, every browser that visited during that window will refuse HTTP connections for two years. There's no server-side fix; the only recovery is a browser-by-browser manual HSTS cache clear. During testing, override the header in the custom config block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# In NPM's Advanced tab — overrides the HSTS checkbox value&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Strict-Transport-Security&lt;/span&gt; &lt;span class="s"&gt;"max-age=86400"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Promote to &lt;code&gt;max-age=63072000; includeSubDomains; preload&lt;/code&gt; only after the host has been stable for at least a week with no certificate errors. The &lt;code&gt;preload&lt;/code&gt; flag is a separate commitment — submitting to the HSTS preload list is effectively permanent and can't be reversed quickly, so don't add it to internal or experimental hosts.&lt;/p&gt;

&lt;p&gt;HTTP/2 ships enabled in NPM's SSL config, but "enabled" and "working end-to-end" aren't the same thing. Some upstream services — particularly older Java apps, Ruby Rack apps, or anything running behind a second nginx with an incompatible config — cause NPM to silently negotiate HTTP/1.1 on the upstream connection while still advertising HTTP/2 to clients. That's usually fine, but occasionally it causes header handling issues. Check the client-facing protocol directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="nt"&gt;--http2&lt;/span&gt; https://your.domain 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;
&lt;span class="c"&gt;# Healthy output starts with:&lt;/span&gt;
&lt;span class="c"&gt;# HTTP/2 200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see &lt;code&gt;HTTP/1.1 200&lt;/code&gt; instead, NPM either isn't presenting HTTP/2 in the ALPN negotiation or something upstream is forcing a downgrade. Check the proxy host's SSL certificate status first — an expired or mismatched cert causes curl to fall back before ALPN happens. If the cert is valid and you're still getting HTTP/1.1, look at whether the upstream &lt;code&gt;proxy_pass&lt;/code&gt; target is using &lt;code&gt;https://&lt;/code&gt; with a self-signed cert that nginx can't verify; that can stall the connection upgrade path in ways that don't surface as obvious errors in NPM's logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring NPM and Catching Problems Before Users Do
&lt;/h2&gt;

&lt;p&gt;The most actionable signal NPM gives you is buried in a field most operators ignore: the upstream response time in the access log. By default, NPM writes logs to &lt;code&gt;/data/logs/&lt;/code&gt; — one subdirectory per proxy host, with &lt;code&gt;access.log&lt;/code&gt; and &lt;code&gt;error.log&lt;/code&gt; side by side. Mount that directory as a Docker volume and you have everything you need to catch degrading backends before a user files a ticket.&lt;/p&gt;

&lt;p&gt;If you're already running a Loki/Grafana stack, the setup is straightforward: point Promtail at &lt;code&gt;/data/logs/*/access.log&lt;/code&gt; with a pipeline stage that parses the upstream response time field, then build a Grafana alert on p95 latency per host. If you're not running Loki yet, the lowest-effort option that still beats nothing is a simple shell watcher on the host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight awk"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tail all NPM access logs and flag anything over 2s upstream response&lt;/span&gt;
&lt;span class="nx"&gt;tail&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;F&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="o"&gt;/*&lt;/span&gt;&lt;span class="sr"&gt;/access.log | awk &lt;/span&gt;&lt;span class="err"&gt;'
&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;# NPM default log format puts upstream_response_time in $NF area&lt;/span&gt;
    &lt;span class="c1"&gt;# adjust field index to match your actual log format&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;/upstream_response_time/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/upstream_response_time: &lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;0-9.&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;print&lt;/span&gt; &lt;span class="s2"&gt;"SLOW: "&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rough, but it fires immediately and requires no additional infrastructure. When you do move to Loki, the upstream_response_time field becomes a proper metric-from-log that tells you exactly which backend is dragging.&lt;/p&gt;

&lt;p&gt;For uptime, I run this on a PM2 cron every 60 seconds across every service that matters. No external dependency, no ping fee, just a dead-simple shell one-liner that logs HTTP status and total time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="nx"&gt;In&lt;/span&gt; &lt;span class="nx"&gt;ecosystem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;js&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;one&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="nx"&gt;per&lt;/span&gt; &lt;span class="nx"&gt;critical&lt;/span&gt; &lt;span class="nx"&gt;service&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;healthcheck-myapp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bash&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-c &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;echo $(date -u +%FT%T) $(curl -o /dev/null -sw '%{http_code} %{time_total}s' https://myapp.internal/health) &amp;gt;&amp;gt; /var/log/healthchecks/myapp.log&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;cron_restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;* * * * *&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;autorestart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Healthy services respond in under 100ms on a local network. Anything logging above 800ms consistently is either a backend problem or an NPM configuration issue — typically a missing keepalive or an oversized buffer setting forcing full response buffering before the first byte goes out.&lt;/p&gt;

&lt;p&gt;The 502 vs 504 distinction is where most operators waste time. A 502 Bad Gateway from NPM means the upstream TCP connection was refused — the process isn't listening. The correct response is a restart, full stop. A 504 Gateway Timeout means the upstream accepted the connection but didn't respond within the timeout window — the process is alive but stalled, usually under load or stuck on a downstream dependency like a database lock. Restarting a 504 without understanding why it stalled just delays the same failure by a few minutes. Watch for the transition pattern in your error logs: if a host starts flipping 502 → 504 → 502 in a short window, the process is crash-looping under load, which is a different problem entirely from either one in isolation. Set NPM's proxy timeout (&lt;code&gt;proxy_read_timeout&lt;/code&gt; in the advanced config block) to something deliberate — the default 60 seconds means a stalled upstream holds an NPM worker thread for a full minute before the 504 fires.&lt;/p&gt;

&lt;p&gt;One area that gets skipped in almost every NPM hardening guide: the proxy layer's interaction with AI tooling and local model infrastructure. Streaming inference responses, long-polling completions, and auth header passthrough all have sharp edges at the reverse proxy level — especially when your local model setup expects specific timeout and buffer behaviors. The &lt;a href="https://techdigestor.com/best-ai-coding-tools-2026/" rel="noopener noreferrer"&gt;AI Coding Tools in 2026: Cloud Copilots vs Local Models&lt;/a&gt; guide covers how local-model setups interact with reverse proxy constraints like auth headers and streaming timeouts, which is worth reading before you wire an Ollama endpoint or OpenAI-compatible server through NPM for the first time.&lt;/p&gt;

&lt;h2&gt;
  
  
  When NPM Is the Wrong Tool
&lt;/h2&gt;

&lt;p&gt;The 30-proxy-host threshold is roughly where NPM's SQLite backend starts working against you rather than for you. Below that, the GUI is a genuine time-saver. Above it, you're fighting drift: a host edited in the UI doesn't show up in Git, rollbacks mean restoring a SQLite file, and your "config" is effectively a database dump. If you're already running infrastructure-as-code for everything else — Terraform, Ansible, Docker Compose in a repo — NPM becomes the odd one out that you can't peer-review or diff. Caddy with a committed &lt;code&gt;Caddyfile&lt;/code&gt; or Traefik driven by Docker labels both solve this cleanly. A &lt;code&gt;git diff&lt;/code&gt; on a Caddyfile shows exactly what changed and when; NPM's export JSON does not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Caddy equivalent of a typical NPM reverse proxy entry — version-controllable, reviewable&lt;/span&gt;
&lt;span class="k"&gt;app.example.com&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;reverse_proxy&lt;/span&gt; &lt;span class="nf"&gt;localhost&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="kn"&gt;header_up&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="kn"&gt;host&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt;
        &lt;span class="s"&gt;header_up&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="kn"&gt;remote_host&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;}&lt;/span&gt;
    &lt;span class="s"&gt;tls&lt;/span&gt; &lt;span class="s"&gt;your@email.com&lt;/span&gt;   &lt;span class="c1"&gt;# ACME handled inline, no separate certbot process&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Streams tab in NPM exposes TCP/UDP proxying, and it works for basic cases — forwarding a Minecraft port or a WireGuard UDP endpoint is straightforward. What you don't get is any tuning surface: no &lt;code&gt;proxy_timeout&lt;/code&gt;, no &lt;code&gt;proxy_buffer_size&lt;/code&gt;, no &lt;code&gt;proxy_connect_timeout&lt;/code&gt;, no way to set &lt;code&gt;so_keepalive&lt;/code&gt; on the upstream socket. For a game server where a 200ms TCP timeout difference is felt by players, or a Postgres tunnel where you want fine-grained keepalive behavior, you need a raw nginx container with a hand-written &lt;code&gt;stream {}&lt;/code&gt; block. That's not a workaround — it's the right architecture for the use case.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# stream block nginx config for a latency-sensitive TCP tunnel&lt;/span&gt;
&lt;span class="c1"&gt;# mount this as /etc/nginx/nginx.conf in a plain nginx:alpine container&lt;/span&gt;
&lt;span class="k"&gt;stream&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;db_backend&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;10.0.0.5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5432&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;5432&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;db_backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_timeout&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;# fail fast — don't let stale connections pile up&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_connect_timeout&lt;/span&gt; &lt;span class="s"&gt;2s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_buffer_size&lt;/span&gt; &lt;span class="mi"&gt;16k&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;# default 16k is fine for PG, tune down for game protocols&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;The process count is the other honest liability. NPM runs nginx, a Node.js GUI server, SQLite, and a Certbot/openssl-backed certificate renewal loop. On a 4GB homelab VM that's background noise. On a device with 512MB or 768MB RAM — an older Raspberry Pi, an edge node, a cheap VPS — that overhead is measurable in available headroom for your actual workloads. Caddy is a single statically-linked binary that handles TLS automatically; bare nginx is even leaner. For edge nodes or constrained devices, install Caddy via its official package and skip the container stack entirely:&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;# Caddy on a Debian/Ubuntu edge node — no Docker, no secondary processes&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; debian-keyring debian-archive-keyring apt-transport-https
curl &lt;span class="nt"&gt;-1sLf&lt;/span&gt; &lt;span class="s1"&gt;'https://dl.cloudsmith.io/public/caddy/stable/gpg.key'&lt;/span&gt; | &lt;span class="nb"&gt;sudo &lt;/span&gt;gpg &lt;span class="nt"&gt;--dearmor&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl &lt;span class="nt"&gt;-1sLf&lt;/span&gt; &lt;span class="s1"&gt;'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/caddy-stable.list
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;caddy
&lt;span class="c"&gt;# Binary is ~45MB, zero runtime dependencies, TLS built in&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The honest summary: NPM earns its keep for solo operators managing a moderate number of HTTPS reverse proxies who want a visual cert dashboard and don't need repeatability guarantees. Push past that boundary in any direction — scale, IaC discipline, non-HTTP protocols, constrained hardware — and the abstraction layer NPM adds becomes friction rather than utility. Knowing which side of that line your setup sits on saves you from bolting on workarounds that never quite fit.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/nginx-proxy-manager-on-your-home-lab-performance-tuning-beyond-the-defaults/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>productivity</category>
      <category>tools</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building a Local Dashboard with Homepage: One Config File to Rule Your Self-Hosted Stack</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Mon, 06 Jul 2026 08:10:40 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/building-a-local-dashboard-with-homepage-one-config-file-to-rule-your-self-hosted-stack-1326</link>
      <guid>https://dev.to/ericwoooo_kr/building-a-local-dashboard-with-homepage-one-config-file-to-rule-your-self-hosted-stack-1326</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The moment you cross about a dozen self-hosted services, browser bookmarks stop working as an ops strategy.  You end up with a mental map that looks something like this: Ollama is on &lt;code&gt;:11434&lt;/code&gt;, n8n is on &lt;code&gt;:5678&lt;/code&gt;, Grafana on &lt;code&gt;:3000&lt;/code&gt;, Portainer on &lt;code&gt;:9000&lt;/code&gt;, your WordPress instance so&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~22 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Problem: Thirty Services, Zero Visibility&lt;/li&gt;
&lt;li&gt;What Homepage Actually Is (and What It Isn't)&lt;/li&gt;
&lt;li&gt;Getting It Running: Docker Compose and Directory Layout&lt;/li&gt;
&lt;li&gt;Configuring Services, Widgets, and Integrations&lt;/li&gt;
&lt;li&gt;Putting It Behind a Reverse Proxy with Auth&lt;/li&gt;
&lt;li&gt;Non-Obvious Behaviors That Will Cost You Time&lt;/li&gt;
&lt;li&gt;When Homepage Earns Its Place (and When It Doesn't)&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Problem: Thirty Services, Zero Visibility
&lt;/h2&gt;

&lt;p&gt;The moment you cross about a dozen self-hosted services, browser bookmarks stop working as an ops strategy. You end up with a mental map that looks something like this: Ollama is on &lt;code&gt;:11434&lt;/code&gt;, n8n is on &lt;code&gt;:5678&lt;/code&gt;, Grafana on &lt;code&gt;:3000&lt;/code&gt;, Portainer on &lt;code&gt;:9000&lt;/code&gt;, your WordPress instance somewhere behind an nginx proxy, and three other things you added last month and have already half-forgotten. None of those tabs tell you whether the service is actually responding. A bookmark to &lt;code&gt;http://localhost:8080&lt;/code&gt; is equally useful whether the container is healthy or crashed three hours ago.&lt;/p&gt;

&lt;p&gt;That gap between "has a port" and "is actually running" is where silent failures live. My n8n flows pull from a local embedding API, send results to a Postgres instance, and occasionally hit Ollama for inference. If any one of those services dies quietly — no alert, no log you happened to be watching — the pipeline just stops producing output. Without a dashboard showing live health checks, the failure mode is: you notice something downstream is wrong, you start manually curling endpoints, you eventually find the dead container. That whole process takes ten to thirty minutes. A dashboard with a working health probe turns it into a ten-second glance.&lt;/p&gt;

&lt;p&gt;The alternatives are real options worth knowing. Heimdall is polished and has a decent app library, but it stores all its state in a SQLite database you have to back up and restore manually on a rebuild. Flame is minimal and fast but the integration ecosystem is thin — you get links and icons, not live service metrics. Dasherr is clean for simple setups. Homepage wins the comparison specifically because your entire configuration lives in YAML files that you can version-control and copy verbatim onto a new machine. There's no GUI state to recreate. Rebuilding the host means &lt;code&gt;docker compose up -d&lt;/code&gt; plus your config directory, and you're back to exactly where you were. That property matters more the more services you're running, because the dashboard itself becomes infrastructure, not a convenience.&lt;/p&gt;

&lt;p&gt;Homepage also ships with first-party integrations for the services that actually matter in a self-hosted stack — Portainer, Sonarr, Radarr, Proxmox, various *arr apps, and generic API widgets for anything custom. Those integrations pull live data, not just link out. The difference between a link tile and a widget showing current queue depth or CPU utilization is the difference between a bookmark manager and an actual operator panel. For context on how a dashboard fits into a broader self-hosted automation workflow, see our guide on &lt;a href="https://techdigestor.com/ultimate-productivity-guide-2026/" rel="noopener noreferrer"&gt;Workflow Automation in 2026: n8n, Zapier, and Self-Hosted Pipelines&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Homepage Actually Is (and What It Isn't)
&lt;/h2&gt;

&lt;p&gt;Homepage reads YAML files off disk and renders a dashboard — no database, no migrations, no state to back up beyond the config directory. That sounds like a minor implementation detail until you realize it means your entire dashboard is just a folder you can commit to git, rsync to a new host, or roll back with &lt;code&gt;git checkout&lt;/code&gt;. The "static-feeling" part of the description is apt: the Next.js app does server-side rendering but feels like a static page to the user because there's no session, no login wall (by default), and no write path through the UI. You configure it entirely by editing files.&lt;/p&gt;

&lt;p&gt;The integration model is polling, not proxying. When you add a Sonarr widget, Homepage makes an authenticated GET request from the server side to your Sonarr API using the key you supply in the config. It renders the result into the widget. That's it. No traffic flows &lt;em&gt;through&lt;/em&gt; Homepage to reach Sonarr — a browser tab to Sonarr still goes directly to Sonarr. This matters for two reasons: widget data can be stale by a few seconds (the poll interval is configurable but defaults are conservative), and if your Sonarr instance is on an internal network segment unreachable from the Homepage container, the widget will silently fail rather than route around it. The network path is container → target service, not browser → Homepage → target service.&lt;/p&gt;

&lt;p&gt;The most common setup mistake I see documented in forums: people deploy Homepage and then expect it to handle reverse proxying or authentication. It does neither. Homepage has no concept of protecting a downstream service, issuing tokens, or acting as an ingress point. If you want &lt;code&gt;sonarr.yourdomain.com&lt;/code&gt; to require a login, that's a job for Nginx Proxy Manager, Traefik, or Authelia — Homepage just shows you a link and a widget. Conflating these tools leads to configs where Homepage is exposed to the internet with no auth layer in front of it, which is a real exposure if you're embedding API keys in the widget data that gets rendered client-side. Keep Homepage on an internal interface or behind an auth-aware reverse proxy.&lt;/p&gt;

&lt;p&gt;Resource footprint is genuinely light. The container sits at roughly 80–120 MB RSS at idle on my workstation, CPU is near zero between renders, and the image pulls at around 200 MB compressed. You can co-locate it comfortably on a host running heavier workloads — it won't compete with Ollama for RAM or a Postgres instance for I/O. The one thing that can spike memory is loading a dashboard with many widgets that all poll simultaneously on startup; that settles within a few seconds. For a Raspberry Pi 4 or a low-spec VPS, this is one of the few dashboard options that won't feel punishing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting It Running: Docker Compose and Directory Layout
&lt;/h2&gt;

&lt;p&gt;The first surprise for most people deploying Homepage is that there's no database, no migration step, and no admin UI to configure. Everything is flat YAML files in a single config directory. That's a feature, not a limitation — it means your entire dashboard is version-controllable and reproducible from scratch in under a minute. But it also means if you skip the directory layout step, Homepage silently renders a blank page with zero indication of what went wrong.&lt;/p&gt;

&lt;p&gt;Pin the image. &lt;code&gt;ghcr.io/gethomepage/homepage:latest&lt;/code&gt; will work, but a specific tag like &lt;code&gt;v0.9.2&lt;/code&gt; means you won't wake up to a broken dashboard after an unattended pull. Here's a compose file that covers the essentials:&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;homepage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/gethomepage/homepage:v0.9.2&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;homepage&lt;/span&gt;
    &lt;span class="na"&gt;ports&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;3000:3000"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# config directory must exist on host before first boot&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/opt/homepage/config:/app/config&lt;/span&gt;
      &lt;span class="c1"&gt;# optional: drop custom PNG/SVG icons here, reference as /icons/myapp.png&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/opt/homepage/icons:/app/public/icons&lt;/span&gt;
      &lt;span class="c1"&gt;# socket mount — read the trade-off discussion below before enabling this&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock:ro&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# "stdout" keeps logs in docker logs output; "file" writes to /app/config/logs/&lt;/span&gt;
      &lt;span class="na"&gt;LOG_TARGETS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;stdout&lt;/span&gt;
      &lt;span class="c1"&gt;# set your local timezone or widget times will be UTC&lt;/span&gt;
      &lt;span class="na"&gt;TZ&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;America/New_York&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before &lt;code&gt;docker compose up&lt;/code&gt; does anything useful, the five config files need to exist. Homepage won't create them. Each one owns a distinct slice of the UI:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;services.yaml&lt;/strong&gt; — the main grid of service cards, grouped into named sections. This is where your Jellyfin, Grafana, and Gitea entries live.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;bookmarks.yaml&lt;/strong&gt; — a separate column of plain links with no status checks. Good for external URLs you want nearby without the overhead of a widget.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;widgets.yaml&lt;/strong&gt; — top-bar info widgets: system stats, weather, search bar, date/time. Configured independently from service cards.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;settings.yaml&lt;/strong&gt; — global layout options: color theme, column count, background image, favicon, custom CSS injection point.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;docker.yaml&lt;/strong&gt; — connection definitions for Docker hosts (local socket or remote TCP). Homepage references these by name inside &lt;code&gt;services.yaml&lt;/code&gt; when doing container auto-discovery.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Create all five as empty files before first boot: &lt;code&gt;touch /opt/homepage/config/{services,bookmarks,widgets,settings,docker}.yaml&lt;/code&gt;. Homepage parses them on startup and on every file change via a filesystem watcher — no restart required when you edit. If any file has a YAML syntax error, the watcher logs the parse failure to stdout but continues serving the last valid state. That's why checking logs immediately after first boot matters: &lt;code&gt;docker logs homepage --follow&lt;/code&gt; for the first 30 seconds will surface any parse errors before you start adding real entries and wondering why nothing appears.&lt;/p&gt;

&lt;p&gt;The Docker socket mount is the sharpest edge in this setup. Mounting &lt;code&gt;/var/run/docker.sock&lt;/code&gt; read-only gives Homepage the ability to query container metadata, which powers the label-based auto-discovery (&lt;code&gt;homepage.name&lt;/code&gt;, &lt;code&gt;homepage.href&lt;/code&gt;, etc.). Read-only helps, but the socket itself has no concept of read-only enforcement at the API level — a process with socket access can issue any Docker API call regardless of the mount flag. That's root-equivalent on the host. For a homelab running on a single machine where you trust everything in the stack, the risk is contained. For anything exposed to the network or running untrusted containers, the mitigation worth deploying is &lt;a href="https://github.com/Tecnativa/docker-socket-proxy" rel="noopener noreferrer"&gt;tecnativa/docker-socket-proxy&lt;/a&gt;. It runs a slim HAProxy instance that sits between Homepage and the real socket, whitelisting only the API endpoints Homepage actually needs (&lt;code&gt;GET /containers&lt;/code&gt;, essentially). You'd replace the socket mount with a TCP connection to the proxy container in &lt;code&gt;docker.yaml&lt;/code&gt;. The proxy itself gets the socket mount and runs with &lt;code&gt;network_mode: none&lt;/code&gt; so it can't initiate outbound connections. It's two extra lines in compose and meaningfully reduces the blast radius if Homepage ever has a supply-chain issue.&lt;/p&gt;

&lt;p&gt;First-boot verification is a three-step check: hit &lt;code&gt;http://localhost:3000&lt;/code&gt; and confirm the default Homepage layout renders (it should show empty sections if your YAML files are valid but empty), run &lt;code&gt;docker logs homepage 2&amp;gt;&amp;amp;1 | grep -i error&lt;/code&gt; to confirm zero parse failures, and if you mounted the socket, run &lt;code&gt;docker logs homepage 2&amp;gt;&amp;amp;1 | grep -i docker&lt;/code&gt; to confirm it connected to the Docker API successfully. If the page loads but is completely blank rather than showing an empty layout, the most common cause is a missing config directory or a permissions mismatch — Homepage runs as UID 1000 by default, so &lt;code&gt;chown -R 1000:1000 /opt/homepage/config&lt;/code&gt; fixes it without needing to add &lt;code&gt;user:&lt;/code&gt; overrides to the compose file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuring Services, Widgets, and Integrations
&lt;/h2&gt;

&lt;p&gt;The gap between Homepage looking like a browser start page and actually functioning as a live operations panel is almost entirely in &lt;code&gt;services.yaml&lt;/code&gt;. Most guides stop at showing icons in a grid. This one doesn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  services.yaml Anatomy
&lt;/h3&gt;

&lt;p&gt;The file is a YAML list of groups. Each group is a named key containing a list of service objects. That hierarchy — group → services — is the whole structure. Here's a concrete example with n8n and Portainer that actually works:&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;Automation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;n8n&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://192.168.1.10:5678&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Workflow&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;automation&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;engine"&lt;/span&gt;
        &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;n8n.png&lt;/span&gt;
        &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-docker&lt;/span&gt;
        &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;n8n&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Portainer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://192.168.1.10:9000&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Docker&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;management&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;UI"&lt;/span&gt;
        &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;portainer.png&lt;/span&gt;
        &lt;span class="na"&gt;widget&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;portainer&lt;/span&gt;
          &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://portainer:9000&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
          &lt;span class="c1"&gt;# env refers to the Portainer environment ID, not an env var&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{HOMEPAGE_VAR_PORTAINER_KEY}}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;icon&lt;/code&gt; field accepts either a slug from the &lt;a href="https://github.com/walkxcode/dashboard-icons" rel="noopener noreferrer"&gt;dashboard-icons&lt;/a&gt; repo (just the filename like &lt;code&gt;n8n.png&lt;/code&gt;) or a path like &lt;code&gt;/icons/custom-thing.png&lt;/code&gt; if you've mounted a local directory. Homepage resolves the slug against its bundled icon CDN automatically. The &lt;code&gt;server&lt;/code&gt; and &lt;code&gt;container&lt;/code&gt; fields on the n8n entry enable the Docker integration — if you've configured a Docker socket in &lt;code&gt;docker.yaml&lt;/code&gt;, the tile will show the container's running/stopped state without any widget block.&lt;/p&gt;

&lt;h3&gt;
  
  
  Uptime Kuma Widget Deep-Dive
&lt;/h3&gt;

&lt;p&gt;The Uptime Kuma widget is one of the cleaner first-party integrations. It hits the Kuma API and surfaces up/down monitor counts plus average response time across all monitors in a given status page slug. The config looks like this:&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;Monitoring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Uptime Kuma&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://192.168.1.10:3001&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Service&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;uptime&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;monitoring"&lt;/span&gt;
        &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uptime-kuma.png&lt;/span&gt;
        &lt;span class="na"&gt;widget&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uptimekuma&lt;/span&gt;
          &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://uptime-kuma:3001&lt;/span&gt;
          &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;homelab&lt;/span&gt;
          &lt;span class="c1"&gt;# "slug" must match the status page slug you created in Kuma's UI&lt;/span&gt;
          &lt;span class="c1"&gt;# under Status Pages → your page → the URL segment after /status/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no API key field on this widget — Kuma's status page endpoint is intentionally public if you've created a status page. The &lt;code&gt;slug&lt;/code&gt; field is the part people get wrong most often: it's not the monitor name, it's the URL slug of a &lt;em&gt;status page&lt;/em&gt; you've set up inside Kuma. Go to Status Pages in the Kuma UI, create one, add your monitors to it, and use whatever you put in the "path" field. The widget will then display total monitors, how many are up, how many are down, and aggregate response time. If it shows zeros or errors, the slug doesn't match or the status page is set to private.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connecting Homepage to Ollama via Custom API Widget
&lt;/h3&gt;

&lt;p&gt;There is no first-party Ollama widget in Homepage as of early 2026. The right move is the &lt;code&gt;customapi&lt;/code&gt; widget type, which does a GET against any JSON endpoint and lets you extract fields with a selector. Ollama's &lt;code&gt;/api/tags&lt;/code&gt; endpoint returns a JSON object with a &lt;code&gt;models&lt;/code&gt; array — one entry per model currently pulled. You can surface the count like this:&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;AI&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Ollama&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://192.168.1.10:11434&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Local&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;LLM&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime"&lt;/span&gt;
        &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ollama.png&lt;/span&gt;
        &lt;span class="na"&gt;widget&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;customapi&lt;/span&gt;
          &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://ollama:11434/api/tags&lt;/span&gt;
          &lt;span class="na"&gt;refreshInterval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30000&lt;/span&gt;
          &lt;span class="c1"&gt;# milliseconds — 30s is fine, model list doesn't change constantly&lt;/span&gt;
          &lt;span class="na"&gt;mappings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;models&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;length&lt;/span&gt;
              &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Models Loaded&lt;/span&gt;
              &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;number&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;field&lt;/code&gt; block navigates the JSON response. &lt;code&gt;models: length&lt;/code&gt; tells Homepage to take the &lt;code&gt;models&lt;/code&gt; array and return its length rather than trying to display the array itself. If you want to go deeper — say, show the name of the first loaded model — you'd use &lt;code&gt;models: 0&lt;/code&gt; as a nested accessor and then &lt;code&gt;name&lt;/code&gt; under it, but the length display is the most useful at a glance. The &lt;code&gt;refreshInterval&lt;/code&gt; is in milliseconds; the default without it is around 10 seconds, which hammers Ollama's API needlessly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Environment Variable Substitution
&lt;/h3&gt;

&lt;p&gt;Homepage supports &lt;code&gt;{{HOMEPAGE_VAR_*}}&lt;/code&gt; substitution in all YAML config files. Any variable prefixed exactly with &lt;code&gt;HOMEPAGE_VAR_&lt;/code&gt; in the environment gets injected at parse time. This keeps API keys out of committed config. The pattern in practice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env file alongside your docker-compose.yaml
&lt;/span&gt;&lt;span class="py"&gt;HOMEPAGE_VAR_PORTAINER_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ptr_abc123yourkeyhere&lt;/span&gt;
&lt;span class="py"&gt;HOMEPAGE_VAR_UPTIME_KUMA_TOKEN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;uk1_notneededforthiswidgetbutshownforpattern&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yaml snippet — the volume mount is required&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;homepage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/gethomepage/homepage:latest&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./config:/app/config&lt;/span&gt;
      &lt;span class="c1"&gt;# Homepage reads HOMEPAGE_VAR_* from the process environment,&lt;/span&gt;
      &lt;span class="c1"&gt;# NOT from a mounted .env — the env_file directive loads them&lt;/span&gt;
      &lt;span class="c1"&gt;# into the container's environment at startup&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A common failure mode here: people mount the &lt;code&gt;.env&lt;/code&gt; file as a volume file instead of using &lt;code&gt;env_file&lt;/code&gt;, then wonder why substitution doesn't work. Homepage's variable substitution reads from process environment variables, not from files on disk. The &lt;code&gt;env_file&lt;/code&gt; key in Compose handles the injection correctly. Also worth knowing: if the variable is missing at startup, Homepage renders the literal string &lt;code&gt;{{HOMEPAGE_VAR_PORTAINER_KEY}}&lt;/code&gt; in the config — which will cause the widget to fail silently with an auth error rather than a parse error, so check your container logs rather than the browser console when debugging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It Behind a Reverse Proxy with Auth
&lt;/h2&gt;

&lt;p&gt;Homepage ships with zero authentication. That's not a criticism — it's a deliberate design choice that puts the auth problem exactly where it belongs: at the proxy layer. The problem is that a lot of people skip that step because it's on a "trusted" home network. Your LAN is not as flat as you think. Any device on it — a smart TV, a guest phone, a compromised IoT gadget — can hit an unauthenticated Homepage instance and immediately read your entire service topology, plus whatever API tokens you've embedded in widget configs. Those tokens are served inline in the page HTML. They're not hidden behind a JS variable or an env lookup at runtime; they're in the source. One curl command from any device on your subnet and someone has your Portainer token, your Grafana service account key, and your Sonarr API key.&lt;/p&gt;

&lt;p&gt;Nginx Proxy Manager is the path of least resistance here. The proxy host setup for Homepage is standard except for one toggle that catches people off guard: &lt;strong&gt;WebSockets Support must be enabled&lt;/strong&gt;. Homepage uses WebSocket connections to stream live widget data — CPU graphs, download speeds, container states. Without it, the page loads but widgets silently fail to update and you end up staring at stale numbers with no error message explaining why. Set the proxy host to forward to your Homepage container on port 3000, flip the WebSockets toggle, and set your custom locations to &lt;code&gt;/&lt;/code&gt;. For basic protection without a full SSO stack, NPM's Access List feature lets you allowlist specific IP ranges — useful if you want to lock the dashboard to a single machine's IP or a narrow subnet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# NPM Access List entry — blocks everything except your admin workstation
# and the local subnet you actually use for management
&lt;/span&gt;&lt;span class="n"&gt;Allow&lt;/span&gt;: &lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;168&lt;/span&gt;.&lt;span class="m"&gt;1&lt;/span&gt;.&lt;span class="m"&gt;50&lt;/span&gt;    &lt;span class="c"&gt;# your main workstation
&lt;/span&gt;&lt;span class="n"&gt;Allow&lt;/span&gt;: &lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;24&lt;/span&gt;    &lt;span class="c"&gt;# management VLAN if you have one
&lt;/span&gt;&lt;span class="n"&gt;Deny&lt;/span&gt;: &lt;span class="n"&gt;all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;IP allowlisting is a speed bump, not a wall — it fails the moment you're on the wrong device or VLAN. For real auth, Authelia and Authentik both work cleanly with Homepage, and the integration requires exactly zero Homepage-side configuration. Both systems operate as forward-auth middleware at the proxy level. In Nginx Proxy Manager with Authelia, you add a single snippet to the Advanced tab of the proxy host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# NPM Advanced tab — Authelia forward auth for Homepage&lt;/span&gt;
&lt;span class="c1"&gt;# Authelia must be reachable at this internal URL from the proxy container&lt;/span&gt;
&lt;span class="k"&gt;auth_request&lt;/span&gt; &lt;span class="n"&gt;/authelia&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;auth_request_set&lt;/span&gt; &lt;span class="nv"&gt;$target_url&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;//&lt;/span&gt;&lt;span class="nv"&gt;$http_host$request_uri&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;error_page&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;302&lt;/span&gt; &lt;span class="s"&gt;https://auth.home.arpa/?rd=&lt;/span&gt;&lt;span class="nv"&gt;$target_url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/authelia&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://authelia:9091/api/verify&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass_request_body&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Content-Length&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Original-URL&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;//&lt;/span&gt;&lt;span class="nv"&gt;$http_host$request_uri&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Method&lt;/span&gt; &lt;span class="nv"&gt;$request_method&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 thing worth being explicit about: neither Authelia nor Authentik gives you per-user views inside Homepage. The dashboard has no user model. Auth here means "authenticated users see everything or nothing" — there's no concept of showing the media stack to one user and the infrastructure widgets to another. If that's a hard requirement, you'd need to run separate Homepage instances with separate configs. Most home lab operators don't need that granularity, but it's a gap to know about before you build around it.&lt;/p&gt;

&lt;p&gt;Completing the setup properly means getting TLS onto an internal domain without opening port 80 to the internet. The correct approach is a DNS-01 ACME challenge. If your domain is managed through Cloudflare, NPM has native Cloudflare DNS challenge support — you provide an API token scoped to DNS edit permissions for the target zone, and NPM requests a wildcard cert from Let's Encrypt entirely over DNS. No inbound HTTP required. Use a real registered domain with an internal subdomain, or a &lt;code&gt;home.arpa&lt;/code&gt; subdomain if you're comfortable managing your own CA for it. A domain like &lt;code&gt;dashboard.home.arpa&lt;/code&gt; resolves to an RFC 8375-compliant private address — but Let's Encrypt won't issue for &lt;code&gt;.arpa&lt;/code&gt;, so in practice most operators use something like &lt;code&gt;dashboard.yourdomain.com&lt;/code&gt; with a private A record pointing to an internal IP. The DNS-01 flow handles cert issuance and renewal without the domain ever needing to be publicly reachable, and you end up with a valid browser-trusted cert on a dashboard that never leaves your network.&lt;/p&gt;

&lt;h2&gt;
  
  
  Non-Obvious Behaviors That Will Cost You Time
&lt;/h2&gt;

&lt;p&gt;The most expensive mistake you'll make with Homepage isn't misconfiguring a widget — it's trusting the UI to tell you something went wrong. It won't. Drop a tab character into a YAML block, nest a widget section at the wrong depth, forget a space after a colon — Homepage silently discards the entire widget block and renders the card as a plain label with no data. The page looks fine. Nothing is red. The only way to catch it is &lt;code&gt;docker logs homepage&lt;/code&gt; after every config change, not just a browser refresh. The parser errors do show up in the container logs, just not anywhere a user would think to look first.&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;# After any services.yaml or widgets.yaml edit, run this immediately&lt;/span&gt;
docker logs homepage &lt;span class="nt"&gt;--tail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;50 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"error&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;warn&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;parse"&lt;/span&gt;

&lt;span class="c"&gt;# If you see something like:&lt;/span&gt;
&lt;span class="c"&gt;# Error: bad indentation of a mapping entry at line 14, column 5&lt;/span&gt;
&lt;span class="c"&gt;# that's your discarded widget block — the card above it in the UI looks fine&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The polling interval is fixed at approximately 30 seconds and is not adjustable on a per-widget basis in the current release. This is a deliberate architectural choice, not an oversight, and there's an open GitHub discussion tracking the demand for configurable intervals (&lt;a href="https://github.com/gethomepage/homepage/discussions/1599" rel="noopener noreferrer"&gt;github.com/gethomepage/homepage/discussions/1599&lt;/a&gt;). If you're building a panel to watch a transcoding queue or monitor a replication lag, Homepage will feel sluggish — you'll be staring at 29-second-old numbers and not know it. For anything requiring sub-minute freshness, route that widget to Grafana or a custom status page. Homepage is the right tool for ambient awareness, not operational monitoring.&lt;/p&gt;

&lt;p&gt;Icon resolution has a specific lookup order that bites air-gapped installs hard: Homepage checks your local &lt;code&gt;/icons/&lt;/code&gt; mount first, then falls back to the &lt;code&gt;dashboard-icons&lt;/code&gt; CDN at &lt;code&gt;https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons&lt;/code&gt;. If your instance sits behind a strict egress firewall or has no outbound access at all, every icon that isn't locally present silently 404s — no broken image indicator, just an empty space where the icon should be. The fix is to pre-populate the local mount before deployment. The dashboard-icons repo is public and mirrors cleanly.&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;# Clone the icon pack locally and mount it into the container&lt;/span&gt;
git clone &lt;span class="nt"&gt;--depth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 https://github.com/walkxcode/dashboard-icons.git /opt/homepage/icons

&lt;span class="c"&gt;# In your docker-compose.yml:&lt;/span&gt;
volumes:
  - /opt/homepage/icons/png:/app/public/icons  &lt;span class="c"&gt;# Homepage expects flat PNGs here&lt;/span&gt;

&lt;span class="c"&gt;# Reference in services.yaml:&lt;/span&gt;
- Sonarr:
    icon: sonarr.png  &lt;span class="c"&gt;# resolves from local mount, never hits CDN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Docker label auto-discovery capitalisation trap is subtle and will produce confusing duplicate groups that look intentional. The &lt;code&gt;homepage.group&lt;/code&gt; label is case-sensitive, so &lt;code&gt;Media&lt;/code&gt; and &lt;code&gt;media&lt;/code&gt; produce two separate sidebar groups — Homepage doesn't normalize them. If you're running more than a handful of Compose stacks written at different times, drift happens. The only real fix is a convention enforced at the Compose file level from the start: pick Title Case or lowercase and document it in a comment at the top of every stack file. Once you have duplicate groups in the UI, you can't merge them from the dashboard — you have to hunt down every label mismatch across all your Compose files manually.&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;# Enforce this pattern across all Compose stacks — pick one, never mix&lt;/span&gt;
labels:
  homepage.group: Media          &lt;span class="c"&gt;# Title Case — pick this OR lowercase, not both&lt;/span&gt;
  homepage.name: Sonarr
  homepage.icon: sonarr.png
  homepage.href: http://sonarr:8989

&lt;span class="c"&gt;# Quick audit command to surface capitalisation drift across stacks:&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"homepage.group"&lt;/span&gt; /opt/stacks/ | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;'='&lt;/span&gt; &lt;span class="s1"&gt;'{print $2}'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;
&lt;span class="c"&gt;# Any group with count &amp;gt; 1 in different cases = a split group in your UI&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When Homepage Earns Its Place (and When It Doesn't)
&lt;/h2&gt;

&lt;p&gt;The clearest signal that Homepage fits your setup is when you've survived a host rebuild and realized how much time you wasted re-entering service URLs, icons, and widget configs by hand. If your dashboard state lives in a browser's local storage or a SQLite file you forgot to back up, the next disk failure takes everything with it. Homepage stores every piece of configuration — services, bookmarks, widget credentials, layout — in plain YAML files that you can commit to a private Git repo and never think about again. That's not a feature worth burying in a bullet list; it's the architectural decision that makes everything else about Homepage worth tolerating.&lt;/p&gt;

&lt;p&gt;Homepage makes sense as your primary dashboard when these conditions are true for your setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Config portability matters more than features:&lt;/strong&gt; All state lives in &lt;code&gt;services.yaml&lt;/code&gt;, &lt;code&gt;bookmarks.yaml&lt;/code&gt;, &lt;code&gt;docker.yaml&lt;/code&gt;, &lt;code&gt;widgets.yaml&lt;/code&gt;, and &lt;code&gt;settings.yaml&lt;/code&gt;. Version-control those five files and a full restore is literally &lt;code&gt;git clone&lt;/code&gt; followed by &lt;code&gt;docker compose up&lt;/code&gt;. No re-entering 30 service URLs, no lost icon customizations.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;You want native integrations without glue code:&lt;/strong&gt; Homepage ships with built-in widgets for Sonarr, Radarr, Jellyfin, Proxmox, Portainer, Nextcloud, Adguard Home, Unifi, and a few dozen others. Each one talks directly to the service's API using credentials you drop into &lt;code&gt;services.yaml&lt;/code&gt;. The alternative is building API polling yourself — which you don't want to do for 15 different services.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;You're the only operator:&lt;/strong&gt; Homepage has no concept of users, roles, or visibility rules. It renders the same dashboard to anyone who can reach the URL. For a single person running a home lab behind Tailscale or a VPN, that's fine — it's actually a simplification. For anything else, it's a hard wall.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The situations where Homepage actively gets in your way are just as specific. If you need per-user views — say, a family member should see Jellyfin and nothing else while you see the whole stack — Homepage cannot do that. Grafana with its folder permissions and dashboard-level auth is the better fit there. If you need real-time metric graphs with sub-second resolution, Homepage's widget polling (which runs on a configurable interval, minimum a few seconds) will feel sluggish and incomplete compared to a Prometheus scrape feeding into Grafana panels. And if your goal is to put an authentication wall in front of all your services from one place, that's Authelia or Authentik's job — Homepage has no SSO, no OAuth proxy, no session management. Treating it as an auth layer is a mistake that'll bite you the moment you expose anything to the public internet.&lt;/p&gt;

&lt;p&gt;The config-as-code payoff is concrete enough to show directly. My full working dashboard state is five files totaling under 300 lines of YAML, sitting in a private repo. After a fresh Debian install on a replacement drive, the restore sequence is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone git@github.com:youruser/homelab-configs.git ~/configs
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/configs/homepage
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Every service tile, every widget credential, every custom icon path, every group label — back in under two minutes, with zero manual re-entry. The compose file mounts the cloned directory directly into the container, so there's no import step and no database to restore. Compare that to any dashboard that stores config in a browser session or an embedded database you have to remember to snapshot, and the trade-off becomes obvious.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/building-a-local-dashboard-with-homepage-one-config-file-to-rule-your-self-hosted-stack/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>productivity</category>
      <category>tools</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Speakr v0.8.19: What Actually Changed and Whether It's Worth Upgrading Your Self-Hosted Transcription Stack</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Fri, 03 Jul 2026 08:10:10 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/speakr-v0819-what-actually-changed-and-whether-its-worth-upgrading-your-self-hosted-5aic</link>
      <guid>https://dev.to/ericwoooo_kr/speakr-v0819-what-actually-changed-and-whether-its-worth-upgrading-your-self-hosted-5aic</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Cloud transcription pricing has a nasty compounding effect: you don't notice it until you're processing a backlog of three-hour interview recordings and the monthly invoice arrives.  AssemblyAI and Deepgram both bill per audio-minute, which sounds reasonable until your podcast ar&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~18 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Problem Speakr Is Solving (And Who It's Actually For)&lt;/li&gt;
&lt;li&gt;What v0.8.19 Actually Changed&lt;/li&gt;
&lt;li&gt;Setup Walkthrough: Docker Compose with GPU Passthrough&lt;/li&gt;
&lt;li&gt;Real Resource Costs on a 32GB VRAM Workstation&lt;/li&gt;
&lt;li&gt;Connecting Speakr to n8n: Webhook Pipeline and the Timestamp Bug&lt;/li&gt;
&lt;li&gt;Three Non-Obvious Behaviors Worth Knowing&lt;/li&gt;
&lt;li&gt;When to Use Speakr vs. Alternatives&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Problem Speakr Is Solving (And Who It's Actually For)
&lt;/h2&gt;

&lt;p&gt;Cloud transcription pricing has a nasty compounding effect: you don't notice it until you're processing a backlog of three-hour interview recordings and the monthly invoice arrives. AssemblyAI and Deepgram both bill per audio-minute, which sounds reasonable until your podcast archive hits four figures in hours, or you're running a legal firm that transcribes depositions daily. The per-minute cost isn't the only concern — both services retain audio and transcripts on their infrastructure by default, which makes them non-starters for anything covered by attorney-client privilege, HIPAA-adjacent workflows, or simply recordings that contain information you'd rather not hand to a third party's training pipeline.&lt;/p&gt;

&lt;p&gt;Speakr sits at a specific point in the self-hosting stack: it's not raw Whisper (which you'd have to wire up yourself), and it's not a full enterprise transcription suite. What it actually gives you is a REST API in front of Whisper or faster-whisper, a persistent job queue so uploads don't block, speaker diarization hooks via pyannote.audio, and a minimal web UI for one-off uploads. That's the practical sweet spot — enough infrastructure to integrate into automation pipelines without requiring you to write the job management layer yourself. The v0.8.19 update tightens the faster-whisper integration specifically, which matters because faster-whisper's CTranslate2 backend runs noticeably leaner on VRAM than stock Whisper for the same model size.&lt;/p&gt;

&lt;p&gt;The operators who get the most out of this are running one of a few specific workflows: interview archives where a journalist or researcher needs searchable transcripts without sending recordings to a cloud service; internal meeting pipelines where the audio never leaves the LAN; podcast post-production where chapter markers and speaker labels feed downstream into editing tools; or medical and legal shops where data residency isn't optional. If your audio is ephemeral and non-sensitive and you're transcribing a few hours a month, paying Deepgram's per-minute rate is probably fine. If any of those conditions don't hold, you're paying for a constraint you don't need.&lt;/p&gt;

&lt;p&gt;The diarization angle is worth calling out separately. Most self-hosted Whisper wrappers skip it or bolt it on poorly. Speakr exposes diarization as a job parameter rather than a post-processing afterthought, which means speaker labels come back in the same job response as the transcript rather than requiring a second API call or manual merge. For interview recordings with two or more speakers, that single design decision is what makes the output actually usable without additional scripting. For a broader look at how transcription fits into local automation pipelines, the guide on &lt;a href="https://techdigestor.com/ultimate-productivity-guide-2026/" rel="noopener noreferrer"&gt;Workflow Automation in 2026: n8n, Zapier, and Self-Hosted Pipelines&lt;/a&gt; is worth a read.&lt;/p&gt;

&lt;h2&gt;
  
  
  What v0.8.19 Actually Changed
&lt;/h2&gt;

&lt;p&gt;The most dangerous change in v0.8.19 isn't the one getting the most attention. The webhook payload schema quietly flipped &lt;code&gt;segments[].start&lt;/code&gt; (and &lt;code&gt;segments[].end&lt;/code&gt;) from milliseconds-as-integer to seconds-as-float. No deprecation warning, no migration guide in the changelog. If you have any downstream parser — an n8n HTTP node, a custom subtitle renderer, anything that reads those timestamps — it will continue to run without error and produce timestamps that are off by a factor of 1000. You'll see clips labeled as starting at 0.5s when they should be 500ms, or worse, subtitle files where every cue fires in the first two seconds. Check your parsers &lt;em&gt;before&lt;/em&gt; upgrading if you have production flows depending on segment timing.&lt;/p&gt;

&lt;p&gt;The switch to &lt;strong&gt;faster-whisper&lt;/strong&gt; as the default backend is a genuine win for cold-start latency, but the changelog undersells what it means for VRAM. faster-whisper uses CTranslate2 under the hood, which loads weights in a more compressed format. On my 32GB box, large-v3 via the original OpenAI Whisper backend was sitting around 10GB VRAM resident after the first job. With faster-whisper as the default, the same model loads closer to 6.5GB and first-token latency on a 5-minute audio file drops noticeably. The trade-off: the CTranslate2 build adds a native dependency, and if you're running a stripped-down container image, your first deploy after upgrading may fail with a missing shared library error rather than a clean startup message.&lt;/p&gt;

&lt;p&gt;The new &lt;code&gt;SPEAKR_BATCH_CONCURRENCY&lt;/code&gt; env var is one of those additions that sounds boring until you've had a GPU OOM-kill a transcription halfway through a 90-minute file. Previously, if three jobs hit the queue simultaneously, Speakr would spin up three parallel GPU contexts with no backpressure. On anything under 24GB VRAM, that's a crash waiting to happen. Now you can actually enforce a ceiling:&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;# docker-compose.yml fragment&lt;/span&gt;
&lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;SPEAKR_BATCH_CONCURRENCY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;   &lt;span class="c1"&gt;# hard cap at 2 parallel GPU jobs&lt;/span&gt;
  &lt;span class="na"&gt;SPEAKR_QUEUE_TIMEOUT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;300"&lt;/span&gt;     &lt;span class="c1"&gt;# seconds before a queued job is dropped&lt;/span&gt;
  &lt;span class="na"&gt;SPEAKR_BACKEND&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;faster-whisper"&lt;/span&gt; &lt;span class="c1"&gt;# now the default, but explicit is better&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting this to 1 is the safest starting point on a shared workstation. Set it to 2 only if you've confirmed your model footprint leaves headroom — faster-whisper's lower VRAM floor makes this more viable than it was before. The queue will block and wait rather than fork a new process, so throughput goes down but reliability goes up. Worth it.&lt;/p&gt;

&lt;p&gt;Word-level timestamps being on by default (&lt;code&gt;--word_timestamps true&lt;/code&gt;) is the right call for most real use cases, but you should know what you're paying for. The latency overhead is real — budget roughly 15% extra wall-clock time on large-v3, more on longer files because the alignment pass scales with audio duration. If you're running batch overnight jobs where timestamp granularity doesn't matter, you can explicitly disable it to recover that time. But for anyone building subtitle pipelines, diarization prep, or anything that needs to sync text to a specific frame, having this on by default means your output is actually usable without a second post-processing pass. The segment-level timestamps were never precise enough for subtitle work; word-level timestamps are the minimum viable granularity for that use case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup Walkthrough: Docker Compose with GPU Passthrough
&lt;/h2&gt;

&lt;p&gt;The part that bites most people first isn't the GPU config — it's that Speakr v0.8.19 will happily pull a 3GB model file &lt;em&gt;at container startup&lt;/em&gt; if it doesn't find the expected cache layout inside &lt;code&gt;/models&lt;/code&gt;. That download blocks the health-check endpoint, Docker marks the container unhealthy, and your orchestrator kills and restarts it before transcription ever runs. Pre-stage the model first, configure second.&lt;/p&gt;

&lt;p&gt;Here's a minimum viable &lt;code&gt;docker-compose.yml&lt;/code&gt; that pins the image, passes the NVIDIA runtime through, and mounts both required volumes:&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;speakr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/speakr-oss/speakr:0.8.19&lt;/span&gt;   &lt;span class="c1"&gt;# pin — latest breaks on model path changes&lt;/span&gt;
    &lt;span class="na"&gt;runtime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/mnt/models/speakr:/models&lt;/span&gt;             &lt;span class="c1"&gt;# pre-staged model files live here&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/mnt/speakr-queue:/queue&lt;/span&gt;               &lt;span class="c1"&gt;# audio queue; survives container restarts&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;SPEAKR_MODEL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;large-v3&lt;/span&gt;                   &lt;span class="c1"&gt;# which Whisper checkpoint to load&lt;/span&gt;
      &lt;span class="na"&gt;SPEAKR_DEVICE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cuda&lt;/span&gt;                      &lt;span class="c1"&gt;# "cpu" is a valid fallback, ~8x slower&lt;/span&gt;
      &lt;span class="na"&gt;SPEAKR_BATCH_CONCURRENCY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;              &lt;span class="c1"&gt;# parallel decode slots; 1 if sharing GPU&lt;/span&gt;
      &lt;span class="na"&gt;NVIDIA_VISIBLE_DEVICES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;
      &lt;span class="na"&gt;NVIDIA_DRIVER_CAPABILITIES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;compute,utility&lt;/span&gt;
    &lt;span class="na"&gt;ports&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;8765:8765"&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&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;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-f"&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://localhost:8765/health"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&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;timeout&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;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;60s&lt;/span&gt;   &lt;span class="c1"&gt;# give it time if model IS loading cold — longer than default&lt;/span&gt;
    &lt;span class="na"&gt;deploy&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;reservations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;devices&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia&lt;/span&gt;
              &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
              &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;gpu&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SPEAKR_BATCH_CONCURRENCY&lt;/code&gt; is the variable that matters most on a shared-GPU box. On my 32GB VRAM workstation running Ollama alongside Speakr, I keep this at &lt;code&gt;1&lt;/code&gt; during Ollama's active window and bump it to &lt;code&gt;2&lt;/code&gt; overnight. At &lt;code&gt;3&lt;/code&gt;, &lt;code&gt;large-v3&lt;/code&gt; plus a loaded Ollama model will OOM without warning. The model selection directly determines how much headroom you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;large-v3&lt;/strong&gt; — roughly 10GB VRAM resident once loaded. Best word-error rate on accented speech, technical vocabulary, mixed-language audio. Use this if Speakr is your primary GPU tenant.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;medium.en&lt;/strong&gt; — roughly 5GB VRAM. English-only, noticably faster per-file, WER degrades on non-native speakers and proper nouns. Good middle ground when sharing with a 7B Ollama model.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;small&lt;/strong&gt; — roughly 2GB VRAM. Latency is fast enough to feel real-time on short clips. WER on anything with background noise or strong accents is bad enough to require a post-processing correction pass if accuracy matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To avoid the startup-download failure, pre-stage the model using &lt;code&gt;huggingface-cli&lt;/code&gt; directly into your mounted path &lt;em&gt;before&lt;/em&gt; the container ever starts:&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;# install once if you don't have it&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;huggingface_hub

&lt;span class="c"&gt;# download large-v3 into the exact directory Speakr expects&lt;/span&gt;
huggingface-cli download &lt;span class="se"&gt;\&lt;/span&gt;
  openai/whisper-large-v3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--local-dir&lt;/span&gt; /mnt/models/speakr/whisper-large-v3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--local-dir-use-symlinks&lt;/span&gt; False

&lt;span class="c"&gt;# verify the weights file is there — Speakr checks for model.safetensors&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lh&lt;/span&gt; /mnt/models/speakr/whisper-large-v3/model.safetensors
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--local-dir-use-symlinks False&lt;/code&gt; flag matters: Hugging Face's default symlink layout confuses Speakr's model loader on first boot, and the error message it gives back ("model not found, downloading") is misleading — the files &lt;em&gt;are&lt;/em&gt; there, just not where it looks first.&lt;/p&gt;

&lt;p&gt;One more failure mode that shows up specifically on long audio files: Speakr's upload endpoint accepts the file fine, but the transcription takes longer than your reverse proxy's read timeout. The proxy closes the connection, the client gets a 504, and the transcription finishes successfully in the container — invisibly, with no way to retrieve the result through the normal response path. In Nginx, set this on the &lt;code&gt;location&lt;/code&gt; block handling the upload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/transcribe&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt;          &lt;span class="s"&gt;http://127.0.0.1:8765&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_read_timeout&lt;/span&gt;  &lt;span class="s"&gt;300s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# 5 min — covers ~45min audio on large-v3&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_send_timeout&lt;/span&gt;  &lt;span class="s"&gt;120s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;client_max_body_size&lt;/span&gt; &lt;span class="mi"&gt;512M&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# Speakr's default upload limit; match this&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Caddy the equivalent is &lt;code&gt;reverse_proxy localhost:8765 { transport http { read_timeout 5m } }&lt;/code&gt;. Traefik handles it via the &lt;code&gt;traefik.http.middlewares.speakr-timeout.forwardAuth.authResponseHeadersRegex&lt;/code&gt; path — or more cleanly, by setting &lt;code&gt;readTimeout&lt;/code&gt; on the entrypoint itself rather than per-service, which avoids having to add labels to every container that shares that entrypoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real Resource Costs on a 32GB VRAM Workstation
&lt;/h2&gt;

&lt;p&gt;The VRAM split between Whisper and your LLM is the first thing you need to model before committing to a configuration. On my 32GB workstation, &lt;code&gt;large-v3&lt;/code&gt; via faster-whisper holds roughly 10GB VRAM resident while a transcription job is active — not allocated at startup, but from the moment the first audio chunk hits the model. That leaves ~22GB for Ollama, which is workable for a 13B or 34B quant as long as you're not triggering both simultaneously. The failure mode is subtle: if an n8n flow kicks off a transcription job while an LLM inference request is mid-generation, you'll hit fragmented VRAM pressure rather than a clean OOM — the transcription stalls or the LLM inference slows to a crawl without an obvious error. The fix is serializing at the orchestration layer, not the model layer.&lt;/p&gt;

&lt;p&gt;If your workload actually needs concurrent transcription rather than LLM coexistence, the concurrency math works out cleaner with a smaller model. &lt;code&gt;medium.en&lt;/code&gt; runs at roughly 5GB per worker, so:&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;# docker-compose or .env&lt;/span&gt;
&lt;span class="s"&gt;SPEAKR_MODEL=medium.en&lt;/span&gt;
&lt;span class="s"&gt;SPEAKR_BATCH_CONCURRENCY=2&lt;/span&gt;
&lt;span class="c1"&gt;# Total Speakr VRAM footprint: ~10-11GB&lt;/span&gt;
&lt;span class="c1"&gt;# Leaves ~21GB for a 7B Q4_K_M quant in Ollama (~4.5GB) with room to breathe&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two &lt;code&gt;medium.en&lt;/code&gt; workers plus a 7B quantized model fits cleanly without serialization constraints. The accuracy delta between &lt;code&gt;medium.en&lt;/code&gt; and &lt;code&gt;large-v3&lt;/code&gt; is noticeable on accented speech and domain-specific vocabulary, but for English interview recordings or meeting audio with clear speakers, &lt;code&gt;medium.en&lt;/code&gt; is good enough that you won't fight the output downstream.&lt;/p&gt;

&lt;p&gt;CPU fallback is more useful than it sounds, but only for specific scheduling patterns. Setting &lt;code&gt;SPEAKR_DEVICE=cpu&lt;/code&gt; zeroes out VRAM usage entirely — the full GPU is free for LLM work around the clock. The cost is real: a 1-hour audio file that finishes in roughly 4 minutes on GPU takes ~35 minutes on CPU with &lt;code&gt;large-v3&lt;/code&gt;. For overnight batch jobs where a PM2 cron queues up everything recorded during the day, that latency is irrelevant. For anything interactive — a user uploads a file and waits for a transcript — it's not usable. The right pattern is a configurable &lt;code&gt;SPEAKR_DEVICE&lt;/code&gt; per deployment profile rather than a single value baked into your compose file.&lt;/p&gt;

&lt;p&gt;Disk is the resource most people forget to track until the model cache directory is suddenly 15GB. &lt;code&gt;large-v3&lt;/code&gt; alone sits at ~3GB on disk. The sharp edge in Speakr's current behavior: changing &lt;code&gt;SPEAKR_MODEL&lt;/code&gt; in your env and restarting pulls the new model but does not clean up the old one. After a week of experimentation across four or five model sizes, the cache directory accumulates all of them silently.&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;# Find your cache mount and audit it&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;speakr_app &lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; /root/.cache/huggingface/hub/models--Systran&lt;span class="k"&gt;*&lt;/span&gt;/
&lt;span class="c"&gt;# or wherever SPEAKR_MODEL_CACHE_DIR points in your compose&lt;/span&gt;

&lt;span class="c"&gt;# Manual prune example — remove a specific model revision&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /data/speakr-cache/models--Systran--faster-whisper-medium/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no &lt;code&gt;speakr prune&lt;/code&gt; command in v0.8.19 — pruning is entirely manual. If you're on a volume with a hard size limit, add a cleanup step to your deployment script whenever you change &lt;code&gt;SPEAKR_MODEL&lt;/code&gt;, otherwise that volume silently fills and the next model pull fails mid-download with an unhelpful I/O error rather than a disk-space warning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting Speakr to n8n: Webhook Pipeline and the Timestamp Bug
&lt;/h2&gt;

&lt;p&gt;The timestamp unit change in v0.8.19 is the kind of silent breakage that produces no errors, just subtitles that are off by a factor of a thousand. If you had a working pipeline before this update, your &lt;code&gt;segment.start / 1000&lt;/code&gt; conversion in any downstream Function node is now double-dividing — Speakr used to return milliseconds, this version returns seconds. The output still looks like a valid float, the workflow runs green, and your SRT file has timestamps like &lt;code&gt;00:16:40,000&lt;/code&gt; where the audio says something at the ten-second mark. Delete the division entirely and treat the value as seconds from the start.&lt;/p&gt;

&lt;p&gt;The basic wiring is straightforward: an n8n HTTP Request node POSTs a binary audio file to &lt;code&gt;/api/v1/jobs&lt;/code&gt;, Speakr returns a job ID immediately, and then you choose between polling and webhooks. Polling is simpler to debug but burns time on long files. The webhook path is cleaner — register a callback URL on the job creation POST, and Speakr will fire a POST back to an n8n Webhook node when processing finishes. The critical gotcha here is that Speakr fires on &lt;em&gt;both&lt;/em&gt; completion and failure, and the payload shape differs. A failed job still hits your webhook endpoint with a &lt;code&gt;job_status: "failed"&lt;/code&gt; field and no &lt;code&gt;segments&lt;/code&gt; array. If your downstream nodes assume &lt;code&gt;segments&lt;/code&gt; exists, they'll throw a runtime error on any failed transcription job. The fix is a single IF node or a Function node guard before anything touches the segments array:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// n8n Function node — drop this before any segment processing&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;job_status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// surface the failure clearly rather than letting it blow up downstream&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Speakr job failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;job_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; — status: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;job_status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On my own stack, the workflow starts with a folder watch — a Node.js script running under PM2 monitors a drop directory and triggers the n8n workflow via its own webhook endpoint when a new audio file lands. The n8n flow calls Speakr, waits for the completion webhook, then immediately pipes the &lt;code&gt;segments&lt;/code&gt; array into a second HTTP Request node calling the local Ollama API (&lt;code&gt;http://localhost:11434/api/generate&lt;/code&gt;) with the full transcript text and a summarization prompt. The summarization model I use for this is Mistral 7B — fast enough that the whole pipeline from drop to draft is under two minutes for a 20-minute audio file. The final step is a WordPress REST API call that creates a draft post with the summary as the body and the raw transcript stuffed into a custom field for reference.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Reconstructing plain-text transcript from segments (post-v0.8.19)&lt;/span&gt;
&lt;span class="c1"&gt;// segment.start is already in seconds — no conversion needed&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`[&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;s] &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;transcript&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 practical note on the HTTP Request node configuration for the initial job POST: set the body content type to &lt;code&gt;multipart/form-data&lt;/code&gt; and attach the binary audio item from earlier in the workflow using the &lt;code&gt;Binary Data&lt;/code&gt; option — not base64. Speakr's file size limits are enforced at the HTTP layer, and if you base64-encode a large WAV before posting, you'll hit the limit with a file that would have been fine as a raw binary. Also register the webhook URL with a path that includes the job ID if you're running multiple concurrent transcription workflows; otherwise all completions land on the same endpoint and you'll need to demux them inside n8n based on the payload's job ID field, which is doable but adds complexity you don't need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Non-Obvious Behaviors Worth Knowing
&lt;/h2&gt;

&lt;p&gt;The diarization support is the biggest gotcha. Speakr's documentation uses language that implies speaker separation is a built-in capability, but what it actually ships is a forwarding layer. The &lt;code&gt;SPEAKR_DIARIZATION_URL&lt;/code&gt; environment variable is not optional configuration for a bundled feature — it's the entire implementation. If that variable isn't set and pointing at a live &lt;a href="https://github.com/pyannote/pyannote-audio" rel="noopener noreferrer"&gt;pyannote-audio&lt;/a&gt; inference endpoint, diarization silently does nothing. You don't get an error. The transcript just comes back without speaker labels and the UI gives no indication why. You have to run pyannote-audio as a separate service, expose it on HTTP, and wire the URL in. Budget another container and a model download (the pyannote speaker diarization pipeline pulls several hundred MB of weights) before you treat diarization as a working feature.&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;# docker-compose excerpt — pyannote sidecar wired to Speakr&lt;/span&gt;
  &lt;span class="na"&gt;speakr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;speakr:0.8.19&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# without this, diarization UI toggle does nothing&lt;/span&gt;
      &lt;span class="na"&gt;SPEAKR_DIARIZATION_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://pyannote:8000/diarize&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pyannote&lt;/span&gt;

  &lt;span class="na"&gt;pyannote&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-pyannote-serving-image&lt;/span&gt;
    &lt;span class="na"&gt;ports&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;8000:8000"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./models:/root/.cache/torch&lt;/span&gt;  &lt;span class="c1"&gt;# reuse weight cache across restarts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SQLite persistence problem will bite you the first time you update the container image. By default, Speakr writes its job database to a path inside the container filesystem. Pull a new image, spin up a new container, and everything — job history, completed transcripts, any stored results — is gone. The fix is a single volume mount, but it's not called out prominently in the v0.8.19 release notes. The database path is &lt;code&gt;/app/data/speakr.db&lt;/code&gt;. Mount that directory as a named volume or a bind mount and your job history survives restarts and image updates. Without it you're running an amnesiac service.&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;speakr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;speakr:0.8.19&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# this one line is the difference between persistent and ephemeral jobs&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;speakr_data:/app/data&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;speakr_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Language auto-detection is more expensive than the docs suggest. Leaving &lt;code&gt;SPEAKR_LANGUAGE&lt;/code&gt; unset causes Speakr to run Whisper's detection pass over the first 30 seconds of each audio file before transcription starts. On longer files that's a minor tax, but on a queue of short clips it adds up fast. The more operationally annoying issue is accuracy: accented English — particularly South Asian, West African, and Australian regional accents — gets misidentified as Hindi, French, or Portuguese with enough regularity to matter. When that happens you get a transcript in the wrong language with no error surfaced, just garbage output. If your use case has a known input language, set it explicitly:&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;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;SPEAKR_LANGUAGE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;en&lt;/span&gt;   &lt;span class="c1"&gt;# ISO 639-1 code; skips detection pass entirely&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The detection failure mode is particularly frustrating to debug because the job shows as completed with a non-zero confidence score — the model is confident, just confidently wrong about which language it's transcribing. Pinning the language also gives you a small but consistent latency reduction on every job, so there's no downside if your audio source is predictable.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Use Speakr vs. Alternatives
&lt;/h2&gt;

&lt;p&gt;The honest decision point is resource allocation: Speakr earns its place when you need a managed queue in front of Whisper and don't want to wire up Redis, a job worker, a REST layer, and a status-polling endpoint yourself. That plumbing is genuinely tedious, and Speakr ships it pre-assembled. If you're running multiple services hitting the same GPU — say, an n8n flow triggering transcriptions alongside a separate ingestion pipeline — the job queue is what keeps you from blowing up VRAM with concurrent Whisper loads. That's the actual value proposition, not the web UI.&lt;/p&gt;

&lt;p&gt;For single-pipeline work, Speakr is overhead you don't need. If you have one Python script, one cron job, or one Node process that needs transcription and nothing else will ever share that path, &lt;code&gt;faster-whisper&lt;/code&gt; directly is cleaner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# faster-whisper, no server, no queue, just a function call
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;faster_whisper&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;WhisperModel&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;WhisperModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;large-v3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cuda&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;compute_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;float16&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transcribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio.mp3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;word_timestamps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;segment&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s] &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same story with &lt;code&gt;whisper.cpp&lt;/code&gt; — if you're embedding transcription into a Go or C++ service and want a single binary with no Python runtime dependency, &lt;code&gt;whisper.cpp&lt;/code&gt; with its server mode is the right call. Speakr's REST API adds round-trip HTTP overhead and a process boundary that only pays off when you actually need isolation and queuing.&lt;/p&gt;

&lt;p&gt;Cloud APIs (Deepgram, AssemblyAI) win on latency at low concurrency — full stop. A self-hosted Whisper &lt;code&gt;large-v3&lt;/code&gt; model on a GPU that's also running inference for other workloads will not beat Deepgram's Nova-2 turnaround time on a 5-minute file. The break-even is data locality: if you're transcribing audio that can't leave your infrastructure, or if your volume is high enough that per-minute API costs become meaningful, self-hosted makes sense. But if your SLA is "user uploads a file and sees a transcript in under 10 seconds," a loaded local GPU is a liability, not an asset.&lt;/p&gt;

&lt;p&gt;On v0.8.19 specifically: upgrade if you were hitting OOM crashes under concurrent load — the concurrency cap that shipped in this release is a direct fix for that failure mode, not a config tweak you can replicate yourself in older versions. Also upgrade if you need word-level timestamps in your output; that feature wasn't stable before this release. Hold off if your downstream parser expects millisecond integers for timestamp values — v0.8.19 changed the timestamp format in the response payload, and anything doing &lt;code&gt;parseInt(segment.start)&lt;/code&gt; or treating it as a raw number will break silently. Check your consumer code before you roll it out.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/speakr-v0-8-19-what-actually-changed-and-whether-its-worth-upgrading-your-self-hosted-transcription-stack/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>productivity</category>
      <category>tools</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Monitoring Kubernetes Clusters with OpenTelemetry Collector: The Agent + Gateway Pattern Explained</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Wed, 01 Jul 2026 08:10:54 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/monitoring-kubernetes-clusters-with-opentelemetry-collector-the-agent-gateway-pattern-explained-1ogj</link>
      <guid>https://dev.to/ericwoooo_kr/monitoring-kubernetes-clusters-with-opentelemetry-collector-the-agent-gateway-pattern-explained-1ogj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The failure mode nobody talks about until it's happening in production: every pod opens a direct gRPC connection to your Tempo or Loki ingest endpoint, and the backend starts dropping spans not because it's overloaded on CPU, but because it hits its concurrent connection limit.  &lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~23 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Problem: Per-Node Chaos Without a Collection Strategy&lt;/li&gt;
&lt;li&gt;Architecture Overview: Agent DaemonSet + Gateway Deployment&lt;/li&gt;
&lt;li&gt;Deploying the Agent: DaemonSet Config That Actually Works&lt;/li&gt;
&lt;li&gt;Deploying the Gateway: Where Batching and Sampling Live&lt;/li&gt;
&lt;li&gt;Three Non-Obvious Behaviors That Will Burn You&lt;/li&gt;
&lt;li&gt;Validating the Pipeline and What to Monitor About the Collector Itself&lt;/li&gt;
&lt;li&gt;When to Use This Pattern and When to Skip It&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Problem: Per-Node Chaos Without a Collection Strategy
&lt;/h2&gt;

&lt;p&gt;The failure mode nobody talks about until it's happening in production: every pod opens a direct gRPC connection to your Tempo or Loki ingest endpoint, and the backend starts dropping spans not because it's overloaded on CPU, but because it hits its concurrent connection limit. gRPC connections aren't free — each one holds state, negotiates keepalives, and occupies a file descriptor. At five nodes with a handful of pods each, this is invisible. At twenty nodes with autoscaling workloads, you're opening hundreds of persistent connections to a single endpoint that was sized for a fraction of that. The ingest service doesn't degrade gracefully; it starts returning &lt;code&gt;RESOURCE_EXHAUSTED&lt;/code&gt; and your traces silently disappear.&lt;/p&gt;

&lt;p&gt;The fan-out math is straightforward and brutal. If each node runs a DaemonSet collector that connects directly to your gateway, and each of those collectors opens connections for metrics (OTLP gRPC), traces, and logs separately, a 20-node cluster with three signal types is already at 60 persistent upstream connections minimum — before you account for any application-level SDK exporters that also decided to phone home directly. Tempo's default ingest configuration isn't built to handle that connection count from a single cluster, and Loki's distributor will start rejecting pushes under the same pressure. The spans don't queue; they're dropped at the exporter with a transient error that most SDKs log once and discard.&lt;/p&gt;

&lt;p&gt;A DaemonSet-only deployment looks clean on paper: one collector per node, scrape local pods, forward upstream. The problem is "forward upstream" implies the DaemonSet agent is doing two jobs simultaneously — staying lightweight enough to not steal resources from the workloads it shares a node with, and being stateful enough to buffer, batch, retry, and route signals reliably. Those are contradictory requirements. A collector trying to hold a retry queue for failed exports while also scraping Prometheus endpoints on tight intervals will either starve its scrape loop under memory pressure or drop its queue when the node evicts it. The two roles genuinely need to be separate processes with separate resource profiles.&lt;/p&gt;

&lt;p&gt;The architecture that actually works separates these concerns by design. The agent — running as a DaemonSet — is intentionally dumb and lean: receive signals from local pods via OTLP, do minimal processing (add node/pod labels, nothing expensive), and forward to an internal gateway endpoint. The gateway — running as a Deployment with persistent storage or at least a proper memory queue — handles everything stateful: batching, retry with backoff, TLS to the external backend, fan-in from all agents into a manageable number of upstream connections. The gateway sees maybe three or four connections going out, regardless of how many nodes are in the cluster. That's the connection count your Tempo ingest was actually sized for.&lt;/p&gt;

&lt;p&gt;Trying to make one collector configuration do both roles causes resource contention in a specific and annoying way. The &lt;code&gt;batch&lt;/code&gt; processor needs memory headroom proportional to the volume it's buffering. The &lt;code&gt;prometheusreceiver&lt;/code&gt; needs CPU for scrape cycles. On a busy node, these compete. You'll see the batch processor's send queue fill up during a scrape spike, which causes backpressure into the receiver, which causes the exporter to the backend to time out, which triggers a retry loop — and now your lightweight DaemonSet pod is sitting at 400MB RAM and getting OOMKilled. The node comes back clean, the collector restarts, and you've lost whatever was in the queue. Split the roles and you size each component appropriately: agents at 64–128MB limits, the gateway at whatever the actual buffering workload demands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Overview: Agent DaemonSet + Gateway Deployment
&lt;/h2&gt;

&lt;p&gt;The split that most people miss when they first read the OpenTelemetry Collector docs is that you're not choosing between an agent and a gateway — you're running both, for different jobs. The agent is a resource-constrained process living on every node. The gateway is a proper service that absorbs all that data and does the expensive work. Conflating them into a single deployment is the fastest way to either blow your node memory budget or lose telemetry during a backend outage.&lt;/p&gt;

&lt;p&gt;The agent runs as a DaemonSet — one pod per node, no exceptions. Its job list is narrow on purpose: scrape &lt;code&gt;kubeletstats&lt;/code&gt; and &lt;code&gt;hostmetrics&lt;/code&gt; for node-level data, tail container logs via the &lt;code&gt;filelog&lt;/code&gt; receiver, and sit on &lt;code&gt;localhost:4317&lt;/code&gt; waiting for OTLP pushes from app containers on the same node. That last one matters: because the agent is node-local, sidecar or SDK instrumentation can hit it over loopback without any service discovery overhead. The memory ceiling for the agent should be hard-capped — something like 200–300Mi depending on log volume — because it has no persistent queue. If the gateway is unreachable, the agent drops data. That's a feature, not a bug. You do not want a DaemonSet silently accumulating gigabytes of retry buffer on every node in a partition event.&lt;/p&gt;

&lt;p&gt;The gateway runs as a Deployment with two or more replicas behind a ClusterIP service. It receives OTLP over gRPC from every agent in the cluster, applies tail-based sampling decisions across the full trace (which requires seeing all spans for a given trace ID in one place — more on that in the sampling section), batches aggressively before forwarding, and owns all the retry queues and remote backend credentials. The gateway is where your Prometheus remote_write URL, your Tempo endpoint, and your Loki push URL live — as Kubernetes Secrets mounted into the gateway pods, not baked into a ConfigMap that every node-level ServiceAccount can read. The data flow in plain terms: your app pushes OTLP to the agent on &lt;code&gt;localhost:4317&lt;/code&gt;, the agent forwards over gRPC to the gateway's ClusterIP (typically something like &lt;code&gt;otel-gateway.monitoring.svc.cluster.local:4317&lt;/code&gt;), and the gateway fans out to backends — Prometheus gets metrics via remote_write, Tempo gets traces via OTLP HTTP or gRPC, Loki gets logs via its push API.&lt;/p&gt;

&lt;p&gt;RBAC is where this split pays off in a concrete security way. The agent ServiceAccount needs real cluster permissions because it's doing discovery work:&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;rbac.authorization.k8s.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;ClusterRole&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;otel-agent&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&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;"&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="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nodes"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nodes/metrics"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pods"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;endpoints"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;services"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&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;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;watch"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&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;extensions"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apps"&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="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replicasets"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&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;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;watch"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="c1"&gt;# needed for kubeletstats receiver hitting /metrics/cadvisor&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;nonResourceURLs&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;/metrics"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/metrics/cadvisor"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&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;get"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gateway ServiceAccount needs none of that. It receives data over a network socket and pushes it to external endpoints. If someone misconfigures the gateway's collector config and opens an unintended receiver, the blast radius is zero Kubernetes API access. Keeping these two ServiceAccounts completely separate means a compromised or misconfigured gateway pod cannot enumerate your cluster topology, and a misconfigured agent pod cannot reach your remote backend credentials. That's the actual value of the split — not just resource isolation, but a meaningful reduction in what any single misconfiguration can touch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying the Agent: DaemonSet Config That Actually Works
&lt;/h2&gt;

&lt;p&gt;The part most tutorials skip: the agent ConfigMap is where most production failures originate, not the gateway. A misconfigured &lt;code&gt;memory_limiter&lt;/code&gt; processor doesn't gracefully shed load — it causes the collector pod to OOMKill and restart in a loop, taking your node-level metrics dark for 30–90 seconds per restart cycle. The processor must be listed &lt;em&gt;first&lt;/em&gt; in every pipeline's processor chain, before &lt;code&gt;batch&lt;/code&gt;, or it has no chance to act before memory is already blown. This is documented in the OpenTelemetry Collector docs but buried far enough that it's easy to miss on first read.&lt;/p&gt;

&lt;p&gt;Here's a minimal but complete ConfigMap that actually runs. Real field names, real units — no placeholders:&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;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;ConfigMap&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;otelcol-agent-config&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;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;config.yaml&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;extensions:&lt;/span&gt;
      &lt;span class="s"&gt;health_check:&lt;/span&gt;
        &lt;span class="s"&gt;endpoint: 0.0.0.0:13133&lt;/span&gt;

    &lt;span class="s"&gt;receivers:&lt;/span&gt;
      &lt;span class="s"&gt;otlp:&lt;/span&gt;
        &lt;span class="s"&gt;protocols:&lt;/span&gt;
          &lt;span class="s"&gt;grpc:&lt;/span&gt;
            &lt;span class="s"&gt;endpoint: 0.0.0.0:4317&lt;/span&gt;
          &lt;span class="s"&gt;http:&lt;/span&gt;
            &lt;span class="s"&gt;endpoint: 0.0.0.0:4318&lt;/span&gt;

      &lt;span class="s"&gt;kubeletstats:&lt;/span&gt;
        &lt;span class="s"&gt;collection_interval: 30s&lt;/span&gt;
        &lt;span class="s"&gt;auth_type: serviceAccount&lt;/span&gt;
        &lt;span class="s"&gt;endpoint: "https://${env:K8S_NODE_NAME}:10250"&lt;/span&gt;
        &lt;span class="s"&gt;insecure_skip_verify: true&lt;/span&gt;
        &lt;span class="s"&gt;metric_groups:&lt;/span&gt;
          &lt;span class="s"&gt;- node&lt;/span&gt;
          &lt;span class="s"&gt;- pod&lt;/span&gt;
          &lt;span class="s"&gt;- container&lt;/span&gt;

      &lt;span class="s"&gt;hostmetrics:&lt;/span&gt;
        &lt;span class="s"&gt;collection_interval: 30s&lt;/span&gt;
        &lt;span class="s"&gt;scrapers:&lt;/span&gt;
          &lt;span class="s"&gt;cpu: {}&lt;/span&gt;
          &lt;span class="s"&gt;disk: {}&lt;/span&gt;
          &lt;span class="s"&gt;filesystem:&lt;/span&gt;
            &lt;span class="s"&gt;exclude_mount_points:&lt;/span&gt;
              &lt;span class="s"&gt;mount_points: [/dev, /proc, /sys, /run/k3s/containerd]&lt;/span&gt;
              &lt;span class="s"&gt;match_type: strict&lt;/span&gt;
          &lt;span class="s"&gt;memory: {}&lt;/span&gt;
          &lt;span class="s"&gt;network: {}&lt;/span&gt;
          &lt;span class="s"&gt;load: {}&lt;/span&gt;

      &lt;span class="s"&gt;filelog:&lt;/span&gt;
        &lt;span class="s"&gt;include:&lt;/span&gt;
          &lt;span class="s"&gt;- /var/log/pods/*/*/*.log&lt;/span&gt;
        &lt;span class="s"&gt;include_file_path: true&lt;/span&gt;
        &lt;span class="s"&gt;include_file_name: false&lt;/span&gt;
        &lt;span class="s"&gt;operators:&lt;/span&gt;
          &lt;span class="s"&gt;- type: router&lt;/span&gt;
            &lt;span class="s"&gt;id: get-format&lt;/span&gt;
            &lt;span class="s"&gt;routes:&lt;/span&gt;
              &lt;span class="s"&gt;- output: parser-docker&lt;/span&gt;
                &lt;span class="s"&gt;expr: 'body matches "^\\{"'&lt;/span&gt;
              &lt;span class="s"&gt;- output: parser-crio&lt;/span&gt;
                &lt;span class="s"&gt;expr: 'body matches "^[^ Z]+ "'&lt;/span&gt;
          &lt;span class="s"&gt;- type: json_parser&lt;/span&gt;
            &lt;span class="s"&gt;id: parser-docker&lt;/span&gt;
            &lt;span class="s"&gt;output: extract-metadata&lt;/span&gt;
          &lt;span class="s"&gt;- type: regex_parser&lt;/span&gt;
            &lt;span class="s"&gt;id: parser-crio&lt;/span&gt;
            &lt;span class="s"&gt;regex: '^(?P[^ Z]+) (?Pstdout|stderr) (?P[^ ]*) ?(?P.*)$'&lt;/span&gt;
            &lt;span class="s"&gt;output: extract-metadata&lt;/span&gt;
          &lt;span class="s"&gt;- type: move&lt;/span&gt;
            &lt;span class="s"&gt;id: extract-metadata&lt;/span&gt;
            &lt;span class="s"&gt;from: attributes["log"]&lt;/span&gt;
            &lt;span class="s"&gt;to: body&lt;/span&gt;

    &lt;span class="s"&gt;processors:&lt;/span&gt;
      &lt;span class="s"&gt;memory_limiter:&lt;/span&gt;
        &lt;span class="s"&gt;# must be first in pipeline — limits before batch can accumulate&lt;/span&gt;
        &lt;span class="s"&gt;check_interval: 1s&lt;/span&gt;
        &lt;span class="s"&gt;limit_mib: 220          # hard ceiling below the 256Mi pod limit&lt;/span&gt;
        &lt;span class="s"&gt;spike_limit_mib: 60     # absorbs bursts without hitting the hard limit&lt;/span&gt;

      &lt;span class="s"&gt;batch:&lt;/span&gt;
        &lt;span class="s"&gt;send_batch_size: 1024&lt;/span&gt;
        &lt;span class="s"&gt;timeout: 5s&lt;/span&gt;
        &lt;span class="s"&gt;send_batch_max_size: 2048&lt;/span&gt;

      &lt;span class="s"&gt;resourcedetection:&lt;/span&gt;
        &lt;span class="s"&gt;detectors: [env, k8snode]&lt;/span&gt;
        &lt;span class="s"&gt;timeout: 5s&lt;/span&gt;

      &lt;span class="s"&gt;k8sattributes:&lt;/span&gt;
        &lt;span class="s"&gt;auth_type: serviceAccount&lt;/span&gt;
        &lt;span class="s"&gt;passthrough: false&lt;/span&gt;
        &lt;span class="s"&gt;filter:&lt;/span&gt;
          &lt;span class="s"&gt;node_from_env_var: K8S_NODE_NAME&lt;/span&gt;
        &lt;span class="s"&gt;extract:&lt;/span&gt;
          &lt;span class="s"&gt;metadata:&lt;/span&gt;
            &lt;span class="s"&gt;- k8s.pod.name&lt;/span&gt;
            &lt;span class="s"&gt;- k8s.pod.uid&lt;/span&gt;
            &lt;span class="s"&gt;- k8s.deployment.name&lt;/span&gt;
            &lt;span class="s"&gt;- k8s.namespace.name&lt;/span&gt;
            &lt;span class="s"&gt;- k8s.node.name&lt;/span&gt;
            &lt;span class="s"&gt;- k8s.container.name&lt;/span&gt;

    &lt;span class="s"&gt;exporters:&lt;/span&gt;
      &lt;span class="s"&gt;otlp/gateway:&lt;/span&gt;
        &lt;span class="s"&gt;endpoint: otelcol-gateway.monitoring.svc.cluster.local:4317&lt;/span&gt;
        &lt;span class="s"&gt;tls:&lt;/span&gt;
          &lt;span class="s"&gt;insecure: true   # mTLS is a gateway-layer concern; agent→gateway is cluster-internal&lt;/span&gt;

    &lt;span class="s"&gt;service:&lt;/span&gt;
      &lt;span class="s"&gt;extensions: [health_check]&lt;/span&gt;
      &lt;span class="s"&gt;pipelines:&lt;/span&gt;
        &lt;span class="s"&gt;metrics:&lt;/span&gt;
          &lt;span class="s"&gt;receivers: [kubeletstats, hostmetrics, otlp]&lt;/span&gt;
          &lt;span class="s"&gt;processors: [memory_limiter, resourcedetection, k8sattributes, batch]&lt;/span&gt;
          &lt;span class="s"&gt;exporters: [otlp/gateway]&lt;/span&gt;
        &lt;span class="s"&gt;logs:&lt;/span&gt;
          &lt;span class="s"&gt;receivers: [filelog, otlp]&lt;/span&gt;
          &lt;span class="s"&gt;processors: [memory_limiter, k8sattributes, batch]&lt;/span&gt;
          &lt;span class="s"&gt;exporters: [otlp/gateway]&lt;/span&gt;
        &lt;span class="s"&gt;traces:&lt;/span&gt;
          &lt;span class="s"&gt;receivers: [otlp]&lt;/span&gt;
          &lt;span class="s"&gt;processors: [memory_limiter, k8sattributes, batch]&lt;/span&gt;
          &lt;span class="s"&gt;exporters: [otlp/gateway]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On resource limits: 200m CPU and 256Mi memory is the right starting ceiling for the agent pod, not because of any single benchmark but because of where the pain points are. CPU for the agent is almost never the bottleneck — the 200m ceiling is defensive, preventing a misbehaving scraper from starving other node workloads. Memory is where you actually hit problems. The &lt;code&gt;memory_limiter&lt;/code&gt; is configured at 220 MiB hard limit with a 60 MiB spike allowance, which leaves a small gap below the 256Mi pod limit. That gap is intentional: if the limiter somehow fails to shed fast enough, the container OOMKills cleanly rather than thrashing. Flip those numbers — set &lt;code&gt;limit_mib&lt;/code&gt; above your container limit — and you get the worst outcome: the kernel kills the process before the limiter ever fires.&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="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;50m&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;128Mi&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;200m&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;256Mi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The filelog receiver requires two hostPath mounts that don't show up in the basic install guides. Without them the receiver starts cleanly but collects nothing, and the only evidence is silence in your log pipeline:&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;volumeMounts&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;varlogpods&lt;/span&gt;
    &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/log/pods&lt;/span&gt;
    &lt;span class="na"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;varlibdockercontainers&lt;/span&gt;
    &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/lib/docker/containers&lt;/span&gt;
    &lt;span class="na"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;volumes&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;varlogpods&lt;/span&gt;
    &lt;span class="na"&gt;hostPath&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;/var/log/pods&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;varlibdockercontainers&lt;/span&gt;
    &lt;span class="na"&gt;hostPath&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;/var/lib/docker/containers&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On containerd-only nodes (k3s, most current kubeadm clusters), &lt;code&gt;/var/lib/docker/containers&lt;/code&gt; won't exist but the mount won't fail either — it'll just be empty. The actual log files are under &lt;code&gt;/var/log/pods&lt;/code&gt; regardless of runtime, so that mount is the critical one. The docker path is worth keeping for mixed clusters or if you're running Docker-in-Docker workloads.&lt;/p&gt;

&lt;p&gt;The health check extension at &lt;code&gt;0.0.0.0:13133&lt;/code&gt; earns its place by catching a failure mode that metrics pipelines can't see: a collector that is running, passing its readiness check, but has internally deadlocked on a slow exporter. The liveness probe should hit &lt;code&gt;/&lt;/code&gt; on port 13133, not the OTLP port. A deadlocked collector will stop updating its internal health endpoint within the check interval, triggering a pod restart before you even notice the gap in your metrics. Without this probe, a stuck collector can look healthy to Kubernetes while silently dropping every signal it receives.&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;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&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;/&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;13133&lt;/span&gt;
  &lt;span class="na"&gt;initialDelaySeconds&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;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&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;3&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;httpGet&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;/&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;13133&lt;/span&gt;
  &lt;span class="na"&gt;initialDelaySeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&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;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Deploying the Gateway: Where Batching and Sampling Live
&lt;/h2&gt;

&lt;p&gt;The single most important architectural decision in this whole pattern is where you put tail sampling — and the answer is &lt;em&gt;never on the agent&lt;/em&gt;. An agent DaemonSet pod sees spans from one node. A trace for a single user request might touch pods on three different nodes, meaning each agent sees a fragment. If you apply a tail sampling policy at the agent, you're making a keep/drop decision on an incomplete picture. The policy fires when it thinks the trace is complete, but it isn't — the spans from the other nodes are just missing. You don't get an error. You get silently incomplete traces in Tempo, and you spend an hour wondering why your database call spans vanished.&lt;/p&gt;

&lt;p&gt;The gateway is the right place because it receives OTLP from all agents and reconstructs full traces before evaluating any policy. The &lt;code&gt;tail_sampling&lt;/code&gt; processor holds spans in memory for a configurable decision wait time, then applies your policies against the assembled trace. Below is a ConfigMap that wires this up end to end — a latency-based policy at 500ms, aggressive batching before the remote write, and the file storage extension that keeps you from losing a queue full of spans when the gateway pod restarts:&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;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;ConfigMap&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;otelcol-gateway-config&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;observability&lt;/span&gt;
&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;config.yaml&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;extensions:&lt;/span&gt;
      &lt;span class="s"&gt;# file_storage keeps the persistent queue on disk across pod restarts.&lt;/span&gt;
      &lt;span class="s"&gt;# Mount a PVC at this path — emptyDir will not survive a restart.&lt;/span&gt;
      &lt;span class="s"&gt;file_storage:&lt;/span&gt;
        &lt;span class="s"&gt;directory: /var/otelcol/queue&lt;/span&gt;
        &lt;span class="s"&gt;timeout: 10s&lt;/span&gt;
        &lt;span class="s"&gt;compaction:&lt;/span&gt;
          &lt;span class="s"&gt;on_start: true&lt;/span&gt;
          &lt;span class="s"&gt;directory: /var/otelcol/queue/compaction&lt;/span&gt;
          &lt;span class="s"&gt;max_transaction_size: 65536&lt;/span&gt;

    &lt;span class="s"&gt;receivers:&lt;/span&gt;
      &lt;span class="s"&gt;otlp:&lt;/span&gt;
        &lt;span class="s"&gt;protocols:&lt;/span&gt;
          &lt;span class="s"&gt;grpc:&lt;/span&gt;
            &lt;span class="s"&gt;endpoint: 0.0.0.0:4317&lt;/span&gt;
          &lt;span class="s"&gt;http:&lt;/span&gt;
            &lt;span class="s"&gt;endpoint: 0.0.0.0:4318&lt;/span&gt;

    &lt;span class="s"&gt;processors:&lt;/span&gt;
      &lt;span class="s"&gt;# tail_sampling MUST come before batch. The processor needs to see&lt;/span&gt;
      &lt;span class="s"&gt;# individual spans to reconstruct traces; batching first breaks that.&lt;/span&gt;
      &lt;span class="s"&gt;tail_sampling:&lt;/span&gt;
        &lt;span class="s"&gt;decision_wait: 30s          # hold spans this long before deciding&lt;/span&gt;
        &lt;span class="s"&gt;num_traces: 50000           # max traces in memory — tune to your heap&lt;/span&gt;
        &lt;span class="s"&gt;expected_new_traces_per_sec: 200&lt;/span&gt;
        &lt;span class="s"&gt;policies:&lt;/span&gt;
          &lt;span class="s"&gt;- name: keep-slow-traces&lt;/span&gt;
            &lt;span class="s"&gt;type: latency&lt;/span&gt;
            &lt;span class="s"&gt;latency:&lt;/span&gt;
              &lt;span class="s"&gt;threshold_ms: 500     # keep any trace with duration &amp;gt;= 500ms&lt;/span&gt;
          &lt;span class="s"&gt;- name: keep-errors&lt;/span&gt;
            &lt;span class="s"&gt;type: status_code&lt;/span&gt;
            &lt;span class="s"&gt;status_code:&lt;/span&gt;
              &lt;span class="s"&gt;status_codes: [ERROR]&lt;/span&gt;
          &lt;span class="s"&gt;- name: probabilistic-baseline&lt;/span&gt;
            &lt;span class="s"&gt;# Keep 10% of everything else so you have a baseline for fast paths&lt;/span&gt;
            &lt;span class="s"&gt;type: probabilistic&lt;/span&gt;
            &lt;span class="s"&gt;probabilistic:&lt;/span&gt;
              &lt;span class="s"&gt;sampling_percentage: 10&lt;/span&gt;

      &lt;span class="s"&gt;batch:&lt;/span&gt;
        &lt;span class="s"&gt;send_batch_size: 8192&lt;/span&gt;
        &lt;span class="s"&gt;timeout: 10s               # flush even if batch isn't full after 10s&lt;/span&gt;
        &lt;span class="s"&gt;send_batch_max_size: 16384&lt;/span&gt;

      &lt;span class="s"&gt;memory_limiter:&lt;/span&gt;
        &lt;span class="s"&gt;check_interval: 1s&lt;/span&gt;
        &lt;span class="s"&gt;limit_mib: 1500&lt;/span&gt;
        &lt;span class="s"&gt;spike_limit_mib: 400&lt;/span&gt;

    &lt;span class="s"&gt;exporters:&lt;/span&gt;
      &lt;span class="s"&gt;# Traces → Tempo&lt;/span&gt;
      &lt;span class="s"&gt;otlp/tempo:&lt;/span&gt;
        &lt;span class="s"&gt;endpoint: tempo.observability.svc.cluster.local:4317&lt;/span&gt;
        &lt;span class="s"&gt;tls:&lt;/span&gt;
          &lt;span class="s"&gt;insecure: true&lt;/span&gt;
        &lt;span class="s"&gt;sending_queue:&lt;/span&gt;
          &lt;span class="s"&gt;enabled: true&lt;/span&gt;
          &lt;span class="s"&gt;num_consumers: 4&lt;/span&gt;
          &lt;span class="s"&gt;queue_size: 5000&lt;/span&gt;
          &lt;span class="s"&gt;storage: file_storage    # references the extension above&lt;/span&gt;
        &lt;span class="s"&gt;retry_on_failure:&lt;/span&gt;
          &lt;span class="s"&gt;enabled: true&lt;/span&gt;
          &lt;span class="s"&gt;initial_interval: 5s&lt;/span&gt;
          &lt;span class="s"&gt;max_interval: 30s&lt;/span&gt;
          &lt;span class="s"&gt;max_elapsed_time: 300s&lt;/span&gt;

      &lt;span class="s"&gt;# Metrics → Prometheus remote_write (Mimir or Grafana Cloud)&lt;/span&gt;
      &lt;span class="s"&gt;prometheusremotewrite:&lt;/span&gt;
        &lt;span class="s"&gt;endpoint: ${MIMIR_REMOTE_WRITE_URL}   # injected from Secret&lt;/span&gt;
        &lt;span class="s"&gt;tls:&lt;/span&gt;
          &lt;span class="s"&gt;insecure_skip_verify: false&lt;/span&gt;
        &lt;span class="s"&gt;headers:&lt;/span&gt;
          &lt;span class="s"&gt;Authorization: "Basic ${MIMIR_AUTH_HEADER}"   # injected from Secret&lt;/span&gt;
        &lt;span class="s"&gt;sending_queue:&lt;/span&gt;
          &lt;span class="s"&gt;enabled: true&lt;/span&gt;
          &lt;span class="s"&gt;queue_size: 5000&lt;/span&gt;
          &lt;span class="s"&gt;storage: file_storage&lt;/span&gt;
        &lt;span class="s"&gt;retry_on_failure:&lt;/span&gt;
          &lt;span class="s"&gt;enabled: true&lt;/span&gt;
          &lt;span class="s"&gt;max_elapsed_time: 300s&lt;/span&gt;

    &lt;span class="s"&gt;service:&lt;/span&gt;
      &lt;span class="s"&gt;extensions: [file_storage]&lt;/span&gt;
      &lt;span class="s"&gt;pipelines:&lt;/span&gt;
        &lt;span class="s"&gt;traces:&lt;/span&gt;
          &lt;span class="s"&gt;receivers: [otlp]&lt;/span&gt;
          &lt;span class="s"&gt;processors: [memory_limiter, tail_sampling, batch]&lt;/span&gt;
          &lt;span class="s"&gt;exporters: [otlp/tempo]&lt;/span&gt;
        &lt;span class="s"&gt;metrics:&lt;/span&gt;
          &lt;span class="s"&gt;receivers: [otlp]&lt;/span&gt;
          &lt;span class="s"&gt;processors: [memory_limiter, batch]&lt;/span&gt;
          &lt;span class="s"&gt;exporters: [prometheusremotewrite]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;file_storage&lt;/code&gt; extension is the piece most tutorials skip. Without it, the &lt;code&gt;sending_queue&lt;/code&gt; lives entirely in memory, and any gateway restart — including a normal rolling update — drops whatever was queued. With it, the queue serializes to disk and resumes after the pod comes back. The critical detail: you &lt;strong&gt;must&lt;/strong&gt; mount a PVC at &lt;code&gt;/var/otelcol/queue&lt;/code&gt;, not an &lt;code&gt;emptyDir&lt;/code&gt;. An &lt;code&gt;emptyDir&lt;/code&gt; is per-pod ephemeral storage — the moment the container exits, the directory is gone, which is exactly the failure mode you're trying to prevent. A &lt;code&gt;ReadWriteOnce&lt;/code&gt; PVC on any standard StorageClass handles this; you don't need anything exotic.&lt;/p&gt;

&lt;p&gt;The credentials problem trips up most first deploys. The &lt;code&gt;${MIMIR_REMOTE_WRITE_URL}&lt;/code&gt; and &lt;code&gt;${MIMIR_AUTH_HEADER}&lt;/code&gt; placeholders above are not Helm template syntax — the OpenTelemetry Collector binary expands environment variable references in its config file at startup. That means you can inject secrets via &lt;code&gt;envFrom&lt;/code&gt; without touching the ConfigMap at all:&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;apps/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;Deployment&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;otelcol-gateway&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;observability&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;template&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;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;otelcol&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otel/opentelemetry-collector-contrib:0.102.0&lt;/span&gt;
          &lt;span class="na"&gt;args&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;--config=/conf/config.yaml"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;envFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# All keys in this Secret become env vars automatically.&lt;/span&gt;
            &lt;span class="c1"&gt;# Add MIMIR_REMOTE_WRITE_URL and MIMIR_AUTH_HEADER here.&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;secretRef&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;otelcol-gateway-secrets&lt;/span&gt;
          &lt;span class="na"&gt;volumeMounts&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;config&lt;/span&gt;
              &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/conf&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;queue-storage&lt;/span&gt;
              &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/otelcol/queue&lt;/span&gt;   &lt;span class="c1"&gt;# must match file_storage.directory&lt;/span&gt;
      &lt;span class="na"&gt;volumes&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;config&lt;/span&gt;
          &lt;span class="na"&gt;configMap&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;otelcol-gateway-config&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;queue-storage&lt;/span&gt;
          &lt;span class="na"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;claimName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otelcol-gateway-queue&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;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;Secret&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;otelcol-gateway-secrets&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;observability&lt;/span&gt;
&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Opaque&lt;/span&gt;
&lt;span class="na"&gt;stringData&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;MIMIR_REMOTE_WRITE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://mimir.example.com/api/v1/push"&lt;/span&gt;
  &lt;span class="na"&gt;MIMIR_AUTH_HEADER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dXNlcjpwYXNzd29yZA=="&lt;/span&gt;   &lt;span class="c1"&gt;# base64(user:password)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few sharp edges worth flagging before you call this production-ready. First, &lt;code&gt;tail_sampling&lt;/code&gt; and &lt;code&gt;batch&lt;/code&gt; ordering matters more than the docs make clear — batch &lt;em&gt;after&lt;/em&gt; tail sampling, not before, otherwise the batch processor groups spans from different traces together and the tail sampler can't reconstruct them correctly. Second, the &lt;code&gt;num_traces: 50000&lt;/code&gt; setting directly determines gateway memory pressure; at 30s decision wait, a spike in traffic can fill that buffer fast. Watch the &lt;code&gt;otelcol_processor_tail_sampling_sampling_traces_on_memory&lt;/code&gt; metric and set a horizontal pod autoscaler trigger on it, not just CPU. Third, if you're sending to Grafana Cloud rather than self-hosted Mimir, the remote write URL format is slightly different — it includes &lt;code&gt;/api/prom/push&lt;/code&gt; not &lt;code&gt;/api/v1/push&lt;/code&gt;, and the auth is HTTP Basic against your Grafana Cloud stack credentials, not a bearer token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Non-Obvious Behaviors That Will Burn You
&lt;/h2&gt;

&lt;p&gt;The one that stings most operators first: &lt;strong&gt;the &lt;code&gt;memory_limiter&lt;/code&gt; processor must be the first entry in every pipeline's processor list&lt;/strong&gt;, not second or third. The instinct is to put &lt;code&gt;batch&lt;/code&gt; first because batching "should happen before limiting," but that logic is backwards under load. When &lt;code&gt;batch&lt;/code&gt; runs first, it accumulates spans and metrics into memory until the batch is full — then hands a large, already-allocated blob to &lt;code&gt;memory_limiter&lt;/code&gt;, which can only reject it after the damage is done. The correct order is &lt;code&gt;processors: [memory_limiter, batch]&lt;/code&gt; in every pipeline, every time, no exceptions. The collector docs mention this, but it's buried, and the default example configs don't always model it correctly.&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;# Correct processor order — memory_limiter gates before batch accumulates&lt;/span&gt;
&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pipelines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;traces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;memory_limiter&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;batch&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# NOT [batch, memory_limiter]&lt;/span&gt;
      &lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;otlp/gateway&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;kubeletstats&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;memory_limiter&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;batch&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;otlp/gateway&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kubeletstats receiver's TLS error is a classic 30-minute timesink. When you see &lt;code&gt;x509: certificate signed by unknown authority&lt;/code&gt; in the collector pod logs, the instinct is to check RBAC — service account permissions, ClusterRole bindings, whether the kubelet endpoint is accessible at all. Those are all fine. The error is purely TLS: the kubelet serves a self-signed cert that the collector can't verify because it doesn't have the cluster CA. Fix it one of two ways: pass &lt;code&gt;insecure_skip_verify: true&lt;/code&gt; under the receiver's &lt;code&gt;tls&lt;/code&gt; block (acceptable inside a private cluster network, not ideal), or mount the cluster CA and point the receiver at it. The permissions angle is a red herring that the error message does nothing to dispel.&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;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kubeletstats&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;collection_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20s&lt;/span&gt;
    &lt;span class="na"&gt;auth_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;serviceAccount&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://${env:K8S_NODE_NAME}:10250"&lt;/span&gt;
    &lt;span class="na"&gt;insecure_skip_verify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;   &lt;span class="c1"&gt;# or: ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt&lt;/span&gt;
    &lt;span class="na"&gt;metric_groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;node&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;pod&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tail sampling with &lt;code&gt;file_storage&lt;/code&gt; will quietly fill a node's disk if you're not watching it. The collector buffers entire traces to disk during the &lt;code&gt;decision_wait&lt;/code&gt; window before it decides whether to sample them. A generous window (say, 30s) combined with a traffic spike means tens of gigabytes can accumulate before decisions flush. &lt;strong&gt;The collector does not self-limit this storage by default.&lt;/strong&gt; Set a &lt;code&gt;size_limit&lt;/code&gt; in the &lt;code&gt;file_storage&lt;/code&gt; extension, and wire up an alert on the filesystem path — not just collector-level metrics. When the disk fills, the collector doesn't degrade gracefully; it fails writes, and you get silent trace loss without obvious errors at the application level.&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;extensions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;file_storage/tail_sampling&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="s"&gt;/var/otelcol/tail-sampling&lt;/span&gt;
    &lt;span class="na"&gt;size_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;4GiB&lt;/span&gt;   &lt;span class="c1"&gt;# without this, growth is unbounded&lt;/span&gt;

&lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;tail_sampling&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;decision_wait&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15s&lt;/span&gt;   &lt;span class="c1"&gt;# shorter window = less disk pressure during spikes&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;file_storage/tail_sampling&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;gRPC keepalive settings between agent and gateway are ignored until they suddenly matter. kube-proxy silently drops idle TCP connections after roughly 15 minutes — the exact timeout varies by cloud provider and kernel settings, but the behavior is consistent. Without &lt;code&gt;keepalive_time&lt;/code&gt; and &lt;code&gt;keepalive_timeout&lt;/code&gt; set on the agent's OTLP exporter, the connection goes idle during a quiet period, gets dropped at the proxy layer, and the next data push fails with a reconnect storm that produces errors resembling a gateway crash. You'll see connection refused or stream reset errors in the agent logs, gateway pod restarts won't help, and the root cause won't be obvious until you correlate the timing with kube-proxy idle timeouts. Set these explicitly on every agent exporter pointing at the gateway:&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;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;otlp/gateway&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;otel-gateway-collector:4317"&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;insecure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;   &lt;span class="c1"&gt;# internal cluster traffic; TLS termination handled at ingress&lt;/span&gt;
    &lt;span class="na"&gt;keepalive&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;          &lt;span class="c1"&gt;# send keepalive ping after 10s of inactivity&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;        &lt;span class="c1"&gt;# wait 5s for pong before declaring connection dead&lt;/span&gt;
      &lt;span class="na"&gt;permit_without_stream&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;   &lt;span class="c1"&gt;# keep the connection alive even with no active RPCs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Validating the Pipeline and What to Monitor About the Collector Itself
&lt;/h2&gt;

&lt;p&gt;The most common mistake when deploying the agent + gateway pattern is trusting the pipeline because pods are running and no errors appear in logs. That's not validation — that's hope. The OpenTelemetry Collector exposes its own Prometheus metrics on &lt;code&gt;:8888/metrics&lt;/code&gt; by default, and before you declare the pipeline production-ready, four specific counters need to be on a dashboard: &lt;code&gt;otelcol_receiver_accepted_spans&lt;/code&gt;, &lt;code&gt;otelcol_receiver_refused_spans&lt;/code&gt;, &lt;code&gt;otelcol_exporter_sent_metric_points&lt;/code&gt;, and &lt;code&gt;otelcol_exporter_send_failed_metric_points&lt;/code&gt;. If accepted spans are climbing but sent metric points are flat, your pipeline has a disconnect — likely a processor misconfiguration or a backend that's silently rejecting data. These four metrics give you the full picture: data arriving, data leaving, and data failing at both ends.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Quick scrape to verify the metrics endpoint is live on an agent pod
&lt;span class="go"&gt;kubectl exec -n observability ds/otel-agent -- \
  wget -qO- http://localhost:8888/metrics | grep otelcol_receiver_accepted_spans

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Expected output looks like:
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;otelcol_receiver_accepted_spans&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;receiver&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"otlp"&lt;/span&gt;,transport&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"grpc"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; 4821
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;If this is zero after your app has been running, check receiver config first
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before you push any agent config to production nodes, &lt;code&gt;otelcol validate&lt;/code&gt; is the correct first gate. It parses the full pipeline graph, checks that every referenced component is enabled, and catches type mismatches in processor config that would only surface at runtime. Pair that with a synthetic trace using &lt;code&gt;otel-cli&lt;/code&gt; — a standalone binary that sends a real OTLP span — and you can confirm end-to-end flow from a single terminal session without touching your application at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Config validation — runs &lt;span class="k"&gt;in &lt;/span&gt;CI, costs nothing
&lt;span class="go"&gt;otelcol validate --config=agent-config.yaml

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Synthetic trace to a locally-running collector &lt;span class="o"&gt;(&lt;/span&gt;replace endpoint as needed&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;otel-cli exec \
  --endpoint http://localhost:4317 \
  --name "smoke-test" \
  --service "validation-check" \
  -- echo "pipeline alive"

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Then immediately check the counter incremented
&lt;span class="go"&gt;kubectl exec -n observability ds/otel-agent -- \
  wget -qO- http://localhost:8888/metrics | grep otelcol_receiver_accepted_spans
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On alerting thresholds: the metric that most operators misconfigure alerts on is &lt;code&gt;otelcol_exporter_queue_size&lt;/code&gt; divided by &lt;code&gt;otelcol_exporter_queue_capacity&lt;/code&gt; — queue utilization. When that ratio climbs above 70%, the instinct is to blame noisy instrumentation or a cardinality explosion on the app side. Usually it's the opposite: your backend (Tempo, Jaeger, a remote OTLP endpoint) is slow to acknowledge, so the exporter's send goroutines are backing up. The queue fills because of push-side latency, not because your app suddenly tripled its span rate. Set a separate alert on &lt;code&gt;otelcol_receiver_accepted_spans&lt;/code&gt; rate for the noisy-app scenario — those are genuinely independent failure modes and conflating them wastes investigation time.&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;# Prometheus alerting rule that distinguishes queue pressure from volume spikes&lt;/span&gt;
&lt;span class="na"&gt;groups&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;otelcol-health&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CollectorExporterQueueHigh&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;otelcol_exporter_queue_size / otelcol_exporter_queue_capacity &amp;gt; 0.70&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&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;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
        &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Exporter&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;queue&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;above&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;70%&lt;/span&gt;&lt;span class="nv"&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;check&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;backend&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;latency,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;not&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cardinality"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CollectorExporterDropping&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;rate(otelcol_exporter_send_failed_metric_points[5m]) &amp;gt; 0&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2m&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;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;critical&lt;/span&gt;
        &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Collector&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;actively&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;dropping&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="nv"&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;backend&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;unreachable&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;or&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;rejecting"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The operator mindset that makes this pipeline trustworthy — local validation before rollout, explicit metric confirmation, distinguishing which side of the pipeline is misbehaving — maps directly to how reliable local-first tooling gets built in general. If you're wiring AI-assisted tooling into your observability stack or CI pipelines, the same discipline applies: validate locally, instrument the tool itself, and don't trust that something is working just because it didn't crash. The &lt;a href="https://techdigestor.com/best-ai-coding-tools-2026/" rel="noopener noreferrer"&gt;AI Coding Tools in 2026: Cloud Copilots vs Local Models&lt;/a&gt; guide covers how that operator mindset translates to choosing and running dev tools — worth reading alongside this if you're building automation that touches your infra.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Use This Pattern and When to Skip It
&lt;/h2&gt;

&lt;p&gt;The most common mistake with OpenTelemetry in Kubernetes is adding the gateway tier reflexively — because the diagram looks like the "right" architecture. The gateway is a real operational burden: it's another Deployment to resource-tune, another config to version, another thing to be down when you're debugging at midnight. Add it only when something specific forces your hand.&lt;/p&gt;

&lt;p&gt;Add the gateway tier when you cross roughly eight nodes. Below that, the per-node agent DaemonSet pods can push directly to your backend without meaningful fan-out problems. But past that threshold, a few concrete pressures appear simultaneously: tail sampling requires seeing all spans for a given trace in one place, which a DaemonSet pod physically cannot do since different services may run on different nodes. If your backend — Grafana Cloud, Honeycomb, a managed Tempo instance — enforces rate limits or requires a per-tenant API key, you don't want that credential baked into every DaemonSet pod config and replicated across 20 nodes. And when you eventually swap from Jaeger to Tempo, or add a second backend for compliance, doing that in one gateway config file beats rolling a DaemonSet update across the entire fleet.&lt;/p&gt;

&lt;p&gt;Skip the gateway entirely when your cluster is three to five nodes and your observability backend lives inside the cluster — say, Victoria Metrics and Grafana running as in-cluster Deployments. Head-based sampling is good enough for a small cluster where you're not drowning in span volume, and an internal backend means there's no auth boundary or rate-limit problem to solve centrally. In that topology, a gateway Deployment just sits between the agent and the backend doing nothing useful while consuming memory you'd rather give to workloads. The agent pods can push OTLP directly to an in-cluster Tempo or Loki endpoint, and the whole config stays in one DaemonSet manifest.&lt;/p&gt;

&lt;p&gt;The hybrid case is the one the official docs gloss over but shows up constantly in real clusters: run Prometheus scrape on the agent for node-level metrics (CPU, memory, disk via the &lt;code&gt;hostmetrics&lt;/code&gt; receiver), but route traces exclusively through the gateway. The reasoning is resource efficiency. Node metrics are high-cardinality and high-volume — pushing them through the gateway's batch processor means the gateway is doing expensive buffering and compression on a stream that doesn't benefit from centralization, because there's no "tail sampling for metrics" concept. Traces, on the other hand, need the central batch processor to correlate spans across nodes before sampling decisions get made. A practical split looks like this in the agent config:&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;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# Metrics go direct to Victoria Metrics — no gateway hop&lt;/span&gt;
  &lt;span class="na"&gt;prometheusremotewrite&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&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://victoria-metrics.monitoring.svc:8428/api/v1/write"&lt;/span&gt;

  &lt;span class="c1"&gt;# Traces go to the gateway for tail sampling&lt;/span&gt;
  &lt;span class="na"&gt;otlp/gateway&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;otelcol-gateway.monitoring.svc:4317"&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;insecure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pipelines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;hostmetrics&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;kubeletstats&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;memory_limiter&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;batch&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;prometheusremotewrite&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;traces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;memory_limiter&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;batch&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;otlp/gateway&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps the gateway's batch processor focused on trace correlation where it earns its keep, and lets the metrics pipeline take the short path. The tradeoff is that you're maintaining two separate pipeline configs and two separate backends, so don't reach for this unless you're actually seeing the gateway become a bottleneck on metric throughput — which typically means the gateway pod's memory limit is getting hit on clusters with dense node-metrics scrape intervals under 15 seconds.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/monitoring-kubernetes-clusters-with-opentelemetry-collector-the-agent-gateway-pattern-explained/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>machinelearning</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Code Mode + MCP: Wiring Your Local LLM Into a Real Development Workflow</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Mon, 29 Jun 2026 08:11:56 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/code-mode-mcp-wiring-your-local-llm-into-a-real-development-workflow-4357</link>
      <guid>https://dev.to/ericwoooo_kr/code-mode-mcp-wiring-your-local-llm-into-a-real-development-workflow-4357</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The hallucination that wastes the most time isn't a wrong algorithm — it's a wrong tool call.  Your LLM confidently writes `prisma db pull --schema=.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~24 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Problem: Your AI Coding Assistant Lives in a Walled Garden&lt;/li&gt;
&lt;li&gt;How MCP Fits Into a Code Mode Agent Loop&lt;/li&gt;
&lt;li&gt;Setting Up an MCP Server for Your Dev Environment&lt;/li&gt;
&lt;li&gt;Routing Between Local Ollama Models and Cloud APIs Inside Code Mode&lt;/li&gt;
&lt;li&gt;Wiring MCP Into an Automated Dev Pipeline (n8n + Webhooks)&lt;/li&gt;
&lt;li&gt;Three Non-Obvious Behaviors That Will Cost You Time&lt;/li&gt;
&lt;li&gt;When This Setup Is Not the Right Call&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Problem: Your AI Coding Assistant Lives in a Walled Garden
&lt;/h2&gt;

&lt;p&gt;The hallucination that wastes the most time isn't a wrong algorithm — it's a wrong tool call. Your LLM confidently writes &lt;code&gt;prisma db pull --schema=./prisma/schema.prisma&lt;/code&gt; against a database that's actually on a non-standard port behind a tunnel, or generates a Docker Compose service block referencing an image tag that hasn't existed since you migrated registries. The model knows your code because you pasted it. It has no idea what's actually running.&lt;/p&gt;

&lt;p&gt;This is the structural gap: the model's context window is a snapshot, not a live connection. You copy in a schema file, a config block, maybe some logs — and the moment any of those change, the model is reasoning against stale state. It can't ask your Postgres instance what columns actually exist. It can't check whether your &lt;code&gt;localhost:3000&lt;/code&gt; health endpoint is up. It can't read the compiled output in &lt;code&gt;/dist&lt;/code&gt; to verify its own edits worked. So it fills the gaps with confident guesses, and those guesses are wrong in proportion to how far your actual stack drifts from "typical project".&lt;/p&gt;

&lt;p&gt;Model Context Protocol fixes this at the architecture level rather than the workflow level. Instead of you manually copying context into a chat window, MCP defines a typed tool-calling surface — the model can call &lt;code&gt;read_file&lt;/code&gt;, &lt;code&gt;run_command&lt;/code&gt;, &lt;code&gt;query_database&lt;/code&gt;, or any custom tool you expose, and get structured responses back. The key word is &lt;em&gt;typed&lt;/em&gt;: tool inputs and outputs are defined schemas, so the model knows exactly what arguments are valid and what shape the response will have. That eliminates an entire class of hallucinated API calls where the model invents parameters that don't exist in your actual SDK.&lt;/p&gt;

&lt;p&gt;Code Mode in tools like Cursor, Cline, and Continue tightens this further because they run an agentic loop — plan a change, write the edit, verify it compiled or the test passed, then loop. That verify step is where everything falls apart without reliable tool access. If the model can't actually run &lt;code&gt;tsc --noEmit&lt;/code&gt; and read stderr, it either skips verification entirely (optimistically assuming success) or asks you to run it and paste back the output, which defeats the loop. MCP gives that verify step a real execution surface. The plan→edit→verify cycle becomes autonomous instead of human-relayed. For a broader map of where this fits, see our guide on &lt;a href="https://techdigestor.com/best-ai-coding-tools-2026/" rel="noopener noreferrer"&gt;AI Coding Tools in 2026: Cloud Copilots vs Local Models&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The practical consequence is that walled-garden assistants optimize for demo quality, not operational accuracy. They look impressive on greenfield TypeScript with a clean schema. They fall apart on the actual project: the monorepo with four package managers, the legacy MySQL 5.7 table with implicit nulls everywhere, the build pipeline that requires three env vars that aren't in any file the model has seen. MCP doesn't make the model smarter — it makes the model's knowledge current, specific, and verifiable against the system that's actually running.&lt;/p&gt;

&lt;h2&gt;
  
  
  How MCP Fits Into a Code Mode Agent Loop
&lt;/h2&gt;

&lt;p&gt;The surprising thing about MCP isn't the protocol itself — it's how little the model actually needs to understand about it. The model sees a flat list of typed tool signatures, picks one, emits a JSON call, and gets a result back. That's it. The JSON-RPC plumbing — server discovery, capability negotiation, result framing — happens entirely in the client layer. Concretely: your editor plugin (the MCP client) maintains a list of running MCP servers; each server advertises its tools as typed schemas; when the model decides to call &lt;code&gt;read_file&lt;/code&gt; or &lt;code&gt;run_tests&lt;/code&gt;, it's doing the same thing it does when calling any function-calling API. The model never speaks directly to the MCP server. The client brokers everything.&lt;/p&gt;

&lt;p&gt;Code Mode is where MCP actually earns its keep, and the distinction from Chat Mode matters operationally. Chat Mode is stateless turn-by-turn: the model answers, forgets, moves on. Code Mode maintains a task context — open files, recent edits, tool call history, failure state — and will re-invoke a tool if the previous attempt returned an error or produced unexpected output. That re-invocation behavior is the sharp edge. Your MCP servers &lt;em&gt;must&lt;/em&gt; be stateless and idempotent, because the agent will retry them without knowing what side effects the first call left behind. A shell tool that runs &lt;code&gt;npm install&lt;/code&gt; twice should produce the same result as running it once. A file-write tool that gets called twice with the same payload shouldn't corrupt the file. If your server holds mutable state between calls, Code Mode will eventually find the broken edge.&lt;/p&gt;

&lt;p&gt;Local models are where this architecture develops cracks. Tool-call reliability — meaning: the model emits well-formed JSON that correctly matches the tool's argument schema — degrades noticeably on quantized models below Q5 quantization. A 7B model under load will produce malformed JSON arguments, miss required fields, or hallucinate argument names that don't exist in the schema. This isn't a fringe case; it happens regularly in agentic loops where the context window fills with prior tool results. On my 32GB VRAM workstation, Q4_K_M at 32B is the practical floor for running a reliable Code Mode loop. Below that, you're spending more time writing retry logic and output validators than you're saving by running locally. For anything business-critical, the fallback to a cloud model isn't a failure — it's the right call.&lt;/p&gt;

&lt;p&gt;Three MCP server types cover the majority of real development workflows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Filesystem servers&lt;/strong&gt; — read and write project files, list directory trees, watch for changes. The agent uses these to inspect source, patch files, and verify its own edits. The main gotcha: scope the allowed paths tightly in the server config, or the agent will happily traverse your entire home directory looking for context.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Shell/process servers&lt;/strong&gt; — run test suites, linters, build commands, and capture stdout/stderr. These are the highest-value tools in a Code Mode loop because they close the feedback cycle: write code, run tests, read failures, patch, repeat. Keep timeouts aggressive — a hanging test suite will stall the entire agent loop.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Service connectors&lt;/strong&gt; — Postgres queries, REST API calls, git operations. Useful when the task involves understanding live schema state, checking API responses, or inspecting commit history. These are also the most dangerous from an idempotency standpoint: a tool that fires a POST request on retry can create duplicate records. Wrap mutating calls in dry-run flags or explicit confirmation steps.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Setting Up an MCP Server for Your Dev Environment
&lt;/h2&gt;

&lt;p&gt;The TypeScript SDK wins on completeness — it ships with typed tool schemas, Zod validation helpers, and the full transport abstraction layer out of the box. The Python SDK is fine for pure data tools, but if your MCP server shells out frequently (running lint, parsing files, invoking CLI tools), the startup overhead on each subprocess call compounds fast. For a dev environment server that needs to feel snappy inside Cursor or Cline, start with &lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt; on Node 20+.&lt;/p&gt;

&lt;p&gt;Here's a minimal but real server scaffold — tool registration, typed input schema, stdio transport — in under 35 lines:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`typescript&lt;br&gt;
// mcp-dev-server/src/index.ts&lt;br&gt;
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";&lt;br&gt;
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";&lt;br&gt;
import { z } from "zod";&lt;br&gt;
import { execSync } from "child_process";&lt;/p&gt;

&lt;p&gt;const server = new McpServer({&lt;br&gt;
  name: "dev-tools",&lt;br&gt;
  version: "0.1.0",&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;// Register a tool — schema is validated before your handler runs&lt;br&gt;
server.tool(&lt;br&gt;
  "run_eslint",&lt;br&gt;
  {&lt;br&gt;
    filepath: z.string().describe("Relative path inside workspace"),&lt;br&gt;
  },&lt;br&gt;
  async ({ filepath }) =&amp;gt; {&lt;br&gt;
    // Don't trust the model to pass safe paths — validate against your allowed root&lt;br&gt;
    const allowed = process.env.WORKSPACE_ROOT ?? "/workspace/src";&lt;br&gt;
    if (!filepath.startsWith(allowed)) {&lt;br&gt;
      return { content: [{ type: "text", text: "Path outside allowed root" }] };&lt;br&gt;
    }&lt;br&gt;
    const result = execSync(&lt;code&gt;npx eslint --format compact ${filepath}&lt;/code&gt;, {&lt;br&gt;
      encoding: "utf8",&lt;br&gt;
      timeout: 15_000,&lt;br&gt;
    });&lt;br&gt;
    return { content: [{ type: "text", text: result }] };&lt;br&gt;
  }&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;const transport = new StdioServerTransport();&lt;br&gt;
await server.connect(transport);&lt;br&gt;
// process stays alive — MCP host controls the lifecycle via stdin/stdout&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;mcp.json&lt;/code&gt; config is what your editor actually reads to know which servers exist and how to spawn them. Cursor looks in &lt;code&gt;.cursor/mcp.json&lt;/code&gt; at the project root; VS Code with the MCP extension uses &lt;code&gt;.vscode/mcp.json&lt;/code&gt;. The structure is the same either way — a named map of server entries with &lt;code&gt;command&lt;/code&gt;, &lt;code&gt;args&lt;/code&gt;, and optional &lt;code&gt;env&lt;/code&gt;. Here's a realistic two-server config with a filesystem tool jailed to a subdirectory and a Postgres query tool:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;json&lt;br&gt;
// .cursor/mcp.json&lt;br&gt;
{&lt;br&gt;
  "mcpServers": {&lt;br&gt;
    "filesystem": {&lt;br&gt;
      "command": "npx",&lt;br&gt;
      "args": [&lt;br&gt;
        "-y",&lt;br&gt;
        "@modelcontextprotocol/server-filesystem",&lt;br&gt;
        "--rootPaths",&lt;br&gt;
        "./src",          // ← jailed to src/, not the repo root&lt;br&gt;
        "./tests"&lt;br&gt;
      ]&lt;br&gt;
    },&lt;br&gt;
    "postgres": {&lt;br&gt;
      "command": "npx",&lt;br&gt;
      "args": ["-y", "@modelcontextprotocol/server-postgres"],&lt;br&gt;
      "env": {&lt;br&gt;
        "POSTGRES_CONNECTION_STRING": "postgresql://readonly_user:pass@localhost:5432/mydb"&lt;br&gt;
      }&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;rootPaths&lt;/code&gt; argument on the filesystem server is the single most important security control most people skip. Omit it and the server defaults to whatever working directory the subprocess inherits — often your entire repo root or home directory. A model operating in code mode can and will read &lt;code&gt;.env&lt;/code&gt; files, private keys, or &lt;code&gt;node_modules/.cache&lt;/code&gt; if the path is reachable. Jail it to &lt;code&gt;./src&lt;/code&gt; and &lt;code&gt;./tests&lt;/code&gt; only, and use a &lt;strong&gt;read-only Postgres role&lt;/strong&gt; for the DB server — not your migration user. These aren't theoretical risks; they're the first two things to audit before you let any agentic flow run unsupervised.&lt;/p&gt;

&lt;p&gt;The stdio vs SSE transport distinction will bite you the first time you wonder why your server loses state mid-session. Stdio (the default in Cursor and Cline) spawns your server as a subprocess and communicates over stdin/stdout — the process is killed when the session closes, which means any in-memory state, open DB connections, or file watchers disappear with it. SSE keeps a long-running HTTP server and multiplexes sessions over it, which is what you want if your server does expensive initialization (loading an embedding model, opening a connection pool). The cost is operational: you need a running process, a port, and CORS headers set to allow your editor's origin. For most local dev setups, stdio is fine and simpler. Switch to SSE only when you're sharing one server instance across multiple clients or need persistent state between tool calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Routing Between Local Ollama Models and Cloud APIs Inside Code Mode
&lt;/h2&gt;

&lt;p&gt;The routing decision that actually matters isn't "local vs. cloud" as a preference — it's token estimate as a proxy for task complexity. A 40-token autocomplete request has no business hitting Claude Sonnet over an API call with 200ms round-trip latency. But a multi-step agent task that needs to read three files, reason about dependencies, and emit a patch? That's where a 7B model starts hallucinating import paths. The split I run is a simple token-count threshold with a task-type override:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`typescript&lt;br&gt;
// route.ts — called before every model dispatch&lt;br&gt;
const FAST_MODEL = "qwen2.5-coder:7b";          // Ollama local, ~5GB VRAM&lt;br&gt;
const STRONG_LOCAL = "qwen2.5-coder:32b-q4_k_m"; // ~22GB VRAM, used for agents&lt;br&gt;
const CLOUD_FALLBACK = "claude-sonnet-4-5";        // API, for when local is saturated&lt;/p&gt;

&lt;p&gt;type TaskType = "autocomplete" | "inline_edit" | "agent" | "multi_file";&lt;/p&gt;

&lt;p&gt;function routeModel(&lt;br&gt;
  estimatedTokens: number,&lt;br&gt;
  taskType: TaskType,&lt;br&gt;
  ollamaHealthy: boolean&lt;br&gt;
): string {&lt;br&gt;
  // Agent tasks and multi-file ops always need the stronger model&lt;br&gt;
  if (taskType === "agent" || taskType === "multi_file") {&lt;br&gt;
    return ollamaHealthy ? STRONG_LOCAL : CLOUD_FALLBACK;&lt;br&gt;
  }&lt;br&gt;
  // Simple threshold for everything else&lt;br&gt;
  if (estimatedTokens &amp;gt; 800 || !ollamaHealthy) {&lt;br&gt;
    return ollamaHealthy ? STRONG_LOCAL : CLOUD_FALLBACK;&lt;br&gt;
  }&lt;br&gt;
  return FAST_MODEL;&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Wiring Cline or Continue to Ollama's OpenAI-compatible endpoint sounds trivial until it isn't. The config looks like this in Continue's &lt;code&gt;config.json&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;json&lt;br&gt;
{&lt;br&gt;
  "models": [&lt;br&gt;
    {&lt;br&gt;
      "title": "Qwen2.5-Coder 7B",&lt;br&gt;
      "provider": "ollama",&lt;br&gt;
      "model": "qwen2.5-coder:7b",&lt;br&gt;
      "apiBase": "http://localhost:11434/v1"&lt;br&gt;
    }&lt;br&gt;
  ]&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Two things burn people here. First: the model name must be the &lt;em&gt;exact tag string&lt;/em&gt; from &lt;code&gt;ollama list&lt;/code&gt; — &lt;code&gt;qwen2.5-coder:7b&lt;/code&gt; not &lt;code&gt;qwen2.5-coder&lt;/code&gt; or &lt;code&gt;qwen2.5-coder:latest&lt;/code&gt;. Ollama will 404 silently or return a generic error that Continue surfaces as "model not found" with no useful hint. Second, and this one is subtle: a trailing slash on &lt;code&gt;apiBase&lt;/code&gt; breaks tool-call parsing in Cline's MCP dispatch layer. &lt;code&gt;http://localhost:11434/v1/&lt;/code&gt; causes malformed endpoint concatenation downstream — the tool-call response comes back as plain text instead of JSON, and the MCP server never receives the structured call. No trailing slash, ever.&lt;/p&gt;

&lt;p&gt;The VRAM arithmetic on a 32GB card is tight but workable if you're disciplined. The 32B Q4_K_M model loads to roughly 22GB. An active filesystem MCP server process plus a live Postgres MCP connection adds maybe 1-2GB of system VRAM overhead depending on your driver and how much the MCP servers cache. That leaves you 8-9GB of headroom — comfortable for a single agent session where the KV cache stays bounded. The problem is the second agent session. Once you're over 30GB allocation, the driver starts paging KV cache to system RAM, and latency on generation steps jumps 4-8x on typical query lengths. You'll feel it as stalls mid-stream rather than a clean slow response. The fix is to serialize agent sessions at the dispatch layer, not the model layer — queue the second request until the first completes rather than letting both run concurrently.&lt;/p&gt;

&lt;p&gt;The health-check-before-dispatch pattern is what keeps the TypeScript automation engine from failing visibly when Ollama is saturated or mid-reload. Ollama's &lt;code&gt;/api/tags&lt;/code&gt; endpoint responds in under 5ms when the service is up and becomes immediately unreachable when it's not — it's a better liveness signal than &lt;code&gt;/api/generate&lt;/code&gt; with a dummy prompt:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`typescript&lt;br&gt;
// health.ts&lt;br&gt;
async function isOllamaHealthy(timeoutMs = 800): Promise {&lt;br&gt;
  try {&lt;br&gt;
    const controller = new AbortController();&lt;br&gt;
    const timer = setTimeout(() =&amp;gt; controller.abort(), timeoutMs);&lt;br&gt;
    const res = await fetch("&lt;a href="http://localhost:11434/api/tags" rel="noopener noreferrer"&gt;http://localhost:11434/api/tags&lt;/a&gt;", {&lt;br&gt;
      signal: controller.signal,&lt;br&gt;
    });&lt;br&gt;
    clearTimeout(timer);&lt;br&gt;
    return res.ok;&lt;br&gt;
  } catch {&lt;br&gt;
    // connection refused OR timeout — treat both as unhealthy&lt;br&gt;
    return false;&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;// In the dispatch loop:&lt;br&gt;
const healthy = await isOllamaHealthy();&lt;br&gt;
const model = routeModel(estimatedTokens, taskType, healthy);&lt;br&gt;
// If model === CLOUD_FALLBACK, swap the client to OpenAI SDK — same interface, different baseURL&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The 800ms timeout is deliberate. Anything longer and a slow-but-alive Ollama instance under load will pass the health check but then time out on the actual generation request — you've just added latency without gaining reliability. If it can't answer &lt;code&gt;/api/tags&lt;/code&gt; in under a second, treat it as unavailable and route to the cloud client. The OpenAI SDK and the Ollama OpenAI-compatible endpoint share enough interface surface that swapping the &lt;code&gt;baseURL&lt;/code&gt; and adding an API key is the only code change needed — keep both clients instantiated and select between them after the health check resolves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring MCP Into an Automated Dev Pipeline (n8n + Webhooks)
&lt;/h2&gt;

&lt;p&gt;The most disruptive part of this whole setup isn't the agent — it's realizing you can close the loop between your repo and your local reasoning layer without touching a single SaaS CI product. A GitHub webhook fires, your local n8n instance picks it up, an MCP-capable agent reads the diff, runs your actual test suite, and posts a structured review back to the PR. The whole chain runs on your workstation. Here's how to wire it.&lt;/p&gt;

&lt;h3&gt;
  
  
  GitHub Webhook → n8n → MCP Agent → PR Comment
&lt;/h3&gt;

&lt;p&gt;The entry point is a Webhook node in n8n listening on something like &lt;code&gt;/webhook/github-pr&lt;/code&gt;. You authenticate it with a shared secret checked against the &lt;code&gt;X-Hub-Signature-256&lt;/code&gt; header — n8n's Webhook node exposes the raw body, so you can verify it in a Function node before anything else runs. After verification, extract &lt;code&gt;pull_request.number&lt;/code&gt;, &lt;code&gt;pull_request.diff_url&lt;/code&gt;, and &lt;code&gt;repository.full_name&lt;/code&gt; from the payload. A second HTTP Request node fetches the raw diff from &lt;code&gt;diff_url&lt;/code&gt; using your GitHub token. That diff string goes into the body of the call to your local MCP agent endpoint.&lt;/p&gt;

&lt;p&gt;The HTTP Request node hitting your MCP-capable agent endpoint should look like this:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;json&lt;br&gt;
{&lt;br&gt;
  "model": "qwen2.5-coder:32b",&lt;br&gt;
  "messages": [&lt;br&gt;
    {&lt;br&gt;
      "role": "user",&lt;br&gt;
      "content": "Review this diff for correctness, style violations, and test coverage gaps. Diff:\n\n{{$json.diff}}"&lt;br&gt;
    }&lt;br&gt;
  ],&lt;br&gt;
  "tool_choice": "auto",&lt;br&gt;
  "tools": [&lt;br&gt;
    {&lt;br&gt;
      "type": "function",&lt;br&gt;
      "function": {&lt;br&gt;
        "name": "mcp__shell__run_command",&lt;br&gt;
        "description": "Run a shell command on the local machine via MCP shell tool",&lt;br&gt;
        "parameters": {&lt;br&gt;
          "type": "object",&lt;br&gt;
          "properties": {&lt;br&gt;
            "command": { "type": "string" },&lt;br&gt;
            "cwd": { "type": "string" }&lt;br&gt;
          },&lt;br&gt;
          "required": ["command"]&lt;br&gt;
        }&lt;br&gt;
      }&lt;br&gt;
    },&lt;br&gt;
    {&lt;br&gt;
      "type": "function",&lt;br&gt;
      "function": {&lt;br&gt;
        "name": "mcp__filesystem__read_file",&lt;br&gt;
        "description": "Read a file from the registered workspace root",&lt;br&gt;
        "parameters": {&lt;br&gt;
          "type": "object",&lt;br&gt;
          "properties": {&lt;br&gt;
            "path": { "type": "string" }&lt;br&gt;
          },&lt;br&gt;
          "required": ["path"]&lt;br&gt;
        }&lt;br&gt;
      }&lt;br&gt;
    }&lt;br&gt;
  ]&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The tool names use the &lt;code&gt;mcp__&amp;lt;server&amp;gt;__&amp;lt;tool&amp;gt;&lt;/code&gt; namespacing convention. When the agent decides to run tests, it will return a tool call for &lt;code&gt;mcp__shell__run_command&lt;/code&gt; with something like &lt;code&gt;"command": "cd /workspace/myrepo &amp;amp;&amp;amp; npm test -- --reporter=json"&lt;/code&gt;. Your n8n workflow catches that tool call response, executes the dispatch back to the MCP server's tool endpoint, feeds the output back into a follow-up messages array, and loops until the agent returns a plain text completion — which then gets POST'd to the GitHub PR comments API as a structured review.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Version Mismatch Failure Mode
&lt;/h3&gt;

&lt;p&gt;The most reliable way to break this pipeline silently is to run two different versions of your MCP server — one that your editor (Cursor, VS Code with Cline, whatever) registered tools against, and a different one that n8n is hitting at runtime. The agent returns a tool call like &lt;code&gt;mcp__shell__run_command&lt;/code&gt;, your MCP server doesn't recognize it because the tool was renamed or its parameter schema changed in a patch release, and you get a generic 400 or an unhandled tool error that n8n logs as a completed node. The PR comment never gets posted. You don't notice until someone asks why the bot went quiet.&lt;/p&gt;

&lt;p&gt;Fix this in two places. First, pin the MCP server package version in your Dockerfile or &lt;code&gt;package.json&lt;/code&gt; — no floating &lt;code&gt;^&lt;/code&gt; or &lt;code&gt;~&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;json&lt;br&gt;
// package.json — MCP server process&lt;br&gt;
{&lt;br&gt;
  "dependencies": {&lt;br&gt;
    "@modelcontextprotocol/server-filesystem": "0.6.2",&lt;br&gt;
    "@modelcontextprotocol/server-shell": "0.4.1"&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Second, add a &lt;code&gt;/info&lt;/code&gt; route to your MCP server wrapper that returns the pinned version. Then have your n8n workflow hit &lt;code&gt;/info&lt;/code&gt; as its first node and assert the version matches what it expects before doing anything else. A mismatch kills the workflow run immediately with a descriptive error rather than a ghost failure downstream:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;typescript&lt;br&gt;
// Express wrapper around your MCP server&lt;br&gt;
app.get('/info', (req, res) =&amp;gt; {&lt;br&gt;
  res.json({&lt;br&gt;
    mcp_server_version: process.env.npm_package_version, // reads from package.json&lt;br&gt;
    tools: registeredTools.map(t =&amp;gt; t.name),&lt;br&gt;
    started_at: startTime.toISOString()&lt;br&gt;
  });&lt;br&gt;
});&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Nightly Unattended Loop via PM2 Cron
&lt;/h3&gt;

&lt;p&gt;The same agent-plus-MCP pattern runs as a nightly static analysis sweep with zero human involvement. The PM2 ecosystem config schedules a Node script that calls &lt;code&gt;git log&lt;/code&gt; to get all files changed since the last tagged deploy, sends them through the MCP-capable agent with the filesystem and shell tools available, instructs the agent to auto-apply ESLint fixes only on functions with cyclomatic complexity below a threshold (I use 10 — above that, it flags rather than touches), commits the result, and opens a draft PR via the GitHub API. If nothing changed or no fixable issues were found, it exits cleanly and PM2 logs the no-op.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;javascript&lt;br&gt;
// ecosystem.config.js&lt;br&gt;
module.exports = {&lt;br&gt;
  apps: [{&lt;br&gt;
    name: 'nightly-lint-agent',&lt;br&gt;
    script: './scripts/nightly-lint-loop.js',&lt;br&gt;
    cron_restart: '0 2 * * *',   // 2 AM local time&lt;br&gt;
    autorestart: false,           // don't restart on exit — this is a one-shot job&lt;br&gt;
    watch: false,&lt;br&gt;
    env: {&lt;br&gt;
      MCP_ENDPOINT: 'http://localhost:3100',&lt;br&gt;
      GITHUB_TOKEN: process.env.GITHUB_TOKEN,&lt;br&gt;
      COMPLEXITY_THRESHOLD: '10',&lt;br&gt;
      BASE_BRANCH: 'main'&lt;br&gt;
    }&lt;br&gt;
  }]&lt;br&gt;
};&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;One gotcha that doesn't show up until the second or third run: if the agent opens a draft PR but the nightly job runs again before anyone closes it, you'll accumulate stacked draft PRs against the same base. Guard against this by querying the GitHub API for open draft PRs authored by your bot token before the agent does any work — if one already exists for the same base branch, append to it rather than opening a new one, or skip the run entirely. The GitHub search API supports &lt;code&gt;is:pr is:draft author:app/your-bot-name base:main&lt;/code&gt; which is fast enough to call synchronously at job start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Non-Obvious Behaviors That Will Cost You Time
&lt;/h2&gt;

&lt;p&gt;The behaviors that actually cost you hours aren't the ones in the error logs — they're the ones that produce &lt;em&gt;wrong but plausible&lt;/em&gt; output and silent data corruption. Here are three that don't show up in the MCP spec until you've already been burned by them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tool Call Cancellation Is Not Guaranteed
&lt;/h3&gt;

&lt;p&gt;If the model emits a partial tool call payload and the session drops mid-flight, several MCP clients will queue that call for retry on reconnect. The spec treats tool calls as idempotent by convention, but nothing in the protocol enforces that — and your file-write tool almost certainly isn't idempotent. The failure mode: reconnect triggers a second &lt;code&gt;write_file&lt;/code&gt; call with the same arguments, and your patch gets applied twice. With line-insertion edits, that means duplicate blocks. With find-and-replace, the second pass may silently corrupt the file by matching on already-modified content.&lt;/p&gt;

&lt;p&gt;The fix is a content guard on every write tool, not an assumption that the client handles this. Before applying any edit, read a hash of the current file state and compare it against a hash captured when the task was first dispatched:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`typescript&lt;br&gt;
// write_tool_handler.ts&lt;br&gt;
async function applyEdit(params: EditParams): Promise {&lt;br&gt;
  const currentHash = await sha256OfFile(params.path);&lt;/p&gt;

&lt;p&gt;// caller passes the hash they saw when they read the file&lt;br&gt;
  // if hashes diverge, the file changed under us — refuse the write&lt;br&gt;
  if (currentHash !== params.expectedHash) {&lt;br&gt;
    return {&lt;br&gt;
      success: false,&lt;br&gt;
      reason: &lt;code&gt;content_mismatch: expected ${params.expectedHash}, got ${currentHash}&lt;/code&gt;&lt;br&gt;
    };&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;await applyPatch(params.path, params.patch);&lt;/p&gt;

&lt;p&gt;return {&lt;br&gt;
    success: true,&lt;br&gt;
    newHash: await sha256OfFile(params.path)&lt;br&gt;
  };&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;A line-range guard works too and is simpler to reason about for bounded edits: the tool refuses to write if the target line range has shifted. Either way, the model gets a clean error it can reason about instead of silently writing corrupted output. The extra round-trip is worth it every time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Context Window Bleed Kills Long Agent Sessions
&lt;/h3&gt;

&lt;p&gt;Code mode accumulates tool call results directly in the context window — every &lt;code&gt;read_file&lt;/code&gt; response, every lint output, every test result goes in as a message. On a 32B model running at 32K context, around 15 tool calls in, the model starts behaviorally ignoring early system instructions. It doesn't error out. It just stops following the task constraints you set at the top of the session — the coding style rules, the file exclusion list, the output format requirements. This is a soft attention failure, not a hard truncation, and it's insidious because the output still looks reasonable.&lt;/p&gt;

&lt;p&gt;The practical mitigation is task checkpointing: break any job that requires more than 8-10 tool calls into explicit subtasks, each with its own fresh context. In my n8n flows, I do this by treating each subtask as a separate HTTP call to the model endpoint, passing only a compressed summary of prior state rather than the full tool call history. Something like a 200-token "checkpoint header" that says &lt;em&gt;files modified so far, constraints still active, next objective&lt;/em&gt; — assembled by a lightweight summarizer node before each subtask starts:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`http&lt;/p&gt;

&lt;h1&gt;
  
  
  n8n HTTP Request node — subtask dispatch
&lt;/h1&gt;

&lt;p&gt;POST /api/generate&lt;br&gt;
{&lt;br&gt;
  "model": "qwen2.5-coder:32b",&lt;br&gt;
  "system": "{{ $json.checkpointHeader }}",&lt;br&gt;
  "messages": [&lt;br&gt;
    { "role": "user", "content": "{{ $json.subtaskPrompt }}" }&lt;br&gt;
  ],&lt;br&gt;
  "context_length": 32768&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The checkpoint header is cheap to generate and completely sidesteps the bleed problem. The tradeoff is that you lose conversational continuity — the model can't reference specific earlier outputs by memory. For code generation tasks that's almost never a problem; for exploratory debugging sessions where context coherence matters, you may prefer a smaller model with a larger context window instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Resources vs Tools: The Cold-Start Latency You're Leaving on the Table
&lt;/h3&gt;

&lt;p&gt;MCP draws a hard architectural line between resources and tools. Resources are fetched and cached by the client at session initialization — they're available to the model with no round-trip cost. Tools are called on demand, which means a network hop plus server-side execution every single invocation. Most MCP server implementations default to exposing everything as a tool because it's simpler to implement, and most tutorials do the same. The cost is paid at cold start: if your agent calls &lt;code&gt;describe_schema&lt;/code&gt; or &lt;code&gt;list_available_endpoints&lt;/code&gt; at the top of every session, you're paying 2-4 seconds per session just to hand the model static information it could have had for free.&lt;/p&gt;

&lt;p&gt;Anything that doesn't change between sessions belongs in a resource. Schema definitions, API surface documentation, file tree snapshots of stable directories, environment capability lists — these are all resource candidates. Exposing them correctly in your MCP server definition looks like this:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`yaml&lt;/p&gt;

&lt;h1&gt;
  
  
  mcp_server_config.yaml
&lt;/h1&gt;

&lt;p&gt;resources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;uri: "schema://db/main"&lt;br&gt;
name: "Main database schema"&lt;br&gt;
mimeType: "application/json"&lt;/p&gt;
&lt;h1&gt;
  
  
  served from a static file or a cached query result refreshed on server start
&lt;/h1&gt;

&lt;p&gt;handler: "handlers.schema.serve_main"&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;uri: "schema://api/openapi"&lt;br&gt;
name: "Internal API surface"&lt;br&gt;
mimeType: "application/yaml"&lt;br&gt;
handler: "handlers.schema.serve_openapi"&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;name: "run_query"
description: "Execute a read-only SQL query"
# this is dynamic — keep it as a tool
handler: "handlers.db.run_query"
`&lt;code&gt;&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The client fetches all declared resources during the handshake phase and includes them in the initial context before the first user message. The model has the schema available immediately without any tool call overhead. The common gotcha here: if your resource handler hits a slow database or a remote API at serve time, you've moved the latency problem rather than eliminated it. Cache the resource payload on server startup and refresh it on a schedule, not on every client connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  When This Setup Is Not the Right Call
&lt;/h2&gt;

&lt;p&gt;The filesystem MCP tool's recursive read is genuinely useful on a focused project directory — it stops being useful the moment your codebase crosses into monorepo territory. Past roughly 500K LOC, the tool will either get truncated by the model's context window before it assembles a coherent picture, or it'll burn most of the context budget on directory traversal before a single line of analysis happens. If you're in that situation, the right architecture is a code embedding index with semantic search in front of it. I use bge-m3 for this — it's dense enough to handle code tokens well and fits comfortably in a moderate VRAM budget. The MCP layer then becomes a thin retrieval interface rather than a raw filesystem reader: the agent queries the index, gets back the 10-15 most relevant chunks, and works from those instead of trying to read the repo whole.&lt;/p&gt;

&lt;p&gt;GPU constraint is the other hard wall. The agentic loop that makes Code Mode + MCP actually useful requires a model capable of multi-step tool use and self-correction — on my 32GB VRAM box that's workable with a quantized 32B or a pair of smaller specialized models. Under 16GB VRAM, you don't have that headroom. A 7B or 8B model will accept MCP tool calls, but the planning quality degrades fast on anything beyond two-step tasks; you'll spend more time fixing the agent's mistakes than you would have spent writing the code. The practical answer is to route the agent to a cloud API — Claude 3.5 Sonnet or GPT-4o handle the reasoning — and keep MCP tool permissions strictly read-only. Read-only means the worst case is a wasted API call, not an overwritten file or an executed shell command you didn't intend.&lt;/p&gt;

&lt;p&gt;Shared dev environments are where this setup can become a genuine security problem rather than just an inconvenience. An MCP server with shell exec permissions is an arbitrary code execution surface. On a single-operator workstation where you control the process list, what the server can spawn, and what credentials are in the environment, that's a manageable risk — you're the only lateral movement target and you can audit it. On shared infra — a team dev box, a cloud VM that multiple engineers SSH into, a Kubernetes pod with a mounted service account — an MCP server with exec access is a privilege escalation path waiting to happen. One misconfigured tool definition or one prompt injection through a malicious code comment, and the blast radius extends to every other user and service that machine can reach. This architecture was designed for single-operator use; treating it as a team tool without a significant rethink of the permission model is the wrong call.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Monorepo scale (&amp;gt;500K LOC):&lt;/strong&gt; Replace recursive filesystem reads with bge-m3 embeddings + semantic retrieval; the MCP tool becomes a query interface, not a directory walker.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Under 16GB VRAM:&lt;/strong&gt; Use a cloud API for the agent brain, lock MCP tools to read-only, accept that local inference isn't the bottleneck worth optimizing here.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Shared infrastructure:&lt;/strong&gt; MCP shell access on multi-user systems is a lateral movement risk; the single-operator assumption baked into this setup doesn't hold and the permission model needs a full redesign before it's safe to deploy that way.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/code-mode-mcp-wiring-your-local-llm-into-a-real-development-workflow/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>tools</category>
      <category>career</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Bind9 vs PowerDNS at Home: Which One to Run When You Actually Need Local DNS</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Fri, 26 Jun 2026 08:11:57 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/bind9-vs-powerdns-at-home-which-one-to-run-when-you-actually-need-local-dns-3ll4</link>
      <guid>https://dev.to/ericwoooo_kr/bind9-vs-powerdns-at-home-which-one-to-run-when-you-actually-need-local-dns-3ll4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The silent failure mode is the one that gets you.  Your router's built-in resolver — whatever dnsmasq stub is baked into your OpenWrt or consumer firmware — handles `nas.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~20 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Problem That Makes Local DNS Worth Running&lt;/li&gt;
&lt;li&gt;What Each Tool Actually Is (And Isn't)&lt;/li&gt;
&lt;li&gt;Setting Up Bind9: Authoritative Server for Internal Zones&lt;/li&gt;
&lt;li&gt;Setting Up PowerDNS: When You Want an API and a Database Backend&lt;/li&gt;
&lt;li&gt;Bind9 vs PowerDNS: The Honest Operator Comparison&lt;/li&gt;
&lt;li&gt;Running Either One in Docker Without Losing Your Mind&lt;/li&gt;
&lt;li&gt;What to Point Your Clients At and How to Test It&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Problem That Makes Local DNS Worth Running
&lt;/h2&gt;

&lt;p&gt;The silent failure mode is the one that gets you. Your router's built-in resolver — whatever dnsmasq stub is baked into your OpenWrt or consumer firmware — handles &lt;code&gt;nas.local&lt;/code&gt; and &lt;code&gt;homeassistant.local&lt;/code&gt; just fine with two devices. Add Nginx Proxy Manager routing to five services behind a single IP, throw in a Traefik instance for containers, and you'll start hitting a specific class of breakage: the hostname resolves, but to the wrong thing, or resolves on some devices and not others, with zero error messages to tell you why. The router just silently returns whatever it last cached or makes a best-guess mDNS query that works on macOS and fails on Linux.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;/etc/hosts&lt;/code&gt; approach feels like a fix but compounds the problem. You add &lt;code&gt;192.168.1.50 grafana.home.lab&lt;/code&gt; to your laptop, forget to add it to your phone, your n8n container can't resolve it at all because it's running in Docker with its own DNS context, and now you're SSHing into three different machines to update flat files every time a service moves IPs. Reverse proxies specifically depend on stable FQDNs because that's how virtual hosting works — Nginx Proxy Manager matches &lt;code&gt;Host:&lt;/code&gt; headers to upstream definitions, and if half your clients are hitting an IP directly or resolving a stale hosts entry, the proxy rule never fires. Traefik has the same dependency; its routing rules are built around hostnames, not IPs.&lt;/p&gt;

&lt;p&gt;What a real local resolver actually gives you breaks down into three concrete things. First, a single authoritative source for your internal zone — change an IP in one place and every device on the network picks it up on next TTL expiry. Second, wildcard records: a single &lt;code&gt;*.home.lab A 192.168.1.50&lt;/code&gt; entry means every subdomain you spin up under that zone resolves immediately without touching DNS config again. Third, query latency that doesn't leave your LAN — a bind9 or PowerDNS instance answering from its local zone file returns under 1ms, versus 20-80ms for an upstream query to your ISP or even a local Pi-hole forwarding upstream. For webhook delivery in automation pipelines, that latency difference is irrelevant, but the &lt;em&gt;reliability&lt;/em&gt; difference isn't: a misconfigured or unavailable upstream resolver kills webhook callbacks silently.&lt;/p&gt;

&lt;p&gt;Split-horizon DNS is where this gets genuinely useful beyond just convenience. The idea is that &lt;code&gt;grafana.home.lab&lt;/code&gt; resolves to &lt;code&gt;192.168.1.50&lt;/code&gt; from inside your network, and either doesn't resolve at all or resolves to a public IP from outside — depending on whether you're also publishing that subdomain publicly. Your router's resolver cannot do this. It has one answer for a name, and that answer is whatever it learned from upstream or from its own static config. A proper authoritative DNS server for your internal zone handles split-horizon natively because it's authoritative for a zone that simply doesn't exist in the public DNS tree. If your home lab is growing toward full automation pipelines, see the guide on &lt;a href="https://techdigestor.com/ultimate-productivity-guide-2026/" rel="noopener noreferrer"&gt;Workflow Automation in 2026: n8n, Zapier, and Self-Hosted Pipelines&lt;/a&gt; — webhook delivery specifically requires that your internal services resolve consistently from wherever the webhook sender lives, which is a DNS problem before it's anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Each Tool Actually Is (And Isn't)
&lt;/h2&gt;

&lt;p&gt;The single distinction that trips up almost every beginner: &lt;strong&gt;authoritative&lt;/strong&gt; versus &lt;strong&gt;recursive&lt;/strong&gt; DNS are completely different jobs, and neither Bind9 nor PowerDNS does both by default. An authoritative server answers "I own this zone, here's the record." A recursive resolver answers "let me go find that for you." Conflating the two is why home lab setups break in confusing ways — your server responds to dig queries about your own domain but silently refuses everything else.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bind9
&lt;/h3&gt;

&lt;p&gt;Bind9 is the reference implementation of the DNS protocol — when the RFCs describe how DNS is supposed to behave, Bind9 is usually what they're testing against. It runs as a single daemon (&lt;code&gt;named&lt;/code&gt;), reads zone files in the RFC-standard text format (one record per line, SOA at the top, trailing dots required), and by default does nothing but serve those zones authoritatively. Version 9.18.x, which ships in Ubuntu 22.04's standard repos, added significantly better structured logging compared to older 9.16.x builds — you get category-level log channels out of the box instead of a wall of undifferentiated output. The zone file format is unambiguous and version-controllable with plain git, which is its real advantage for home use. The downside: editing zone files by hand means manually incrementing the SOA serial number every time, and forgetting that will cause secondaries to silently ignore your changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  PowerDNS
&lt;/h3&gt;

&lt;p&gt;PowerDNS ships as two completely separate binaries that do not share configuration files or processes: &lt;code&gt;pdns&lt;/code&gt; (the authoritative server) and &lt;code&gt;pdns_recursor&lt;/code&gt;. You can run one without the other. The authoritative server's default backend is a SQL database — SQLite for single-node setups, PostgreSQL or MySQL for anything you care about. That database dependency is real overhead, but it means you can update DNS records with a single SQL statement or a REST API call instead of editing a text file, incrementing a serial, and reloading a daemon. For automation — feeding records from a provisioning script or n8n flow — this is a significant practical advantage. The Recursor is a separate project on a separate release cadence; as of this guide, PowerDNS Authoritative is at &lt;strong&gt;4.8.x&lt;/strong&gt; and pdns_recursor is at &lt;strong&gt;5.x&lt;/strong&gt;. Do not assume the version numbers track each other.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Neither Tool Is
&lt;/h3&gt;

&lt;p&gt;These are the things beginners reach for Bind9 or PowerDNS to do, and shouldn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;DHCP server&lt;/strong&gt; — that's &lt;code&gt;dnsmasq&lt;/code&gt; or Kea. Both Bind9 and PowerDNS will answer DNS queries; neither assigns IP addresses. If you want DNS records to update automatically when a device gets a DHCP lease, you need a separate integration layer.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;DNS-over-HTTPS or DNS-over-TLS termination&lt;/strong&gt; — that's &lt;code&gt;dnsdist&lt;/code&gt; (which is actually a PowerDNS project) or a stub resolver like Unbound with TLS wrapping. Neither &lt;code&gt;pdns&lt;/code&gt; nor &lt;code&gt;named&lt;/code&gt; speaks DoH natively in their standard configurations.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Firewall-level ad blocking&lt;/strong&gt; — that's Pi-hole, which is a web UI layered on top of dnsmasq (or FTL, their fork of it). Pi-hole blocks ads by returning NXDOMAIN or a null IP for blocklisted domains at the resolver level. Bind9 and PowerDNS can technically be configured to do something similar with RPZ (Response Policy Zones), but that's a significantly more complex setup and not what this guide covers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keeping these boundaries clear matters before you pick a tool. If your goal is a local authoritative server for &lt;code&gt;home.lab&lt;/code&gt; with predictable, version-controlled zone data, Bind9 is the lower-friction path. If your goal is automation — records managed by scripts, a REST API, or a database you already operate — the PowerDNS authoritative server's SQL backend earns its extra dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up Bind9: Authoritative Server for Internal Zones
&lt;/h2&gt;

&lt;p&gt;The part that surprises most people setting up Bind9 for the first time: the default package configuration on Debian/Ubuntu ships with recursion &lt;em&gt;enabled&lt;/em&gt; for any querying host. That's fine on a locked-down machine with a firewall, but the correct fix isn't to rely on firewall rules — it's to configure Bind9 to not do recursion at all on an authoritative-only server. Open recursive resolvers on home networks get abused for DNS amplification attacks. Lock it down at the application layer first.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`shell&lt;/p&gt;

&lt;h1&gt;
  
  
  Install bind9 and the companion utilities (dig, named-checkconf, etc.)
&lt;/h1&gt;

&lt;p&gt;apt install bind9 bind9utils bind9-doc&lt;/p&gt;

&lt;h1&gt;
  
  
  /etc/bind/named.conf.options
&lt;/h1&gt;

&lt;p&gt;options {&lt;br&gt;
    directory "/var/cache/bind";&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Only listen on your LAN interface and loopback — not 0.0.0.0
listen-on { 192.168.1.0/24; 127.0.0.1; };

# This server is authoritative-only. No recursion, no forwarding.
allow-recursion { none; };
recursion no;

# Don't answer queries from outside your LAN
allow-query { 192.168.1.0/24; 127.0.0.1; };

dnssec-validation auto;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;};&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;After locking down the options, wire up your internal zone in &lt;code&gt;/etc/bind/named.conf.local&lt;/code&gt;. The zone declaration just tells Bind9 where to find the zone file — the actual records live separately.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`conf&lt;/p&gt;

&lt;h1&gt;
  
  
  /etc/bind/named.conf.local
&lt;/h1&gt;

&lt;p&gt;zone "home.lab" {&lt;br&gt;
    type master;&lt;br&gt;
    file "/etc/bind/db.home.lab";&lt;br&gt;
};&lt;/p&gt;

&lt;h1&gt;
  
  
  Reverse zone for 192.168.1.x — the name format is counterintuitive
&lt;/h1&gt;

&lt;p&gt;zone "1.168.192.in-addr.arpa" {&lt;br&gt;
    type master;&lt;br&gt;
    file "/etc/bind/db.192.168.1";&lt;br&gt;
};&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Now the zone file itself. The trailing dot on FQDNs is the single most common silent failure in Bind9 setups. If you write &lt;code&gt;ns1.home.lab&lt;/code&gt; without a trailing dot inside a zone file, Bind9 appends the zone origin, giving you &lt;code&gt;ns1.home.lab.home.lab&lt;/code&gt;. Named-checkzone won't always flag this as an error — it's syntactically valid, just wrong. You'll see successful zone loads and completely broken resolution. Add the dot, every time, on any name that's fully qualified.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`plaintext&lt;/p&gt;

&lt;h1&gt;
  
  
  /etc/bind/db.home.lab
&lt;/h1&gt;

&lt;p&gt;$TTL 3600&lt;br&gt;
@   IN  SOA  ns1.home.lab. admin.home.lab. (&lt;br&gt;
                2024010101  ; Serial — increment this on every edit&lt;br&gt;
                3600        ; Refresh&lt;br&gt;
                900         ; Retry&lt;br&gt;
                604800      ; Expire&lt;br&gt;
                300 )       ; Negative TTL&lt;/p&gt;

&lt;p&gt;; NS record — trailing dot required on the FQDN&lt;br&gt;
@       IN  NS   ns1.home.lab.&lt;/p&gt;

&lt;p&gt;; A records for your infrastructure&lt;br&gt;
ns1     IN  A    192.168.1.10&lt;br&gt;
router  IN  A    192.168.1.1&lt;br&gt;
nas     IN  A    192.168.1.20&lt;br&gt;
n8n     IN  A    192.168.1.30&lt;br&gt;
ollama  IN  A    192.168.1.32&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`plaintext&lt;/p&gt;

&lt;h1&gt;
  
  
  /etc/bind/db.192.168.1
&lt;/h1&gt;

&lt;p&gt;$TTL 3600&lt;br&gt;
@   IN  SOA  ns1.home.lab. admin.home.lab. (&lt;br&gt;
                2024010101&lt;br&gt;
                3600&lt;br&gt;
                900&lt;br&gt;
                604800&lt;br&gt;
                300 )&lt;/p&gt;

&lt;p&gt;@       IN  NS   ns1.home.lab.&lt;/p&gt;

&lt;p&gt;; PTR records — left side is just the last octet&lt;br&gt;
10      IN  PTR  ns1.home.lab.&lt;br&gt;
1       IN  PTR  router.home.lab.&lt;br&gt;
20      IN  PTR  nas.home.lab.&lt;br&gt;
30      IN  PTR  n8n.home.lab.&lt;br&gt;
32      IN  PTR  ollama.home.lab.&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;PTR records aren't optional decoration. SSH builds its &lt;code&gt;known_hosts&lt;/code&gt; entries using reverse DNS when it can resolve them, and several Docker healthcheck scripts do reverse lookups to verify they're talking to the right host. Missing PTR records cause subtle, hard-to-diagnose failures that show up weeks after initial setup — especially if you're running anything that validates hostnames against IPs.&lt;/p&gt;

&lt;p&gt;Test everything before touching your router's DNS setting. The distinction between SERVFAIL and NXDOMAIN matters here: NXDOMAIN means "zone loaded fine, record doesn't exist"; SERVFAIL means "something is broken in the zone itself." If you're getting SERVFAIL on a name you just added, the zone file has a syntax error and Bind9 is serving stale data or nothing at all.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`shell&lt;/p&gt;

&lt;h1&gt;
  
  
  Validate config syntax first — exits non-zero on any error
&lt;/h1&gt;

&lt;p&gt;named-checkconf&lt;/p&gt;

&lt;h1&gt;
  
  
  Validate the zone file specifically
&lt;/h1&gt;

&lt;p&gt;named-checkzone home.lab /etc/bind/db.home.lab&lt;/p&gt;

&lt;h1&gt;
  
  
  Expected output: zone home.lab/IN: loaded serial 2024010101
&lt;/h1&gt;

&lt;h1&gt;
  
  
  OK
&lt;/h1&gt;

&lt;p&gt;named-checkzone 1.168.192.in-addr.arpa /etc/bind/db.192.168.1&lt;/p&gt;

&lt;h1&gt;
  
  
  Reload after a clean check — don't restart, reload preserves state
&lt;/h1&gt;

&lt;p&gt;systemctl reload bind9&lt;/p&gt;

&lt;h1&gt;
  
  
  Forward lookup — expect NOERROR + an A record in the ANSWER section
&lt;/h1&gt;

&lt;p&gt;dig &lt;a class="mentioned-user" href="https://dev.to/127"&gt;@127&lt;/a&gt;.0.0.1 ollama.home.lab A&lt;/p&gt;

&lt;h1&gt;
  
  
  Reverse lookup — expect NOERROR + PTR record
&lt;/h1&gt;

&lt;p&gt;dig &lt;a class="mentioned-user" href="https://dev.to/127"&gt;@127&lt;/a&gt;.0.0.1 -x 192.168.1.32&lt;/p&gt;

&lt;h1&gt;
  
  
  If you get SERVFAIL here, check: journalctl -u named | tail -20
&lt;/h1&gt;

&lt;h1&gt;
  
  
  Look for "zone home.lab/IN: loading from master file failed"
&lt;/h1&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up PowerDNS: When You Want an API and a Database Backend
&lt;/h2&gt;

&lt;p&gt;The thing that surprises most people about PowerDNS is that it ships as two completely separate binaries — &lt;code&gt;pdns_server&lt;/code&gt; (the authoritative server) and &lt;code&gt;pdns_recursor&lt;/code&gt; — and you almost always need both running together for a home lab setup to actually work. Get one wrong and clients either can't resolve internal names or get &lt;code&gt;REFUSED&lt;/code&gt; for anything outside your domain. That split architecture is worth understanding before you touch a config file.&lt;/p&gt;

&lt;p&gt;Start with just the authoritative server and the SQLite3 backend. For a home lab with a handful of zones, there's no reason to spin up PostgreSQL:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`shell&lt;br&gt;
apt install pdns-server pdns-backend-sqlite3&lt;/p&gt;

&lt;h1&gt;
  
  
  Initialize the schema — PowerDNS ships the SQL file at this path
&lt;/h1&gt;

&lt;p&gt;sqlite3 /var/lib/powerdns/pdns.sqlite3 &amp;lt; /usr/share/doc/pdns-backend-sqlite3/schema.sqlite3.sql&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Then edit &lt;code&gt;/etc/powerdns/pdns.conf&lt;/code&gt; — the default file is heavily commented, which helps. The minimum working config for an API-enabled authoritative server looks like this:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`conf&lt;/p&gt;

&lt;h1&gt;
  
  
  /etc/powerdns/pdns.conf
&lt;/h1&gt;

&lt;p&gt;launch=gsqlite3&lt;br&gt;
gsqlite3-database=/var/lib/powerdns/pdns.sqlite3&lt;/p&gt;

&lt;h1&gt;
  
  
  Run on a non-standard port so pdns-recursor can sit in front on :53
&lt;/h1&gt;

&lt;p&gt;local-port=5300&lt;br&gt;
local-address=127.0.0.1&lt;/p&gt;

&lt;h1&gt;
  
  
  Enable the REST API
&lt;/h1&gt;

&lt;p&gt;api=yes&lt;br&gt;
api-key=yoursecretkey&lt;br&gt;
webserver=yes&lt;br&gt;
webserver-address=127.0.0.1&lt;br&gt;
webserver-port=8081&lt;br&gt;
webserver-allow-from=127.0.0.1&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Once &lt;code&gt;pdns_server&lt;/code&gt; is running, you can create a zone without touching any flat files. This is the actual architectural difference from Bind9 — everything goes through the API or the database, not a text file you reload:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`shell&lt;/p&gt;

&lt;h1&gt;
  
  
  Create a zone
&lt;/h1&gt;

&lt;p&gt;curl -s -X POST &lt;a href="http://localhost:8081/api/v1/servers/localhost/zones" rel="noopener noreferrer"&gt;http://localhost:8081/api/v1/servers/localhost/zones&lt;/a&gt; \&lt;br&gt;
  -H "X-API-Key: yoursecretkey" \&lt;br&gt;
  -H "Content-Type: application/json" \&lt;br&gt;
  -d '{&lt;br&gt;
    "name": "home.arpa.",&lt;br&gt;
    "kind": "Native",&lt;br&gt;
    "nameservers": ["ns1.home.arpa."]&lt;br&gt;
  }'&lt;/p&gt;

&lt;h1&gt;
  
  
  Add an A record
&lt;/h1&gt;

&lt;p&gt;curl -s -X PATCH &lt;a href="http://localhost:8081/api/v1/servers/localhost/zones/home.arpa" rel="noopener noreferrer"&gt;http://localhost:8081/api/v1/servers/localhost/zones/home.arpa&lt;/a&gt;. \&lt;br&gt;
  -H "X-API-Key: yoursecretkey" \&lt;br&gt;
  -H "Content-Type: application/json" \&lt;br&gt;
  -d '{&lt;br&gt;
    "rrsets": [{&lt;br&gt;
      "name": "router.home.arpa.",&lt;br&gt;
      "type": "A",&lt;br&gt;
      "ttl": 300,&lt;br&gt;
      "changetype": "REPLACE",&lt;br&gt;
      "records": [{"content": "192.168.1.1", "disabled": false}]&lt;br&gt;
    }]&lt;br&gt;
  }'&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Now install &lt;code&gt;pdns-recursor&lt;/code&gt; as the second process. This one listens on port 53 and handles all upstream recursion — Google, Cloudflare, your ISP — while forwarding your internal zones back to the authoritative server running on &lt;code&gt;127.0.0.1:5300&lt;/code&gt;. Edit &lt;code&gt;/etc/powerdns/recursor.conf&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`conf&lt;/p&gt;

&lt;h1&gt;
  
  
  /etc/powerdns/recursor.conf
&lt;/h1&gt;

&lt;p&gt;local-address=0.0.0.0&lt;br&gt;
local-port=53&lt;/p&gt;

&lt;h1&gt;
  
  
  Forward internal zone to the authoritative server
&lt;/h1&gt;

&lt;p&gt;forward-zones=home.arpa.=127.0.0.1:5300&lt;/p&gt;

&lt;h1&gt;
  
  
  Use upstream resolvers for everything else
&lt;/h1&gt;

&lt;p&gt;forward-zones-recurse=.=1.1.1.1;8.8.8.8&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The gotcha that burns everyone at least once: the PowerDNS authoritative server will flat-out &lt;strong&gt;refuse&lt;/strong&gt; recursive queries. Point a client directly at port 5300 and ask for &lt;code&gt;google.com&lt;/code&gt; — you get &lt;code&gt;REFUSED&lt;/code&gt;, not a timeout, not a forwarded answer. &lt;code&gt;REFUSED&lt;/code&gt;. This is correct behavior per DNS spec, but it looks like a misconfiguration the first time you see it. The fix is always the same: your clients and your DHCP server should point at the recursor on port 53, never directly at the authoritative server. The authoritative server is an internal implementation detail. If you're also running dnsmasq for DHCP, you can point its upstream at the recursor instead of removing dnsmasq entirely — they compose fine as long as port 53 isn't double-bound.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bind9 vs PowerDNS: The Honest Operator Comparison
&lt;/h2&gt;

&lt;p&gt;The most useful thing I can tell you upfront: these two servers solve the same core problem with completely different philosophies, and the right choice becomes obvious once you know which direction your setup is heading.&lt;/p&gt;

&lt;p&gt;Bind9 to a first working zone is genuinely about 20 minutes. You edit &lt;code&gt;named.conf.local&lt;/code&gt; to declare the zone, write the zone file itself, run &lt;code&gt;named-checkzone&lt;/code&gt; to verify syntax, and reload:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`conf&lt;/p&gt;

&lt;h1&gt;
  
  
  /etc/bind/named.conf.local
&lt;/h1&gt;

&lt;p&gt;zone "home.lab" {&lt;br&gt;
    type master;&lt;br&gt;
    file "/etc/bind/zones/db.home.lab";&lt;br&gt;
};&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`shell&lt;/p&gt;

&lt;h1&gt;
  
  
  verify before reloading — catches silent syntax errors
&lt;/h1&gt;

&lt;p&gt;named-checkzone home.lab /etc/bind/zones/db.home.lab&lt;/p&gt;

&lt;h1&gt;
  
  
  reload without restarting (preserves cache)
&lt;/h1&gt;

&lt;p&gt;rndc reload&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Two files, one command. PowerDNS's equivalent starting point involves picking a backend (SQLite is the right answer for a single-operator setup), initializing the schema, configuring &lt;code&gt;pdns.conf&lt;/code&gt; with the backend path and API key, then understanding that PowerDNS splits authoritative serving and recursive resolution into two separate binaries — &lt;code&gt;pdns_server&lt;/code&gt; and &lt;code&gt;pdns_recursor&lt;/code&gt; — that need to be configured to talk to each other on different ports. None of this is hard, but it's not 20 minutes either. If you want stable internal DNS and don't need automation, Bind9 is the pragmatic choice.&lt;/p&gt;

&lt;p&gt;The calculus flips the moment you want to programmatically register records. Every time a new Docker container comes up, or an n8n webhook endpoint gets created, you do &lt;em&gt;not&lt;/em&gt; want to be sed-ing a zone file and calling &lt;code&gt;rndc reload&lt;/code&gt;. That path breaks on concurrent writes, requires root or sudo for file edits, and the error surface is wide. A PowerDNS API call is atomic and purpose-built:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`shell&lt;/p&gt;

&lt;h1&gt;
  
  
  add an A record via PowerDNS REST API — no file manipulation, no reload
&lt;/h1&gt;

&lt;p&gt;curl -s -X PATCH &lt;a href="http://localhost:8081/api/v1/servers/localhost/zones/home.lab" rel="noopener noreferrer"&gt;http://localhost:8081/api/v1/servers/localhost/zones/home.lab&lt;/a&gt;. \&lt;br&gt;
  -H "X-API-Key: your-api-key-here" \&lt;br&gt;
  -H "Content-Type: application/json" \&lt;br&gt;
  -d '{&lt;br&gt;
    "rrsets": [{&lt;br&gt;
      "name": "new-container.home.lab.",&lt;br&gt;
      "type": "A",&lt;br&gt;
      "ttl": 60,&lt;br&gt;
      "changetype": "REPLACE",&lt;br&gt;
      "records": [{ "content": "192.168.1.45", "disabled": false }]&lt;br&gt;
    }]&lt;br&gt;
  }'&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That call returns immediately, the record is live, and you can call it from an n8n HTTP Request node or a Node.js script without touching the filesystem. Bind9 does have &lt;code&gt;nsupdate&lt;/code&gt; for dynamic updates via DNS protocol, but it requires TSIG key setup and the tooling is significantly more awkward than a JSON POST.&lt;/p&gt;

&lt;p&gt;On resource footprint: Bind9 with a single zone idles around 20–30 MB RSS. PowerDNS authoritative plus recursor plus SQLite backend runs closer to 60–80 MB total across both processes. On any machine already running Docker, this is completely irrelevant — Docker's own overhead dwarfs it. Where it actually matters is a constrained device like a Pi Zero 2W, where you're budgeting memory carefully and a single lean Bind9 process is the cleaner fit.&lt;/p&gt;

&lt;p&gt;Factor&lt;/p&gt;

&lt;p&gt;Bind9&lt;/p&gt;

&lt;p&gt;PowerDNS&lt;/p&gt;

&lt;p&gt;Setup to first working zone&lt;/p&gt;

&lt;p&gt;~20 min&lt;/p&gt;

&lt;p&gt;~40 min&lt;/p&gt;

&lt;p&gt;Backend&lt;/p&gt;

&lt;p&gt;Flat zone files&lt;/p&gt;

&lt;p&gt;SQLite / PostgreSQL / MySQL&lt;/p&gt;

&lt;p&gt;REST API&lt;/p&gt;

&lt;p&gt;No (rndc + nsupdate only)&lt;/p&gt;

&lt;p&gt;Yes, built-in&lt;/p&gt;

&lt;p&gt;Recursion&lt;/p&gt;

&lt;p&gt;Yes, same process with config&lt;/p&gt;

&lt;p&gt;Separate binary (pdns_recursor)&lt;/p&gt;

&lt;p&gt;Idle memory (single zone)&lt;/p&gt;

&lt;p&gt;~20–30 MB&lt;/p&gt;

&lt;p&gt;~60–80 MB (both binaries)&lt;/p&gt;

&lt;p&gt;Best for&lt;/p&gt;

&lt;p&gt;Static internal zones, fast setup&lt;/p&gt;

&lt;p&gt;Dynamic records, scripted automation&lt;/p&gt;

&lt;h2&gt;
  
  
  Running Either One in Docker Without Losing Your Mind
&lt;/h2&gt;

&lt;p&gt;The single biggest reason DNS containers fail to start has nothing to do with your config files. On Ubuntu 22.04 and Debian 12, &lt;code&gt;systemd-resolved&lt;/code&gt; runs a stub listener on &lt;code&gt;127.0.0.53:53&lt;/code&gt; before your container even tries to bind. You'll see &lt;code&gt;bind: address already in use&lt;/code&gt; and spend an hour re-reading your &lt;code&gt;named.conf&lt;/code&gt; looking for a typo that isn't there. Fix this first, before you pull any image:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`shell&lt;/p&gt;

&lt;h1&gt;
  
  
  Stop and permanently disable the stub resolver
&lt;/h1&gt;

&lt;p&gt;sudo systemctl disable --now systemd-resolved&lt;/p&gt;

&lt;h1&gt;
  
  
  Remove the symlink resolv.conf and write a real one
&lt;/h1&gt;

&lt;p&gt;sudo rm /etc/resolv.conf&lt;br&gt;
echo "nameserver 192.168.1.10" | sudo tee /etc/resolv.conf&lt;/p&gt;

&lt;h1&gt;
  
  
  Replace 192.168.1.10 with the LAN IP of your Docker host —
&lt;/h1&gt;

&lt;h1&gt;
  
  
  that's where your new DNS container will answer queries
&lt;/h1&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;After that, verify nothing is still holding port 53 with &lt;code&gt;sudo ss -tulpn | grep ':53'&lt;/code&gt;. The output should be empty. Only then does it make sense to bring up a DNS container — otherwise you're just restarting a container that will immediately fail with the same error and a different service name in the log.&lt;/p&gt;

&lt;p&gt;For Bind9, the &lt;code&gt;internetsystemsconsortium/bind9:9.18&lt;/code&gt; image is the one to use — it's maintained by ISC, ships with a predictable directory layout, and doesn't include distro patches that quietly change default behavior. The critical networking decision is &lt;strong&gt;do not use bridge networking with NAT for a DNS server&lt;/strong&gt;. ACLs based on source IP break when Docker's NAT layer rewrites the client address. Use &lt;code&gt;network_mode: host&lt;/code&gt;, or map ports explicitly and accept that allow-query will see your host IP, not the actual client. Here's a working compose file:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;yaml&lt;br&gt;
version: "3.9"&lt;br&gt;
services:&lt;br&gt;
  bind9:&lt;br&gt;
    image: internetsystemsconsortium/bind9:9.18&lt;br&gt;
    container_name: bind9&lt;br&gt;
    network_mode: host        # preserves real client IPs for ACL matching&lt;br&gt;
    restart: unless-stopped&lt;br&gt;
    volumes:&lt;br&gt;
      - ./config/named.conf:/etc/bind/named.conf:ro&lt;br&gt;
      - ./config/zones:/etc/bind/zones:ro&lt;br&gt;
      - bind9-cache:/var/cache/bind&lt;br&gt;
    # No ports: block needed — host mode handles it&lt;br&gt;
volumes:&lt;br&gt;
  bind9-cache:&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Your &lt;code&gt;named.conf&lt;/code&gt; and zone files live in &lt;code&gt;./config/&lt;/code&gt; relative to the compose file, mounted read-only. The cache volume is a named Docker volume so query caches survive container restarts without cluttering your host filesystem. One gotcha: Bind9 inside the container runs as the &lt;code&gt;bind&lt;/code&gt; user (UID 101 on that image), so if you get permission errors on your zone files, &lt;code&gt;chmod 644&lt;/code&gt; on the files and confirm the mount path matches exactly — a trailing slash difference will cause a silent empty mount.&lt;/p&gt;

&lt;p&gt;PowerDNS in Docker has a separate problem: the SQLite backend needs a database file that actually persists, and the default socket directory inside the container is &lt;code&gt;/var/run/pdns&lt;/code&gt;, not &lt;code&gt;/run/pdns&lt;/code&gt; like the Debian package assumes. If you mount a &lt;code&gt;pdns.conf&lt;/code&gt; written for a bare-metal install without adjusting those paths, the control socket won't open and &lt;code&gt;pdns_control&lt;/code&gt; commands will fail silently. Mount a named volume for the database and a custom config that corrects both paths:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;yaml&lt;br&gt;
version: "3.9"&lt;br&gt;
services:&lt;br&gt;
  powerdns:&lt;br&gt;
    image: powerdns/pdns-auth-48:4.8.3&lt;br&gt;
    container_name: powerdns&lt;br&gt;
    network_mode: host&lt;br&gt;
    restart: unless-stopped&lt;br&gt;
    volumes:&lt;br&gt;
      - ./config/pdns.conf:/etc/powerdns/pdns.conf:ro&lt;br&gt;
      - pdns-data:/var/lib/powerdns   # SQLite db lives here&lt;br&gt;
    environment:&lt;br&gt;
      - PDNS_AUTH_API_KEY=changeme&lt;br&gt;
volumes:&lt;br&gt;
  pdns-data:&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`conf&lt;/p&gt;

&lt;h1&gt;
  
  
  pdns.conf — key paths that differ from Debian package defaults
&lt;/h1&gt;

&lt;p&gt;launch=gsqlite3&lt;br&gt;
gsqlite3-database=/var/lib/powerdns/pdns.sqlite3&lt;br&gt;
socket-dir=/var/run/pdns        # must match what's inside the container image&lt;br&gt;
local-address=0.0.0.0&lt;br&gt;
local-port=53&lt;br&gt;
api=yes&lt;br&gt;
api-key=changeme&lt;br&gt;
webserver=yes&lt;br&gt;
webserver-address=0.0.0.0&lt;br&gt;
webserver-port=8081&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Initialize the schema before first start — the container won't create it automatically:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`shell&lt;/p&gt;

&lt;h1&gt;
  
  
  Run once against the named volume to create the schema
&lt;/h1&gt;

&lt;p&gt;docker run --rm \&lt;br&gt;
  -v pdns-data:/var/lib/powerdns \&lt;br&gt;
  powerdns/pdns-auth-48:4.8.3 \&lt;br&gt;
  pdnsutil create-bind-db /var/lib/powerdns/pdns.sqlite3&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;After that, &lt;code&gt;docker compose up -d&lt;/code&gt; should bring PowerDNS up cleanly. Test with &lt;code&gt;dig @127.0.0.1 your.domain A&lt;/code&gt; from the host — if you get a &lt;code&gt;REFUSED&lt;/code&gt; instead of &lt;code&gt;NXDOMAIN&lt;/code&gt;, your &lt;code&gt;allow-query&lt;/code&gt; or &lt;code&gt;local-address&lt;/code&gt; setting isn't matching the interface the query arrived on, which loops back to the network mode decision above.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Point Your Clients At and How to Test It
&lt;/h2&gt;

&lt;p&gt;The most common mistake after getting Bind9 or PowerDNS responding correctly on the server is assuming all your clients will just pick it up. They won't — not until you tell your router to advertise the new DNS server via DHCP. Go into your router's DHCP server config (usually under LAN settings) and replace the default DNS server field with the static IP of your DNS host. Every client that renews its lease after that change will start using your resolver automatically. No touching individual machines, no static DNS entries on laptops you'll forget about. The one gotcha: clients mid-lease won't switch until renewal, so either wait or run &lt;code&gt;sudo dhclient -r &amp;amp;&amp;amp; sudo dhclient&lt;/code&gt; on Linux, or &lt;code&gt;ipconfig /release &amp;amp;&amp;amp; ipconfig /renew&lt;/code&gt; on Windows to force it.&lt;/p&gt;

&lt;p&gt;Once a client has renewed, the first real test is dead simple:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`shell&lt;/p&gt;

&lt;h1&gt;
  
  
  From any DHCP client on your LAN — not the DNS host itself
&lt;/h1&gt;

&lt;p&gt;nslookup myservice.home.lab&lt;/p&gt;

&lt;h1&gt;
  
  
  Expected output:
&lt;/h1&gt;

&lt;h1&gt;
  
  
  Server:  192.168.1.53
&lt;/h1&gt;

&lt;h1&gt;
  
  
  Address: 192.168.1.53#53
&lt;/h1&gt;

&lt;h1&gt;
  
  
  Name:    myservice.home.lab
&lt;/h1&gt;

&lt;h1&gt;
  
  
  Address: 192.168.1.100
&lt;/h1&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;If you see the right IP and the server field shows your DNS host rather than 8.8.8.8, the DHCP push worked. If it still shows 8.8.8.8, the lease hasn't renewed — force it and try again.&lt;/p&gt;

&lt;p&gt;Split-horizon verification deserves its own explicit check because misconfigured forwarders are subtle. The goal is three separate behaviors working simultaneously: external names forward out correctly, internal names resolve locally, and your internal names don't leak onto public DNS. Test all three explicitly, not just the one that seems most obvious:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`shell&lt;/p&gt;

&lt;h1&gt;
  
  
  External name via your resolver — should return real GitHub IPs
&lt;/h1&gt;

&lt;p&gt;dig github.com @192.168.1.53&lt;/p&gt;

&lt;h1&gt;
  
  
  Internal name via your resolver — should return your LAN A record
&lt;/h1&gt;

&lt;p&gt;dig vault.home.lab @192.168.1.53&lt;/p&gt;

&lt;h1&gt;
  
  
  Internal name against Google — should return NXDOMAIN, NOT your LAN IP
&lt;/h1&gt;

&lt;h1&gt;
  
  
  If this returns an IP, you have a leak or a naming collision
&lt;/h1&gt;

&lt;p&gt;dig vault.home.lab @8.8.8.8&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The third query is the one people skip, and it's the important one. If &lt;code&gt;vault.home.lab&lt;/code&gt; returns anything from 8.8.8.8 other than NXDOMAIN, either someone registered that domain publicly (use a proper RFC 2606 suffix like &lt;code&gt;.internal&lt;/code&gt; or &lt;code&gt;.home.arpa&lt;/code&gt; to avoid this entirely) or your split-horizon config is wrong.&lt;/p&gt;

&lt;p&gt;For ongoing health monitoring, a cron job beats installing another agent. The &lt;code&gt;dig&lt;/code&gt; exit code is reliable — it returns non-zero on timeout or SERVFAIL, which covers the most common failure modes: the daemon crashed, the socket isn't listening, or the zone got corrupted. Drop this in your DNS host's crontab:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`shell&lt;/p&gt;

&lt;h1&gt;
  
  
  /etc/cron.d/dns-health — runs every 5 minutes, mails root on failure
&lt;/h1&gt;

&lt;p&gt;*/5 * * * * root dig +time=2 +tries=1 &lt;a class="mentioned-user" href="https://dev.to/127"&gt;@127&lt;/a&gt;.0.0.1 myservice.home.lab A &amp;gt; /dev/null || \&lt;br&gt;
  echo "DNS health check failed at $(date)" | mail -s "DNS DOWN" root&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;If you're already running Uptime Kuma — and if you're doing any home lab monitoring it's worth having — add a DNS monitor pointing at your resolver's IP with the hostname &lt;code&gt;vault.home.lab&lt;/code&gt; and expected response set to your LAN IP. Kuma will poll it on whatever interval you set and give you a dashboard entry alongside your HTTP checks. That's better than the cron job for visibility, but the cron job runs even if Kuma itself is down, so keep both.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/bind9-vs-powerdns-at-home-which-one-to-run-when-you-actually-need-local-dns/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>tools</category>
      <category>webdev</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Vaultwarden 1.36.0: What Changed, What to Patch, and How to Upgrade Without Losing Your Vault</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Thu, 25 Jun 2026 05:48:58 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/vaultwarden-1360-what-changed-what-to-patch-and-how-to-upgrade-without-losing-your-vault-3gd2</link>
      <guid>https://dev.to/ericwoooo_kr/vaultwarden-1360-what-changed-what-to-patch-and-how-to-upgrade-without-losing-your-vault-3gd2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The self-hosted threat model inverts the SaaS assumption entirely.  With Bitwarden's cloud offering, a dedicated security team monitors infrastructure, rotates credentials on breach indicators, and deploys patches before most users notice a CVE dropped.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~19 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Why This Update Isn't Optional&lt;/li&gt;
&lt;li&gt;What 1.36.0 Actually Patched&lt;/li&gt;
&lt;li&gt;Pre-Upgrade Checklist: Back Up Before You Touch Anything&lt;/li&gt;
&lt;li&gt;The Actual Upgrade: Docker Compose Steps and Config Changes&lt;/li&gt;
&lt;li&gt;Hardening the Instance Beyond the Patch&lt;/li&gt;
&lt;li&gt;Monitoring After the Upgrade: Know When Something Goes Wrong&lt;/li&gt;
&lt;li&gt;When to Delay an Upgrade (and When You Can't)&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Why This Update Isn't Optional
&lt;/h2&gt;

&lt;p&gt;The self-hosted threat model inverts the SaaS assumption entirely. With Bitwarden's cloud offering, a dedicated security team monitors infrastructure, rotates credentials on breach indicators, and deploys patches before most users notice a CVE dropped. With Vaultwarden, you are that team — one person, one instance, zero on-call rotation. Every credential you own, every API key you've vaulted, every SSH passphrase — all of it lives behind whatever you deployed and last touched six months ago.&lt;/p&gt;

&lt;p&gt;Vaultwarden 1.36.0 closes specific attack surface in two areas that matter most: the admin panel authentication flow and session token handling. The admin panel in particular has always been an awkward piece of Vaultwarden's security posture — it's protected by a single shared token rather than a proper account, which means any weakness in how that token is validated or how sessions persist becomes an immediate privilege escalation vector. Skipping this update doesn't mean you're running a slightly older version; it means you're running something with a &lt;em&gt;known&lt;/em&gt;, now-public vulnerability class that any scanner can probe for. The difference between "unpatched" and "targeted" shrinks the moment a CVE gets indexed.&lt;/p&gt;

&lt;p&gt;The highest-risk configuration is also the most common one: Vaultwarden running in Docker, exposed on a public subdomain, sitting behind nginx or Caddy as a reverse proxy. If that describes your setup, the attack surface isn't theoretical. Your admin panel path (&lt;code&gt;/admin&lt;/code&gt;) is reachable from the public internet unless you've explicitly blocked it at the proxy layer. The correct posture is to restrict it by source IP or require an additional auth layer — but regardless, you still need the patch. Proxy-layer restrictions reduce exposure; they don't substitute for fixing the underlying vulnerability. If you're on Caddy, something like this belongs in your config alongside the update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;@admin_block&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;path&lt;/span&gt; &lt;span class="n"&gt;/admin*&lt;/span&gt;
    &lt;span class="s"&gt;not&lt;/span&gt; &lt;span class="s"&gt;remote_ip&lt;/span&gt; &lt;span class="mf"&gt;192.168&lt;/span&gt;&lt;span class="s"&gt;.1.0/24&lt;/span&gt; &lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="s"&gt;.0.0/8&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;span class="s"&gt;respond&lt;/span&gt; &lt;span class="s"&gt;@admin_block&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running Vaultwarden also means your vault stores more than personal passwords — it ends up holding automation credentials, webhook secrets, API tokens for n8n flows, and anything else you've decided a password manager is the right place for. That's a reasonable call, but it means a compromised instance doesn't just leak your email password; it leaks your entire operational secret graph. Managing that surface area — deciding what goes in the vault versus what lives in &lt;code&gt;.env&lt;/code&gt; files or a secrets manager — is its own discipline. For broader context on wiring automation credentials across a home lab stack, the &lt;a href="https://techdigestor.com/ultimate-productivity-guide-2026/" rel="noopener noreferrer"&gt;Workflow Automation in 2026: n8n, Zapier, and Self-Hosted Pipelines&lt;/a&gt; guide covers tooling decisions that intersect with this directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 1.36.0 Actually Patched
&lt;/h2&gt;

&lt;p&gt;The most operationally significant fix is the admin panel rate limiting — and the reason it took this long to land properly is instructive. Prior versions delegated rate limiting to whatever reverse proxy sat in front of Vaultwarden, which is fine until you consider that the admin endpoint reads &lt;code&gt;X-Forwarded-For&lt;/code&gt; and similar headers to determine the "real" client IP. If your Nginx or Caddy config forwarded those headers without stripping attacker-controlled values, a brute-force attempt against &lt;code&gt;/admin&lt;/code&gt; could cycle through IP representations and sidestep any proxy-level rate limit you'd configured. 1.36.0 moves a hard server-side rate limit into Vaultwarden itself, independent of what headers arrive. It applies regardless of proxy configuration. You should still rate-limit at the proxy layer, but you're no longer betting your admin panel on that config being correct.&lt;/p&gt;

&lt;p&gt;The session token expiry fix is quieter but arguably more consequential for anyone running Vaultwarden exposed to the internet. The prior behavior had a validation gap where the server didn't consistently enforce token TTL on certain API call paths — a token grabbed from a compromised client could remain usable past the point where the user had changed their password or the session should have expired. The 1.36.0 fix tightens the freshness check on the server side so token expiry is evaluated on every protected API call, not just at login. If you're already rotating your API keys and using short session windows, the practical blast radius of the old behavior was small — but "small" is still non-zero on a credential vault.&lt;/p&gt;

&lt;p&gt;The Rust dependency tree also got updated, with &lt;code&gt;openssl&lt;/code&gt; and &lt;code&gt;tower-http&lt;/code&gt; both bumped to pull in upstream CVE fixes. Neither was a Vaultwarden-specific exploit path — the &lt;code&gt;tower-http&lt;/code&gt; issue affected denial-of-service behavior in certain HTTP/1.1 header handling scenarios, and the OpenSSL bump followed standard upstream advisory cadence. Still worth understanding the shape of the change: Vaultwarden uses &lt;code&gt;tower-http&lt;/code&gt; as middleware in its Axum-based server stack, so any DoS vector there is a real availability concern for a single-instance self-hosted vault. You can inspect what actually changed in the crate tree yourself:&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;# from the Vaultwarden source root after pulling 1.36.0&lt;/span&gt;
cargo tree &lt;span class="nt"&gt;--duplicates&lt;/span&gt;
cargo audit
&lt;span class="c"&gt;# cargo-audit will cross-reference your Cargo.lock against the RustSec advisory database&lt;/span&gt;
&lt;span class="c"&gt;# install it once with: cargo install cargo-audit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what 1.36.0 did &lt;em&gt;not&lt;/em&gt; fix: &lt;code&gt;ADMIN_TOKEN&lt;/code&gt; is still a plaintext environment variable by default. If your &lt;code&gt;.env&lt;/code&gt; file or Docker Compose config has &lt;code&gt;ADMIN_TOKEN=some_string&lt;/code&gt;, that string is sitting in process environment memory and in whatever secrets management (or lack thereof) you've wired up. Vaultwarden has supported bcrypt-hashed tokens for a while — generate one with &lt;code&gt;echo -n "your_token" | argon2 $(openssl rand -base64 16) -id -t 3 -m 16 -p 4 -l 32&lt;/code&gt; or more simply via the &lt;code&gt;vaultwarden hash&lt;/code&gt; subcommand — but the default experience hasn't changed. If you're pulling 1.36.0 as an opportunity to audit your deployment, converting to a hashed token and injecting it from a secrets manager (Docker secrets, Vault, even just a &lt;code&gt;chmod 600&lt;/code&gt; env file mounted read-only) is the higher-use move than the patches themselves for most self-hosted setups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pre-Upgrade Checklist: Back Up Before You Touch Anything
&lt;/h2&gt;

&lt;p&gt;The backup step most people skip is the SQLite-level one, not the volume copy. Volume copies preserve file state, but if SQLite was mid-write when you ran &lt;code&gt;cp -r&lt;/code&gt;, you've got a corrupt backup you won't discover until you need it. The safe sequence starts with SQLite's own backup mechanism inside the running container:&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;# Flush WAL and take a consistent snapshot inside the container&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;vaultwarden sqlite3 /data/db.sqlite3 &lt;span class="s1"&gt;'.backup /data/db_backup.sqlite3'&lt;/span&gt;

&lt;span class="c"&gt;# Then tar the whole data directory with a timestamp&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--volumes-from&lt;/span&gt; vaultwarden &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/backups:/backups &lt;span class="se"&gt;\&lt;/span&gt;
  alpine &lt;span class="nb"&gt;tar &lt;/span&gt;czf /backups/vaultwarden-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d-%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;.tar.gz /data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.backup&lt;/code&gt; pragma runs a hot backup through SQLite's API — it handles page-level locking and copies a consistent snapshot even while Vaultwarden is live. The volume tar that follows captures attachments, RSA keys, config files, and the icon cache. Both steps together take under ten seconds on a typical self-hosted instance, and skipping either one is how people end up with a vault full of encrypted blobs and no way to decrypt them.&lt;/p&gt;

&lt;p&gt;Before you touch the image, verify the backup is actually readable — not just present on disk. Copy &lt;code&gt;db_backup.sqlite3&lt;/code&gt; somewhere outside the container and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqlite3 db_backup.sqlite3 &lt;span class="s1"&gt;'PRAGMA integrity_check;'&lt;/span&gt;
&lt;span class="c"&gt;# Expected output: ok&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything other than a single &lt;code&gt;ok&lt;/code&gt; means the file is damaged and you should re-run the backup before proceeding. If you have a second machine handy, opening the file there rules out filesystem-level corruption on your host. This takes 30 seconds and is the difference between a recoverable mistake and a complete vault loss.&lt;/p&gt;

&lt;p&gt;Pin down exactly what you're upgrading from before pulling 1.36.0. Vaultwarden has had migration issues when minor versions are skipped — the database schema migrations are sequential, and jumping too far has caused apply-order errors in past releases:&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 the currently running image tag&lt;/span&gt;
docker inspect vaultwarden | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; image

&lt;span class="c"&gt;# Or if you're using compose&lt;/span&gt;
docker compose ps &lt;span class="nt"&gt;--format&lt;/span&gt; json | jq &lt;span class="s1"&gt;'.[].Image'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm you're on 1.35.x. If you're on something older, check the Vaultwarden GitHub releases page for any migration notes between your version and 1.36.0 before pulling. Separately, dump your current &lt;code&gt;docker-compose.yml&lt;/code&gt; environment block — especially &lt;code&gt;ADMIN_TOKEN&lt;/code&gt;, &lt;code&gt;DOMAIN&lt;/code&gt;, and all &lt;code&gt;SMTP_*&lt;/code&gt; variables — into a scratch file. These values survive the upgrade cleanly, but a compose rewrite during the update is a reliable way to accidentally clear a variable and spend an hour debugging why admin panel access is broken or outbound email has gone silent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actual Upgrade: Docker Compose Steps and Config Changes
&lt;/h2&gt;

&lt;p&gt;The pull-and-restart sequence matters more than most upgrade guides acknowledge. Running &lt;code&gt;docker compose down&lt;/code&gt; before pulling creates a window where your Vaultwarden instance is completely unreachable — not just syncing slowly, but returning connection refused. Bitwarden clients handle this gracefully on the next sync cycle, but browser extensions in active use will throw errors until they retry. The cleaner approach:&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;# Pull the new image without stopping the running container&lt;/span&gt;
docker compose pull vaultwarden

&lt;span class="c"&gt;# Recreate only the vaultwarden service — compose handles the stop/start atomically&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; vaultwarden
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you DNS-level continuity. The old container keeps serving until the new one is ready, and clients that were mid-sync reconnect on their next cycle without user-visible errors. On a typical VPS with a cached layer pull, the gap between old container stopping and new container accepting connections is under five seconds.&lt;/p&gt;

&lt;p&gt;The one config change that will silently break rate limiting if you skip it: 1.36.0 made IP header handling explicit. If your reverse proxy (nginx, Caddy, Traefik — all common in front of Vaultwarden) passes &lt;code&gt;X-Forwarded-For&lt;/code&gt; or &lt;code&gt;X-Real-IP&lt;/code&gt; and you never set &lt;code&gt;IP_HEADER&lt;/code&gt; in your environment, the rate limiter may be keying on the proxy's internal IP instead of the real client IP. That means every client looks like the same source, and you hit rate limits in unexpected ways. Add this to your &lt;code&gt;.env&lt;/code&gt; or &lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# In your .env file — match this to what your specific proxy actually sends
&lt;/span&gt;&lt;span class="py"&gt;IP_HEADER&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt;
&lt;span class="c"&gt;# OR if you're using nginx with proxy_set_header X-Real-IP $remote_addr:
# IP_HEADER=X-Real-IP
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're not sure which header your proxy sends, check your nginx or Caddy config. Caddy sets &lt;code&gt;X-Forwarded-For&lt;/code&gt; by default. Nginx only sets it if you've added &lt;code&gt;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for&lt;/code&gt; explicitly — many minimal configs omit this and use &lt;code&gt;X-Real-IP&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;Now that 1.36.0 actually enforces admin panel protections meaningfully, two hardening steps are worth doing on this upgrade pass rather than deferring. First, stop using a plaintext &lt;code&gt;ADMIN_TOKEN&lt;/code&gt;. The official supported method since 1.28+ uses argon2:&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;# Generate the hash — substitute your actual password for 'yourpassword'&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'yourpassword'&lt;/span&gt; | argon2 vaultwarden &lt;span class="nt"&gt;-t&lt;/span&gt; 3 &lt;span class="nt"&gt;-m&lt;/span&gt; 15 &lt;span class="nt"&gt;-p&lt;/span&gt; 4 &lt;span class="nt"&gt;-l&lt;/span&gt; 32 &lt;span class="nt"&gt;-e&lt;/span&gt;

&lt;span class="c"&gt;# Output looks like: $argon2id$v=19$m=32768,t=3,p=4$...&lt;/span&gt;
&lt;span class="c"&gt;# Paste the full output as your ADMIN_TOKEN value in .env&lt;/span&gt;
&lt;span class="c"&gt;# Wrap it in single quotes — the $ characters will break unquoted shell expansion&lt;/span&gt;
&lt;span class="nv"&gt;ADMIN_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'$argon2id$v=19$m=32768,t=3,p=4$$'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, restrict &lt;code&gt;/admin&lt;/code&gt; at the reverse-proxy layer regardless of token strength. In nginx, this is a two-line addition to your server block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/admin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;# Allow only your LAN CIDR — adjust to match your actual network&lt;/span&gt;
    &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="mf"&gt;192.168&lt;/span&gt;&lt;span class="s"&gt;.1.0/24&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://vaultwarden:80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;# ...your existing proxy headers&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the upgrade, the smoke test takes about ninety seconds and catches the most common failure modes before your password manager goes dark mid-day. Log into the web vault directly, then hit the admin panel. Then:&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;# Clean startup shows version string and listening address — no ERROR lines&lt;/span&gt;
docker logs vaultwarden &lt;span class="nt"&gt;--tail&lt;/span&gt; 50 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"(ERROR|Vaultwarden|Listening)"&lt;/span&gt;

&lt;span class="c"&gt;# Expected output on a healthy start:&lt;/span&gt;
&lt;span class="c"&gt;# [INFO] Vaultwarden version 1.36.0 ...&lt;/span&gt;
&lt;span class="c"&gt;# [INFO] Listening on 0.0.0.0:80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see &lt;code&gt;ERROR&lt;/code&gt; lines around IP header parsing or rate limiter initialization, that's almost always the missing &lt;code&gt;IP_HEADER&lt;/code&gt; env var. If the admin panel returns 403 immediately, double-check that your argon2 hash is quoted correctly in the env file — unquoted dollar signs in bash will silently mangle the hash value before Docker ever sees it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardening the Instance Beyond the Patch
&lt;/h2&gt;

&lt;p&gt;Before anything else: if &lt;code&gt;SIGNUPS_ALLOWED&lt;/code&gt; is still set to &lt;code&gt;true&lt;/code&gt; on a single-user instance, stop reading and fix that first. Every minute that flag is true, your instance is an open registration endpoint. Set it to &lt;code&gt;false&lt;/code&gt; in your &lt;code&gt;.env&lt;/code&gt; or Docker Compose environment block, restart the container, and verify it stuck by hitting &lt;code&gt;/register&lt;/code&gt; in a browser — you should get a hard block, not a form.&lt;/p&gt;

&lt;p&gt;The 1.36.0 rate limiter is internal to Vaultwarden, but internal rate limiting is your last line of defense, not your first. Push the enforcement upstream to your reverse proxy where connections can be dropped before they touch the app. The two endpoints worth locking down are &lt;code&gt;/api/accounts/prelogin&lt;/code&gt; and &lt;code&gt;/api/accounts/login&lt;/code&gt; — both are credential-stuffing targets because they're unauthenticated and return distinguishable responses. For Nginx:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Define the zone once in http {} block&lt;/span&gt;
&lt;span class="k"&gt;limit_req_zone&lt;/span&gt; &lt;span class="nv"&gt;$binary_remote_addr&lt;/span&gt; &lt;span class="s"&gt;zone=vw_auth:10m&lt;/span&gt; &lt;span class="s"&gt;rate=5r/m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/accounts/prelogin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;limit_req&lt;/span&gt; &lt;span class="s"&gt;zone=vw_auth&lt;/span&gt; &lt;span class="s"&gt;burst=3&lt;/span&gt; &lt;span class="s"&gt;nodelay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/accounts/login&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;limit_req&lt;/span&gt; &lt;span class="s"&gt;zone=vw_auth&lt;/span&gt; &lt;span class="s"&gt;burst=3&lt;/span&gt; &lt;span class="s"&gt;nodelay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:8080&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;If you're on Caddy, the &lt;code&gt;rate_limit&lt;/code&gt; directive from the &lt;code&gt;caddy-ratelimit&lt;/code&gt; module covers the same surface. The directive isn't in the standard Caddy binary — you'll need a custom build or the &lt;code&gt;xcaddy&lt;/code&gt; approach. The equivalent config limits to 5 requests per minute per IP on those two routes and rejects with a 429 before Caddy ever proxies the request. Either way, the proxy-level block means the connection dies at the edge; the Vaultwarden rate limiter becomes a redundant backstop rather than the primary gate.&lt;/p&gt;

&lt;p&gt;Fail2ban can parse Vaultwarden's log output, but the Docker logging path needs explicit setup. Vaultwarden doesn't write to a file by default when running in Docker — you need to redirect &lt;code&gt;docker logs&lt;/code&gt; output to a file, or configure the container's logging driver to write to &lt;code&gt;/var/log/vaultwarden/&lt;/code&gt;. The easiest approach without changing the logging driver is a log forwarder or simply setting &lt;code&gt;--log-driver json-file&lt;/code&gt; with a known path in your Compose file, then symlinking or tailing into Fail2ban's watched directory. The filter pattern itself is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/fail2ban/filter.d/vaultwarden.conf
&lt;/span&gt;&lt;span class="nn"&gt;[Definition]&lt;/span&gt;
&lt;span class="py"&gt;failregex&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;^.*Username or password is incorrect&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt; &lt;span class="s"&gt;Try again&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt; &lt;span class="s"&gt;IP: .*$&lt;/span&gt;
&lt;span class="py"&gt;ignoreregex&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/fail2ban/jail.d/vaultwarden.conf
&lt;/span&gt;&lt;span class="nn"&gt;[vaultwarden]&lt;/span&gt;
&lt;span class="py"&gt;enabled&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;port&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;http,https&lt;/span&gt;
&lt;span class="py"&gt;filter&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;vaultwarden&lt;/span&gt;
&lt;span class="py"&gt;logpath&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/var/log/vaultwarden/vaultwarden.log&lt;/span&gt;
&lt;span class="py"&gt;maxretry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;5&lt;/span&gt;
&lt;span class="py"&gt;findtime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;600&lt;/span&gt;
&lt;span class="py"&gt;bantime&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1800&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 5-attempt/10-minute threshold (&lt;code&gt;findtime = 600&lt;/code&gt;) is conservative enough to catch automated stuffing without locking out a legitimate user who fat-fingered their master password twice. Adjust &lt;code&gt;bantime&lt;/code&gt; upward if you're seeing repeat offenders — 30 minutes is a reasonable starting point, not a ceiling.&lt;/p&gt;

&lt;p&gt;On secrets management: if you're running an automation layer like n8n that reads Vaultwarden credentials or calls the admin API, the &lt;code&gt;ADMIN_TOKEN&lt;/code&gt; hash sitting in a &lt;code&gt;.env&lt;/code&gt; file that gets committed to a repo (even a private one) is a real exposure. Docker secrets solve this without requiring a full secrets manager. In Compose v3.7+:&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;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;vaultwarden&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vaultwarden/server:1.36.0&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# Reference the secret file path instead of embedding the value&lt;/span&gt;
      &lt;span class="na"&gt;ADMIN_TOKEN_FILE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/run/secrets/vw_admin_token&lt;/span&gt;
    &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;vw_admin_token&lt;/span&gt;

&lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;vw_admin_token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./secrets/vw_admin_token.txt&lt;/span&gt;  &lt;span class="c1"&gt;# lives outside the repo root&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vaultwarden 1.36.0 supports the &lt;code&gt;_FILE&lt;/code&gt; suffix convention for reading secrets from mounted files — verify against the current docs since this behavior can shift between minor releases. The &lt;code&gt;secrets/&lt;/code&gt; directory goes in your &lt;code&gt;.gitignore&lt;/code&gt;, and the value itself never touches the Compose file. If your n8n flows use the Vaultwarden admin API, give those flows their own scoped token if the admin API ever gains per-token scoping — for now, the Docker secret approach at least keeps the hash off disk in plaintext and out of environment variable dumps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring After the Upgrade: Know When Something Goes Wrong
&lt;/h2&gt;

&lt;p&gt;The upgrade itself is five minutes of work. The part that actually protects you is what runs the other 99.9% of the time. Vaultwarden 1.36.0 added distinct log entries for rate-limit triggers — where earlier versions would silently drop requests or log a generic rejection, you now get a structured line you can grep for. That single change makes brute-force detection possible without a full SIEM. The minimum viable version: a cron job that runs every hour, counts occurrences of &lt;code&gt;rate limit exceeded&lt;/code&gt; in the container log, and fires a webhook if the count crosses your threshold.&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;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# /opt/scripts/check_ratelimit.sh&lt;/span&gt;
&lt;span class="c"&gt;# Counts rate-limit log entries from the last 60 minutes&lt;/span&gt;
&lt;span class="nv"&gt;THRESHOLD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10
&lt;span class="nv"&gt;CONTAINER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"vaultwarden"&lt;/span&gt;
&lt;span class="nv"&gt;LOGFILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/var/log/vaultwarden_ratelimit_check.log"&lt;/span&gt;

&lt;span class="nv"&gt;COUNT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker logs &lt;span class="nt"&gt;--since&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1h &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"rate limit exceeded"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$COUNT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$THRESHOLD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WEBHOOK_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;Vaultwarden: &lt;/span&gt;&lt;span class="nv"&gt;$COUNT&lt;/span&gt;&lt;span class="s2"&gt; rate-limit events in the last hour&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; ALERT: &lt;/span&gt;&lt;span class="nv"&gt;$COUNT&lt;/span&gt;&lt;span class="s2"&gt; events"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOGFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop that in &lt;code&gt;/etc/cron.d/&lt;/code&gt; on an hourly schedule and you have an effective brute-force early warning with zero extra infrastructure. If you're already running Grafana with Loki, point a log stream at the container and create an alert on the query &lt;code&gt;{container="vaultwarden"} |= "rate limit exceeded"&lt;/code&gt; — you'll get rate-over-time graphing as a bonus, which is genuinely useful for distinguishing a scanning bot from a misconfigured mobile client hammering the sync endpoint.&lt;/p&gt;

&lt;p&gt;For uptime monitoring, two checks cover most failure modes. A TCP check on the Vaultwarden port (default 3012 for WebSocket, 80/443 through your reverse proxy) will catch container crashes within seconds. But a container can stay up and the app can wedge — the event loop stalls, DB connections exhaust, whatever. That's what &lt;code&gt;/alive&lt;/code&gt; is for. Vaultwarden exposes this health endpoint that returns a 200 when the app is actually responding. In Uptime Kuma, set up an HTTP keyword monitor pointed at &lt;code&gt;https://your-vault-domain/alive&lt;/code&gt;, check interval 60 seconds, keyword &lt;code&gt;OK&lt;/code&gt;. The TCP check catches hard crashes; the HTTP check catches soft failures the TCP check misses entirely.&lt;/p&gt;

&lt;p&gt;Backup verification is where most self-hosters have a gap. Copying a SQLite file to S3 every night is not a backup strategy — it's a hope strategy. The actual test is whether that file can be opened and passes integrity checks. This is one place where my n8n setup earns its keep: a weekly workflow that pulls the latest backup, runs &lt;code&gt;sqlite3 db.sqlite3 "PRAGMA integrity_check;"&lt;/code&gt;, and posts the result to a webhook. If the output is anything other than &lt;code&gt;ok&lt;/code&gt;, the flow sends an alert.&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;# Run from a cron or n8n Execute Command node&lt;/span&gt;
&lt;span class="nv"&gt;BACKUP_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/mnt/backups/vaultwarden/db_&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.sqlite3"&lt;/span&gt;

&lt;span class="c"&gt;# -batch: non-interactive mode, -init /dev/null: skip .sqliterc&lt;/span&gt;
&lt;span class="nv"&gt;RESULT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;sqlite3 &lt;span class="nt"&gt;-batch&lt;/span&gt; &lt;span class="nt"&gt;-init&lt;/span&gt; /dev/null &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"PRAGMA integrity_check;"&lt;/span&gt; 2&amp;gt;&amp;amp;1&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RESULT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ok"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"status":"ok","file":"'&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s1"&gt;'"}'&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"status":"FAILED","output":"'&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RESULT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s1"&gt;'"}'&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And when something does go wrong with the upgrade itself, the rollback is mechanical if you prepared correctly. The full sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;code&gt;docker compose down&lt;/code&gt; — stops the container cleanly, doesn't touch the data volume&lt;/li&gt;
&lt;li&gt; Edit &lt;code&gt;docker-compose.yml&lt;/code&gt;: change &lt;code&gt;vaultwarden/server:1.36.0&lt;/code&gt; back to &lt;code&gt;vaultwarden/server:1.35.0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt; Restore the pre-upgrade SQLite snapshot to the data volume: &lt;code&gt;cp /mnt/backups/vaultwarden/pre_upgrade_db.sqlite3 /opt/vaultwarden/data/db.sqlite3&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt; &lt;code&gt;docker compose up -d&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Clients reconnect and see their vault exactly as it was before the upgrade. The critical dependency is that backup being current — which is exactly why the integrity check and the pre-upgrade snapshot aren't optional steps you do when you remember. The rollback only works cleanly if the backup was taken &lt;em&gt;after&lt;/em&gt; a clean shutdown and &lt;em&gt;before&lt;/em&gt; the new container ever touched the database file. If you let 1.36.0 run migrations and then try to roll back to a stale backup, you will lose data written between the migration and the restore. Shut down, copy, then bring up the new version. That order is non-negotiable.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Delay an Upgrade (and When You Can't)
&lt;/h2&gt;

&lt;p&gt;The 24-48 hour hold is a legitimate strategy for self-hosted software — but you have to actually use it, not just wait randomly. The Vaultwarden GitHub issues tab fills up fast after a release. Early adopters running unusual configs (SQLite on ARM, non-standard SMTP setups, Cloudflare Tunnel proxies) will surface startup panics or migration failures within hours. A silent database schema change that works fine on Postgres 16 can corrupt a SQLite WAL file in an edge case that nobody caught in testing. Waiting a day and skimming the open issues costs you nothing unless you're actively being attacked.&lt;/p&gt;

&lt;p&gt;1.36.0 is one of the releases where the hold strategy breaks down for a specific subset of operators: anyone with the admin panel exposed directly to the internet without an additional auth layer in front of it. The rate-limiting fix addresses a real attack surface that exists right now. If your &lt;code&gt;/admin&lt;/code&gt; path is reachable without IP allowlisting, a Cloudflare Access rule, or at minimum HTTP basic auth at the reverse proxy, you're not in a position to wait. The version of Vaultwarden running on that box is already a target. Update, then harden the ingress.&lt;/p&gt;

&lt;p&gt;Version pinning by digest is how you get the best of both worlds — controlled rollout without riding &lt;code&gt;latest&lt;/code&gt; into whatever the next release drops. Pull the digest after you've confirmed the release is stable:&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;# Get the digest for a specific tag after it's been out 48 hours&lt;/span&gt;
docker pull vaultwarden/server:1.36.0
docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{{index .RepoDigests 0}}'&lt;/span&gt; vaultwarden/server:1.36.0
&lt;span class="c"&gt;# outputs something like:&lt;/span&gt;
&lt;span class="c"&gt;# vaultwarden/server@sha256:a3f9c2e1...&lt;/span&gt;

&lt;span class="c"&gt;# Use that in your compose file, not the tag&lt;/span&gt;
image: vaultwarden/server@sha256:a3f9c2e1d4b8f7e6c5d2a1b9e3f8c7d6e5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tradeoff: pinning by digest means you will not accidentally move, which is good. It also means you have to actually watch the release feed — GitHub releases RSS, the Vaultwarden Discord, or the GitHub tags API — otherwise you'll sit on a vulnerable version indefinitely and feel safe because your compose file looks deliberate. Set a calendar reminder or wire up a GitHub Actions workflow that pings you when a new tag appears on the repo.&lt;/p&gt;

&lt;p&gt;The update cadence issue is subtler and most operators miss it entirely. Vaultwarden ships its own build of the Bitwarden web vault client alongside the server binary. A security-relevant fix in the upstream web vault JavaScript — a token handling bug, a CSP regression, a change in how the client validates server responses — can land in a Vaultwarden release without triggering a CVE assignment or a "security" label on the GitHub release. The release notes will mention the upstream web vault version bump, but you have to read them to catch it. Treating version tags as the only signal and skipping the actual changelog is how you miss half the security-relevant changes in any given release.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/vaultwarden-1-36-0-what-changed-what-to-patch-and-how-to-upgrade-without-losing-your-vault/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>tools</category>
      <category>webdev</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Monitoring a Kubernetes Cluster with OpenTelemetry Collector: Agent + Gateway Pattern That Actually Works</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Wed, 24 Jun 2026 07:44:10 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/monitoring-a-kubernetes-cluster-with-opentelemetry-collector-agent-gateway-pattern-that-actually-3foc</link>
      <guid>https://dev.to/ericwoooo_kr/monitoring-a-kubernetes-cluster-with-opentelemetry-collector-agent-gateway-pattern-that-actually-3foc</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The single-collector setup is almost always how this starts: one DaemonSet or Deployment running a monolithic OpenTelemetry Collector that scrapes node metrics, receives traces from instrumented apps, and ships everything to your backend.  It works fine right up until it doesn't.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~21 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Problem: One Collector Per Cluster Is a Reliability Trap&lt;/li&gt;
&lt;li&gt;Architecture Before You Touch a YAML File&lt;/li&gt;
&lt;li&gt;Deploying the Agent DaemonSet: Config and Gotchas&lt;/li&gt;
&lt;li&gt;Deploying the Gateway: Batching, Retries, and Memory Limiter&lt;/li&gt;
&lt;li&gt;Kubernetes-Specific Receivers Worth Enabling&lt;/li&gt;
&lt;li&gt;Sizing, Resource Reality, and When This Pattern Breaks Down&lt;/li&gt;
&lt;li&gt;Validating the Stack End-to-End Before You Trust It&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Problem: One Collector Per Cluster Is a Reliability Trap
&lt;/h2&gt;

&lt;p&gt;The single-collector setup is almost always how this starts: one DaemonSet or Deployment running a monolithic OpenTelemetry Collector that scrapes node metrics, receives traces from instrumented apps, and ships everything to your backend. It works fine right up until it doesn't. One pipeline misconfiguration — say, a regex scrub rule that burns CPU on high-cardinality trace attributes — and the collector process OOMs. Every metric, every trace, every log from every node: gone simultaneously. There's no blast radius containment because there's no architectural separation.&lt;/p&gt;

&lt;p&gt;The failure mode is especially sharp on self-hosted and home-lab clusters where you're not running multiple collector replicas behind a load balancer. A single pod with a memory limit of 512Mi hits a traffic spike during a deployment, the kernel OOM killer fires, and you lose observability exactly when you needed it most — during the rollout you were trying to watch. The monolithic collector is a single point of failure dressed up as a convenience.&lt;/p&gt;

&lt;p&gt;The agent + gateway split fixes this structurally. Agents run as a DaemonSet — one collector pod per node — and their only jobs are local scraping (kubelet metrics, cAdvisor, node exporters) and short-term buffering. They're intentionally lightweight. The gateway runs as a separate Deployment (typically two replicas minimum) and handles everything expensive: batching, retry logic, tail sampling, and the actual export to Prometheus remote write, Grafana Cloud OTLP, or wherever your backend lives. A crashed agent takes out observability for one node. A crashed gateway replica doesn't take out collection — agents keep buffering locally until the gateway recovers.&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;# agent config: scrape local kubelet, forward to gateway only&lt;/span&gt;
&lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;otel-gateway.monitoring.svc.cluster.local:4317"&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;insecure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  &lt;span class="c1"&gt;# internal cluster traffic, terminate TLS at gateway&lt;/span&gt;

&lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kubeletstats&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;collection_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15s&lt;/span&gt;
    &lt;span class="na"&gt;auth_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;serviceAccount&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${K8S_NODE_NAME}:10250"&lt;/span&gt;
    &lt;span class="na"&gt;insecure_skip_verify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pipelines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;kubeletstats&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# never export to backend directly from agent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The concern separation also makes debugging tractable. If your Prometheus remote write endpoint starts rejecting samples, you debug the gateway config in one place instead of hunting through every node's collector logs. If one node's kubelet metrics are spiking cardinality, you adjust that agent's pipeline without touching anything else. This maps cleanly to how other automation systems isolate concerns — the same logic applies if you're wiring observability signals into external workflows; &lt;a href="https://techdigestor.com/ultimate-productivity-guide-2026/" rel="noopener noreferrer"&gt;Workflow Automation in 2026: n8n, Zapier, and Self-Hosted Pipelines&lt;/a&gt; covers how those handoff points work when your cluster signals need to trigger downstream pipeline steps.&lt;/p&gt;

&lt;p&gt;One gotcha that doesn't appear in the OpenTelemetry Collector docs until you've already been burned: agents buffering to disk (via the &lt;code&gt;file_storage&lt;/code&gt; extension) need a PersistentVolume or at minimum a &lt;code&gt;hostPath&lt;/code&gt; mount, otherwise a pod restart loses whatever was queued. Most example configs skip this entirely and leave you with in-memory queuing only — fine for traces with short TTLs, quietly catastrophic for metrics you're trying to preserve across a gateway outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Before You Touch a YAML File
&lt;/h2&gt;

&lt;p&gt;The pattern that actually works at scale splits responsibility along a hard boundary: agents are dumb collectors with tight resource limits, the gateway is where all the expensive processing happens. Conflating these two roles into a single collector deployment is the most common mistake I see in OTel setups — you either end up with fat DaemonSet pods starving your workloads, or you lose the HA guarantees you need on the processing side.&lt;/p&gt;

&lt;p&gt;The agent runs as a DaemonSet — one pod per node, no exceptions. Its job is narrow: pull kubelet metrics via the &lt;code&gt;hostMetrics&lt;/code&gt; receiver, tail container logs via the &lt;code&gt;filelog&lt;/code&gt; receiver, and accept spans from app pods on localhost over OTLP gRPC port 4317. That last part is important — pods send to &lt;code&gt;localhost:4317&lt;/code&gt; because the agent is guaranteed to be on the same node, which avoids cross-node hops for span traffic. Processing on the agent should be near-zero: maybe a &lt;code&gt;memory_limiter&lt;/code&gt; and a basic &lt;code&gt;resourcedetection&lt;/code&gt; processor for node-level labels, nothing else. A 128Mi memory limit is realistic for this role and you should enforce it. Agents that balloon past that are usually doing work they shouldn't be doing.&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;# agent memory_limiter — keep it lean&lt;/span&gt;
&lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;memory_limiter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;check_interval&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;limit_mib&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;110&lt;/span&gt;        &lt;span class="c1"&gt;# soft ceiling below the 128Mi pod limit&lt;/span&gt;
    &lt;span class="na"&gt;spike_limit_mib&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;   &lt;span class="c1"&gt;# headroom for burst without OOMKill&lt;/span&gt;

  &lt;span class="na"&gt;resourcedetection&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;detectors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;k8snode&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# node name + any env vars you inject&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
    &lt;span class="na"&gt;override&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gateway is a regular Deployment — run at least two replicas for HA, with a HorizontalPodAutoscaler if your span volume is spiky. It receives OTLP from every agent over gRPC, and this is where the real pipeline work happens: &lt;code&gt;k8sattributes&lt;/code&gt; processor to enrich with pod/namespace/deployment labels, a &lt;code&gt;batch&lt;/code&gt; processor to reduce backend write pressure, and tail-based sampling if you're running traces at any meaningful volume. All your exporters — Prometheus remote write, Jaeger, Loki — live here, not on the agent. The gateway is the single egress point, which means you can change backends without redeploying anything on your nodes.&lt;/p&gt;

&lt;p&gt;Traffic flow is strictly unidirectional: pod → agent (4317) → gateway (4317) → backend. That unidirectionality is load-bearing. The moment you introduce a feedback loop — say, a gateway that tries to scrape something that also pushes to the gateway — you get circular pipeline failures that are genuinely hard to debug because they only manifest under backpressure. The Prometheus Operator is the reason a lot of teams end up in this trap: they already have it running, it handles metrics fine, so they bolt OTel on top without rethinking the flow. The fundamental problem with stopping at the Prometheus Operator is that it doesn't handle traces or logs at all, and its scrape interval model (default 15s, minimum practical ~5s) is the wrong primitive for span data where you need sub-second decisions on sampling. The OTel Collector handles all three signals — metrics, logs, traces — in one binary with one config surface, which is why the agent/gateway split on top of it beats a hybrid Prometheus + something-else approach once you have more than one signal to care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying the Agent DaemonSet: Config and Gotchas
&lt;/h2&gt;

&lt;p&gt;The most expensive mistake you can make with the agent tier is under-provisioning memory on nodes that run GPU workloads. Set &lt;code&gt;resources.limits.memory: 256Mi&lt;/code&gt; and &lt;code&gt;resources.requests.memory: 128Mi&lt;/code&gt; — GPU pods on the same node will spike memory pressure during model loading, and a collector agent with no headroom will get OOM-killed silently. You won't see a crash loop because the DaemonSet restarts it immediately, but you'll have gaps in your metrics that look like a scrape timing issue until you check &lt;code&gt;kubectl describe pod&lt;/code&gt; on the agent and see the OOM eviction history.&lt;/p&gt;

&lt;p&gt;Install with the official Helm chart at version 0.97 or later — earlier versions had issues with the filelog receiver's multiline parsing defaults that caused partial log lines to flush incorrectly:&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="s"&gt;helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts&lt;/span&gt;
&lt;span class="s"&gt;helm repo update&lt;/span&gt;

&lt;span class="s"&gt;helm upgrade --install otel-agent open-telemetry/opentelemetry-collector \&lt;/span&gt;
  &lt;span class="s"&gt;--namespace monitoring \&lt;/span&gt;
  &lt;span class="s"&gt;--create-namespace \&lt;/span&gt;
  &lt;span class="s"&gt;--version 0.97.0 \&lt;/span&gt;
  &lt;span class="s"&gt;--values agent-values.yaml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;agent-values.yaml&lt;/code&gt; core receiver block looks like this in practice. The kubeletstats receiver endpoint using the node name env var is not optional — hardcoding a node IP breaks pod scheduling entirely:&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;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;daemonset&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;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="s"&gt;256Mi&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;200m&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="s"&gt;128Mi&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;50m&lt;/span&gt;

&lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;hostmetrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;collection_interval&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;scrapers&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="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="pi"&gt;{}&lt;/span&gt;
        &lt;span class="na"&gt;network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
        &lt;span class="na"&gt;filesystem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="c1"&gt;# exclude tmpfs and overlay to avoid noise from container layers&lt;/span&gt;
          &lt;span class="na"&gt;exclude_mount_points&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;match_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;regexp&lt;/span&gt;
            &lt;span class="na"&gt;mount_points&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;/dev.*"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/sys.*"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/proc.*"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;overlay"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tmpfs"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

    &lt;span class="na"&gt;kubeletstats&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;collection_interval&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;auth_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;serviceAccount&lt;/span&gt;
      &lt;span class="c1"&gt;# requires K8S_NODE_NAME env var injected via fieldRef&lt;/span&gt;
      &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://${env:K8S_NODE_NAME}:10250"&lt;/span&gt;
      &lt;span class="na"&gt;insecure_skip_verify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;metric_groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;node&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pod&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;container&lt;/span&gt;

    &lt;span class="na"&gt;filelog&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="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/log/pods/*/*/*.log&lt;/span&gt;
      &lt;span class="na"&gt;include_file_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;include_file_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;operators&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes_metadata&lt;/span&gt;
          &lt;span class="c1"&gt;# enriches log records with pod name, namespace, container name, labels&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;json_parser&lt;/span&gt;
          &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;parse_from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;attributes.time&lt;/span&gt;
            &lt;span class="na"&gt;layout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%Y-%m-%dT%H:%M:%S.%LZ"&lt;/span&gt;

  &lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otel-gateway.monitoring.svc.cluster.local:4317&lt;/span&gt;
      &lt;span class="na"&gt;compression&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gzip&lt;/span&gt;
      &lt;span class="na"&gt;sending_queue&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;num_consumers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;
        &lt;span class="na"&gt;queue_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt;
      &lt;span class="na"&gt;retry_on_failure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;initial_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
        &lt;span class="na"&gt;max_interval&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;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;pipelines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;hostmetrics&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;kubeletstats&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;logs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;filelog&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kubeletstats 403 gotcha will burn you if you assume the Helm chart handles RBAC completely. It creates a ServiceAccount and basic Role, but &lt;strong&gt;does not create a ClusterRole for &lt;code&gt;nodes/stats&lt;/code&gt; and &lt;code&gt;nodes/proxy&lt;/code&gt;&lt;/strong&gt;. The failure mode is subtle: the collector starts successfully, the DaemonSet reports Ready, but kubeletstats silently drops metrics. You'll only see it if you're watching collector logs directly — it surfaces as repeated &lt;code&gt;failed to scrape: 403 Forbidden&lt;/code&gt; lines, not as a container that won't start. Create this manually:&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;rbac.authorization.k8s.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;ClusterRole&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;otel-agent-kubeletstats&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&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;"&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="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;nodes/stats&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;nodes/proxy&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;nodes/metrics&lt;/span&gt;
    &lt;span class="na"&gt;verbs&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;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;watch"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="c1"&gt;# needed by kubernetes_metadata operator in filelog&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&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;"&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="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;pods&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespaces&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&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;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;watch"&lt;/span&gt;&lt;span class="pi"&gt;]&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;rbac.authorization.k8s.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;ClusterRoleBinding&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;otel-agent-kubeletstats&lt;/span&gt;
&lt;span class="na"&gt;roleRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;apiGroup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io&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;ClusterRole&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;otel-agent-kubeletstats&lt;/span&gt;
&lt;span class="na"&gt;subjects&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&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;ServiceAccount&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;otel-agent-opentelemetry-collector&lt;/span&gt;  &lt;span class="c1"&gt;# default name from Helm chart&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;sending_queue&lt;/code&gt; size of 300 on the OTLP exporter is deliberate. Gateway restarts during upgrades or rollouts can take 15–30 seconds, and at 30-second collection intervals across a node with 40+ pods, you'll queue up a meaningful number of metric batches before the gateway is back. 300 entries covers that window without letting the agent balloon in memory — the queue is in-memory, so a 300-item queue on a busy node adds roughly 20–30MB depending on cardinality. If your nodes run dense, high-cardinality workloads, watch the agent's own memory usage via &lt;code&gt;kubectl top pod&lt;/code&gt; in the monitoring namespace and tune the limit up before hitting the OOM wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying the Gateway: Batching, Retries, and Memory Limiter
&lt;/h2&gt;

&lt;p&gt;The most counterintuitive thing about the gateway's &lt;code&gt;memory_limiter&lt;/code&gt; processor is that its position is non-negotiable — it &lt;strong&gt;must&lt;/strong&gt; be first in every pipeline, before batching, before sampling, before anything else. If you put it second, the batch processor has already allocated memory for a full buffer before the limiter gets a chance to refuse incoming data. By the time the limiter fires, you're already in an OOM spiral on a pod with a 512Mi limit. The processor ordering in OTEL Collector config isn't cosmetic; the runtime processes them in array order.&lt;/p&gt;

&lt;p&gt;Here's the Helm values block I use for the gateway 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="c1"&gt;# values-gateway.yaml&lt;/span&gt;
&lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deployment&lt;/span&gt;
&lt;span class="na"&gt;replicaCount&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;resources&lt;/span&gt;&lt;span class="pi"&gt;:&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="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;1"&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="s"&gt;256Mi&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;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;memory_limiter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;limit_mib&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;400&lt;/span&gt;
      &lt;span class="na"&gt;spike_limit_mib&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;check_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;

    &lt;span class="na"&gt;batch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;send_batch_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8192&lt;/span&gt;
      &lt;span class="na"&gt;send_batch_max_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10000&lt;/span&gt;
      &lt;span class="na"&gt;timeout&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;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;pipelines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;memory_limiter&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;batch&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# order matters&lt;/span&gt;
      &lt;span class="na"&gt;traces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;memory_limiter&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;tail_sampling&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;batch&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;spike_limit_mib: 80&lt;/code&gt; gives you a cushion between the soft limit (400Mi) and the hard container limit (512Mi). Without that headroom, a sudden burst of spans can push the process over the cgroup limit before the check interval fires. With &lt;code&gt;check_interval: 5s&lt;/code&gt;, the limiter polls every five seconds — coarse, but cheap. If you set it to 1s you'll see CPU overhead in the collector metrics itself.&lt;/p&gt;

&lt;p&gt;On batching: the collector's default &lt;code&gt;send_batch_size&lt;/code&gt; is 8192 items per the current docs, but depending on the chart version you're pulling, it may default to something far smaller — I've seen deployments shipping 200-item batches to a Prometheus remote write endpoint, which means hundreds of HTTP POSTs per minute for any reasonably active cluster. That hammers Mimir's ingester with tiny requests and inflates your HTTP overhead ratio badly. The config above sets &lt;code&gt;send_batch_max_size: 10000&lt;/code&gt; as a ceiling so a single goroutine flush can't send an unbounded payload if a queue backs up, and &lt;code&gt;timeout: 10s&lt;/code&gt; forces a flush even if the batch isn't full — useful during low-traffic windows so metrics don't sit in the buffer stale.&lt;/p&gt;

&lt;p&gt;For Prometheus remote write to a self-hosted Mimir instance, the exporter config looks like this:&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;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheusremotewrite&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://mimir.monitoring.svc:9009/api/v1/push&lt;/span&gt;
    &lt;span class="na"&gt;retry_on_failure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;initial_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;max_interval&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;max_elapsed_time&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;300s&lt;/span&gt;
    &lt;span class="c1"&gt;# tls is optional if you're terminating inside the cluster mesh&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;insecure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;retry_on_failure&lt;/code&gt; block is the part most people skip on first deploy and then regret. Without it, a Mimir pod restart or a brief network hiccup between namespaces silently drops whatever was in the exporter's send queue. With &lt;code&gt;max_elapsed_time: 300s&lt;/code&gt;, the collector will retry for up to five minutes before giving up on a batch — which covers most rolling restarts cleanly. Note that retried data is held in memory, so size your &lt;code&gt;limit_mib&lt;/code&gt; with that in mind if your backend goes down for extended periods.&lt;/p&gt;

&lt;p&gt;Tail-based sampling is where the gateway topology gets sticky. The &lt;code&gt;tail_sampling&lt;/code&gt; processor needs to see &lt;em&gt;all spans from a single trace&lt;/em&gt; before it can make a keep/drop decision — so if trace &lt;code&gt;abc123&lt;/code&gt; has spans landing on both gateway pod replicas, neither pod has enough information to evaluate the policy. The standard approach is sticky routing from the agent side using the &lt;code&gt;loadbalancing&lt;/code&gt; exporter, which hashes on trace ID:&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;# agent config — routes traces to gateway by trace ID hash&lt;/span&gt;
&lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;loadbalancing&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;routing_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traceID&lt;/span&gt;
    &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;:&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;insecure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;resolver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;dns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otel-gateway.monitoring.svc.cluster.local&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;4317&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this, every span for a given trace ID routes to the same gateway replica regardless of which node the agent pod lives on. Then on the gateway, the tail sampling policy can do the useful thing — keep 100% of traces that contain at least one error span, and sample down to 5% of clean traces:&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;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;tail_sampling&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;decision_wait&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;   &lt;span class="c1"&gt;# wait up to 10s for all spans before deciding&lt;/span&gt;
    &lt;span class="na"&gt;num_traces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50000&lt;/span&gt;    &lt;span class="c1"&gt;# in-memory trace buffer; tune to your trace volume&lt;/span&gt;
    &lt;span class="na"&gt;policies&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;keep-errors&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;status_code&lt;/span&gt;
        &lt;span class="na"&gt;status_code&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;status_codes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;ERROR&lt;/span&gt;&lt;span class="pi"&gt;]&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;sample-healthy&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;probabilistic&lt;/span&gt;
        &lt;span class="na"&gt;probabilistic&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;sampling_percentage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;5&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;decision_wait: 10s&lt;/code&gt; means the processor holds spans in memory for up to 10 seconds before committing a sampling decision. Combined with &lt;code&gt;num_traces: 50000&lt;/code&gt;, you can do the math on how much memory this buffer consumes relative to your average spans-per-trace count. High-cardinality microservice traces with 200+ spans per trace will eat that budget much faster than a simple 3-service path. If you see the gateway's heap climbing toward the &lt;code&gt;limit_mib&lt;/code&gt; threshold under load, reducing &lt;code&gt;num_traces&lt;/code&gt; or tightening &lt;code&gt;decision_wait&lt;/code&gt; is the first dial to turn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kubernetes-Specific Receivers Worth Enabling
&lt;/h2&gt;

&lt;p&gt;The receiver placement question trips up almost every first-time OTel deployment on Kubernetes: the &lt;code&gt;k8s_cluster&lt;/code&gt; receiver belongs on the &lt;strong&gt;gateway&lt;/strong&gt;, full stop. It talks directly to the Kubernetes API server to pull pod phase transitions, node conditions, and deployment replica counts — that's cluster-wide state, not node-local telemetry. If you accidentally run it on every agent DaemonSet pod (easy to do when you're copying config between components), you'll end up with the same metric emitted from a dozen sources simultaneously, each with a slightly different collection timestamp. Prometheus or your OTLP backend will either deduplicate them incorrectly or surface false gaps. Run exactly one instance on the gateway, and give that gateway pod a service account with &lt;code&gt;list&lt;/code&gt; and &lt;code&gt;watch&lt;/code&gt; on pods, nodes, namespaces, and replicasets.&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;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;k8s_cluster&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;collection_interval&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;node_conditions_to_report&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Ready&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MemoryPressure&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DiskPressure&lt;/span&gt;
    &lt;span class="na"&gt;allocatable_types_to_report&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cpu&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;memory&lt;/span&gt;
    &lt;span class="c1"&gt;# auth_type defaults to serviceAccount — works inside the cluster&lt;/span&gt;
    &lt;span class="c1"&gt;# Do NOT add this block to your agent config&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;k8sevents&lt;/code&gt; receiver is the sleeper feature most people ignore until they've spent an hour grepping through logs for why a pod won't start. It surfaces Kubernetes events — OOMKills, &lt;code&gt;BackOff&lt;/code&gt; loops, failed scheduling decisions, image pull failures — as structured log records with resource attributes. Route these into Loki with a pipeline that tags them by &lt;code&gt;k8s.namespace.name&lt;/code&gt; and &lt;code&gt;k8s.pod.name&lt;/code&gt;, and suddenly you can run a single LogQL query that shows both the application's stdout and the Kubernetes event that preceded the crash. The key config detail is the &lt;code&gt;auth_type: serviceAccount&lt;/code&gt; plus a ClusterRole that grants &lt;code&gt;get&lt;/code&gt; and &lt;code&gt;list&lt;/code&gt; on the &lt;code&gt;events&lt;/code&gt; resource. Without the resource attribute filter on the Loki exporter side, events from all namespaces land in the same stream and become useless noise.&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;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;k8sevents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;auth_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;serviceAccount&lt;/span&gt;

&lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;loki&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://loki-gateway:3100/loki/api/v1/push&lt;/span&gt;
    &lt;span class="na"&gt;default_labels_enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;exporter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;job&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="c1"&gt;# resource attributes promoted to Loki stream labels:&lt;/span&gt;
    &lt;span class="na"&gt;resource_to_labels_mappings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;k8s.namespace.name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;namespace&lt;/span&gt;
      &lt;span class="na"&gt;k8s.object.kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kind&lt;/span&gt;
      &lt;span class="na"&gt;k8s.object.name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;object_name&lt;/span&gt;

&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pipelines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;logs/k8sevents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;k8sevents&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;memory_limiter&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;batch&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;loki&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;resourcedetection&lt;/code&gt; processor with &lt;code&gt;detectors: [k8snode, env]&lt;/code&gt; is the piece that makes cross-signal correlation actually work in Grafana. Without it, a metric arrives tagged with a pod name but nothing that tells you which node it ran on or which cloud region that node lives in. The &lt;code&gt;k8snode&lt;/code&gt; detector reads the node name from the downward API (you expose it as &lt;code&gt;K8S_NODE_NAME&lt;/code&gt; env var on the agent pod), then enriches every span, metric, and log with &lt;code&gt;k8s.node.name&lt;/code&gt;. The &lt;code&gt;env&lt;/code&gt; detector picks up whatever you've set in &lt;code&gt;OTEL_RESOURCE_ATTRIBUTES&lt;/code&gt; — this is where you inject cluster name since there's no native detector for it yet. Drop this processor early in every pipeline on both the agent and gateway, or you'll find metrics that correlate fine within a single node but fall apart when you try to build a node-comparison dashboard.&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;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;resourcedetection&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;detectors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;k8snode&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;k8snode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# reads NODE_NAME from downward API env injection&lt;/span&gt;
      &lt;span class="na"&gt;node_from_env_var&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;K8S_NODE_NAME&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2s&lt;/span&gt;
    &lt;span class="na"&gt;override&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;  &lt;span class="c1"&gt;# don't overwrite attributes set by the app itself&lt;/span&gt;

&lt;span class="c1"&gt;# In your agent DaemonSet spec:&lt;/span&gt;
&lt;span class="c1"&gt;# env:&lt;/span&gt;
&lt;span class="c1"&gt;#   - name: K8S_NODE_NAME&lt;/span&gt;
&lt;span class="c1"&gt;#     valueFrom:&lt;/span&gt;
&lt;span class="c1"&gt;#       fieldRef:&lt;/span&gt;
&lt;span class="c1"&gt;#         fieldPath: spec.nodeName&lt;/span&gt;
&lt;span class="c1"&gt;#   - name: OTEL_RESOURCE_ATTRIBUTES&lt;/span&gt;
&lt;span class="c1"&gt;#     value: "k8s.cluster.name=prod-us-east-1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha with the &lt;code&gt;k8s_cluster&lt;/code&gt; receiver: it emits metrics under the &lt;code&gt;k8s.&lt;/code&gt; namespace but the exact metric names shifted between OTel Collector versions around 0.80. If you're referencing dashboards built against an older collector, you may find deployment replica metrics named &lt;code&gt;k8s.deployment.desired&lt;/code&gt; vs &lt;code&gt;k8s.deployment.desired_pods&lt;/code&gt; depending on what version generated them. Pin your collector image tag explicitly and validate metric names with a one-off debug export to &lt;code&gt;debug&lt;/code&gt; exporter (verbosity: detailed) before wiring up Grafana panels — saves a lot of "why is this panel empty" time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sizing, Resource Reality, and When This Pattern Breaks Down
&lt;/h2&gt;

&lt;p&gt;The numbers here are smaller than most blog posts admit. On a three-node home-lab cluster — 8 cores, 32GB RAM per node — the agent DaemonSet pods sit at roughly 50–80Mi RSS and 0.05–0.1 CPU cores at steady state with the &lt;code&gt;k8sattributes&lt;/code&gt;, &lt;code&gt;hostmetrics&lt;/code&gt;, and &lt;code&gt;kubeletstats&lt;/code&gt; receivers active. The gateway Deployment pair (two replicas) lands at 150–300Mi RAM depending on trace and log volume. Those numbers sound fine until you enable the &lt;code&gt;filelog&lt;/code&gt; receiver without a &lt;code&gt;max_log_size&lt;/code&gt; cap on a node running chatty Java services — memory climbs past 600Mi per agent within hours as the receiver buffers partial reads. Always set this:&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;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;filelog&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="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;/var/log/pods/*/*/*.log&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;operators&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;recombine&lt;/span&gt;
        &lt;span class="na"&gt;is_last_entry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;matches&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"\\n$"'&lt;/span&gt;
    &lt;span class="c1"&gt;# Without this, a single runaway container will OOM your agent&lt;/span&gt;
    &lt;span class="na"&gt;max_log_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1MiB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gateway pattern solves the wrong problem if your observability backend can't absorb what the gateway is forwarding. A self-hosted Prometheus with a 15-second scrape interval and no remote write configured is almost always the actual bottleneck — not the collector. Once you're retaining more than 30 days of metrics, the local Prometheus TSDB becomes a liability: compaction stalls, WAL replay on restart takes minutes, and head block size blows up. Mimir (single-binary mode) or Thanos sidecar are the practical exits. Mimir's single-binary mode is underrated for home-lab — one pod, object storage backend, and you get multi-tenancy and long-term retention without the Thanos component sprawl. The gateway's &lt;code&gt;prometheusremotewrite&lt;/code&gt; exporter points at Mimir and you're done:&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;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheusremotewrite&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&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://mimir:9009/api/v1/push"&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;X-Scope-OrgID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;homelab&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;insecure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  &lt;span class="c1"&gt;# internal cluster, no cert needed&lt;/span&gt;
    &lt;span class="na"&gt;retry_on_failure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;initial_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;max_elapsed_time&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;120s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Single-node k3s or a local &lt;code&gt;kind&lt;/code&gt; cluster doesn't need this pattern. Two Helm releases, separate ConfigMaps, a Service for gateway ingestion — that's real operational overhead when you're the only person maintaining it and the cluster has four pods running. A single collector Deployment with &lt;code&gt;hostmetrics&lt;/code&gt;, &lt;code&gt;k8s_cluster&lt;/code&gt;, and &lt;code&gt;kubeletstats&lt;/code&gt; receivers combined is simpler and covers every signal you'll realistically query. The agent + gateway split pays for itself when you have three or more nodes, multiple teams writing to the cluster, or a backend that needs traffic shaping before it receives data. Before that threshold, it's complexity theater.&lt;/p&gt;

&lt;p&gt;The debug workflow that actually catches misconfiguration before it silently drops your data: wire a &lt;code&gt;debug&lt;/code&gt; exporter with &lt;code&gt;verbosity: detailed&lt;/code&gt; into a parallel pipeline that mirrors your production receivers, then check the zpages endpoint. You don't need to redeploy anything in production to see what's flowing:&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;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;debug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;verbosity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;detailed&lt;/span&gt;        &lt;span class="c1"&gt;# logs full telemetry to collector stdout&lt;/span&gt;
    &lt;span class="na"&gt;sampling_initial&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;        &lt;span class="c1"&gt;# first 5 per second, then throttle&lt;/span&gt;
    &lt;span class="na"&gt;sampling_thereafter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;

&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;extensions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;zpages&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;pipelines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metrics/debug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# same receiver, separate pipeline&lt;/span&gt;
      &lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;memory_limiter&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;debug&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;extensions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;zpages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.0.0.0:55679&lt;/span&gt;   &lt;span class="c1"&gt;# port-forward this to localhost&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then from your workstation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl port-forward &lt;span class="nt"&gt;-n&lt;/span&gt; otel svc/otel-gateway 55679:55679
&lt;span class="c"&gt;# Open: http://localhost:55679/debug/pipelinez&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;/debug/pipelinez&lt;/code&gt; page shows accepted, refused, and dropped counts per pipeline in real time. Refused means your processor rejected it (often a memory limiter threshold hit). Dropped means the exporter failed and the retry budget ran out. That distinction matters — refused data never left the agent; dropped data made it to the gateway and then got lost trying to reach the backend. If you're seeing drops and not refused counts, the problem is downstream, not in your pipeline config.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validating the Stack End-to-End Before You Trust It
&lt;/h2&gt;

&lt;p&gt;The most common mistake after wiring up an agent/gateway pipeline is trusting the absence of errors as proof it works. Collector logs staying quiet and Prometheus targets showing green means nothing if your tail sampler is silently dropping everything or the &lt;code&gt;k8sattributes&lt;/code&gt; processor can't call the API server. Verify the full signal path before you put any workload under this stack.&lt;/p&gt;

&lt;h3&gt;
  
  
  Firing a Synthetic Trace Span
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;otel-cli&lt;/code&gt; is a statically compiled binary that speaks the OTLP gRPC protocol directly — no SDK, no app code, no sidecars. Install it on any jump box or run it inside the cluster as a one-shot pod, then fire a span at the agent's ClusterIP:&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;# Run from inside the cluster — replace agent-svc with your actual Service name&lt;/span&gt;
otel-cli span &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; test-span &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--service&lt;/span&gt; my-app &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--endpoint&lt;/span&gt; http://agent-svc:4317 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tp-required&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--verbose&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--verbose&lt;/code&gt; flag prints the gRPC status response so you can distinguish "agent accepted" from "connection refused" immediately. If the span doesn't appear in Jaeger or Tempo after about 10 seconds, resist the urge to blame the agent first. Check the gateway's tail sampler policy. A tail sampler configured with &lt;code&gt;policy: probabilistic, sampling_percentage: 10&lt;/code&gt; will silently discard 90% of traffic, including your synthetic span if it lands in a sampled-out trace ID bucket. Temporarily set &lt;code&gt;sampling_percentage: 100&lt;/code&gt;, refire the span, then tune back down. If it still doesn't appear, check whether the gateway's OTLP exporter is targeting the right backend address — a wrong hostname fails silently unless you have &lt;code&gt;verbosity: detailed&lt;/code&gt; set under the exporter's &lt;code&gt;sending_queue&lt;/code&gt; block.&lt;/p&gt;

&lt;h3&gt;
  
  
  Metric Pipeline Integrity via Internal Telemetry
&lt;/h3&gt;

&lt;p&gt;Both the agent and the gateway expose their own Prometheus metrics on port 8888 by default. The two counters worth watching are &lt;code&gt;otelcol_receiver_accepted_metric_points&lt;/code&gt; and &lt;code&gt;otelcol_exporter_sent_metric_points&lt;/code&gt;, scoped per receiver/exporter label. A healthy pipeline has these tracking each other closely. When accepted exceeds sent for longer than a rolling 60-second window, data is being dropped inside the collector — not lost on the wire, but discarded after ingestion.&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;# Prometheus query to catch sustained drop gaps on the gateway&lt;/span&gt;
&lt;span class="o"&gt;(&lt;/span&gt;
  rate&lt;span class="o"&gt;(&lt;/span&gt;otelcol_receiver_accepted_metric_points&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;job&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"otelcol-gateway"&lt;/span&gt;&lt;span class="o"&gt;}[&lt;/span&gt;2m]&lt;span class="o"&gt;)&lt;/span&gt;
  -
  rate&lt;span class="o"&gt;(&lt;/span&gt;otelcol_exporter_sent_metric_points&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;job&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"otelcol-gateway"&lt;/span&gt;&lt;span class="o"&gt;}[&lt;/span&gt;2m]&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A non-zero result here almost always points to the memory limiter processor. The default &lt;code&gt;memory_limiter&lt;/code&gt; config in most example pipelines sets &lt;code&gt;limit_mib: 512&lt;/code&gt; — fine for small clusters, but under real scrape load with hundreds of pods the gateway resident set climbs fast. Either raise &lt;code&gt;limit_mib&lt;/code&gt; to match the container's actual memory limit (leave 20% headroom), or reduce the agent's scrape interval from 15s to 30s if you have flexibility there. The other thing to check is &lt;code&gt;otelcol_exporter_send_failed_metric_points&lt;/code&gt;: that counter increments when the downstream backend (Prometheus remote write, Cortex, Mimir) is rejecting writes, which looks identical to a memory limiter problem in dashboards unless you separate the two counters.&lt;/p&gt;

&lt;h3&gt;
  
  
  Log Pipeline Label Smoke Test
&lt;/h3&gt;

&lt;p&gt;The log path has a failure mode that produces no errors anywhere: the &lt;code&gt;k8sattributes&lt;/code&gt; processor runs, calls the API server to enrich log records, fails due to an RBAC gap, and then passes the log through &lt;em&gt;without&lt;/em&gt; the Kubernetes metadata labels rather than dropping it or logging a warning. Logs arrive in Loki, they look fine, but every query that filters on &lt;code&gt;kubernetes.pod_name&lt;/code&gt; or &lt;code&gt;kubernetes.namespace_name&lt;/code&gt; returns nothing.&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;# Write a known log line from an arbitrary pod&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; your-ns deploy/any-deployment &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo "otel-label-test-$(date +%s)"'&lt;/span&gt;

&lt;span class="c"&gt;# Then query Loki — requires logcli or the Loki HTTP API&lt;/span&gt;
logcli query &lt;span class="s1"&gt;'{namespace="your-ns"}'&lt;/span&gt; &lt;span class="nt"&gt;--limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5 &lt;span class="nt"&gt;--output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;jsonl | &lt;span class="se"&gt;\&lt;/span&gt;
  jq &lt;span class="s1"&gt;'.labels | has("kubernetes.pod_name")'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the &lt;code&gt;has()&lt;/code&gt; check returns &lt;code&gt;false&lt;/code&gt;, the processor isn't attaching metadata. The most common cause is a missing ClusterRole binding. The &lt;code&gt;k8sattributes&lt;/code&gt; processor needs &lt;code&gt;get&lt;/code&gt;, &lt;code&gt;watch&lt;/code&gt;, and &lt;code&gt;list&lt;/code&gt; on &lt;code&gt;pods&lt;/code&gt;, &lt;code&gt;namespaces&lt;/code&gt;, &lt;code&gt;nodes&lt;/code&gt;, and &lt;code&gt;replicasets&lt;/code&gt; — that last one catches people because it's needed for resolving owner references back to Deployments. A quick way to confirm it's an RBAC problem rather than a misconfigured processor is to check the collector pod logs for lines containing &lt;code&gt;k8sattributes&lt;/code&gt; and &lt;code&gt;Forbidden&lt;/code&gt;; those only appear at startup when the processor does its initial list call. If the collector started cleanly but labels are still missing, the issue is more likely the &lt;code&gt;extract.metadata&lt;/code&gt; block in your processor config not listing &lt;code&gt;k8s.pod.name&lt;/code&gt; explicitly.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/monitoring-a-kubernetes-cluster-with-opentelemetry-collector-agent-gateway-pattern-that-actually-works/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>cloud</category>
      <category>docker</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>FileRun, OnlyOffice, and Pangolin for a Self-Hosted Web Calendar: Which One Actually Fits Your Stack</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Mon, 22 Jun 2026 07:49:05 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/filerun-onlyoffice-and-pangolin-for-a-self-hosted-web-calendar-which-one-actually-fits-your-stack-4257</link>
      <guid>https://dev.to/ericwoooo_kr/filerun-onlyoffice-and-pangolin-for-a-self-hosted-web-calendar-which-one-actually-fits-your-stack-4257</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The setup sounds simple until you actually try to build it: a calendar you can open in a browser, sync to your phone over CalDAV, host on your own hardware, and never pay a per-seat bill for.  That constraint eliminates almost every polished option immediately.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~23 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Problem: You Want a Web Calendar Without Renting Someone Else's Server&lt;/li&gt;
&lt;li&gt;The Constraint That Forces a Choice&lt;/li&gt;
&lt;li&gt;FileRun 2026: Calendar as a Bolt-On to a File Manager&lt;/li&gt;
&lt;li&gt;OnlyOffice: Calendar Inside a Document Collaboration Suite&lt;/li&gt;
&lt;li&gt;Pangolin: The Reverse-Proxy-Native Approach&lt;/li&gt;
&lt;li&gt;Side-by-Side: What You Actually Get Per Use Case&lt;/li&gt;
&lt;li&gt;Gotchas That Cost Time Across All Three&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Problem: You Want a Web Calendar Without Renting Someone Else's Server
&lt;/h2&gt;

&lt;p&gt;The setup sounds simple until you actually try to build it: a calendar you can open in a browser, sync to your phone over CalDAV, host on your own hardware, and never pay a per-seat bill for. That constraint eliminates almost every polished option immediately. What's left is a pile of self-hosted tools that technically touch calendars but were mostly built to solve adjacent problems — and the documentation rarely admits that.&lt;/p&gt;

&lt;p&gt;The usual suspects disappoint in predictable ways. Nextcloud does have a working CalDAV implementation and a decent browser UI, but you're pulling in a full PHP application stack, a mandatory database, and a sprawling plugin ecosystem just to get a calendar. On a small VPS or a home server with limited RAM, Nextcloud idles at several hundred megabytes before anyone logs in. Radicale is the opposite extreme — a tight Python CalDAV/CardDAV server that runs in about 20MB, has zero browser UI, and requires you to already know what a &lt;code&gt;.ics&lt;/code&gt; file is to do anything with it. The guides that recommend Radicale almost always pair it with a third-party web frontend that's either abandoned or requires a separate Node or PHP runtime, which defeats the point. And most tutorials conflate file sync with calendar functionality entirely — they'll walk you through mounting a WebDAV share and call it done.&lt;/p&gt;

&lt;p&gt;FileRun 2026, OnlyOffice, and Pangolin approach the problem from three genuinely different angles, which is why comparing them forces a real decision rather than a preference. FileRun is a file manager that added calendar and contacts through tight OnlyOffice integration — the calendar exists because OnlyOffice Documents ships with one, and FileRun wires up the auth. OnlyOffice as a standalone Document Server gives you the office suite and calendar surface, but calendar is not its primary job and the deployment complexity reflects that. Pangolin is a newer entrant that treats calendaring as a first-class feature alongside its reverse proxy and identity layer, which means the calendar ships with SSO baked in rather than bolted on. Each of those architectural choices has downstream consequences: what breaks when you upgrade, how CalDAV sync actually behaves with iOS and Android clients, and how much Docker Compose YAML you're maintaining on a Sunday afternoon.&lt;/p&gt;

&lt;p&gt;The trade-off is real because none of these are drop-in. FileRun with OnlyOffice gives you the most polished browser experience but requires two cooperating containers plus a database, and the OnlyOffice Document Server alone wants at least 2GB of RAM allocated before it feels stable. Pangolin's calendar story is newer and the ecosystem is thinner, but the identity and routing layer it ships with is genuinely useful if you're running multiple self-hosted services behind the same domain. If you're already evaluating what tooling sits around your self-hosted stack — including AI-assisted development tools — the tradeoffs around local vs. cloud-hosted capability are worth thinking through in parallel; see our guide on &lt;a href="https://techdigestor.com/best-ai-coding-tools-2026/" rel="noopener noreferrer"&gt;AI Coding Tools in 2026: Cloud Copilots vs Local Models&lt;/a&gt; for how that decision tree plays out.&lt;/p&gt;

&lt;p&gt;One thing that doesn't get said enough in self-hosting calendar guides: CalDAV compliance is not binary. A server can pass basic sync and still break iOS's birthday calendar sync, still corrupt recurring event exceptions on Android, or still refuse to serve free/busy data to a second client. The version of the CalDAV server underneath — whether that's the one bundled in OnlyOffice, FileRun's integration layer, or Pangolin's backend — determines which of those edge cases you'll hit. That's the part worth testing before you migrate anything important off Google Calendar or iCloud.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Constraint That Forces a Choice
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Hardware Baseline and Why It Forces Real Decisions
&lt;/h3&gt;

&lt;p&gt;The comparison here isn't theoretical. Single-node Docker host, 16 GB RAM, no GPU, Caddy handling TLS termination and reverse proxying. That constraint eliminates a whole class of recommendations immediately — anything that idles at 2+ GB RAM per service is already competing for headroom with databases, reverse proxies, and whatever else is running on the same box. Calendar workloads have no GPU requirement, but they do have a latency expectation: a user hitting a CalDAV sync or loading a shared calendar invite shouldn't wait four seconds while a JVM warms up.&lt;/p&gt;

&lt;p&gt;Three axes determine which stack survives that environment. First: calendar feature completeness — specifically CalDAV protocol support, per-user sharing with granular permissions, and outbound invite handling that external clients (iOS Calendar, Thunderbird, Android via DAVx⁵) can actually consume without custom workarounds. Second: resource overhead at idle &lt;em&gt;and&lt;/em&gt; under concurrent sessions, because a service that idles at 80 MB but balloons to 900 MB when three users sync simultaneously is a different animal than one that's consistently 300 MB. Third: integration surface — how cleanly does this service plug into an existing Docker Compose stack with a shared Postgres instance, a shared Redis, or an SMTP relay container? Services that want their own bundled database, or that assume they own the network namespace, create operational drag that compounds over time.&lt;/p&gt;

&lt;p&gt;Nextcloud comes up in every self-hosted calendar conversation for obvious reasons: the ecosystem is massive, the CalDAV implementation is mature, and the file access story is genuinely good. The problem is that Nextcloud's calendar features come attached to a full collaboration suite you probably don't want. On a 16 GB single-node host, a production Nextcloud install with PostgreSQL backend, Redis for file locking, and a PHP-FPM pool sized for even modest concurrency will consume 600 MB–1.2 GB at idle depending on worker configuration — before you add ONLYOFFICE or any other app. If the requirement is "calendar plus light file access," you're paying a significant resource tax for hub features (Nextcloud Talk, Activities, the full Files app with chunked uploads) that sit idle and still consume memory through background workers and cron jobs.&lt;/p&gt;

&lt;p&gt;The sharper problem with Nextcloud is its cron dependency. The &lt;code&gt;nextcloud-cron&lt;/code&gt; container — or a system cron hitting &lt;code&gt;occ cron&lt;/code&gt; — has to run every five minutes or calendar reminders drift, shares stop propagating, and background jobs queue up silently. Miss it for an hour and you start debugging why invites aren't arriving, not realizing the jobs table has 400 pending entries. That operational overhead is fine when you're running a full Nextcloud deployment for a team, but it's hard to justify for a stack whose primary workload is CalDAV sync and occasional document preview. FileRun, Radicale, and Baikal sidestep this entirely — they're stateless or near-stateless per-request services without background job infrastructure. That's the concrete reason to look past Nextcloud when the scope is narrow.&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;# Rough idle footprint comparison — measure on your own host with:&lt;/span&gt;
docker stats &lt;span class="nt"&gt;--no-stream&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"table {{.Name}}&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;{{.MemUsage}}"&lt;/span&gt;

&lt;span class="c"&gt;# Nextcloud (PHP-FPM + background workers, no apps beyond Calendar):&lt;/span&gt;
&lt;span class="c"&gt;# nextcloud-app     ~420MiB / 16GiB&lt;/span&gt;
&lt;span class="c"&gt;# nextcloud-cron    ~180MiB / 16GiB&lt;/span&gt;
&lt;span class="c"&gt;# nextcloud-redis   ~12MiB  / 16GiB&lt;/span&gt;

&lt;span class="c"&gt;# Radicale (Python, single process, SQLite or filesystem storage):&lt;/span&gt;
&lt;span class="c"&gt;# radicale          ~28MiB  / 16GiB&lt;/span&gt;

&lt;span class="c"&gt;# Baikal (PHP, no background workers):&lt;/span&gt;
&lt;span class="c"&gt;# baikal            ~55MiB  / 16GiB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those aren't benchmarks — run them yourself, because PHP-FPM pool sizes and OPcache configuration will shift numbers significantly. The point is the order of magnitude difference. A service stack that leaves 14 GB free after the calendar layer is running means you can colocate FileRun, an ONLYOFFICE Document Server for previews, and a Postgres 16 instance without swapping. That arithmetic is why the hardware baseline has to be explicit before any recommendation lands.&lt;/p&gt;

&lt;h2&gt;
  
  
  FileRun 2026: Calendar as a Bolt-On to a File Manager
&lt;/h2&gt;

&lt;p&gt;The important framing to get right before you touch a config file: FileRun is a Dropbox-style self-hosted file manager that happens to expose CalDAV and CardDAV endpoints. It is not a calendar application with file storage attached. That distinction changes your expectations immediately — the calendar UI inside FileRun is bare-bones by design, and the real value of the CalDAV endpoint is for syncing with an external client (Thunderbird, Apple Calendar, DAVx⁵ on Android). If you want a rich calendar interface in the browser, you will be disappointed. If you want a single container that handles file sync &lt;em&gt;and&lt;/em&gt; lets your phone sync its calendar without running a separate Nextcloud or Radicale instance, FileRun in 2026 is still a reasonable answer.&lt;/p&gt;

&lt;p&gt;The MySQL 8 requirement is not negotiable and it is where most first-time deployments stall. FileRun's PHP layer also requires &lt;code&gt;iconv&lt;/code&gt; and &lt;code&gt;gd&lt;/code&gt; to be present — they are not always included in base PHP-FPM images and the error you get when they are missing is a generic 500, not a helpful "extension not found" message. A minimal working &lt;code&gt;docker-compose.yml&lt;/code&gt; that avoids the common traps:&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;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.9"&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;filerun-db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql:8.0&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;filerun-db&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_ROOT_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;changeme_root&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;filerun&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;filerun&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;changeme_filerun&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;filerun-db:/var/lib/mysql&lt;/span&gt;
    &lt;span class="c1"&gt;# innodb_buffer_pool_size controls RAM floor; 128M is workable for small deployments&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;--innodb_buffer_pool_size=128M --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci&lt;/span&gt;

  &lt;span class="na"&gt;filerun&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;filerun/filerun:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;filerun&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;filerun-db&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;FR_DB_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;filerun-db&lt;/span&gt;
      &lt;span class="na"&gt;FR_DB_PORT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3306&lt;/span&gt;
      &lt;span class="na"&gt;FR_DB_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;filerun&lt;/span&gt;
      &lt;span class="na"&gt;FR_DB_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;filerun&lt;/span&gt;
      &lt;span class="na"&gt;FR_DB_PASS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;changeme_filerun&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;filerun-html:/var/www/html&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/mnt/data/filerun-userfiles:/user-files&lt;/span&gt;
    &lt;span class="na"&gt;ports&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;8080:80"&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;filerun-db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;filerun-html&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The official FileRun image bundles the PHP extensions including &lt;code&gt;gd&lt;/code&gt; and &lt;code&gt;iconv&lt;/code&gt;, so if you pull &lt;code&gt;filerun/filerun&lt;/code&gt; directly you will not hit the extension trap. Where people get burned is when they try to run FileRun on a generic &lt;code&gt;php:8.2-fpm&lt;/code&gt; base behind their own Nginx config — at that point you need to explicitly add &lt;code&gt;docker-php-ext-install gd intl&lt;/code&gt; in your Dockerfile and rebuild. The &lt;code&gt;utf8mb4&lt;/code&gt; charset flags on MySQL are not optional either; FileRun stores file and calendar metadata that includes Unicode characters and the default MySQL 8 charset handling will silently truncate emoji in filenames or event titles.&lt;/p&gt;

&lt;p&gt;The CalDAV endpoint lives at &lt;code&gt;/dav.php&lt;/code&gt;. For Thunderbird with the TbSync or CardBook extension, or Apple Calendar on macOS/iOS, the URL format is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Apple Calendar / Thunderbird CardBook
https://filerun.yourdomain.com/dav.php/calendars/USERNAME/default/

# What the FileRun docs show (correct)
https://filerun.yourdomain.com/dav.php/calendars/USERNAME/

# What breaks Android DAVx⁵ (missing trailing slash)
https://filerun.yourdomain.com/dav.php/calendars/USERNAME
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That trailing slash on the &lt;code&gt;/calendars/USERNAME/&lt;/code&gt; path is not mentioned in the FileRun documentation but it is the first thing to check when DAVx⁵ connects, authenticates successfully, and then shows zero calendars. The underlying issue is that the PHP router does not issue a redirect for the slash-less variant when the request comes from a DAV client rather than a browser — the server returns a 200 with an empty response body instead of a 301, so DAVx⁵ interprets it as "no calendars found" rather than a misconfigured URL. Apple Calendar is more forgiving and will follow the redirect; Android clients are not.&lt;/p&gt;

&lt;p&gt;On resource usage: expect the PHP-FPM container to sit at roughly 80–120 MB RAM at idle with no active users. Under active file sync or calendar operations it will spike, but it returns to baseline quickly. The MySQL container is the variable — at the 128 MB &lt;code&gt;innodb_buffer_pool_size&lt;/code&gt; shown above, it will idle around 200–250 MB total process RSS. If you push that setting to 512 MB (reasonable for a small team), MySQL alone can consume 400–500 MB. On a VPS with 2 GB total RAM, the combination is workable but leaves limited headroom if you are also running a reverse proxy and anything else on the same host. FileRun does not cache aggressively at the PHP layer, so repeated directory listings do go back to MySQL — keep that buffer pool large enough to hold your working set of file metadata or you will feel it in latency.&lt;/p&gt;

&lt;h2&gt;
  
  
  OnlyOffice: Calendar Inside a Document Collaboration Suite
&lt;/h2&gt;

&lt;p&gt;The most common OnlyOffice mistake isn't a configuration error — it's running the wrong product entirely. OnlyOffice Docs (the standalone document editor, the one virtually every Docker tutorial covers) has no calendar. Zero. The calendar feature lives exclusively in OnlyOffice Community Server, which is a completely different container with a completely different resource profile. If you followed a guide that had you pull &lt;code&gt;onlyoffice/documentserver&lt;/code&gt;, you got the editor only. Community Server is &lt;code&gt;onlyoffice/communityserver&lt;/code&gt;, and conflating the two will cost you hours before you realize the calendar tab simply doesn't exist in what you deployed.&lt;/p&gt;

&lt;p&gt;The Docker deployment reality for Community Server is aggressive on resources. The single container image runs MySQL, Elasticsearch, and RabbitMQ internally — not as separate compose services you can tune individually, but bundled inside the one container with its own internal process supervisor. On a 16 GB host, expect 3–5 GB RAM consumed at idle before a single user connects. Elasticsearch alone accounts for a significant chunk of that. You can cap it somewhat with JVM heap flags passed as environment variables, but you're fighting the architecture rather than tuning it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; onlyoffice-community &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 80:80 &lt;span class="nt"&gt;-p&lt;/span&gt; 443:443 &lt;span class="nt"&gt;-p&lt;/span&gt; 5222:5222 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;ELASTICSEARCH_SERVER_JAVA_OPTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"-Xms512m -Xmx512m"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /app/onlyoffice/CommunityServer/data:/var/www/onlyoffice/Data &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /app/onlyoffice/CommunityServer/mysql:/var/lib/mysql &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /app/onlyoffice/CommunityServer/logs:/var/log/onlyoffice &lt;span class="se"&gt;\&lt;/span&gt;
  onlyoffice/communityserver
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dropping Elasticsearch's heap to 512 MB helps, but the service will start complaining under any real indexing load. This is not a box you run on a $6/month VPS.&lt;/p&gt;

&lt;p&gt;The calendar feature surface is genuinely capable once you're in. Room booking is built in, external CalDAV sync exists, and iCal feed export works reliably. The UI is polished — arguably the most finished calendar UI in the self-hosted space. The friction shows up in CalDAV client compatibility. DAVx⁵ on Android connects without drama. macOS Calendar is where things get fiddly: the auto-discovery URL that Apple expects doesn't always resolve correctly, and you'll often end up manually constructing the endpoint URL in the format &lt;code&gt;https://your-domain/caldav/[user-guid]/&lt;/code&gt; rather than just pointing at the domain root. The docs claim broad CalDAV compatibility; the reality is you'll debug at least one client before everything syncs cleanly.&lt;/p&gt;

&lt;p&gt;The overhead is defensible exactly once: when your team already needs collaborative document editing and would otherwise run a separate OnlyOffice Docs instance anyway. In that scenario, Community Server consolidates two services into one, and the 4 GB idle RAM cost gets spread across both use cases. If you only want a web calendar — maybe with some file storage on the side — that calculus falls apart immediately. FileRun with a CalDAV sidecar, or Nextcloud on a constrained machine, will deliver a usable calendar at a fraction of the memory footprint. Community Server's bundled architecture is a product decision optimized for replacing Google Workspace entirely, not for running one module of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pangolin: The Reverse-Proxy-Native Approach
&lt;/h2&gt;

&lt;p&gt;Pangolin solves a different problem than FileRun or OnlyOffice do. It doesn't ship a calendar, a file manager, or a document editor — it's a self-hosted tunneling and reverse proxy platform, roughly analogous to Cloudflare Tunnel but running entirely on infrastructure you control. The interesting move here is using Pangolin as the exposure layer for a lightweight CalDAV server like Baïkal or Radicale, so you get identity-aware access control and encrypted tunneling without touching a firewall rule. If your calendar host lives on a separate VLAN, a homelab node behind CGNAT, or a remote machine you'd rather not punch holes in, this architecture is worth understanding.&lt;/p&gt;

&lt;p&gt;The core architectural difference: FileRun and OnlyOffice assume the service is directly reachable — you configure a reverse proxy in front, you open ports, you manage TLS termination yourself. Pangolin flips this. The tunnel daemon on your internal host dials outward to your Pangolin server; no inbound port ever opens. The Pangolin edge then applies zero-trust rules (user identity, device posture, resource policies) before a request even reaches your Baïkal container. For a calendar endpoint specifically, this means a stolen session token is less dangerous — the attacker still has to pass the identity check at the Pangolin layer before they can even issue a CalDAV request.&lt;/p&gt;

&lt;p&gt;Configuring a Pangolin site target to front a Baïkal container requires careful attention to header forwarding. CalDAV clients — iOS Calendar, Thunderbird with the TbSync extension, and most DAV-aware clients — rely on the &lt;code&gt;Authorization&lt;/code&gt; header passing through untouched, and they use the &lt;code&gt;Host&lt;/code&gt; and &lt;code&gt;DAV&lt;/code&gt; headers to discover capability. A common failure mode is the proxy stripping or rewriting &lt;code&gt;Authorization: Digest&lt;/code&gt; headers, which causes silent auth failures that look like connectivity problems. The Pangolin site config to get this right:&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;# pangolin/config/sites/baikal-cal.yaml&lt;/span&gt;
&lt;span class="na"&gt;site&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;baikal-cal&lt;/span&gt;
  &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://baikal.internal:8800&lt;/span&gt;   &lt;span class="c1"&gt;# internal container, no port exposure needed&lt;/span&gt;
  &lt;span class="na"&gt;tunnel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;daemon_host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;homelab-node-01&lt;/span&gt;        &lt;span class="c1"&gt;# the host running the newt tunnel daemon&lt;/span&gt;

  &lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;preserve_host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;                 &lt;span class="c1"&gt;# CalDAV clients break if Host gets rewritten&lt;/span&gt;
    &lt;span class="na"&gt;trusted_headers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt;
    &lt;span class="na"&gt;pass_through_headers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Authorization&lt;/span&gt;                   &lt;span class="c1"&gt;# Digest auth must not be consumed or stripped&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DAV&lt;/span&gt;                             &lt;span class="c1"&gt;# capability negotiation header&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Depth&lt;/span&gt;                           &lt;span class="c1"&gt;# required for PROPFIND recursion&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Destination&lt;/span&gt;                     &lt;span class="c1"&gt;# required for MOVE/COPY operations&lt;/span&gt;
    &lt;span class="na"&gt;strip_headers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;X-Internal-Token&lt;/span&gt;                &lt;span class="c1"&gt;# don't leak internal routing headers&lt;/span&gt;

  &lt;span class="na"&gt;access&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;authenticated&lt;/span&gt;               &lt;span class="c1"&gt;# Pangolin identity check before any proxying&lt;/span&gt;
    &lt;span class="na"&gt;allowed_roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;calendar-users&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Depth&lt;/code&gt; and &lt;code&gt;Destination&lt;/code&gt; headers aren't documented in most proxy guides because they're invisible in browser-based apps — but CalDAV PROPFIND requests use &lt;code&gt;Depth: 1&lt;/code&gt; extensively, and any proxy that doesn't pass them through will produce 400 errors that look like Baïkal configuration failures. Test with &lt;code&gt;curl&lt;/code&gt; before trusting a GUI client's error message:&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;# Verify PROPFIND reaches Baïkal with correct headers intact&lt;/span&gt;
curl &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--request&lt;/span&gt; PROPFIND &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Depth: 1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/xml"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="s2"&gt;"user:password"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://baikal-cal.your-pangolin-domain.example/dav.php/calendars/user/

&lt;span class="c"&gt;# A working response starts with HTTP/2 207 (Multi-Status)&lt;/span&gt;
&lt;span class="c"&gt;# A proxy header problem usually returns 401 or 400 with no DAV response body&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The honest trade-off: you're now operating two separate systems where FileRun runs one. The Pangolin tunnel daemon (&lt;code&gt;newt&lt;/code&gt;) on each internal host needs to stay running and connected, and you need to reason about two failure domains — the calendar service going down versus the tunnel or Pangolin edge going down. On my setup I'd handle this by running the newt daemon under a systemd unit with &lt;code&gt;Restart=always&lt;/code&gt; and a separate health check, rather than assuming the Pangolin dashboard will catch a dropped tunnel fast enough. The payoff is real network isolation: the Baïkal container genuinely has no listening port reachable from outside its own host, and adding a second calendar service for a different team means adding a site config entry, not modifying firewall rules or figuring out NAT hairpinning again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side-by-Side: What You Actually Get Per Use Case
&lt;/h2&gt;

&lt;p&gt;The comparison that matters isn't feature lists — it's what each stack costs you when it's idle and what breaks first under real conditions. Most self-hosters discover these limits the hard way after a weekend of setup, not from the docs. Let me short-circuit that.&lt;/p&gt;

&lt;p&gt;Criteria&lt;/p&gt;

&lt;p&gt;FileRun&lt;/p&gt;

&lt;p&gt;OnlyOffice&lt;/p&gt;

&lt;p&gt;Pangolin&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CalDAV standard compliance&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Partial — CalDAV exposed via bundled component, not a first-class implementation; interop quirks with Thunderbird&lt;/p&gt;

&lt;p&gt;Reasonable RFC 4791 coverage but config is non-obvious; requires explicit HTTPS or clients silently refuse to connect&lt;/p&gt;

&lt;p&gt;N/A — Pangolin is a reverse proxy/tunnel layer, CalDAV compliance is entirely the backend's problem&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idle RAM floor&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;~200–350 MB with PHP-FPM + MariaDB; predictable and low&lt;/p&gt;

&lt;p&gt;1.2–2 GB at genuine idle with Document Server running; JVM-adjacent behavior — it never really sleeps&lt;/p&gt;

&lt;p&gt;~50–80 MB; it's a Go binary doing tunnel brokering, not processing documents&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disk footprint after initial pull&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;~800 MB–1.2 GB including MariaDB image&lt;/p&gt;

&lt;p&gt;6–8 GB for the full Document Server stack; the community edition image alone is over 4 GB compressed&lt;/p&gt;

&lt;p&gt;Under 200 MB; single binary distribution path is available&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-user sharing UI&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Solid file-level sharing with link expiry, password protection; calendar sharing is minimal compared to the file side&lt;/p&gt;

&lt;p&gt;Room/group calendars work well when the full platform is deployed; sharing model is tightly coupled to the broader workspace concept&lt;/p&gt;

&lt;p&gt;No sharing UI — delegates entirely to whatever is sitting behind it&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mobile client compatibility&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Works with DAVx⁵ on Android and iOS native Accounts; occasional sync delay on the file side&lt;/p&gt;

&lt;p&gt;Mobile web is passable; native mobile CalDAV via DAVx⁵ functions but the UI push is clearly toward the desktop browser experience&lt;/p&gt;

&lt;p&gt;Transparent to clients — they hit whatever URL Pangolin exposes and never know there's a tunnel&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Biggest single dealbreaker&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Commercial license required for production; the free tier isn't usable long-term for a real deployment&lt;/p&gt;

&lt;p&gt;Resource floor makes it indefensible on a 16 GB host running other services; you're burning RAM 24/7 for a feature most users open twice a week&lt;/p&gt;

&lt;p&gt;People mistake it for a calendar product and are confused when there's no calendar — it's access infrastructure, full stop&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FileRun verdict:&lt;/strong&gt; The right call if you're already running it as a file manager and want calendar as a secondary convenience, not a primary capability. On constrained hardware — say a 2-core VPS with 4 GB RAM — FileRun's PHP-FPM footprint is manageable where OnlyOffice would be immediately out of the question. The CalDAV implementation is good enough for personal use and small teams, provided you're not expecting tight spec compliance. License cost is the real gate: budget for it or pick something else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OnlyOffice verdict:&lt;/strong&gt; Defensible only when real-time document collaboration is also on the requirements list. If someone on the team needs co-editing on DOCX or XLSX files in the browser, the resource spend amortizes across multiple use cases and starts to make sense. Running OnlyOffice &lt;em&gt;purely&lt;/em&gt; for CalDAV on a host that also runs a database, a Node app, and a reverse proxy is resource waste you'll feel every time you SSH in and check &lt;code&gt;htop&lt;/code&gt;. On a dedicated 32 GB machine with room to spare it's fine, but that's exactly the scenario where you probably have better options too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pangolin verdict:&lt;/strong&gt; Not a calendar product and shouldn't be evaluated as one. Its specific value is solving the external access problem — getting a CalDAV server that lives on a machine without a public IP reliably reachable from the internet without punching firewall holes. Pair it with Baïkal (Docker image under 50 MB, strict RFC 4791 implementation, near-zero idle RAM beyond PHP-FPM) and you get a minimal-footprint calendar stack that's actually reachable from iOS and Android over WireGuard or the Pangolin tunnel. That combination beats any of the heavier stacks for operators who just want calendars to sync and don't need a document suite hanging off the side.&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;# Baïkal + Pangolin: the lightest viable external CalDAV setup&lt;/span&gt;
&lt;span class="c1"&gt;# docker-compose.yml fragment&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;baikal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ckulka/baikal:nginx&lt;/span&gt;  &lt;span class="c1"&gt;# ~48 MB compressed; nginx variant avoids Apache overhead&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./baikal/config:/var/www/baikal/config&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./baikal/Specific:/var/www/baikal/Specific&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="c1"&gt;# Do NOT expose a port directly — let Pangolin handle TLS termination&lt;/span&gt;

  &lt;span class="na"&gt;pangolin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fosrl/pangolin:latest&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PANGOLIN_UPSTREAM=http://baikal:80&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PANGOLIN_DOMAIN=cal.yourdomain.com&lt;/span&gt;
      &lt;span class="c1"&gt;# Pangolin handles ACME cert renewal; clients see valid TLS, baikal sees plain HTTP internally&lt;/span&gt;
    &lt;span class="na"&gt;ports&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;443:443"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;baikal&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one gotcha with this Baïkal pairing: Baïkal's admin interface uses the same nginx instance as the CalDAV endpoint. Restrict &lt;code&gt;/admin&lt;/code&gt; at the Pangolin or upstream config level — don't leave it accessible to the public-facing URL. Pangolin's route config supports path-level rules, so this is a one-liner addition rather than a separate nginx config overlay.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas That Cost Time Across All Three
&lt;/h2&gt;

&lt;p&gt;The CalDAV principal URL problem will burn you on every new client setup, no matter which backend you're running. Most calendar apps — Thunderbird, iOS Calendar, DAVx⁵ — attempt auto-discovery by hitting &lt;code&gt;/.well-known/caldav&lt;/code&gt; and following redirects to find the principal URL, then the calendar collection. The spec describes how this should work. Reality is messier: some clients interpret a redirect to the principal as the collection itself, others stop after one hop, and a few just silently fail without logging what they actually tried. The only configuration that reliably works across all clients is skipping auto-discovery entirely and hardcoding the full collection path. For FileRun that's something like &lt;code&gt;https://files.example.com/dav.php/calendars/username/default/&lt;/code&gt;. For Baikal behind Pangolin, it's &lt;code&gt;https://cal.example.com/dav.php/principals/username/&lt;/code&gt; for the principal, but clients want the collection one level deeper. Test each client explicitly — don't assume that because auto-discovery worked in one, it'll work in another.&lt;/p&gt;

&lt;p&gt;TLS termination at a reverse proxy breaks CalDAV in a way that's genuinely confusing to debug because the app logs show successful requests while every client silently refuses to authenticate. What's actually happening: Caddy or Nginx is terminating HTTPS and forwarding plain HTTP to the backend container. The CalDAV server generates redirect URLs and auth challenges using HTTP, the client sees those and either refuses to send credentials over what it thinks is a plaintext connection, or the authentication handshake fails because the scheme mismatch breaks the URL comparison logic. The fix is the same whether you're using Caddy or Nginx — you need to explicitly forward the original protocol. In Nginx:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="s"&gt;https&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;# Without these two lines, PROPFIND responses contain http:// hrefs&lt;/span&gt;
&lt;span class="c1"&gt;# and clients will either fall back silently or fail auth&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Caddy, &lt;code&gt;reverse_proxy&lt;/code&gt; forwards &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; automatically only if you're using the &lt;code&gt;https&lt;/code&gt; scheme in the upstream address — if you're proxying to a container by Docker network name over plain HTTP (which is normal), you need &lt;code&gt;header_up X-Forwarded-Proto https&lt;/code&gt; explicitly. Miss this and you'll spend an hour staring at 401s that aren't actually authentication failures.&lt;/p&gt;

&lt;p&gt;FileRun has a specific gotcha that isn't prominently documented: the &lt;code&gt;APP_ID&lt;/code&gt; environment variable gets written into the database on first boot, and that database value is what the application actually uses for URL generation and CSRF validation. If you change the domain the instance is served from — even just switching from an IP to a hostname, or adding a subdomain — updating the env var alone does nothing. The running instance reads the stored value. You need a direct database update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Run against your FileRun MySQL/MariaDB database&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;fc_settings&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'https://newdomain.example.com'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'APP_ID'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Then restart the FileRun container; the env var at that point&lt;/span&gt;
&lt;span class="c1"&gt;-- should match what's in the DB to avoid future confusion&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Failing to do this produces subtly broken behavior — file sharing links generate with the old domain, CalDAV redirects point to the wrong origin, and WebDAV clients reject the server's responses. The env var and the DB value need to be in sync, with the DB value being authoritative.&lt;/p&gt;

&lt;p&gt;OnlyOffice Community Server bundles Elasticsearch as an internal dependency for search and, depending on your installation path, for calendar indexing. ES requires a kernel-level setting — &lt;code&gt;vm.max_map_count&lt;/code&gt; must be at least &lt;code&gt;262144&lt;/code&gt; — and on most default VPS images it's set to &lt;code&gt;65530&lt;/code&gt;. When the ES container hits that limit it fails to start, but the OnlyOffice application container doesn't surface this as a clear error. The calendar UI just stops working or loads empty. Check before anything else:&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 host — not inside the container&lt;/span&gt;
sysctl vm.max_map_count
&lt;span class="c"&gt;# If output is below 262144:&lt;/span&gt;
sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; vm.max_map_count&lt;span class="o"&gt;=&lt;/span&gt;262144
&lt;span class="c"&gt;# To persist across reboots:&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"vm.max_map_count=262144"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /etc/sysctl.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a shared VPS where you don't have host-level access, this is a hard blocker — you cannot fix it from inside a container, and privileged mode doesn't help with kernel parameters that require host access. If you're on a managed host without shell access to the hypervisor node, OnlyOffice Community Server is not a viable option. That's not a configuration problem you can engineer around.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/filerun-onlyoffice-and-pangolin-for-a-self-hosted-web-calendar-which-one-actually-fits-your-stack/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>tools</category>
      <category>webdev</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Chamberlain MyQ and HomeKit: Self-Hosted Bridges That Actually Work</title>
      <dc:creator>우병수</dc:creator>
      <pubDate>Fri, 19 Jun 2026 07:46:57 +0000</pubDate>
      <link>https://dev.to/ericwoooo_kr/chamberlain-myq-and-homekit-self-hosted-bridges-that-actually-work-1k0d</link>
      <guid>https://dev.to/ericwoooo_kr/chamberlain-myq-and-homekit-self-hosted-bridges-that-actually-work-1k0d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Chamberlain's 2023 API lockdown was deliberate and aggressive.  They didn't deprecate an old endpoint or bump a version — they actively blocked third-party apps from authenticating with the MyQ cloud API, killing integrations for Google Home, SmartThings, and every HomeKit bridge&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;📖 Reading time: ~19 min&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in this article
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Problem: MyQ Locked You Out of Your Own Garage&lt;/li&gt;
&lt;li&gt;Option 1: Homebridge with the homebridge-myq Plugin (Cloud-Dependent, Increasingly Brittle)&lt;/li&gt;
&lt;li&gt;Option 2: ratgdo — Local Control Hardware That Removes MyQ From the Equation&lt;/li&gt;
&lt;li&gt;The Docker Stack That Ties It Together&lt;/li&gt;
&lt;li&gt;Comparing the Two Approaches on Real Constraints&lt;/li&gt;
&lt;li&gt;Gotchas That Will Cost You Time&lt;/li&gt;
&lt;li&gt;Where This Fits in a Broader Self-Hosted Automation Stack&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Problem: MyQ Locked You Out of Your Own Garage
&lt;/h2&gt;

&lt;p&gt;Chamberlain's 2023 API lockdown was deliberate and aggressive. They didn't deprecate an old endpoint or bump a version — they actively blocked third-party apps from authenticating with the MyQ cloud API, killing integrations for Google Home, SmartThings, and every HomeKit bridge that routed through their servers. The stated reason was "unauthorized" access. The actual effect was forcing users toward Chamberlain's own paid ecosystem. That decision isn't being walked back.&lt;/p&gt;

&lt;p&gt;The native HomeKit path Chamberlain offers costs real money. MyQ doesn't support HomeKit natively — you need their separate MyQ Home Bridge hardware, which runs around $100, stacks on top of a garage opener you already paid for, and still depends on Chamberlain's cloud infrastructure to function. So you're buying hardware to access a cloud service that can break whenever Chamberlain has downtime or decides to change the rules again. That's not a smart trade-off for anyone running a home automation stack with reliability requirements.&lt;/p&gt;

&lt;p&gt;If you already have Docker running on a home server, the compute cost of a local bridge is negligible — a container using under 100MB of RAM, no GPU required. More importantly, a properly configured local bridge survives three failure modes that the cloud path can't handle: your internet going down, Chamberlain's servers having an outage, and future API policy changes. That last one is the critical argument for self-hosting here. You're not just solving today's problem — you're insulating your garage automation from a company that has already demonstrated willingness to break integrations for commercial reasons.&lt;/p&gt;

&lt;p&gt;Two fundamentally different approaches exist, and conflating them leads to bad decisions. The first category is software-only bridges — tools like &lt;code&gt;homebridge-myq&lt;/code&gt; or older forks that still attempt to reverse-engineer or work around the MyQ cloud API. These are fragile post-lockdown. They work until the next authentication change, and Chamberlain has shown they'll keep patching those gaps. The second category is hardware-based local control — ratgdo, for example, which replaces the MyQ cloud dependency with a local device wired directly to your opener's security+ bus. No cloud call, no API key, no Chamberlain servers in the loop. This article covers both approaches honestly, including where the software path still makes sense (it does, in specific narrow situations) and where hardware-local control is the only durable answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1: Homebridge with the homebridge-myq Plugin (Cloud-Dependent, Increasingly Brittle)
&lt;/h2&gt;

&lt;p&gt;The surprising thing about homebridge-myq is that it still functions at all. Chamberlain has actively blocked third-party API access multiple times — explicitly telling integrators their platform is off-limits — yet the plugin keeps getting patched because the MyQ mobile app still has to talk to &lt;em&gt;some&lt;/em&gt; endpoint, and determined plugin maintainers keep reverse-engineering it. That's the entire situation summarized: you're betting on a cat-and-mouse game staying in your favor.&lt;/p&gt;

&lt;p&gt;Homebridge itself is solid. It's a Node.js process running a HAP server that iOS treats as a real HomeKit bridge. Spin it up with Docker Compose and your phone finds it within a minute or two. The web UI on port 8581 handles plugin installs, config editing, and log tailing — you don't need to touch the container shell for day-to-day operation. Here's a minimal compose file that actually works:&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;homebridge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;homebridge/homebridge:latest&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;network_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;host&lt;/span&gt;          &lt;span class="c1"&gt;# HAP discovery requires mDNS — bridge networking breaks pairing&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./homebridge:/homebridge&lt;/span&gt; &lt;span class="c1"&gt;# persistent config, plugins, and credentials&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PGID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PUID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;HOMEBRIDGE_CONFIG_UI_PORT=8581&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;network_mode: host&lt;/code&gt; is non-negotiable. HAP uses mDNS for HomeKit discovery, and if you run this behind Docker's NAT bridge, your iPhone will never find the bridge — or it'll find it once and lose it on every container restart. Once the container is up, install homebridge-myq from the plugin UI and add this block to your config.json:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"myQ"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"you@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"password"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"yourpassword"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"polling"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"garage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lockoutMinutes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;backs&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;off&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;automatically&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Chamberlain&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;starts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;rejecting&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;requests&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't set polling below 30 seconds. The MyQ backend rate-limits aggressively, and once your account gets flagged you'll get auth failures that look identical to a broken plugin — hours of debugging a problem that's actually just a cooldown. Pin the plugin version in your package.json inside the Homebridge volume after you find a working release. The plugin has gone through version 9, 10, and now v11 branches with breaking changes on each; auto-updating through the UI has bitten a lot of people. Check the GitHub issues for homebridge-myq before any Chamberlain app update rolls out, because those updates frequently rotate API tokens or change endpoint paths within days of release.&lt;/p&gt;

&lt;p&gt;The honest trade-off here: this costs nothing extra, takes under ten minutes, and the HomeKit UX is genuinely clean once it's working. But the failure mode is brutal — Chamberlain changes an endpoint, the plugin stops polling, and your garage door disappears from Home app with zero notification. No alert, no automation failure email, just silence. If you have a physical keypad or a secondary entry point, that's annoying. If the garage &lt;em&gt;is&lt;/em&gt; your front door and you run automations like "unlock on arrival," a silent failure at 11pm is a real problem. For that use case, keep reading and look at the hardware-based options instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: ratgdo — Local Control Hardware That Removes MyQ From the Equation
&lt;/h2&gt;

&lt;p&gt;The most interesting thing about ratgdo (Rage Against The Garage Door Opener) is what it &lt;em&gt;doesn't&lt;/em&gt; do: it doesn't talk to Chamberlain's cloud, it doesn't poll an API, and it doesn't break when Chamberlain decides to lock down their ecosystem again. Instead, it wires directly to the Security+ 2.0 serial bus that your opener already exposes on its terminal strip and speaks the opener's native protocol. That means the board gets real door state — not an approximated tilt sensor reading — and can issue open/close commands at the same level as a hardwired wall button. The firmware exposes everything over MQTT or ESPHome. No cloud path exists in this architecture because none is needed.&lt;/p&gt;

&lt;p&gt;The hardware side is genuinely simple. You connect three wires from the ratgdo board to the opener's terminal strip: GND, and the two Security+ 2.0 serial lines (labeled TX and RX from the board's perspective). That's the entire physical installation on a compatible Chamberlain or LiftMaster opener. Flashing is done through the browser-based installer at &lt;a href="https://install.ratgdo.info" rel="noopener noreferrer"&gt;install.ratgdo.info&lt;/a&gt; — you plug the board into USB, hit install, and the site handles the WebSerial flash without touching the Arduino IDE. Total BOM cost lands between $20 and $35 depending on whether you source a pre-assembled board or build your own around an ESP8266/ESP32 module. Compare that to the monthly risk of whatever Chamberlain decides to do next quarter.&lt;/p&gt;

&lt;p&gt;The HomeKit integration path has a few hops but each one is solid. Flash the ESPHome firmware variant (not the MQTT one — ESPHome gives you cleaner integration downstream), then add the device to your ESPHome instance running either in Docker or as a Home Assistant add-on. From there you have two bridging options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Home Assistant native HomeKit integration&lt;/strong&gt;: HA's built-in HomeKit bridge exposes entities directly to the Home app. If you're already running HA, this is zero extra software.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Homebridge with homebridge-homeassistant&lt;/strong&gt;: routes HA entities into Homebridge, which then bridges to HomeKit. More moving parts, but useful if Homebridge is already your HomeKit hub for other devices.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way, the ratgdo door entity shows up as a garage door accessory in HomeKit with open, closed, and opening/closing states — not a binary switch workaround.&lt;/p&gt;

&lt;p&gt;The failure modes here are fundamentally different from the cloud-dependent options, and that matters operationally. Your RF remotes continue working in parallel — ratgdo adds a control channel, it doesn't replace the existing one. If the ratgdo board loses power or the firmware crashes, your remotes and wall button are completely unaffected. The realistic failure scenarios are a wiring mistake during install (reversing TX/RX is the classic one — swap them and reflash), or a firmware flash that didn't complete cleanly (re-run the web installer). Neither of those is a silent failure at 2 AM because Chamberlain rotated an API key. The board also survives any future Chamberlain cloud changes because it has never spoken to that cloud in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Docker Stack That Ties It Together
&lt;/h2&gt;

&lt;p&gt;The surprising part of this whole stack is how little you actually need. Three services, one Compose file, and the ratgdo firmware does most of the heavy lifting on the MQTT side. You're not running Home Assistant here — that adds a roughly 500MB image pull plus its own persistence layer, and it's unnecessary if your only goal is HomeKit control of a garage door.&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;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.9"&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mosquitto&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eclipse-mosquitto:2.0&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mosquitto&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&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;1883:1883"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;mosquitto_data:/mosquitto/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;mosquitto_logs:/mosquitto/log&lt;/span&gt;
      &lt;span class="c1"&gt;# config must exist before first start or mosquitto refuses to launch&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&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;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mosquitto_sub"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-t"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$$SYS/#"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-C"&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;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-i"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;healthcheck"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-W"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&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;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;

  &lt;span class="na"&gt;homebridge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;homebridge/homebridge:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;homebridge&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;network_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;host&lt;/span&gt;
    &lt;span class="c1"&gt;# host networking is not optional — mDNS for HomeKit pairing breaks in bridge mode&lt;/span&gt;
    &lt;span class="c1"&gt;# unless you run an mDNS reflector alongside it&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homebridge_config:/homebridge&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PGID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PUID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;HOMEBRIDGE_CONFIG_UI_PORT=8581&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&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;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-f"&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://localhost:8581"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;60s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&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;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;90s&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;mosquitto&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mosquitto_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mosquitto_logs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;homebridge_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;network_mode: host&lt;/code&gt; line on Homebridge is the thing that catches people. HomeKit device discovery uses mDNS (Bonjour), which doesn't cross Docker's bridge network boundary cleanly. If Homebridge is in a container network, your iPhone will pair once and then show "No Response" after a restart because the mDNS announcement never reaches your local subnet. &lt;code&gt;host&lt;/code&gt; mode fixes this on a flat network. If ratgdo and Homebridge are on separate VLANs — say you've put IoT devices on a segregated subnet — you need Avahi running on the host with &lt;code&gt;enable-reflector=yes&lt;/code&gt; in &lt;code&gt;/etc/avahi/avahi-daemon.conf&lt;/code&gt;, plus a DNS override or static IP so ratgdo can resolve your broker hostname across the VLAN boundary. Pi-hole users: add a local DNS record for &lt;code&gt;mqtt.home.arpa&lt;/code&gt; or whatever you're using, because ratgdo's MQTT firmware won't retry indefinitely if the first broker connection attempt fails at boot.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;homebridge-mqttthing&lt;/code&gt; plugin config is where the mapping happens. Install it through the Homebridge UI or drop this directly into your &lt;code&gt;config.json&lt;/code&gt; accessories array:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"accessory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mqttthing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"garageDoorOpener"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Garage Door"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mqtt://mosquitto:1883"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"topics"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"getCurrentDoorState"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"homebridge/garage/state"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"getTargetDoorState"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"homebridge/garage/state"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"setTargetDoorState"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"homebridge/garage/set"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"values"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"open"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="s2"&gt;"OPEN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"closed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CLOSED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"opening"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OPENING"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"closing"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CLOSING"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"stopped"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"STOPPED"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"optimistic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ratgdo's MQTT firmware publishes exactly these uppercase string payloads on its state topic by default — no transform function needed, no value template gymnastics. The &lt;code&gt;optimistic: false&lt;/code&gt; flag matters: with it set to &lt;code&gt;true&lt;/code&gt;, Homebridge assumes the door reached its target state immediately and won't update the tile until the next MQTT message. With it false, the Home app holds the "opening" animation until ratgdo publishes &lt;code&gt;OPEN&lt;/code&gt;, which reflects actual reed switch state. That's the behavior you want.&lt;/p&gt;

&lt;p&gt;For the Mosquitto config, the default out-of-box config rejects all anonymous connections since version 2.0 — you'll hit a silent failure where ratgdo connects and immediately disconnects with no useful log output from the container side. A minimal &lt;code&gt;mosquitto.conf&lt;/code&gt; that actually works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;listener&lt;/span&gt; &lt;span class="m"&gt;1883&lt;/span&gt;
&lt;span class="n"&gt;allow_anonymous&lt;/span&gt; &lt;span class="n"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;persistence&lt;/span&gt; &lt;span class="n"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;persistence_location&lt;/span&gt; /&lt;span class="n"&gt;mosquitto&lt;/span&gt;/&lt;span class="n"&gt;data&lt;/span&gt;/
&lt;span class="n"&gt;log_dest&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt; /&lt;span class="n"&gt;mosquitto&lt;/span&gt;/&lt;span class="n"&gt;log&lt;/span&gt;/&lt;span class="n"&gt;mosquitto&lt;/span&gt;.&lt;span class="n"&gt;log&lt;/span&gt;
&lt;span class="n"&gt;log_type&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;
&lt;span class="n"&gt;log_type&lt;/span&gt; &lt;span class="n"&gt;warning&lt;/span&gt;
&lt;span class="n"&gt;log_type&lt;/span&gt; &lt;span class="n"&gt;information&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want auth, add a &lt;code&gt;password_file&lt;/code&gt; line and pre-generate credentials with &lt;code&gt;mosquitto_passwd&lt;/code&gt; — but do that after you've confirmed the unauthenticated flow works end-to-end. Debugging MQTT auth failures through two firmware layers simultaneously is not a good use of an afternoon. Once the stack is stable, the healthcheck on Homebridge (&lt;code&gt;curl -f http://localhost:8581&lt;/code&gt;) will catch process crashes that otherwise show up silently as "No Response" in the Home app hours later with no obvious cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparing the Two Approaches on Real Constraints
&lt;/h2&gt;

&lt;p&gt;The most important thing ratgdo changes isn't features — it's the dependency graph. Once the ESP32 is flashed and talking MQTT to your local broker, the entire open/close/status loop runs inside your LAN. Chamberlain's servers can go down, your ISP can have an outage, the plugin author can abandon the project — none of that touches your garage door. homebridge-myq sits at the opposite extreme: it depends on Chamberlain's API being up, your internet connection being stable, the Homebridge plugin tracking whatever undocumented API changes Chamberlain pushes, and the MyQ app not triggering a lockout when it detects unusual auth patterns. Any one of those links breaks the chain.&lt;/p&gt;

&lt;p&gt;The silent breakage problem with homebridge-myq deserves specific attention because it's worse than an obvious failure. The most common failure mode isn't an error — it's stale state. The Home app shows the door as closed when it's open, or vice versa, because a polling call silently failed and the plugin didn't invalidate its cached state. You don't find out until you ask Siri to close a door that's already closed, or worse, until you assume it's closed and leave. With ratgdo over MQTT, the ESP32 publishes state changes as they happen on the serial bus — there's no polling, no cache, and no cloud intermediary to go quiet without telling you.&lt;/p&gt;

&lt;p&gt;Setup cost is real but asymmetric in an important way. homebridge-myq is genuinely fifteen minutes if you're already running Homebridge in Docker — &lt;code&gt;npm install -g homebridge-myq&lt;/code&gt;, drop credentials into the config, restart. That's it. ratgdo is a different skill set entirely: you're physically accessing the opener's terminal block, connecting three wires (ground, serial TX, serial RX — no soldering required on the ratgdo v2.5 board), and flashing ESPHome firmware to an ESP32. None of those steps are hard, but "comfortable running physical wires near a garage ceiling" is a real prerequisite that homebridge-myq doesn't have. The ESPHome side is straightforward if you've touched it before:&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;# ratgdo ESPHome minimal config excerpt&lt;/span&gt;
&lt;span class="na"&gt;substitutions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;device_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ratgdo&lt;/span&gt;
  &lt;span class="na"&gt;friendly_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Garage Door&lt;/span&gt;

&lt;span class="na"&gt;external_components&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&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github://ratgdo/esphome-ratgdo@main&lt;/span&gt;
    &lt;span class="na"&gt;refresh&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0s&lt;/span&gt;

&lt;span class="na"&gt;ratgdo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myratgdo&lt;/span&gt;
  &lt;span class="na"&gt;input_obst_pin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GPIO21&lt;/span&gt;   &lt;span class="c1"&gt;# matches ratgdo v2.5 pinout&lt;/span&gt;
  &lt;span class="na"&gt;output_gdo_pin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GPIO19&lt;/span&gt;
  &lt;span class="na"&gt;input_gdo_pin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GPIO20&lt;/span&gt;

&lt;span class="na"&gt;lock&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;platform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ratgdo&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${friendly_name}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Lock"&lt;/span&gt;
    &lt;span class="na"&gt;ratgdo_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myratgdo&lt;/span&gt;

&lt;span class="na"&gt;cover&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;platform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ratgdo&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${friendly_name}"&lt;/span&gt;
    &lt;span class="na"&gt;ratgdo_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myratgdo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Latency is the axis that surprises people most when they switch. ratgdo's serial bus command reaches the opener in under a second — usually you hear the motor before Siri finishes confirming. homebridge-myq adds a full cloud round-trip: your command goes to Chamberlain's servers, gets processed, comes back as a state change, and Homebridge polls for it. On a good day that's one to four seconds. Under API load or with a slow polling interval, it can spike to ten or more, and occasionally the command just doesn't register. For an automation that's supposed to close the door when you leave home, a four-second delay is annoying; a silent failure is a security problem.&lt;/p&gt;

&lt;p&gt;The decision tree is actually simple. Use homebridge-myq only if physical access to the opener is impossible — you're renting, the unit is in a shared space you can't touch, or this is a temporary setup you're tearing down in weeks. For any permanent installation, ratgdo is the correct answer. And if you're already running Home Assistant, skip the Homebridge layer entirely: the ratgdo ESPHome integration surfaces natively in HA as a cover entity with lock, light, and obstruction sensor sub-entities, and from there HomeKit integration is one toggle in the Home Assistant Apple TV / HomePod bridge. Fewer processes, fewer config files, fewer things to break on an upstream update.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas That Will Cost You Time
&lt;/h2&gt;

&lt;p&gt;The mDNS problem swallows more hours than any other issue in this stack. Homebridge uses HAP (HomeKit Accessory Protocol) discovery over mDNS, and Docker's default bridge network silently blocks multicast traffic. iOS never sees the accessory — the Home app just shows nothing. No error, no log entry, just absence. The fix is one line in your Compose file, but you won't find it in the Homebridge Docker README until you've already burned an afternoon:&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;homebridge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;homebridge/homebridge:latest&lt;/span&gt;
    &lt;span class="c1"&gt;# host networking lets mDNS multicast reach your LAN interface&lt;/span&gt;
    &lt;span class="c1"&gt;# without this, iOS cannot discover the HAP bridge at all&lt;/span&gt;
    &lt;span class="na"&gt;network_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;host&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;HOMEBRIDGE_CONFIG_UI_PORT=8581&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./homebridge:/homebridge&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If host networking is off the table for your setup (shared host, port conflicts), macvlan is the other viable path — it gives the container its own MAC and IP on your LAN, so mDNS behaves as if it's a physical device. Expect to spend 20 minutes configuring the macvlan parent interface correctly. Either way, never run Homebridge on the default bridge network and wonder why it won't appear.&lt;/p&gt;

&lt;p&gt;The Security+ 1.0 vs 2.0 distinction matters enormously for what you can actually do. ratgdo communicates over the serial bus that Chamberlain introduced with Security+ 2.0, present on most openers from 2011 onward. If your opener is older — or a lower-end model that never got the serial bus — ratgdo falls back to dry-contact relay mode. That works for triggering open and close, but the opener has no way to report its current state back over a relay. Your HomeKit tile becomes write-only: you can tap it, but the position shown is whatever Homebridge last assumed, not ground truth. A reed sensor on the door itself fixes this, wired back to ratgdo's obstruction/sensor input or to a separate ESP32 running ESPHome. Without it, automations that check "is the garage closed?" will eventually lie to you.&lt;/p&gt;

&lt;p&gt;If you're running homebridge-myq (the cloud-dependent plugin) rather than ratgdo, Chamberlain's rate limiter is a real operational hazard. Polling too frequently returns HTTP 423 — not a network error, not a timeout, just a locked-out response — and the lockout lasts 15 to 30 minutes. During that window every door tile shows "No Response." Set your polling interval to 60 seconds minimum, and enable the plugin's built-in post-command delay:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;homebridge&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;config.json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;homebridge-myq&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;platform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;block&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"myQ"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"polling"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"openDuration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"closedDuration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;avoid&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;re-polling&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;immediately&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;after&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;you&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;send&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;an&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;open/close&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;command&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"eventDuration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ratgdo firmware update trap is subtle specifically because it fails silently. When ratgdo renames an MQTT topic between releases, homebridge-mqttthing stops receiving state updates but logs nothing useful — the plugin is still subscribed, the broker is still running, the topic just no longer matches. The Home app displays "No Response" and nothing in the Homebridge logs points at the real cause. Pin your firmware version in the ESPHome YAML and treat ratgdo upgrades as a deliberate change requiring verification, not a routine update:&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;# esphome device YAML — pin the ratgdo component ref&lt;/span&gt;
&lt;span class="na"&gt;external_components&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&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/ratgdo/esphome-ratgdo&lt;/span&gt;
      &lt;span class="c1"&gt;# pin to a specific commit hash, not 'main'&lt;/span&gt;
      &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3f8a2c1&lt;/span&gt;
    &lt;span class="na"&gt;components&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;ratgdo&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After any ratgdo firmware change, pull up your MQTT broker's topic tree with &lt;code&gt;mosquitto_sub -h localhost -t '#' -v&lt;/code&gt; and confirm the topics your homebridge-mqttthing config expects are actually being published before you close the terminal. Thirty seconds of verification saves a debugging session that will happen at the worst possible time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Fits in a Broader Self-Hosted Automation Stack
&lt;/h2&gt;

&lt;p&gt;The most underrated benefit of getting this working locally isn't the HomeKit integration itself — it's that your garage door finally behaves like a &lt;em&gt;real sensor&lt;/em&gt; in your stack. Once ratgdo is publishing state over MQTT and Homebridge is exposing a stable local accessory, HomeKit's native automation engine can treat door state as a first-class trigger. Arrival and departure automations via iPhone location work without phoning home to Chamberlain's servers. Time-of-day rules that close the door at 10 PM if it's been left open — those work during an internet outage. The reliability difference between a cloud-polled accessory and a local one becomes obvious the first time your ISP has a bad afternoon.&lt;/p&gt;

&lt;p&gt;If you're already running an MQTT broker alongside n8n, the ratgdo state topic is a clean, push-based event source. Instead of a cron job hammering a cloud API every 60 seconds hoping the door state changed, you get an MQTT trigger node that fires exactly when the door opens or closes. A practical flow looks like this:&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;# ratgdo publishes to topics like:&lt;/span&gt;
&lt;span class="s"&gt;ratgdo/garage/status/door&lt;/span&gt;   &lt;span class="c1"&gt;# → "open" | "closed" | "opening" | "closing"&lt;/span&gt;
&lt;span class="s"&gt;ratgdo/garage/status/light&lt;/span&gt;  &lt;span class="c1"&gt;# → "on" | "off"&lt;/span&gt;
&lt;span class="s"&gt;ratgdo/garage/status/motion&lt;/span&gt; &lt;span class="c1"&gt;# → "detected" | "clear"&lt;/span&gt;

&lt;span class="c1"&gt;# n8n MQTT Trigger node config:&lt;/span&gt;
&lt;span class="c1"&gt;# Broker: mqtt://192.168.1.x:1883&lt;/span&gt;
&lt;span class="c1"&gt;# Topic: ratgdo/garage/status/door&lt;/span&gt;
&lt;span class="c1"&gt;# Then: IF node checks payload === "open"&lt;/span&gt;
&lt;span class="c1"&gt;#       + a Wait node holds 10 minutes&lt;/span&gt;
&lt;span class="c1"&gt;#       + another MQTT node re-checks current state&lt;/span&gt;
&lt;span class="c1"&gt;#       + if still open AND current time after sunset → push notification via Pushover&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That sunset condition is the part worth building properly. Polling "is it dark out" from a cron is awkward. In n8n you can pull sunset time from a simple weather API call at the start of the flow and store it in a variable, then compare against &lt;code&gt;new Date()&lt;/code&gt; in a Function node. The MQTT trigger eliminates the polling entirely — the only HTTP call in the whole flow is the outbound Pushover notification. For a broader look at how local event sources like this slot into multi-tool pipelines, the &lt;a href="https://techdigestor.com/ultimate-productivity-guide-2026/" rel="noopener noreferrer"&gt;Workflow Automation in 2026: n8n, Zapier, and Self-Hosted Pipelines&lt;/a&gt; guide covers the architectural patterns in more depth.&lt;/p&gt;

&lt;p&gt;The principle this whole setup demonstrates is worth generalizing. Any device where state lives in a vendor cloud is a liability — the MyQ situation made this visceral for a lot of people when Chamberlain started locking out third-party API access. The pattern that fixes it is consistent: find the local protocol (serial bus, Wiegand, Z-Wave, local HTTP), bridge it with cheap hardware (ratgdo, a Sonoff with custom firmware, an ESP32), publish to a local MQTT broker, and present it to HomeKit or whatever UI layer you need via a bridge like Homebridge. Smart locks that expose a Wiegand interface, older Ecobee thermostats with a local API, even some irrigation controllers — all of them respond to this same pattern. The ratgdo/Chamberlain case is just an unusually clean example because the hardware is purpose-built and the MQTT topic schema is well documented.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://techdigestor.com/chamberlain-myq-and-homekit-self-hosted-bridges-that-actually-work/" rel="noopener noreferrer"&gt;techdigestor.com&lt;/a&gt;. Follow for more developer-focused tooling reviews and productivity guides.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>productivity</category>
      <category>tools</category>
    </item>
  </channel>
</rss>
