<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged raspberrypi</title>
<link>https://coderlegion.com/tag/raspberrypi</link>
<description>Powered by Question2Answer</description>
<item>
<title>Nothing Reported It</title>
<link>https://coderlegion.com/30851/nothing-reported-it</link>
<description>&lt;h4 id=&quot;five-ways-my-own-test-harness-lied-to-me-and-the-one-that-nearly-got-published&quot;&gt;Five ways my own test harness lied to me, and the one that nearly got published&lt;/h4&gt;
&lt;p&gt;The number that gave it away was sitting in a table I had already read three times.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;prompt size    97 tokens median, 112 max
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is the grounded question-answering workflow. It sends four reference documents with every question. The documents are 447 words.&lt;/p&gt;
&lt;p&gt;Four hundred and forty-seven words cannot be ninety-seven tokens.&lt;/p&gt;
&lt;p&gt;I had written that number into a results document as good news about context headroom. &quot;Prompt sizes never approached the 4096-token context window.&quot; It was not good news about headroom. It was the harness telling me, in the only field capable of telling me, that the reference material was not in the prompt. The directory holding it had never been committed to the repository. Every one of those fifteen questions had been asked against an empty reference block.&lt;/p&gt;
&lt;p&gt;Both models had behaved impeccably. One had been instructed to answer &lt;code&gt;NOT IN THE MATERIAL&lt;/code&gt; when the material did not contain the answer, and the material contained nothing, so it said &lt;code&gt;NOT IN THE MATERIAL&lt;/code&gt; thirteen times out of fifteen. The other had no such instruction, so it answered from whatever it had memorized during training and invented a wine.&lt;/p&gt;
&lt;p&gt;Every call returned 200. The scorer scored it. The scores were internally consistent, had plausible and &lt;em&gt;different&lt;/em&gt; failure modes on each side, and I wrote two thousand seven hundred words about what they revealed regarding the capabilities of small language models.&lt;/p&gt;
&lt;p&gt;They revealed nothing about small language models. I had measured what happens when you ask a question with no reference material, which is a question I was not asking and which has an uninteresting answer.&lt;/p&gt;
&lt;p&gt;This is the post about that, and about the four other things I found in the same weekend, because the missing directory was not the first one and it was not even the most instructive.&lt;/p&gt;
&lt;h3 id=&quot;the-experiment-briefly&quot;&gt;The experiment, briefly&lt;/h3&gt;
&lt;p&gt;A Raspberry Pi 4, two small models through Ollama, and three workflows: pull an identifier out of a message, route a message to one of six queues, answer a question from four documents. Ninety-five test cases. A deterministic baseline for the first workflow. Predictions committed before anything was installed.&lt;/p&gt;
&lt;p&gt;The findings are a separate post. This one is about the code I wrote to produce them, which turned out to be where the actual bugs were.&lt;/p&gt;
&lt;p&gt;Five flaws. Every single one was found by reading output, not by reading code. I never once found one of these by looking at the scorer.&lt;/p&gt;
&lt;h3 id=&quot;kind-one-a-metric-that-rewards-the-safe-failure&quot;&gt;Kind one: a metric that rewards the safe failure&lt;/h3&gt;
&lt;p&gt;This one I hit twice, in two different workflows, and did not recognize the second instance as the same bug until I had already fixed the first.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The classification scorer.&lt;/strong&gt; Six labels, one of which is &lt;code&gt;escalate&lt;/code&gt;. The instruction said to escalate when a message needs a person, including when you cannot tell what it is about. So I built a metric called &lt;em&gt;confident wrong&lt;/em&gt;: wrong, and not an escalation. The reasoning is sound. A model that escalates when unsure is usable. A model that routes a trademark notice to billing is not.&lt;/p&gt;
&lt;p&gt;Then the 360M model answered &lt;code&gt;escalate&lt;/code&gt; to 43 of 50 messages.&lt;/p&gt;
&lt;p&gt;It scored &lt;strong&gt;3&lt;/strong&gt; on confident wrong. The 1.5B model, which was genuinely attempting the task and getting 46% of it right, scored &lt;strong&gt;26&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;My metric had just certified the useless model as roughly six times safer than the mediocre one. Because escalations are exempt, a model that escalates &lt;em&gt;always&lt;/em&gt; cannot register a confident error. The exemption I added to reward caution had instead rewarded paralysis, and it did so in the direction that looks best.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The grounded QA scorer.&lt;/strong&gt; Four of the fifteen questions have no answer in the corpus. Correctly saying so is the behavior that matters most, because a system that invents a plausible answer with no evidence is worse than one that fails loudly: it is indistinguishable from one that worked. So the headline number was correct abstentions out of four.&lt;/p&gt;
&lt;p&gt;Then the 1.5B model said &lt;code&gt;NOT IN THE MATERIAL&lt;/code&gt; to 13 of 15 questions.&lt;/p&gt;
&lt;p&gt;It scored &lt;strong&gt;4 out of 4&lt;/strong&gt;. A perfect score, on the metric I cared most about, for a model that had answered almost nothing.&lt;/p&gt;
&lt;p&gt;Same bug. Different workflow. Two hours apart. I fixed the classification one, wrote a comment in the code explaining the principle, and then failed to apply that principle to the file I opened next.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The shape, stated once:&lt;/strong&gt; a metric that rewards the safe behavior will certify any model that only ever does the safe thing. If you exempt a behavior because it is cautious, you have to pair that exemption with evidence that the model is making distinctions at all. Otherwise, the exemption &lt;em&gt;is&lt;/em&gt; the score.&lt;/p&gt;
&lt;p&gt;Both fixes are the same three lines: count how concentrated the answers are, and when one answer dominates, print a warning that the headline number is meaningless and say why.&lt;/p&gt;
&lt;h3 id=&quot;kind-two-a-metric-that-measures-nothing-at-all&quot;&gt;Kind two: a metric that measures nothing at all&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;The vacuous fixture.&lt;/strong&gt; I caught this one before the run, which is the only reason it is not a bigger part of this post. A test case that passed regardless of what the model did, because the fixture was inert either way. It contributed a tick to a column and no information.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The keyword check&lt;/strong&gt;, which is worse, because it survived longer and looked more useful.&lt;/p&gt;
&lt;p&gt;The eleven answerable QA questions each carry a list of words the correct answer should contain. The scorer checks whether any of them appear anywhere in the reply. I labeled it &quot;indicative only&quot; and moved on.&lt;/p&gt;
&lt;p&gt;On the invalid run it scored 1 of 11 for both models. The single hit was the word &quot;reservation&quot;, appearing inside this answer:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;You owe a tasting fee of $100 for the weekend. You also need to book a reservation for the weekend.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The correct fee is forty dollars. The hundred is invented. That one hit was the entire positive signal in my automated QA scoring and it was noise.&lt;/p&gt;
&lt;p&gt;Here is the part that makes it a better story. After I fixed the corpus and reran, the keyword check scored &lt;strong&gt;11 of 11&lt;/strong&gt; for the 1.5B model. Vindicated, apparently. I read all eleven answers by hand anyway, and nine of them are right.&lt;/p&gt;
&lt;p&gt;So the check was wrong both times. It was wrong in the pessimistic direction when the answers were bad and wrong in the optimistic direction when they were good, and on the rerun one of its eleven hits is an answer that gets the arithmetic wrong and hits on the word &quot;reservation&quot; anyway.&lt;/p&gt;
&lt;p&gt;A check that agrees with the truth because the truth happened to be agreeable has not been validated. It has been lucky.&lt;/p&gt;
&lt;h3 id=&quot;kind-three-a-detector-with-a-blind-spot-in-the-obvious-direction&quot;&gt;Kind three: a detector with a blind spot in the obvious direction&lt;/h3&gt;
&lt;p&gt;I wrote a decay check because of a thermal problem. A Pi 4 with heat sinks and no fan hit its soft temperature limit during a thirty-call run and started capping the clock, and nothing anywhere said so. So the scorer now compares generation speed in the first third of a run to the last third and flags a slowdown.&lt;/p&gt;
&lt;p&gt;It then reported this, as &lt;em&gt;steady&lt;/em&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;across the run 4.3 -&amp;gt; 7.6 tok/s (176% of starting speed, steady)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The hardware got seventy-six percent faster partway through a run. My detector, which was looking for numbers below a threshold, saw a number above it and reported no problem.&lt;/p&gt;
&lt;p&gt;Hardware does not speed up. That reading is either contention at the start of the run or, as it turned out, generations too short to time. Either way it is a signal that the measurement is unreliable, and I had built a detector that would only ever notice half of the ways that can happen.&lt;/p&gt;
&lt;p&gt;Anything you threshold in one direction, threshold in both. An implausibly good number is as informative as an implausibly bad one and nobody ever writes the check for it.&lt;/p&gt;
&lt;h3 id=&quot;kind-four-errors-that-get-dropped-instead-of-counted&quot;&gt;Kind four: errors that get dropped instead of counted&lt;/h3&gt;
&lt;p&gt;Partway through the first extraction run, Ollama returned a 500 and the runner died at case seven of thirty. The traceback was useless because &lt;code&gt;urllib&lt;/code&gt; raises &lt;code&gt;HTTPError&lt;/code&gt; and discards the response body, which is where Ollama puts its reason. Reading &lt;code&gt;e.read()&lt;/code&gt; gave it up immediately:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{&quot;error&quot;:&quot;prediction aborted, token repeat limit reached&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The 360M model, asked to find an order number in &quot;Thanks for a lovely afternoon yesterday,&quot; where the correct answer is one word, had fallen into a repetition loop and been killed after twenty-one seconds. Reproducible at temperature zero. The input causes it.&lt;/p&gt;
&lt;p&gt;My first fix was to record failures and keep going, which was right. My second was to exclude all failures from the scores as infrastructure noise, which was badly wrong.&lt;/p&gt;
&lt;p&gt;That call was not infrastructure noise. The model ran. It received the prompt, generated tokens, and produced nothing usable. That is a result, and excluding it would have dropped from the denominator the single case this model demonstrably could not handle, scoring it only on the cases it survived.&lt;/p&gt;
&lt;p&gt;So the runner now tags every failure as one of two kinds:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Model failure.&lt;/strong&gt; The call reached a working model and the model failed. Stays in the denominator. Counts against the model. Reported on its own line as &quot;no usable answer.&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Transport failure.&lt;/strong&gt; The call never reached a working model. Ollama was down, the tag was wrong, the request timed out. Nothing was measured, so it is excluded and the run is reported as incomplete.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The distinction is a five-line change and it is the difference between measuring a model and measuring a model on the subset of inputs it happens to tolerate.&lt;/p&gt;
&lt;h3 id=&quot;kind-five-inputs-that-go-missing-without-raising&quot;&gt;Kind five: inputs that go missing without raising&lt;/h3&gt;
&lt;p&gt;This is the missing corpus from the top of the post, and it is a different and worse category than everything above it.&lt;/p&gt;
&lt;p&gt;The function was four lines:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def corpus_text():
    parts = []
    for p in sorted((FIX / &quot;corpus&quot;).glob(&quot;*.md&quot;)):
        parts.append(p.read_text(encoding=&quot;utf-8&quot;).strip())
    return &quot;\n\n&quot;.join(parts)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A glob that matches nothing yields nothing. An empty list joins to the empty string. No exception, no warning, no exit code. It returns a perfectly valid value that happens to be the absence of the thing it was asked for.&lt;/p&gt;
&lt;p&gt;And on the same evening, a sibling. The offline test's entire product is a text transcript. I copied it into the run directory, committed, pushed, and it was not there. The standard Python &lt;code&gt;.gitignore&lt;/code&gt; template, which I had pulled in wholesale, carries &lt;code&gt;*.log&lt;/code&gt; on line 165, and a pattern without a leading slash matches at any depth.&lt;/p&gt;
&lt;p&gt;The copy succeeded. &lt;code&gt;git status&lt;/code&gt; came back clean. The push reported success. The one artifact the test exists to produce was gone, and everything in the chain said fine.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Absence does not raise.&lt;/strong&gt; A missing directory, a missing file and an inert fixture all present as an ordinary successful run. That is narrower and more useful than &quot;test your tests,&quot; and it generalizes: every input a harness depends on needs a loader that refuses to proceed without it, and every artifact it produces needs to be verified as present rather than inferred from an exit status.&lt;/p&gt;
&lt;p&gt;The loader now looks like this, and the comment is longer than the code because the comment is the part I will need next year:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;paths = sorted(corpus_dir.glob(&quot;*.md&quot;))
if not paths:
    raise SystemExit(
        f&quot;No corpus documents found in {corpus_dir}\n&quot;
        &quot;Grounded QA cannot run without reference material, and running\n&quot;
        &quot;it against an empty reference block produces numbers that look\n&quot;
        &quot;like a result.&quot;
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It also refuses a corpus implausibly smaller than the real one, the run prints the document count and token estimate before its first call, and the scorer independently flags any QA run whose median prompt is under 400 tokens. Three checks for one bug, in the input, in the run, and in the output, because this is the bug that cost a day.&lt;/p&gt;
&lt;h3 id=&quot;the-detector-that-fired-and-was-right-and-told-me-the-wrong-thing&quot;&gt;The detector that fired, and was right, and told me the wrong thing&lt;/h3&gt;
&lt;p&gt;Here is the part I find hardest to write and most worth keeping.&lt;/p&gt;
&lt;p&gt;The abstention-collapse check from kind one &lt;em&gt;did&lt;/em&gt; fire on the invalid run. It printed exactly this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;!! ABSTAINED on 13/15 questions overall. It is refusing, not judging.
   The abstention score below is not evidence of discrimination:
   a model that never answers gets it right for free.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It was correct. Something was badly wrong with that run.&lt;/p&gt;
&lt;p&gt;Its diagnosis was completely false. The model was not refusing. It was obeying, precisely, an instruction to report when the material lacks the answer, in a situation where the material lacked everything. The fault was not in the model's judgment, and it was not in the metric. It was in a directory that did not exist, two layers upstream.&lt;/p&gt;
&lt;p&gt;And I acted on the diagnosis. I wrote it up: the model has a stuck needle, its perfect abstention score is an artifact of refusal, here are the implications for anyone building on a 1.5B model. Confident, specific, sourced to real output, and wrong in every particular.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A flag that tells you something is off is not a flag that tells you what.&lt;/strong&gt; The check could only see the output. The fault was in the input. It fired for the right reason and pointed in the wrong direction, and because it fired I felt &lt;em&gt;more&lt;/em&gt; confident in a conclusion that was false, not less.&lt;/p&gt;
&lt;p&gt;That is the failure mode I had no defense against and still only partly do. A monitoring system that is right about the existence of a problem and wrong about its cause is more dangerous than no monitoring, because it terminates the search.&lt;/p&gt;
&lt;h3 id=&quot;what-i-actually-changed&quot;&gt;What I actually changed&lt;/h3&gt;
&lt;p&gt;For anyone who wants the diffs rather than the moral:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Collapse checks on both classification and QA: when one answer exceeds half the set, print the concentration and declare the headline metric meaningless for that model.&lt;/li&gt;
&lt;li&gt;The decay check flags implausible speedups as well as slowdowns, and notes when generations are too short to time.&lt;/li&gt;
&lt;li&gt;Failures split into model and transport, with model failures staying in the denominator.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;corpus_text()&lt;/code&gt; raises on an empty or implausibly small corpus.&lt;/li&gt;
&lt;li&gt;The QA run prints its corpus size before the first call.&lt;/li&gt;
&lt;li&gt;The scorer flags a QA run whose prompts are too small to contain the corpus.&lt;/li&gt;
&lt;li&gt;The keyword check is relabelled as a weak lower bound, in the output, in the README, and in the results.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.gitignore&lt;/code&gt; no longer lets &lt;code&gt;*.log&lt;/code&gt; swallow run transcripts.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;vcgencmd get_throttled&lt;/code&gt; before and after every run, recorded in the run document rather than checked and forgotten. The reading reports bits set since boot, so it is perishable: power-cycle the board and the evidence is gone.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-uncomfortable-part&quot;&gt;The uncomfortable part&lt;/h3&gt;
&lt;p&gt;Every one of these was found by looking at data. Not one was found by reading code, reviewing the scorer, or writing a unit test for the harness.&lt;/p&gt;
&lt;p&gt;The vacuous fixture turned up while hand-checking a passing case. The confident-wrong flaw turned up because a score of 3 against 26 looked backwards. The abstention flaw turned up because 4 out of 4 looked too good. The keyword flaw turned up because 1 of 11 looked too bad, and I read the one. The missing corpus turned up because I was double-checking a latency figure and noticed a prompt that was too small to be possible.&lt;/p&gt;
&lt;p&gt;Which means the only reason I found them is that I kept pulling threads after I had numbers that looked publishable. The version of this project that shipped on schedule shipped on Tuesday, with a clean verdict table, a confident conclusion about small models and grounded question answering, and a repository anyone could clone to reproduce the exact same wrong result.&lt;/p&gt;
&lt;p&gt;Every number in it would have been reproducible. That is the thing I keep turning over. Reproducibility is table stakes and it would not have saved me, because the harness reproduced its own bug faithfully every single time.&lt;/p&gt;
&lt;p&gt;I do not have a process that catches this reliably. What I have is a weaker and more honest claim: the checks I added are each a direct response to one specific thing that actually happened, which is the only kind of check I trust, and the number of them that I thought up in advance is zero.&lt;/p&gt;
&lt;p&gt;So the two rows I am adding to my prediction template, to be filled in before anything runs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;What would have to be true for these numbers to be meaningless?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would anything in the harness tell me if it were?&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Twenty-two predictions about what the models would do. Not one about what the measuring code would do. Four of the five flaws were in the measuring code.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The harness, the fixtures, the committed predictions and the full run record, including the invalid run and what it produced, are at&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/kenwalger/right-size-the-model&quot;&gt;github.com/kenwalger/right-size-the-model&lt;/a&gt;.&lt;br&gt;
The findings the harness eventually produced are in &lt;a rel=&quot;nofollow&quot; href=&quot;[https://www.kenwalger.com/blog/ai/do-you-actually-need-an-llm/](https://www.kenwalger.com/blog/ai/do-you-actually-need-an-llm/?utm_source=kenwalger&amp;amp;utm_medium=blog&amp;amp;utm_campaign=right_size_the_model)&quot;&gt;the companion post&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30851/nothing-reported-it</guid>
<pubDate>Thu, 22 Oct 2026 13:59:00 +0000</pubDate>
</item>
<item>
<title>Do You Actually Need an LLM?</title>
<link>https://coderlegion.com/30818/do-you-actually-need-an-llm</link>
<description>&lt;p&gt;I spent a weekend running two small language models against three jobs I would normally hand to a language model without thinking about it. For one of those jobs I also wrote a regular expression, as a control.&lt;/p&gt;
&lt;p&gt;The regex won. Thirty test cases out of thirty, in zero milliseconds, from a pattern that took an afternoon to write. The better of the two models scored twenty-eight, took five seconds a call, and the two it missed are both mistakes a regex is structurally incapable of making.&lt;/p&gt;
&lt;p&gt;That is the kind of result I was looking for, and the question behind it is one I keep avoiding: &lt;strong&gt;what is the smallest amount of computation that reliably does this job?&lt;/strong&gt; Not the cheapest model. Not the best small model. The smallest thing that works, which means the ladder has four rungs and the bottom one is not a model at all.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Deterministic code.&lt;/strong&gt; A regex, a lookup table, an if statement.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A small local model.&lt;/strong&gt; Runs on hardware you already own.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A larger local model.&lt;/strong&gt; Needs a GPU.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A remote frontier model.&lt;/strong&gt; Someone else's hardware, metered.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Most projects start at rung four and never ask about the others. This is a weekend spent asking.&lt;/p&gt;
&lt;p&gt;The hardware was a Raspberry Pi 4, 4 GB of RAM, heat sinks and a fan, a hundred and seventy dollars most of which I already owned. The models were &lt;code&gt;smollm2:360m&lt;/code&gt; and &lt;code&gt;qwen2.5:1.5b&lt;/code&gt; through Ollama. The test cases are ninety-five questions about a fictional small winery, so nothing can be looked up and every answer can be judged by reading. Sixteen of the fifty classification messages are  deliberately ambiguous, because real inboxes are mostly ambiguous.&lt;/p&gt;
&lt;p&gt;Four verdicts, assigned per workflow, to force a result you can act on instead of a number:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Unnecessary&lt;/strong&gt;: deterministic code already does this.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sufficient&lt;/strong&gt;: a small local model does it well enough to run unattended.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Marginal&lt;/strong&gt;: usable with a person checking the output.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Insufficient&lt;/strong&gt;: the error profile or the latency is not acceptable.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And one rule, which is the part I would insist on if you copy nothing else: &lt;strong&gt;I wrote predictions for every workflow and committed them before I installed Ollama.&lt;/strong&gt; Twenty-two of them, each marked confident, leaning, or coin flip. Once there is a timestamped expectation there is a defined moment when you are done, and no room to decide afterward that you knew all along. I will come back to how they scored, because it is not flattering.&lt;/p&gt;
&lt;h3 id=&quot;extraction-pulling-an-order-number-out-of-a-message&quot;&gt;Extraction: pulling an order number out of a message&lt;/h3&gt;
&lt;p&gt;Find &lt;code&gt;ORD-&lt;/code&gt; followed by six digits, or report that there isn't one. Thirty cases: twenty-two with an identifier, five without, and three decoys. The decoys are a membership number, a UPS tracking number, and a phone number, all of which look like identifiers and none of which are order numbers.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;regex &lt;/th&gt;&lt;th&gt;qwen2.5:1.5b &lt;/th&gt;&lt;th&gt;smollm2:360m&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Overall &lt;/td&gt;&lt;td&gt;&lt;strong&gt;30/30&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;28/30 &lt;/td&gt;&lt;td&gt;8/30&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Declined correctly when absent (8) &lt;/td&gt;&lt;td&gt;8 &lt;/td&gt;&lt;td&gt;8 &lt;/td&gt;&lt;td&gt;0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Decoys ignored (3) &lt;/td&gt;&lt;td&gt;3 &lt;/td&gt;&lt;td&gt;3 &lt;/td&gt;&lt;td&gt;0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Invented an answer &lt;/td&gt;&lt;td&gt;0 &lt;/td&gt;&lt;td&gt;0 &lt;/td&gt;&lt;td&gt;7&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The regex won. Thirty out of thirty, in zero milliseconds, at zero watts above idle.&lt;/p&gt;
&lt;p&gt;That result is boring until you read the two the best model missed.&lt;/p&gt;
&lt;p&gt;On &lt;code&gt;ORD-999000: wrong address, please hold.&lt;/code&gt; it answered &lt;code&gt;999000&lt;/code&gt;, dropping the prefix. Annoying, recoverable, the kind of thing you fix with a post-processing step you should not need.&lt;/p&gt;
&lt;p&gt;On &lt;code&gt;ORD-111222 please refund&lt;/code&gt; it answered &lt;code&gt;NONE&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;That is the shortest and least decorated input in the whole set. Almost the entire message is the order number. And the model returned a confident, correctly formatted assertion that there is no order number present. In a pipeline, that is a refund request for an order that exists arriving at a system that has decided it does not.&lt;/p&gt;
&lt;p&gt;A regex cannot make that mistake. Not &quot;rarely does&quot; — cannot. The two errors separating 28 from 30 are both of a class that the deterministic baseline is structurally incapable of producing, which is a different and much stronger statement than a two-point accuracy gap.&lt;/p&gt;
&lt;h4 id=&quot;the-360m-result-needs-two-numbers-not-one&quot;&gt;The 360M result needs two numbers, not one&lt;/h4&gt;
&lt;p&gt;By the contract, &lt;code&gt;smollm2:360m&lt;/code&gt; scored 8 of 22 on the cases that contained an identifier. That reads like a model that cannot find things.&lt;/p&gt;
&lt;p&gt;It is not. All fourteen of its wrong answers are the correct six digits with &lt;code&gt;ORD-&lt;/code&gt; stripped. &lt;code&gt;ORD-556120&lt;/code&gt; came back as &lt;code&gt;556120&lt;/code&gt;, fourteen times out of fourteen.&lt;/p&gt;
&lt;p&gt;It located the right identifier in 22 of 22 and formatted it correctly in 8. Those are two different skills and the single score hides one of them. The model understood the task perfectly and could not obey the output contract, which is a completely different engineering problem than &quot;the model is too small,&quot; and it has a completely different fix. (Constrained decoding. I have not tested it. It is the obvious follow-up and it is not in this post.)&lt;/p&gt;
&lt;p&gt;The decoys follow from the same gap. It handed back &lt;code&gt;MEM-443322&lt;/code&gt;, &lt;code&gt;1Z999AA10123456784&lt;/code&gt; and &lt;code&gt;503-555-0142&lt;/code&gt; verbatim, all three. It has no model of the format at all. It returns whatever in the message looks most like an identifier.&lt;/p&gt;
&lt;h4 id=&quot;which-suggests-a-use-for-it-that-is-not-extraction&quot;&gt;Which suggests a use for it that is not extraction&lt;/h4&gt;
&lt;p&gt;A model with perfect recall and no precision is not a bad extractor. It is a candidate generator that has been asked to do the wrong job.&lt;/p&gt;
&lt;p&gt;Twenty-two of twenty-two located, eight of twenty-two formatted. Put that output through the regex as a validator rather than trusting it directly, and you get the model's recall with the pattern's precision. The model proposes anything that looks like an identifier, including format variants the pattern was never written for. The pattern rejects anything malformed.&lt;/p&gt;
&lt;p&gt;Crucially, neither half of that pipeline can produce the error the 1.5B model produced alone. The model cannot cause a false negative because it does not decide, and the regex cannot invent because it only matches.&lt;/p&gt;
&lt;p&gt;I have not tested that composition and it is not in these numbers. But it is the one place in the extraction result where a small model has a job worth giving it, and it is a job where being sloppy is free.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Verdict for extraction itself: Unnecessary.&lt;/strong&gt; Write the regex.&lt;/p&gt;
&lt;h3 id=&quot;classification-routing-a-message-to-one-of-six-queues&quot;&gt;Classification: routing a message to one of six queues&lt;/h3&gt;
&lt;p&gt;Appointment, billing, availability, complaint, question, escalate. Fifty messages. The instruction said to use &lt;code&gt;escalate&lt;/code&gt; when the message needs a person, including when you cannot tell what it is about.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;qwen2.5:1.5b &lt;/th&gt;&lt;th&gt;smollm2:360m&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Overall &lt;/td&gt;&lt;td&gt;23/50 (46%) &lt;/td&gt;&lt;td&gt;15/50 (30%)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Labels used &lt;/td&gt;&lt;td&gt;6 of 6 &lt;/td&gt;&lt;td&gt;3 of 6&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Most common label &lt;/td&gt;&lt;td&gt;complaint, 38% &lt;/td&gt;&lt;td&gt;escalate, &lt;strong&gt;86%&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Wrong and not an escalation &lt;/td&gt;&lt;td&gt;&lt;strong&gt;26&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;3 (meaningless)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The 360M model answered &lt;code&gt;escalate&lt;/code&gt; to 43 of 50 messages, which is not classification, and there is nothing further to say about it here. The rest of this section is the 1.5B model.&lt;/p&gt;
&lt;p&gt;It is classifying, and it is not good at it. Forty-six percent, with twenty-six messages routed confidently to a wrong queue that was not the safe one.&lt;/p&gt;
&lt;p&gt;Read those twenty-six and the verdict writes itself:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Someone slipped on the steps by the barrel room and hurt their wrist →&lt;br&gt;
&lt;strong&gt;complaint&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;A formal notice regarding a trademark → &lt;strong&gt;complaint&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&quot;I have a severe sulfite allergy, what is actually in the wine&quot; →&lt;br&gt;
&lt;strong&gt;complaint&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;A reporter asking about the 2024 harvest → &lt;strong&gt;question&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;A distributor wanting to carry the wine in forty stores → &lt;strong&gt;question&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;An injury report, a legal notice and an allergy question, all routed to the complaints queue, confidently, with no escalation. All five billing messages about money problems also went to complaints.&lt;/p&gt;
&lt;p&gt;Forty-six percent sounds like something you could work with. Twenty-six confident misroutes including an injury and a legal notice is not something you can work with.&lt;/p&gt;
&lt;h4 id=&quot;and-then-i-broke-the-score-down-by-class&quot;&gt;And then I broke the score down by class&lt;/h4&gt;
&lt;p&gt;This is the part I nearly missed, and it is the most useful thing in the experiment.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Label &lt;/th&gt;&lt;th&gt;Correct &lt;/th&gt;&lt;th&gt;Of&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;complaint &lt;/td&gt;&lt;td&gt;6 &lt;/td&gt;&lt;td&gt;6&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;question &lt;/td&gt;&lt;td&gt;9 &lt;/td&gt;&lt;td&gt;10&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;availability &lt;/td&gt;&lt;td&gt;3 &lt;/td&gt;&lt;td&gt;6&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;appointment &lt;/td&gt;&lt;td&gt;4 &lt;/td&gt;&lt;td&gt;9&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;billing &lt;/td&gt;&lt;td&gt;1 &lt;/td&gt;&lt;td&gt;7&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;escalate &lt;/td&gt;&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;strong&gt;12&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two classes near perfect. Four poor. One at zero.&lt;/p&gt;
&lt;p&gt;The split is not difficulty, and it is not class size. It is what kind of label each one is.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;complaint&lt;/code&gt; and &lt;code&gt;question&lt;/code&gt; are &lt;strong&gt;topics&lt;/strong&gt;. You can tell what a message is from the words in it. Someone complaining sounds like someone complaining.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;escalate&lt;/code&gt; is a &lt;strong&gt;routing decision&lt;/strong&gt;. Nothing in the content of a message makes it an escalation. It is an escalation because of what should happen next. &quot;I have a severe sulfite allergy, what is actually in the wine&quot; and &quot;what time do you close&quot; are both questions about the business, with the same grammar and the same tone, and one of them is a liability event.&lt;/p&gt;
&lt;p&gt;The model did topics at 90 to 100 percent and routing decisions at zero. And &lt;code&gt;billing&lt;/code&gt; sat in the middle for exactly the same reason: a message about being charged twice &lt;em&gt;is&lt;/em&gt; a complaint, as text. The label is about which department owns the problem, which is not a property of the sentence.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Small models answer &quot;what is this about.&quot; They do not answer &quot;what should we do about it.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That reframes the five examples above. They are not five unlucky misroutings. All five are escalations, and escalate scored zero of twelve. The model was never going to find them, because finding them requires knowing a consequence that the text does not contain.&lt;/p&gt;
&lt;p&gt;Two caveats on how hard to lean on this. The classes hold between six and twelve cases each, so &quot;six of six&quot; is six cases and the individual numbers are thin. And this is one model. The pattern across all six classes is consistent enough that I believe the explanation, but I am offering it as the best account of the data rather than as a measurement.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Verdict: Insufficient.&lt;/strong&gt; And the actionable version: if one of your labels means &quot;a human needs to see this,&quot; a small model will not find it. Build that one as a rule.&lt;/p&gt;
&lt;h3 id=&quot;grounded-qa-answering-a-question-from-four-documents&quot;&gt;Grounded QA: answering a question from four documents&lt;/h3&gt;
&lt;p&gt;Fifteen questions against a 650-token corpus: club policy, tasting room, production notes, shipping. Eight are answerable from one passage, three require combining two, and four have no answer in the material at all.&lt;/p&gt;
&lt;p&gt;I read all thirty answers by hand against the corpus. That is the only way to score this, and I will say why in a moment.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;qwen2.5:1.5b &lt;/th&gt;&lt;th&gt;smollm2:360m&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Single passage (8) &lt;/td&gt;&lt;td&gt;&lt;strong&gt;8/8&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;7/8&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Two passage (3) &lt;/td&gt;&lt;td&gt;1/3 &lt;/td&gt;&lt;td&gt;0/3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Answerable total (11)&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;strong&gt;9/11&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;7/11&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Correctly said &quot;not in the material&quot; (4) &lt;/td&gt;&lt;td&gt;2/4 &lt;/td&gt;&lt;td&gt;0/4&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Three boundaries separate cleanly here, and they are the useful result.&lt;/p&gt;
&lt;h4 id=&quot;retrieval-works&quot;&gt;Retrieval works&lt;/h4&gt;
&lt;p&gt;The 1.5B model answered all eight single-passage questions correctly. Six bottles in the Vineyard tier. Twenty dollars per person. September 9th. Thirty-four states. No dogs indoors. 21.8 Brix on the 12th. Twenty-five dollar restocking fee. Ten people on the barrel room tour.&lt;/p&gt;
&lt;p&gt;Asked what happens if you join the club and immediately want out, it returned the two sentences that answer the question and stopped:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Cancellation requires one completed shipment. Members who cancel before receiving a shipment are charged for that shipment.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That is competent lookup across four documents, on a $170 computer, with the wifi switched off. I did not expect it and it is the most encouraging thing in the weekend.&lt;/p&gt;
&lt;h4 id=&quot;combination-does-not&quot;&gt;Combination does not&lt;/h4&gt;
&lt;p&gt;One club member brings five guests on a Saturday. Club policy waives the tasting fee for the member plus three guests. The tasting room charges twenty dollars a head. Six people, four waived, two paying: forty dollars.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Both models answered twenty.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Neither could hold &quot;plus three guests&quot; against a party of six and subtract. The 1.5B model did get the second half right, correctly working out that a party of six is not a group &lt;em&gt;larger&lt;/em&gt; than six and therefore needs no reservation, which is the only piece of multi-document reasoning either model performed all weekend.&lt;/p&gt;
&lt;p&gt;The second two-passage question is the one I keep thinking about. Asked why someone would not find a 2024 rose and what they would be offered instead, the 1.5B model quoted the corpus exactly, and then:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;...the reference material states that &quot;We did not bottle a rose in 2024. The fruit that would normally go to rose was needed to make volume on the estate Pinot Noir.&quot; Therefore, the 2024 rose would be replaced with the fruit that would typically go into the estate Pinot Noir production.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The quotation is verbatim and right. The next sentence reverses the only relationship in it. The fruit went &lt;em&gt;into&lt;/em&gt; the Pinot Noir, so what you would be offered is the Pinot Noir. You cannot be offered fruit.&lt;/p&gt;
&lt;p&gt;Correct retrieval and inverted inference, in consecutive sentences, with the evidence on screen between them.&lt;/p&gt;
&lt;p&gt;The 360M model's multi-document failures are worse than wrong. They are contaminated. It took the fourteen-days-notice rule, which governs skipping a club shipment, and applied it to booking a tasting room reservation. It reached into the shipping document to answer a cancellation question. Given more documents to search, it finds more wrong ones.&lt;/p&gt;
&lt;h4 id=&quot;knowing-the-answer-is-absent-is-the-weakest-link&quot;&gt;Knowing the answer is absent is the weakest link&lt;/h4&gt;
&lt;p&gt;Four questions have no answer in the corpus, chosen to be things a reader would reasonably expect a winery to have written down: a wine's alcohol percentage, who the winemaker is, what a club tier costs, what yeast was used.&lt;/p&gt;
&lt;p&gt;The 1.5B model abstained on two and fabricated two. The 360M model fabricated all four.&lt;/p&gt;
&lt;p&gt;And the fabrications are a more dangerous species than invention. Asked for the alcohol percentage of the 2024 estate Pinot Noir, the 1.5B model answered:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The 2024 estate Pinot Noir had a Brix range of 23.1 to 24.6 at pick.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Every component of that is true. Right wine, right vintage, right numbers, right document. Brix is sugar at harvest and has nothing to do with alcohol in the bottle. It reached for the nearest real fact instead of making one up, and produced something that reads as expertise while answering a question nobody asked. If you do not know what Brix is, that answer is indistinguishable from a correct one.&lt;/p&gt;
&lt;p&gt;Both models made the same mistake on what the Cellar tier costs: both answered &quot;twelve bottles per quarter.&quot; Grounding gave them the right tier and they handed back the quantity in place of the price.&lt;/p&gt;
&lt;h4 id=&quot;the-one-answer-the-evidence-did-not-change&quot;&gt;The one answer the evidence did not change&lt;/h4&gt;
&lt;p&gt;The 360M model, asked whether a dog can come inside the tasting room:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Yes, you can bring a dog inside the tasting room.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The corpus says the patio is dog friendly and the indoor room is not, service animals excepted. I have the same answer from a run where the model had no corpus at all, and it is identical byte for byte. Having the right answer directly in the prompt changed nothing.&lt;/p&gt;
&lt;p&gt;In a category where being wrong means a family drives out to a winery and gets turned around at the door.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Verdict: Marginal.&lt;/strong&gt; Usable with a person reading the output. Not usable without one.&lt;/p&gt;
&lt;h3 id=&quot;what-it-costs&quot;&gt;What it costs&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Workflow &lt;/th&gt;&lt;th&gt;Model &lt;/th&gt;&lt;th&gt;tok/s &lt;/th&gt;&lt;th&gt;Prompt &lt;/th&gt;&lt;th&gt;Answer &lt;/th&gt;&lt;th&gt;Median call&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;extraction &lt;/td&gt;&lt;td&gt;qwen2.5:1.5b &lt;/td&gt;&lt;td&gt;4.3 &lt;/td&gt;&lt;td&gt;92 &lt;/td&gt;&lt;td&gt;9 &lt;/td&gt;&lt;td&gt;5.0 s&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;classification &lt;/td&gt;&lt;td&gt;qwen2.5:1.5b &lt;/td&gt;&lt;td&gt;7.5 &lt;/td&gt;&lt;td&gt;104 &lt;/td&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;2.9 s&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;QA &lt;/td&gt;&lt;td&gt;qwen2.5:1.5b &lt;/td&gt;&lt;td&gt;3.7 &lt;/td&gt;&lt;td&gt;708 &lt;/td&gt;&lt;td&gt;15 &lt;/td&gt;&lt;td&gt;6.9 s&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two things in here surprised me.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The compute tax does not track parameter count.&lt;/strong&gt; The 1.5B model is &lt;em&gt;slower&lt;/em&gt; than the 360M on extraction, 4.3 against 5.9 tokens per second, and &lt;em&gt;faster&lt;/em&gt; on classification, 7.5 against 6.2, because on classification it answers in two tokens where the smaller model takes six. Four times the parameters, and which one wins depends entirely on how much each one decides to say.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A bigger prompt is cheap. A longer answer is what you pay for.&lt;/strong&gt; I have a clean comparison here by accident: an earlier QA run went out with 97-token prompts and the real one with 708. Seven times the prompt added fifteen percent to the wait before output started, because prompt evaluation parallelizes. The latency increase came from answers three times longer, plus a twenty-one percent drop in per-token generation speed from attending over a longer context. Median latency went from 3.6 to 6.9 seconds and the slowest single call took ninety-eight.&lt;/p&gt;
&lt;p&gt;If you are sizing a local system, that is the inversion to internalize. Stuffing more context in is affordable. Letting the model be chatty is not.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;And check your thermals, which is not optional advice.&lt;/strong&gt; My first attempt at this ran with heat sinks and no fan. Thirty calls to a 360M model was enough to hit the Pi 4's soft temperature limit and start capping the clock. &lt;code&gt;vcgencmd get_throttled&lt;/code&gt; returned &lt;code&gt;0xe0000&lt;/code&gt;. I threw those latency numbers away.&lt;/p&gt;
&lt;p&gt;Nothing in Ollama's output, my scorer, or the result files said a word about it. Every number on screen was internally consistent and quietly describing a machine that was slowing down. The only way to find out was to ask the firmware a question I had to already suspect the answer to. Buy the fan.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;And it does run with the network off.&lt;/strong&gt; I switched the radio off, proved there was no route out, and ran one call from each workflow. All three worked. The claim that supports is narrow and worth stating carefully: this&lt;br&gt;
configuration, on this afternoon, ran with no route to the internet. It is not a claim that local models are private or that this setup is secure, and a Pi can be misconfigured as thoroughly as anything in a data center. But for the specific question of whether the inference needs the network, it does not.&lt;/p&gt;
&lt;h3 id=&quot;so-do-you-actually-need-an-llm-&quot;&gt;So: do you actually need an LLM?&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Workload &lt;/th&gt;&lt;th&gt;Verdict&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Pull a known-format identifier out of text &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Unnecessary&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Route a message to one of six queues &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Insufficient&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Answer a question from your own documents &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Marginal&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Three workflows that all sound like language tasks, three different answers, and not one of them is &quot;yes, use a model.&quot;&lt;/p&gt;
&lt;p&gt;And the same line runs through all three. Extraction asks what this string is. Topic labels ask what this message is about. Single-passage lookup asks what the document says. Those went well, or well enough. Escalate asks what we should do, the fee question asks what the customer owes, and the unanswerable questions ask whether we know. Zero of twelve, both models wrong, and two of four.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A small model will tell you what something is. It will not tell you what to do about it.&lt;/strong&gt; Every result here falls on one side or the other of that sentence, and I did not have it written down when I started.&lt;/p&gt;
&lt;p&gt;Which gives four properties worth checking before you reach for one:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The answer is already present in the input.&lt;/strong&gt; Not inferable from it. Present in it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;There is exactly one of it.&lt;/strong&gt; The moment two facts have to be combined, this stops working, and the arithmetic in that tasting fee question is the proof.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The output is short.&lt;/strong&gt; Longer answers are where the latency lives on this hardware, and where the fabrication lives everywhere.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Something other than the model decides whether the output is acceptable.&lt;/strong&gt; A regex, a schema, a person, an index that either returns rows or does not.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Take away any one of those four and the experiment says do not do it. The 46 percent and the zero of twelve are what it looks like when you ask a small model to be the thing that decides.&lt;/p&gt;
&lt;p&gt;And if you have all four, you may not need the model either. That is worth checking first, because it was true for one of my three workflows and I did not expect it to be.&lt;/p&gt;
&lt;h3 id=&quot;where-i-was-wrong&quot;&gt;Where I was wrong&lt;/h3&gt;
&lt;p&gt;Twenty-two predictions, committed before I installed anything. Ten right, eleven wrong, one unresolvable.&lt;/p&gt;
&lt;p&gt;Two of my seven confident calls broke. I was sure the 360M model would get most of the easy extraction cases, and it got 8 of 22 by failing at format rather than at finding. I was sure it would produce invalid output often, and it did so once in fifty, then emitted the same &lt;em&gt;valid&lt;/em&gt; word forty-three times.&lt;/p&gt;
&lt;p&gt;But the number that bothers me is not in the confident column.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Confidence &lt;/th&gt;&lt;th&gt;Right &lt;/th&gt;&lt;th&gt;Wrong&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Confident &lt;/td&gt;&lt;td&gt;5 &lt;/td&gt;&lt;td&gt;2&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Leaning &lt;/td&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;7&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Coin flip &lt;/td&gt;&lt;td&gt;3 &lt;/td&gt;&lt;td&gt;2&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;My hunches did worse than my coin flips.&lt;/strong&gt; Two of ten on the calls where I had a feeling but not much behind it, three of five on the ones I explicitly labeled as guesses.&lt;/p&gt;
&lt;p&gt;That ordering is backwards and it is the most useful thing on the sheet. On the coin flips I knew I was guessing, so I guessed cleanly. On the leaning calls I was pattern-matching against intuitions about model behavior that turned out not to describe these models at all, and the feeling of having a reason was the problem. If I had marked all twelve of those as coin flips I would have scored better.&lt;/p&gt;
&lt;p&gt;I will take that over a clean result. The honest version of this exercise is not &quot;here are the numbers,&quot; it is &quot;here is what I believed, here is what happened, and here is the gap.&quot;&lt;/p&gt;
&lt;p&gt;The gap, this time, was mostly in the hunches.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The harness that produced these numbers had five measurement flaws in it, none of which manufactured an entire finding out of a missing directory and nearly got published. That is a separate post.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Everything here is reproducible: fixtures, runner, scorer, the committed predictions and the run record are at&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/kenwalger/right-size-the-model&quot;&gt;github.com/kenwalger/right-size-the-model&lt;/a&gt;.&lt;br&gt;
Fixture set v1. Read &lt;code&gt;LIMITATIONS.md&lt;/code&gt; before citing any of these numbers:&lt;br&gt;
two models, one board, one quantization, ninety-five cases. This is an illustration of a method, not a benchmark.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30818/do-you-actually-need-an-llm</guid>
<pubDate>Tue, 13 Oct 2026 14:17:00 +0000</pubDate>
</item>
<item>
<title>FramePort: HDMI Capture Directly Inside VS Code</title>
<link>https://coderlegion.com/28467/frameport-hdmi-capture-directly-inside-vs-code</link>
<description>&lt;p&gt;Hi everyone!&lt;/p&gt;
&lt;p&gt;I'm a college junior, and I built &lt;strong&gt;FramePort&lt;/strong&gt; while working on Tomato, my custom 32-bit computer with a dual-LUT ALU running on an Artix-7 FPGA.&lt;/p&gt;
&lt;p&gt;I wanted to see its HDMI output beside my assembly code without constantly switching to OBS. So I built a VS Code extension that brings live video capture directly into the editor.&lt;/p&gt;
&lt;p&gt;FramePort supports HDMI capture cards, cameras, and other compatible video sources, with features including:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Live video preview with adjustable scaling&lt;/li&gt;
&lt;li&gt;Screenshot capture&lt;/li&gt;
&lt;li&gt;Silent MP4 recording&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Although I originally built it for Tomato, I figured it could also be useful for Raspberry Pi projects, embedded systems, and other hardware-development workflows.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Here's FramePort running alongside Tomato's assembly code:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/rjedlrcg8c9amvwj68fs.gif&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;FramePort is now available on Open VSX as a public preview for macOS. It requires FFmpeg and a compatible capture device.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Try FramePort:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://open-vsx.org/extension/tmarhguy/frameport&quot;&gt;https://open-vsx.org/extension/tmarhguy/frameport&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Source code:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/tmarhguy/frameport&quot;&gt;https://github.com/tmarhguy/frameport&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I'd love feedback from anyone interested in testing FramePort or contributing to its development!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28467/frameport-hdmi-capture-directly-inside-vs-code</guid>
<pubDate>Thu, 24 Sep 2026 11:47:06 +0000</pubDate>
</item>
<item>
<title>Raspberry Pi Server: Fortifying Your Digital Fortress with Safeline WAF Protection</title>
<link>https://coderlegion.com/13894/raspberry-pi-server-fortifying-your-digital-fortress-with-safeline-waf-protection</link>
<description>&lt;p&gt;Have you ever wondered if that little credit-card-sized computer, the Raspberry Pi, could be a robust server for your home or small business? The answer is a resounding &lt;em&gt;yes&lt;/em&gt;! These versatile devices are incredibly capable of handling everything from media streaming and home automation to hosting websites and running complex applications. But as with any server, security is paramount. When you're opening your Raspberry Pi server up to the internet, you're also opening it up to potential threats. That’s where a &lt;strong&gt;Web Application Firewall (WAF)&lt;/strong&gt; comes into play, and today, we’re diving deep into how you can leverage Safeline WAF protection on your Raspberry Pi to build a truly secure digital fortress.&lt;/p&gt;
&lt;p&gt;Think of your web server like a castle. It has walls, a gate, and guards. A WAF is like a highly intelligent, constantly vigilant security system that sits &lt;em&gt;in front&lt;/em&gt; of your castle's main gate. It doesn't just check if someone has a key; it scrutinizes &lt;em&gt;everything&lt;/em&gt; they're trying to bring in, looking for anything suspicious, dangerous, or out of the ordinary. It’s your first line of defense against a whole host of nasty online attacks.&lt;/p&gt;
&lt;h3 id=&quot;why-a-raspberry-pi-server-needs-waf-protection&quot;&gt;Why a Raspberry Pi Server Needs WAF Protection&lt;/h3&gt;
&lt;p&gt;Let's be honest, the Raspberry Pi is an absolute marvel of technology. Its affordability, low power consumption, and flexibility have made it a darling of hobbyists and professionals alike. You can set up a Raspberry Pi as a file server, a VPN gateway, a personal cloud, or even a web server for your personal blog or a small business website. But here’s the catch: once your Raspberry Pi server is accessible from the public internet, it becomes a target.&lt;/p&gt;
&lt;p&gt;Hackers are constantly scanning the internet for vulnerable servers to exploit. They’re looking for ways to steal your data, inject malicious code, disrupt your services, or even use your server as a launchpad for their own attacks. Common threats include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SQL Injection:&lt;/strong&gt; Attackers try to trick your database into revealing sensitive information by inserting malicious SQL code into input fields.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-Site Scripting (XSS):&lt;/strong&gt; This involves injecting malicious scripts into websites viewed by other users, often to steal session cookies or redirect users to fake sites.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Malicious File Uploads:&lt;/strong&gt; Attackers might try to upload harmful scripts or executables to your server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Denial-of-Service (DoS) Attacks:&lt;/strong&gt; These aim to overwhelm your server with traffic, making it unavailable to legitimate users.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bot Traffic:&lt;/strong&gt; Automated bots can crawl your site, looking for vulnerabilities, scraping content, or attempting brute-force logins.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;While basic server security measures like strong passwords, regular updates, and firewalls (like &lt;code&gt;ufw&lt;/code&gt; or &lt;code&gt;iptables&lt;/code&gt;) are essential, they often don't go deep enough to inspect the &lt;em&gt;content&lt;/em&gt; of the traffic hitting your web applications. This is precisely where a WAF shines. A WAF analyzes the HTTP/S traffic flowing to and from your web server, identifying and blocking malicious requests before they can even reach your applications.&lt;/p&gt;
&lt;h3 id=&quot;introducing-safeline-waf-your-raspberry-pi-s-digital-bodyguard&quot;&gt;Introducing Safeline WAF: Your Raspberry Pi's Digital Bodyguard&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://safepoint.cloud/landing/safeline&quot;&gt;Safeline WAF&lt;/a&gt; is a light but powerful, open-source Web Application Firewall designed to protect web applications from a wide range of threats. It’s built on top of the ModSecurity engine, a well-established and highly respected WAF technology. What makes Safeline compelling for Raspberry Pi users is its relative ease of setup and its comprehensive rule sets, which are regularly updated to combat emerging threats.&lt;/p&gt;
&lt;p&gt;Think of ModSecurity as the engine, and Safeline as the polished, user-friendly interface and curated set of rules that make it accessible and effective. It works by inspecting HTTP requests and responses, comparing them against a set of predefined rules (often called the &lt;strong&gt;OWASP Core Rule Set&lt;/strong&gt; – more on that later!). If a request matches a rule indicating malicious intent, Safeline will block it, log the event, and prevent it from reaching your web application.&lt;/p&gt;
&lt;h4 id=&quot;the-power-of-modsecurity-and-the-owasp-core-rule-set&quot;&gt;The Power of ModSecurity and the OWASP Core Rule Set&lt;/h4&gt;
&lt;p&gt;ModSecurity is the industry-standard open-source WAF module that can be deployed on web servers like Apache, Nginx, and IIS. It acts as a plug-in, intercepting traffic. The real magic of ModSecurity (and by extension, Safeline) lies in its rule engine and the sophisticated rule sets that can be loaded into it.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;Open Web Application Security Project (OWASP)&lt;/strong&gt; is a renowned non-profit foundation that works to improve software security. Their &lt;strong&gt;OWASP Core Rule Set (CRS)&lt;/strong&gt; is a set of generic attack detection rules for use with ModSecurity or compatible WAFs. It's community-driven, constantly evolving, and widely considered the gold standard for WAF rules. By using Safeline, which leverages ModSecurity and often integrates with or is inspired by the OWASP CRS, you’re tapping into a vast pool of collective security intelligence.&lt;/p&gt;
&lt;p&gt;The OWASP CRS is designed to protect against many common web application vulnerabilities, including those listed in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://owasp.org/www-project-top-ten/&quot;&gt;OWASP Top 10&lt;/a&gt;. This list is a critical resource for understanding the most significant security risks to web applications. Having a WAF that effectively implements these rules is a massive step towards securing your Raspberry Pi server.&lt;/p&gt;
&lt;h3 id=&quot;setting-up-safeline-waf-on-your-raspberry-pi-a-step-by-step-journey&quot;&gt;Setting Up Safeline WAF on Your Raspberry Pi: A Step-by-Step Journey&lt;/h3&gt;
&lt;p&gt;Alright, let's get down to business. Setting up Safeline WAF on your Raspberry Pi involves a few key steps. We'll assume you have a working Raspberry Pi with a recent version of Raspberry Pi OS (formerly Raspbian) installed, and you’re comfortable using the command line. We'll also assume you already have a web server (like Nginx or Apache) installed and serving your website or application.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Prerequisites:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A functioning Raspberry Pi server:&lt;/strong&gt; With SSH access enabled or direct console access.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A web server:&lt;/strong&gt; Apache (&lt;code&gt;apache2&lt;/code&gt;) or Nginx (&lt;code&gt;nginx&lt;/code&gt;) installed and configured.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Internet connectivity:&lt;/strong&gt; For downloading packages and updates.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Basic Linux command-line knowledge.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Step 1: Install ModSecurity&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Safeline WAF is essentially a set of configurations and rules for ModSecurity. So, the first step is to install ModSecurity itself. The installation process varies slightly depending on whether you're using Apache or Nginx.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;For Apache:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt update
sudo apt upgrade -y
sudo apt install libapache2-mod-security2 -y
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After installation, ModSecurity is usually enabled automatically for Apache. You can check its status with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo a2enmod security2
sudo systemctl restart apache2
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;For Nginx:&lt;/strong&gt;&lt;br&gt;
Installing ModSecurity for Nginx is a bit more involved, often requiring you to compile Nginx with the ModSecurity module or install a pre-compiled version. A common approach is to use the &lt;code&gt;libnginx-mod-http-modsecurity&lt;/code&gt; package.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt update
sudo apt upgrade -y
sudo apt install libnginx-mod-http-modsecurity -y
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then, you need to enable the module in your Nginx configuration. Edit your main Nginx configuration file (often &lt;code&gt;/etc/nginx/nginx.conf&lt;/code&gt; or a file in &lt;code&gt;/etc/nginx/conf.d/&lt;/code&gt;) and add the following line within the &lt;code&gt;http&lt;/code&gt; block:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nginx&quot;&gt;load_module modules/ngx_http_modsecurity_module.so;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After that, you need to configure Nginx to use ModSecurity for specific locations or servers. This is typically done within your server block configuration (e.g., &lt;code&gt;/etc/nginx/sites-available/your_site&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nginx&quot;&gt;server {
    # ... your server configuration ...

    modsecurity on;
    modsecurity_rules_file /etc/nginx/modsec/main.conf; # We'll create this later

    location / {
        # ... your location configuration ...
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, restart Nginx:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl restart nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Step 2: Obtain and Configure Safeline WAF Rules&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Now that ModSecurity is installed, we need to give it the &quot;brain&quot; – the Safeline WAF rules. Safeline provides a convenient way to manage these rules.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Download Safeline:&lt;/strong&gt; You can typically clone the Safeline repository from GitHub.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd ~
git clone https://github.com/safeline/safeline.git
sudo mv safeline /etc/safeline
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Note: The exact GitHub repository and commands might change. Always refer to the official Safeline documentation or GitHub page for the most up-to-date instructions.&lt;/em&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Install Dependencies (if any):&lt;/strong&gt; Safeline might have specific dependencies. Check its documentation.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Configure ModSecurity to Use Safeline:&lt;/strong&gt; This is the crucial step where you tell ModSecurity where to find and how to use the Safeline rules.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;For Apache:&lt;/strong&gt;&lt;br&gt;
Safeline usually provides an &lt;code&gt;apache2&lt;/code&gt; subdirectory with configuration files. You'll need to copy these and potentially link them. A common approach is to copy the main configuration file and the rules directory.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo cp /etc/safeline/apache2/safeline.conf /etc/apache2/conf-available/
sudo a2enconf safeline.conf
sudo systemctl restart apache2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You might also need to ensure the &lt;code&gt;SecRuleEngine&lt;/code&gt; is set to &lt;code&gt;On&lt;/code&gt; in &lt;code&gt;/etc/modsecurity/modsecurity.conf&lt;/code&gt; (or a similar file). Safeline's setup script often handles this.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;For Nginx:&lt;/strong&gt;&lt;br&gt;
As shown in the Nginx setup above, you need a &lt;code&gt;modsecurity_rules_file&lt;/code&gt; directive. Safeline typically provides a &lt;code&gt;main.conf&lt;/code&gt; file that includes all other necessary rule files.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Ensure the directory for ModSecurity rules exists
sudo mkdir -p /etc/nginx/modsec
# Copy Safeline's main configuration and rule files
sudo cp /etc/safeline/nginx/main.conf /etc/nginx/modsec/
sudo cp -r /etc/safeline/rules/* /etc/nginx/modsec/rules/ # Or similar path
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Make sure the &lt;code&gt;modsecurity_rules_file&lt;/code&gt; path in your Nginx server block points to the correct &lt;code&gt;main.conf&lt;/code&gt; file you just copied.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nginx&quot;&gt;server {
    # ...
    modsecurity on;
    modsecurity_rules_file /etc/nginx/modsec/main.conf;
    # ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Restart Nginx:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl restart nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Step 3: Configure ModSecurity's &lt;code&gt;modsecurity.conf&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You'll need to edit the main ModSecurity configuration file. This file is usually located at &lt;code&gt;/etc/modsecurity/modsecurity.conf&lt;/code&gt; or &lt;code&gt;/etc/nginx/modsec/modsecurity.conf&lt;/code&gt; (for Nginx).&lt;/p&gt;
&lt;p&gt;Key settings to check and adjust:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;SecRuleEngine&lt;/code&gt;&lt;/strong&gt;: This must be set to &lt;code&gt;On&lt;/code&gt; to enable ModSecurity. You might initially set it to &lt;code&gt;DetectionOnly&lt;/code&gt; to monitor traffic without blocking, allowing you to tune the rules.&lt;pre&gt;&lt;code&gt;SecRuleEngine On
# or
SecRuleEngine DetectionOnly
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;SecRequestBodyAccess&lt;/code&gt;&lt;/strong&gt;: Set to &lt;code&gt;On&lt;/code&gt; to allow ModSecurity to inspect the request body.&lt;pre&gt;&lt;code&gt;SecRequestBodyAccess On
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;SecResponseBodyAccess&lt;/code&gt;&lt;/strong&gt;: Set to &lt;code&gt;On&lt;/code&gt; to allow ModSecurity to inspect the response body (useful for preventing data leakage).&lt;pre&gt;&lt;code&gt;SecResponseBodyAccess On
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;SecAuditLog&lt;/code&gt;&lt;/strong&gt;: Define the path for the audit log. This is crucial for troubleshooting and monitoring.&lt;pre&gt;&lt;code&gt;SecAuditLog /var/log/modsec_audit.log
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;SecAuditEngine&lt;/code&gt;&lt;/strong&gt;: Controls what gets logged. &lt;code&gt;RelevantOnly&lt;/code&gt; is often a good balance.&lt;pre&gt;&lt;code&gt;SecAuditEngine RelevantOnly
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;SecAuditLogParts&lt;/code&gt;&lt;/strong&gt;: Specify which parts of the transaction to log.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Step 4: Enable the OWASP Core Rule Set (CRS)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Safeline often bundles or guides you on integrating the OWASP CRS. If it doesn't, you'll need to download and configure it separately.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Download OWASP CRS:&lt;/strong&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd /etc/nginx/modsec/ # Or your ModSecurity rules directory
sudo git clone https://github.com/coreruleset/coreruleset.git
sudo mv coreruleset owasp-crs
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Configure CRS:&lt;/strong&gt;&lt;br&gt;
Navigate into the &lt;code&gt;owasp-crs&lt;/code&gt; directory and copy the example configuration file.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd owasp-crs
sudo cp crs-setup.conf.example crs-setup.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now, edit &lt;code&gt;crs-setup.conf&lt;/code&gt;. You'll find many directives here that allow you to customize the CRS behavior, such as paranoia level, anomaly scoring thresholds, and enabling/disabling specific rule categories. For a start, you might not need to change much, but understanding these settings is key for fine-tuning.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Link CRS to ModSecurity:&lt;/strong&gt;&lt;br&gt;
You need to ensure ModSecurity loads the CRS rules. This is usually done within the &lt;code&gt;main.conf&lt;/code&gt; file you pointed ModSecurity to earlier. You'll add lines to include the CRS setup and rules. This often looks something like this within &lt;code&gt;/etc/nginx/modsec/main.conf&lt;/code&gt; (or your equivalent):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Include the CRS setup file
Include owasp-crs/crs-setup.conf

# Include all CRS rules
Include owasp-crs/rules/*.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Refer to Safeline's documentation and the OWASP CRS documentation for the precise inclusion method, as it can vary.&lt;/em&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Step 5: Testing and Tuning&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This is arguably the &lt;em&gt;most important&lt;/em&gt; phase. Simply turning on a WAF with default settings can often lead to legitimate traffic being blocked (a &quot;false positive&quot;).&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Start in &lt;code&gt;DetectionOnly&lt;/code&gt; Mode:&lt;/strong&gt; As mentioned, set &lt;code&gt;SecRuleEngine DetectionOnly&lt;/code&gt; in your &lt;code&gt;modsecurity.conf&lt;/code&gt;. Restart your web server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Generate Test Traffic:&lt;/strong&gt; Browse your website normally. Try performing common actions: logging in, submitting forms, searching.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monitor Logs:&lt;/strong&gt; Check the ModSecurity audit log (&lt;code&gt;/var/log/modsec_audit.log&lt;/code&gt; or wherever you specified). Look for entries indicating potential threats. The logs will tell you which rule was triggered and why.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Identify False Positives:&lt;/strong&gt; If you see legitimate actions being flagged, you'll need to tune the rules. This might involve:&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Disabling specific rules:&lt;/strong&gt; If a rule consistently blocks legitimate traffic, you can disable it using &lt;code&gt;SecRuleRemoveById&lt;/code&gt;. For example: &lt;code&gt;SecRuleRemoveById 941100&lt;/code&gt; (This is just an example rule ID).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Adjusting anomaly scoring:&lt;/strong&gt; The CRS uses a scoring system. You can adjust the thresholds at which a transaction is considered malicious.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Using &lt;code&gt;SecMarker&lt;/code&gt; and &lt;code&gt;SecAction&lt;/code&gt;:&lt;/strong&gt; For more advanced tuning, you can create custom rules or modify existing ones.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gradually Enable Blocking:&lt;/strong&gt; Once you're confident that false positives are minimal, switch &lt;code&gt;SecRuleEngine&lt;/code&gt; to &lt;code&gt;On&lt;/code&gt;. Continue monitoring logs closely.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Step 6: Securing the Logs&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Ensure your audit logs are protected. They contain sensitive information about attacks.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Permissions:&lt;/strong&gt; Set strict file permissions so only root and the webserver user can read them.&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo chown root:www-data /var/log/modsec_audit.log
sudo chmod 640 /var/log/modsec_audit.log
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(Adjust user/group based on your web server configuration).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Log Rotation:&lt;/strong&gt; Set up &lt;code&gt;logrotate&lt;/code&gt; to manage the size of your audit log file, preventing it from filling up your disk. Create a configuration file in &lt;code&gt;/etc/logrotate.d/&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;advanced-considerations-and-best-practices&quot;&gt;Advanced Considerations and Best Practices&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Regular Updates:&lt;/strong&gt; Both ModSecurity and the OWASP CRS are under active development. Regularly update them to benefit from new security rules and bug fixes.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd /etc/safeline # Or your Safeline directory
git pull
# Re-run any installation/configuration scripts if necessary
cd /etc/nginx/modsec/owasp-crs # Or your CRS directory
git pull
# Re-copy crs-setup.conf if it was modified
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Remember to restart your web server after updates.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Performance Impact:&lt;/strong&gt; WAFs, especially those inspecting request and response bodies, can introduce some overhead. On a low-power device like a Raspberry Pi, this is something to be mindful of.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;Start with essential rules:&lt;/em&gt; Don't enable every single rule if you don't need it.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Optimize configurations:&lt;/em&gt; Tune &lt;code&gt;SecRequestBodyLimit&lt;/code&gt;, &lt;code&gt;SecRequestBodyNoFilesLimit&lt;/code&gt;, and other settings to match your application's needs.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Monitor CPU/RAM usage:&lt;/em&gt; Keep an eye on your Pi's resources. If performance degrades significantly, you may need to adjust the WAF or consider hardware upgrades.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HTTPS is Non-Negotiable:&lt;/strong&gt; A WAF inspects HTTP traffic. If your site uses HTTP, the traffic is unencrypted and can be intercepted &lt;em&gt;before&lt;/em&gt; it even reaches the WAF. Always use &lt;strong&gt;HTTPS&lt;/strong&gt; (SSL/TLS) for your Raspberry Pi web server. Let's Encrypt provides free SSL certificates, making this easy. &lt;a rel=&quot;nofollow&quot; href=&quot;https://letsencrypt.org/&quot;&gt;Let's Encrypt&lt;/a&gt; is a fantastic resource for this.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keep Your System Updated:&lt;/strong&gt; This goes without saying, but always keep your Raspberry Pi OS and all installed packages up-to-date.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt update
sudo apt upgrade -y
sudo apt dist-upgrade -y
sudo reboot
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Beyond the WAF:&lt;/strong&gt; Remember that a WAF is just one layer of security. You still need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Strong passwords and SSH key authentication.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A properly configured host-based firewall&lt;/strong&gt; (like &lt;code&gt;ufw&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Regular backups&lt;/strong&gt; of your data and configurations.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Application-level security:&lt;/strong&gt; Secure coding practices if you're developing your own applications.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Intrusion detection systems (IDS)&lt;/strong&gt; like Fail2ban to block brute-force attempts.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For many Raspberry Pi users, a self-hosted solution like Safeline offers a great balance of security and control without additional monthly fees. It’s an excellent way to learn about web security and harden your server effectively.&lt;/p&gt;
&lt;h3 id=&quot;troubleshooting-common-issues&quot;&gt;Troubleshooting Common Issues&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Website Not Loading:&lt;/strong&gt; This is often due to overly aggressive rules or incorrect configuration.&lt;ul&gt;
&lt;li&gt;Check &lt;code&gt;SecRuleEngine&lt;/code&gt; is &lt;code&gt;On&lt;/code&gt; or &lt;code&gt;DetectionOnly&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Examine &lt;code&gt;/var/log/modsec_audit.log&lt;/code&gt; for blocked requests.&lt;/li&gt;
&lt;li&gt;Temporarily disable ModSecurity (&lt;code&gt;SecRuleEngine Off&lt;/code&gt;) to confirm if it's the cause.&lt;/li&gt;
&lt;li&gt;Review recent changes to your rules or &lt;code&gt;crs-setup.conf&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;High CPU Usage:&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;Too many rules enabled, or complex rules firing frequently.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SecRequestBodyAccess&lt;/code&gt; and &lt;code&gt;SecResponseBodyAccess&lt;/code&gt; can be resource-intensive.&lt;/li&gt;
&lt;li&gt;Consider disabling rules not relevant to your application.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Log Files Not Being Written:&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;Check file permissions for the log directory and file.&lt;/li&gt;
&lt;li&gt;Ensure the &lt;code&gt;SecAuditLog&lt;/code&gt; directive points to a valid, writable path.&lt;/li&gt;
&lt;li&gt;Verify the &lt;code&gt;SecAuditEngine&lt;/code&gt; is set correctly.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;conclusion-empowering-your-raspberry-pi-server&quot;&gt;Conclusion: Empowering Your Raspberry Pi Server&lt;/h3&gt;
&lt;p&gt;Running a web server on a Raspberry Pi is an incredibly rewarding and practical endeavor. By implementing a Web Application Firewall like Safeline, you’re taking a significant leap forward in securing your digital assets. It’s not just about preventing attacks; it’s about building trust and reliability for your users and ensuring your services remain available.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/13894/raspberry-pi-server-fortifying-your-digital-fortress-with-safeline-waf-protection</guid>
<pubDate>Fri, 03 Apr 2026 09:43:40 +0000</pubDate>
</item>
<item>
<title>Installing and Configuring n8n on a Raspberry Pi (Private Home Server)</title>
<link>https://coderlegion.com/13390/installing-and-configuring-n8n-on-a-raspberry-pi-private-home-server</link>
<description>&lt;hr&gt;
&lt;p&gt;title: Installing and Configuring n8n on a Raspberry Pi (Private Home Server)&lt;br&gt;
published: true&lt;br&gt;
date: 2026-03-23 14:49:52 UTC&lt;br&gt;
tags: raspberrypi,n8n,ai,aiagent&lt;/p&gt;
&lt;h3 id=&quot;canonical-url-https-medium-com-mahinshanazeer-installing-and-configuring-n8n-on-a-raspberry-pi-private-home-server-2ce086e7408b&quot;&gt;canonical_url: &lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/@mahinshanazeer/installing-and-configuring-n8n-on-a-raspberry-pi-private-home-server-2ce086e7408b&quot;&gt;https://medium.com/@mahinshanazeer/installing-and-configuring-n8n-on-a-raspberry-pi-private-home-server-2ce086e7408b&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/mahinshanazeer/n8n-home-server&quot;&gt;GitHub - mahinshanazeer/n8n-home-server: Installing and Configuring n8n on a Raspberry Pi (Private Home Server)&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*vJ2ba2oMHH5UwiadEJ4cLw.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;introduction&quot;&gt;Introduction&lt;/h4&gt;
&lt;p&gt;Automation is becoming a core part of modern workflows, whether for DevOps, personal productivity, or integrations. &lt;strong&gt;n8n&lt;/strong&gt; is a powerful, open-source workflow automation platform that allows you to create custom integrations with full control over your data.&lt;/p&gt;
&lt;p&gt;In this guide, we will set up n8n on a Raspberry Pi using Docker, configured for &lt;strong&gt;private network access (no domain, no SSL)&lt;/strong&gt; — ideal for home labs.&lt;/p&gt;
&lt;h4 id=&quot;why-self-host-n8n-on-raspberry-pi-&quot;&gt;Why Self-Host n8n on Raspberry Pi?&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Low-cost, always-on device&lt;/li&gt;
&lt;li&gt;Full data privacy (no third-party cloud dependency)&lt;/li&gt;
&lt;li&gt;Perfect for home lab and DevOps experimentation&lt;/li&gt;
&lt;li&gt;Lightweight yet powerful automation engine&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h4&gt;
&lt;p&gt;Ensure the following are ready:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Raspberry Pi (Pi 4/5 recommended, 4GB+ RAM)&lt;/li&gt;
&lt;li&gt;Linux OS (Ubuntu Server / Raspberry Pi OS Lite)&lt;/li&gt;
&lt;li&gt;Docker &amp;amp; Docker Compose installed&lt;/li&gt;
&lt;li&gt;Static private IP (recommended)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;project-structure&quot;&gt;Project Structure&lt;/h4&gt;
&lt;p&gt;We will keep everything inside a single directory:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;~/n8n/
├── .env
├── docker-compose.yml
├── n8n-data/
└── local-files/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/591/1*y3mnvZK6cgNZcx5cm_7FxQ.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;directory structure&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;This approach ensures:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Easy backup&lt;/li&gt;
&lt;li&gt;Clean portability between systems&lt;/li&gt;
&lt;li&gt;Full control over persistent data&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;step-1-create-project-directory&quot;&gt;Step 1: Create Project Directory&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;mkdir -p ~/n8n &amp;amp;&amp;amp; cd ~/n8n
mkdir n8n-data local-files
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-2-create-environment-configuration&quot;&gt;Step 2: Create Environment Configuration&lt;/h4&gt;
&lt;p&gt;Create a .env file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;nano .env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add the following:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;N8N_HOST=192.168.1.200
N8N_PORT=5678
N8N_PROTOCOL=http

WEBHOOK_URL=http://192.168.1.200:5678/

GENERIC_TIMEZONE=Asia/Kolkata

N8N_SECURE_COOKIE=false
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;key-notes-&quot;&gt;Key Notes:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;N8N_HOST → Your Raspberry Pi private IP&lt;/li&gt;
&lt;li&gt;WEBHOOK_URL → Required for workflows&lt;/li&gt;
&lt;li&gt;N8N_SECURE_COOKIE=false → Mandatory for HTTP access&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;step-3-create-docker-compose-file&quot;&gt;Step 3: Create Docker Compose File&lt;/h4&gt;
&lt;p&gt;Create docker-compose.yml:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  n8n:
    image: docker.n8n.io/n8nio/n8n
    container_name: n8n
    restart: always
    ports:
      - &quot;5678:5678&quot;
    environment:
      - N8N_HOST=${N8N_HOST}
      - N8N_PORT=${N8N_PORT}
      - N8N_PROTOCOL=${N8N_PROTOCOL}
      - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=${N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS}
      - N8N_SECURE_COOKIE=${N8N_SECURE_COOKIE}
      - NODE_ENV=${NODE_ENV}
      - WEBHOOK_URL=${WEBHOOK_URL}
      - GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
      - TZ=${GENERIC_TIMEZONE}
    volumes:
      # n8n data: SQLite database and encryption key
      - ./n8n-data:/home/node/.n8n
      # Shared files between n8n and host (use /files path inside n8n)
      - ./local-files:/files
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-4-start-n8n&quot;&gt;Step 4: Start n8n&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;docker compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;docker compose ps
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-5-access-n8n&quot;&gt;Step 5: Access n8n&lt;/h4&gt;
&lt;p&gt;Open in browser:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;http://192.168.1.200:5678
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*315lf1gY5_y7oILgtpxKMg.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;home page&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/716/1*G4B5wgxA_cO_B4iymNQoeg.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Create your admin account&lt;/li&gt;
&lt;li&gt;Start building workflows (will create another blog on building workflows)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;common-issue-important-&quot;&gt;Common Issue (Important)&lt;/h4&gt;
&lt;h4 id=&quot;secure-cookie-error&quot;&gt;Secure Cookie Error&lt;/h4&gt;
&lt;p&gt;You may see:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;“Your n8n server is configured to use a secure cookie…”&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;fix-&quot;&gt;Fix:&lt;/h4&gt;
&lt;p&gt;Set in .env:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;N8N_SECURE_COOKIE=false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Restart:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;docker compose down
docker compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;data-persistence&quot;&gt;Data Persistence&lt;/h4&gt;
&lt;p&gt;All important data is stored in:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;~/n8n/n8n-data
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Workflows&lt;/li&gt;
&lt;li&gt;Credentials&lt;/li&gt;
&lt;li&gt;SQLite database&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Github: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/mahinshanazeer/n8n-home-server&quot;&gt;https://github.com/mahinshanazeer/n8n-home-server&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/13390/installing-and-configuring-n8n-on-a-raspberry-pi-private-home-server</guid>
<pubDate>Mon, 23 Mar 2026 15:01:38 +0000</pubDate>
</item>
<item>
<title>Upgrading Our Raspberry Pi Kubernetes Cluster: From v1.29 to v1.34 &amp; ETCD Backup Guide</title>
<link>https://coderlegion.com/13289/upgrading-our-raspberry-pi-kubernetes-cluster-from-v1-29-to-v1-34-etcd-backup-guide</link>
<description>&lt;hr&gt;
&lt;p&gt;title: Upgrading Our Raspberry Pi Kubernetes Cluster: From v1.29 to v1.34 &amp;amp; ETCD Backup Guide&lt;br&gt;
published: true&lt;br&gt;
date: 2025-10-16 14:35:59 UTC&lt;br&gt;
tags: raspberrypi,kubernetes,etcd,kubernetescluster&lt;/p&gt;
&lt;h3 id=&quot;canonical-url-https-medium-com-mahinshanazeer-upgrading-our-raspberry-pi-kubernetes-cluster-from-v1-29-to-v1-34-etcd-backup-guide-97a2074237b4&quot;&gt;canonical_url: &lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/@mahinshanazeer/upgrading-our-raspberry-pi-kubernetes-cluster-from-v1-29-to-v1-34-etcd-backup-guide-97a2074237b4&quot;&gt;https://medium.com/@mahinshanazeer/upgrading-our-raspberry-pi-kubernetes-cluster-from-v1-29-to-v1-34-etcd-backup-guide-97a2074237b4&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*D0P7sopIzTaubncyFnNPag.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;Keeping your cluster up-to-date is crucial for security, stability, and access to new features. In this blog, we’ll take a closer look at our home lab Kubernetes cluster running on Raspberry Pi devices. The setup includes 1 master node and 3 worker nodes, and it has been running smoothly for over four months without any major issues.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/702/1*RISM21an6y-TUght4dRieg.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;k8s home lab configuration — nodes&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;master2 — master node/ control plane&lt;br&gt;
master1 — worker node 3&lt;br&gt;
worker 1 — worker node 1&lt;br&gt;
worker 2 — worker node 2&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*AJBUK-y3Yy_dK1vLvcOLlw.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;k8s home lab configuration — system components&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;As part of regular maintenance and to stay up-to-date with the latest features and security patches, we decided to upgrade the cluster from Kubernetes v1.29 to v1.34. Alongside the upgrade, we’ll also walk through the process of backing up the ETCD datastore, which is crucial for fault tolerance and disaster recovery.&lt;/p&gt;
&lt;p&gt;Here’s what we’ll cover:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;⬆️ Step-by-step guide to upgrading Kubernetes components.&lt;/li&gt;
&lt;li&gt;️ Safely backing up ETCD in the master node.&lt;/li&gt;
&lt;li&gt;✅ Post-upgrade verification.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;a-backing-up-etcd&quot;&gt;A. Backing Up ETCD&lt;/h4&gt;
&lt;p&gt;Before diving into the upgrade process, it’s essential to create a backup of ETCD, the key-value store that holds all cluster state data. This step is crucial because if anything goes wrong during the upgrade — whether it’s a misconfiguration or a failed component — we need a reliable way to restore the cluster to its current stable state.&lt;/p&gt;
&lt;p&gt;Think of the ETCD backup as your safety net. It ensures that even in the worst-case scenario, you won’t lose your cluster’s configuration, workloads, or networking setup.&lt;/p&gt;
&lt;p&gt;Run the following command to confirm the ETCD pod name running on your control plane:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl get pods -n kube-system | grep etcd
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*kCOudifkDT-Bb0uCn8rJ2A.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;k8s configuration — ETCD setup&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Suppose your control plane (master node) runs ETCD as a static pod (which is the default in kubeadm-based clusters, including Raspberry Pi setups). In that case, you can run etcdctl directly on the master node host — because the ETCD data directory and certificates are mounted locally at &lt;em&gt;/etc/kubernetes/pki/etcd/&lt;/em&gt; and &lt;em&gt;/var/lib/etcd/.&lt;/em&gt; In that case, you can use the following command to take the backup:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo ETCDCTL_API=3 etcdctl snapshot save /var/lib/etcd/backup.db \
  --endpoints=https://127.0.0.1:2379 \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If ETCD runs as a container (inside the pod) or you don’t have the etcdctl binary available on the host, then you’ll need to exec into the pod to run the backup from inside the container.&lt;/p&gt;
&lt;p&gt;In my setup, where &lt;strong&gt;etcd&lt;/strong&gt; runs as a container, I need to access the pod directly to execute the backup. There is no etcdctl binary available on the host.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl exec -n kube-system &amp;lt;node-label&amp;gt;-- \
  etcdctl snapshot save /var/lib/etcd/backup.db \
  --endpoints=https://127.0.0.1:2379 \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key

#edit the &amp;lt;node-label&amp;gt; with etcd node
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*xlWBIPqp8McKoOjKxkbKjg.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;Creating backup&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Now, verify the snapshot using the following command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl exec -n kube-system &amp;lt;node-label&amp;gt;-- \
  etcdctl snapshot status /var/lib/etcd/backup.db -w table
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/870/1*9GsdIbKWKprGPs_Seoc2kA.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;In the above screenshot, you can seee a deprecation warning. ETCD v3.6+ is deprecated &lt;em&gt;etcdctl snapshot status&lt;/em&gt; and recommends using &lt;em&gt;etcdutl snapshot status&lt;/em&gt;. However, in my kubeadm static pod setup, the &lt;em&gt;etcdutl&lt;/em&gt; binary is not included in the ETCD container. Only &lt;em&gt;etcdctl&lt;/em&gt; exists inside the pod. The warning is simply a deprecation notice, not a failure. You can safely continue using it &lt;em&gt;etcdctl snapshot status&lt;/em&gt; — it works perfectly.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In my setup, I came across a deprecation warning while using etcdctl. To verify, I checked the current version of etcd using the following command.&lt;/p&gt;
&lt;p&gt;Since my cluster is kubeadm-managed, even though etcd v3.6+ recommends using etcdutl for snapshot management, etcdctl inside the etcd pod still works perfectly for backup and restore. etcdutl is a newer utility introduced in etcd v3.6+ as a replacement for certain snapshot operations, but in my case, continuing with etcdctl is fully sufficient.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl exec -n kube-system etcd-master2 -- etcdctl version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*YP_D_wTZ1iGmGXza0cm2OQ.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;etcd version checking&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;In some scenarios, the etcd backup file may reside only inside the etcd pod and not in a host-accessible directory.&lt;/p&gt;
&lt;p&gt;This can happen if the etcd container is minimal and lacks tools like tar to move files around, or if the host does not have etcdctl installed to create the snapshot directly. In such cases, you can use specific commands to move the backup file to a host-accessible directory.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl cp kube-system/etcd-master2:/tmp/backup.tar.gz ./backup.tar.gz
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In my setup, I created the etcd snapshot directly on the host in a host-accessible directory (for example, /var/lib/etcd/backup.db). Since the backup is already on the host, it can be copied or archived without using kubectl cp.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo cp /var/lib/etcd/backup.db ~/backup.db
sudo chown $USER:$USER ~/backup.db
ls -l ~/backup.db
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/855/1*0_RkF8kgG0E7bHsZgk3ywA.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;backup.db file status&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Additionally, it’s a good practice to create a copy of the backup on a remote server. This ensures an extra layer of safety and helps protect against data loss in case of hardware failure or accidental deletion.&lt;/p&gt;
&lt;h4 id=&quot;b-upgrading-the-cluster&quot;&gt;B. Upgrading the cluster&lt;/h4&gt;
&lt;p&gt;Since we have backed up etcd, we can safely proceed with the kubeadm upgrade for our Raspberry Pi Kubernetes cluster. Let’s go through a step-by-step process to upgrade the cluster from v1.29 → v1.34.&lt;/p&gt;
&lt;p&gt;Kubernetes officially supports upgrading one minor version at a time, which means that to move from 1.29 to 1.34, you need to perform incremental upgrades:&lt;/p&gt;
&lt;p&gt;Step 1: v1.29 → v1.30 &lt;br&gt;
Step 2: v1.30 → v1.31&lt;br&gt;
Step 3: v1.31 → v1.32&lt;br&gt;
Step 4: v1.32 → v1.33&lt;br&gt;
Step 5: v1.33 → v1.34&lt;/p&gt;
&lt;p&gt;Please refer to the following URL for k8s official documentation on skew policy:&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://v1-30.docs.kubernetes.io/releases/version-skew-policy/&quot;&gt;Version Skew Policy&lt;/a&gt;&lt;/p&gt;
&lt;h5 id=&quot;step-1-pre-checks-for-upgrading-the-cluster&quot;&gt;Step 1: Pre-Checks for upgrading the cluster&lt;/h5&gt;
&lt;p&gt;Before upgrading the cluster, it’s essential to ensure that the cluster is healthy and stable. This helps prevent issues during the upgrade and ensures a smooth transition between versions.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl version
kubectl get nodes
kubectl get cs
kubectl get pods -n kube-system
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*87FwxUqQ2TSc9YgpXHZPXw.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;Current status of the cluster&lt;/em&gt;&lt;/p&gt;
&lt;h5 id=&quot;step-2-upgrade-kubeadm-on-the-master-node&quot;&gt;Step 2: Upgrade kubeadm on the Master Node&lt;/h5&gt;
&lt;p&gt;You can also refer to the following documentation for more details regarding kubeadm upgrade:&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://v1-30.docs.kubernetes.io/docs/tasks/administer-cluster/kubeadm/kubeadm-upgrade/&quot;&gt;Upgrading kubeadm clusters&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;To upgrade to v1.30, ensure that the directory /etc/apt/keyrings exists. If it doesn’t, create it before running the curl command (see the note below). Afterwards, update the package repository to target v1.30.&lt;/p&gt;
&lt;p&gt;Each Kubernetes minor release repository (v1.29, v1.30, v1.31, etc.) may use a different GPG key. Using the key from an older version, such as v1.29, can cause package verification failures when installing packages from the v1.30 repository.&lt;/p&gt;
&lt;p&gt;To prevent such errors, always use the GPG key and repository provided specifically for the minor version you are upgrading to.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# If the directory `/etc/apt/keyrings` does not exist, it should be created before the curl command, read the note below.
# sudo mkdir -p -m 755 /etc/apt/keyrings
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.30/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg

# This overwrites any existing configuration in /etc/apt/sources.list.d/kubernetes.list
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.30/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Before upgradingkubeadmEnsure your package lists are up to date. Then, install the required version by using the following commands&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo apt-mark unhold kubelet kubeadm kubectl &amp;amp;&amp;amp; \
sudo apt-get update
sudo apt-get install -y kubeadm=1.30.14-1.1 kubelet=1.30.14-1.1 kubectl=1.30.14-1.1
sudo apt-mark hold kubelet kubeadm kubectl
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you want to upgrade to a specific Kubernetes version, you can use the following command to list all available minor versions in the corresponding repository:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apt-cache madison kubeadm
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/978/1*RDcuF8IS82lXJ05pf4RgwQ.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;available packages in the repo&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;For comprehensive guidance on upgrading your Kubernetes cluster to version &lt;strong&gt;v1.30&lt;/strong&gt; , refer to the official Kubernetes documentation:&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/install-kubeadm/&quot;&gt;Installing kubeadm&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;This resource provides detailed, step-by-step instructions tailored for clusters managed with &lt;strong&gt;kubeadm&lt;/strong&gt; , ensuring a smooth and efficient upgrade process.&lt;/p&gt;
&lt;p&gt;Once the upgrade is complete, verify the &lt;strong&gt;kubeadm&lt;/strong&gt; version using the following command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubeadm version
kubectl version --client
kubelet --version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*z4cxNAMceG76DxwAJF3s6g.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;current kubeadm version&lt;/em&gt;&lt;/p&gt;
&lt;h5 id=&quot;step-3-plan-the-upgrade&quot;&gt;Step 3: Plan the Upgrade&lt;/h5&gt;
&lt;p&gt;After upgrading kubeadm, it’s important to check the upgrade path and verify the versions of all cluster components. This ensures that all parts of your Kubernetes cluster are compatible and running the expected versions.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo kubeadm upgrade plan
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It will display:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;current cluster version&lt;/strong&gt; and control plane version.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;available target versions&lt;/strong&gt; you can upgrade to.&lt;/li&gt;
&lt;li&gt;The versions of &lt;strong&gt;kubelet&lt;/strong&gt; and &lt;strong&gt;kubectl&lt;/strong&gt; on your nodes.&lt;/li&gt;
&lt;li&gt;Any &lt;strong&gt;required steps or notes&lt;/strong&gt; for a smooth upgrade.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*15KMi7LmDU8VNoAp1yaaSw.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;kubeadm upgrade plan output — 1&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*iYYt88VaUFyo_7Se5QW1kQ.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;kubeadm upgrade plan output — 2&lt;/em&gt;&lt;/p&gt;
&lt;h5 id=&quot;step-4-upgrade-the-control-plane&quot;&gt;Step 4: Upgrade the Control Plane&lt;/h5&gt;
&lt;p&gt;Once the upgrade plan is reviewed and all changes are understood, you can proceed with upgrading the cluster. Use the kubeadm upgrade apply command, which is provided in the output of kubeadm upgrade plan.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo kubeadm upgrade apply v1.30.14
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The upgrade process may take some time, depending on the size and complexity of your cluster. Once it completes successfully, you should see a message similar to the following:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*uTURS7u-pxMBDoiHaUpdMg.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;upgraded to “v1.30.14”&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Now, verify the control plane using the following commands:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#rebooting the master node or restarting the kubelete is highly recommended
sudo systemctl daemon-reload
sudo systemctl restart kubelet
kubectl get nodes
kubectl get pods -n kube-system
&lt;/code&gt;&lt;/pre&gt;
&lt;h5 id=&quot;step-5-upgrade-worker-nodes&quot;&gt;Step 5: Upgrade Worker Nodes&lt;/h5&gt;
&lt;p&gt;Now repeat the same steps from ‘Step 2: Upgrade kubeadm on the Master Node’ again in master1, worker1 and worker2 nodes (worker nodes). Workers do not control the cluster state; running upgrade plan there doesn’t provide meaningful information. First drain the worker node from the control plane, so that all the resources will be taken out of the node&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh to controlplane (master2)
-------------------------------
#step 1:
kubectl drain &amp;lt;worker-node&amp;gt; --ignore-daemonsets --delete-emptydir-data

***
kubectl drain worker1 --ignore-daemonsets --delete-emptydir-data
***
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*K82WBCjXW1xi6br5ep5r3A.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;draining resources from worker node (master1)&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Now you can repeat the steps we followed for control plane for upgrading the version:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh to master1 node
-------------------------------
#step 1:
sudo rm -rf /etc/apt/sources.list.d/kubernetes.list

#step 2:
# If the directory `/etc/apt/keyrings` does not exist, it should be created before the curl command, read the note below.
# sudo mkdir -p -m 755 /etc/apt/keyrings
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.30/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
#step 3:
# This overwrites any existing configuration in /etc/apt/sources.list.d/kubernetes.list
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.30/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list

#step 4:
sudo apt-mark unhold kubelet kubeadm kubectl &amp;amp;&amp;amp; \
sudo apt-get update
sudo apt-get install -y kubeadm=1.30.14-1.1 kubelet=1.30.14-1.1 kubectl=1.30.14-1.1
sudo apt-mark hold kubelet kubeadm kubectl

#step 5:
kubeadm version

#step 6:
#rebooting the master node or restarting the kubelete is highly recommended
sudo systemctl daemon-reload
sudo systemctl restart kubelet
kubectl get nodes
kubectl get pods -n kube-system

#step 7:
kubeadm upgrade node

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*X1wudh5rErmUwuoFw4yLFg.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;upgrading worker node — host ‘master2’ is a worker node here. (master2 — master node/ control planemaster1 — worker node 3worker 1 — worker node 1worker 2 — worker node 2)&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Also verify the nodes from control plane:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/852/1*BtfZU1oK2v7Mp4rqYZ7SzA.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;upgrade completed&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Now we can following commmand to make the node available for schedule;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl uncordon master1
#run this command in the control plane.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/671/1*9rBFG6wuuuSvW5donzFW1Q.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;Marking node as schedulable&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;kubectl uncordon command is used to &lt;strong&gt;mark a node as schedulable again&lt;/strong&gt; after it has been drained.&lt;/p&gt;
&lt;p&gt;Similarly we can do the upgrades in other 2 worker nodes, I will mention the commands below:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;em&gt;worker1:&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh to controlplane (master2)
-------------------------------
#step 1:
kubectl drain &amp;lt;worker-node&amp;gt; --ignore-daemonsets --delete-emptydir-data

***
kubectl drain worker1 --ignore-daemonsets --delete-emptydir-data
***

ssh to worker1 node

#step 1:
sudo rm -rf /etc/apt/sources.list.d/kubernetes.list

#step 2:
# If the directory `/etc/apt/keyrings` does not exist, it should be created before the curl command, read the note below.
# sudo mkdir -p -m 755 /etc/apt/keyrings
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.30/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
#step 3:
# This overwrites any existing configuration in /etc/apt/sources.list.d/kubernetes.list
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.30/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list

#step 4:
sudo apt-mark unhold kubelet kubeadm kubectl &amp;amp;&amp;amp; \
sudo apt-get update
sudo apt-get install -y kubeadm=1.30.14-1.1 kubelet=1.30.14-1.1 kubectl=1.30.14-1.1
sudo apt-mark hold kubelet kubeadm kubectl

#step 5:
kubeadm version

#step 6:
#rebooting the master node or restarting the kubelete is highly recommended
sudo systemctl daemon-reload
sudo systemctl restart kubelet

#step 7:
sudo kubeadm upgrade node

ssh to controlplane (master2)
-------------------------------
#step 1:
kubectl uncordon worker1
kubectl get nodes
kubectl get pods -n kube-system
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;worker2:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh to controlplane (master2)
-------------------------------
#step 1:
kubectl drain &amp;lt;worker-node&amp;gt; --ignore-daemonsets --delete-emptydir-data

***
kubectl drain worker2 --ignore-daemonsets --delete-emptydir-data
***

ssh to worker2 node
-------------------------------
#step 1:
sudo rm -rf /etc/apt/sources.list.d/kubernetes.list

#step 2:
# If the directory `/etc/apt/keyrings` does not exist, it should be created before the curl command, read the note below.
# sudo mkdir -p -m 755 /etc/apt/keyrings
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.30/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
#step 3:
# This overwrites any existing configuration in /etc/apt/sources.list.d/kubernetes.list
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.30/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list

#step 4:
sudo apt-mark unhold kubelet kubeadm kubectl &amp;amp;&amp;amp; \
sudo apt-get update
sudo apt-get install -y kubeadm=1.30.14-1.1 kubelet=1.30.14-1.1 kubectl=1.30.14-1.1
sudo apt-mark hold kubelet kubeadm kubectl

#step 5:
kubeadm version

#step 6:
#rebooting the master node or restarting the kubelete is highly recommended
sudo systemctl daemon-reload
sudo systemctl restart kubelet
kubectl get nodes
kubectl get pods -n kube-system

#step 7:
sudo kubeadm upgrade node

ssh to controlplane (master2)
-------------------------------
#step 1:
kubectl uncordon worker2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All nodes have now been successfully upgraded to &lt;strong&gt;v1.30.14&lt;/strong&gt; , as shown in the following screenshot.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/617/1*cX2D2v7kwsgAx5dpAu_CAA.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;kubernetes version v1.30.14&lt;/em&gt;&lt;/p&gt;
&lt;h5 id=&quot;step-5-upgrading-to-1-34&quot;&gt;Step 5: Upgrading to 1.34&lt;/h5&gt;
&lt;p&gt;Following the same process, we’ll now proceed to upgrade the cluster from &lt;strong&gt;v1.30.14&lt;/strong&gt; to &lt;strong&gt;v1.31&lt;/strong&gt;. Then we will proceed with with&lt;/p&gt;
&lt;p&gt;Step 1: v1.29.15 → v1.30.14–1.1&lt;br&gt;
Step 2: v1.30.14–1.1 → v1.31.13–1.1&lt;br&gt;
Step 3: v1.31.13–1.1 → v1.32.9&lt;br&gt;
Step 4: v1.32.9 → v1.33.5&lt;br&gt;
Step 5: v1.33.5 → v1.34.1&lt;/p&gt;
&lt;p&gt;In control-plane (master2) use the following steps to upgrade to version 1.31:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh to controlplane (master2)

#step 1:
sudo rm -rf /etc/apt/sources.list.d/kubernetes.list

# If the directory `/etc/apt/keyrings` does not exist, it should be created before the curl command, read the note below.
# sudo mkdir -p -m 755 /etc/apt/keyrings
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.31/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg

# This overwrites any existing configuration in /etc/apt/sources.list.d/kubernetes.list
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.31/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list

#step 2:
sudo apt update
apt-cache madison kubeadm

#step 3:
sudo apt-mark unhold kubelet kubeadm kubectl &amp;amp;&amp;amp; \
sudo apt-get update
sudo apt-get install -y kubeadm kubelet kubectl #will take the latest version
sudo apt-mark hold kubelet kubeadm kubectl

#step 4:
kubeadm version
kubectl version --client
kubelet --version

#step 5:
sudo kubeadm upgrade plan

#step 6:
sudo kubeadm upgrade apply &amp;lt;version&amp;gt;
#I used the following command
sudo kubeadm upgrade apply v1.31.13

#step 7:
#rebooting the master node or restarting the kubelete is highly recommended
sudo systemctl daemon-reload
sudo systemctl restart kubelet
kubectl get nodes
kubectl get pods -n kube-system
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now lets setup the worker nodes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh to controlplane (master2)
-------------------------------
#step 1:
kubectl drain &amp;lt;worker-node&amp;gt; --ignore-daemonsets --delete-emptydir-data

ssh to &amp;lt;worker-node&amp;gt; node
-------------------------------
#step 1:
sudo rm -rf /etc/apt/sources.list.d/kubernetes.list

#step 2:
# If the directory `/etc/apt/keyrings` does not exist, it should be created before the curl command, read the note below.
# sudo mkdir -p -m 755 /etc/apt/keyrings
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.31/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-key
&lt;/code&gt;&lt;/pre&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/13289/upgrading-our-raspberry-pi-kubernetes-cluster-from-v1-29-to-v1-34-etcd-backup-guide</guid>
<pubDate>Sat, 21 Mar 2026 13:53:24 +0000</pubDate>
</item>
<item>
<title>Raspberry Pi K8S Cluster Setup for Home Lab with Cilium</title>
<link>https://coderlegion.com/12999/raspberry-pi-k8s-cluster-setup-for-home-lab-with-cilium</link>
<description>&lt;hr&gt;
&lt;p&gt;title: Raspberry Pi K8S Cluster Setup for Home Lab with Cilium&lt;br&gt;
published: true&lt;br&gt;
date: 2025-06-06 16:13:49 UTC&lt;br&gt;
tags: kubernetes,raspberrypi,docker,kubernetescluster&lt;/p&gt;
&lt;h3 id=&quot;canonical-url-https-medium-com-mahinshanazeer-raspberry-pi-k8s-cluster-setup-for-home-lab-with-cilium-c861f7815511&quot;&gt;canonical_url: &lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/@mahinshanazeer/raspberry-pi-k8s-cluster-setup-for-home-lab-with-cilium-c861f7815511&quot;&gt;https://medium.com/@mahinshanazeer/raspberry-pi-k8s-cluster-setup-for-home-lab-with-cilium-c861f7815511&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*7DQNW_xyewzN37E_e0oA2Q.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;I’m setting up a Kubernetes cluster using Raspberry Pis for home lab testing and learning purposes. The cluster consists of four nodes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Master 1:&lt;/strong&gt; Raspberry Pi 5 (8GB RAM) 192.168.1.200&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Master 2:&lt;/strong&gt; Raspberry Pi 5 (4GB RAM) 192.168.1.201&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Worker 1:&lt;/strong&gt; Raspberry Pi 3B+ 192.168.1.202&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Worker 2:&lt;/strong&gt; Raspberry Pi 3B 192.168.1.203&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I’ve overclocked the Raspberry Pis to enhance performance. Below are the detailed specifications and overclocking configurations.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;root@master1:/home/admin# neofetch
       _,met\(\)$gg. root@master1 
    ,g\(\)\(\)\(\)\($P. ------------ 
  ,g\)P&quot; &quot;&quot;&quot;Y\(.&quot;. OS: Debian GNU/Linux 12 (bookworm) aarch64 
 ,\)P' `\($. Host: Raspberry Pi 5 Model B Rev 1.0 
',\)P ,ggs. `\(b: Kernel: 6.12.25+rpt-rpi-2712 
`d\)' ,$P&quot;' . \($ Uptime: 2 hours 
 \)P d$' , \(P Packages: 1099 (dpkg), 2 (snap) 
 \): \(. - ,d\)' Shell: bash 5.2.15 
 \(; Y$b._ _,d$P' CPU: (4) @ 2.700GHz 
 Y\). `.`&quot;Y\(\)P&quot;' Memory: 360MiB / 8063MiB 
 `\(b &quot;-.__
  `Y\)                                                
   `Y\(.                                              
     `\)b.
       `Y$$b.
          `&quot;Y$b._
              `&quot;&quot;&quot;

root@master1:/home/admin#

root@master2:/home/admin# neofetch
       _,met\(\)$gg. root@master2 
    ,g\(\)\(\)\(\)\($P. ------------ 
  ,g\)P&quot; &quot;&quot;&quot;Y\(.&quot;. OS: Debian GNU/Linux 12 (bookworm) aarch64 
 ,\)P' `\($. Host: Raspberry Pi 5 Model B Rev 1.0 
',\)P ,ggs. `\(b: Kernel: 6.12.25+rpt-rpi-2712 
`d\)' ,$P&quot;' . \($ Uptime: 1 hour, 6 mins 
 \)P d$' , \(P Packages: 783 (dpkg) 
 \): \(. - ,d\)' Shell: bash 5.2.15 
 \(; Y$b._ _,d$P' CPU: (4) @ 2.700GHz 
 Y\). `.`&quot;Y\(\)P&quot;' Memory: 234MiB / 4050MiB 
 `\(b &quot;-.__
  `Y\)                                                
   `Y\(.                                              
     `\)b.
       `Y$$b.
          `&quot;Y$b._
              `&quot;&quot;&quot;

root@master2:/home/admin#  

admin@worker1:~ $ sudo su
root@worker1:/home/admin# neofetch
       _,met\(\)$gg. root@worker1 
    ,g\(\)\(\)\(\)\($P. ------------ 
  ,g\)P&quot; &quot;&quot;&quot;Y\(.&quot;. OS: Debian GNU/Linux 12 (bookworm) aarch64 
 ,\)P' `\($. Host: Raspberry Pi 3 Model B Plus Rev 1.3 
',\)P ,ggs. `\(b: Kernel: 6.6.74+rpt-rpi-v8 
`d\)' ,$P&quot;' . \($ Uptime: 1 min 
 \)P d$' , \(P Packages: 751 (dpkg) 
 \): \(. - ,d\)' Shell: bash 5.2.15 
 \(; Y$b._ _,d$P' Resolution: 1024x768 
 Y\). `.`&quot;Y\(\)P&quot;' CPU: (4) @ 1.400GHz 
 `\(b &quot;-.__ Memory: 166MiB / 907MiB 
  `Y\)
   `Y\(.                                              
     `\)b.                                            
       `Y$$b.  
          `&quot;Y$b._
              `&quot;&quot;&quot;

root@worker1:/home/admin# 

root@worker2:/home/admin# neofetch
       _,met\(\)$gg. root@worker2 
    ,g\(\)\(\)\(\)\($P. ------------ 
  ,g\)P&quot; &quot;&quot;&quot;Y\(.&quot;. OS: Debian GNU/Linux 12 (bookworm) aarch64 
 ,\)P' `\($. Host: Raspberry Pi 3 Model B Rev 1.2 
',\)P ,ggs. `\(b: Kernel: 6.12.25+rpt-rpi-v8 
`d\)' ,$P&quot;' . \($ Uptime: 1 hour, 6 mins 
 \)P d$' , \(P Packages: 743 (dpkg) 
 \): \(. - ,d\)' Shell: bash 5.2.15 
 \(; Y$b._ _,d$P' CPU: (4) @ 1.400GHz 
 Y\). `.`&quot;Y\(\)P&quot;' Memory: 116MiB / 906MiB 
 `\(b &quot;-.__
  `Y\)                                                
   `Y\(.                                              
     `\)b.
       `Y$$b.
          `&quot;Y$b._
              `&quot;&quot;&quot;

root@worker2:/home/admin#te
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;First, we’ll configure m &lt;strong&gt;aster2&lt;/strong&gt; (Raspberry Pi 5–4GB) as the &lt;strong&gt;initial control plane node&lt;/strong&gt; , while &lt;strong&gt;master1&lt;/strong&gt; , &lt;strong&gt;worker1&lt;/strong&gt; , and &lt;strong&gt;worker2&lt;/strong&gt; will function as &lt;strong&gt;worker nodes&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;This setup is intentional — it allows us to explore advanced scenarios in future blog posts, such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Setting up a new cluster using kubeadm with Cilium.&lt;/li&gt;
&lt;li&gt;Handling control plane degradation and recovery&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Starting simple provides a solid foundation while leaving room to scale and experiment.&lt;/p&gt;
&lt;h4 id=&quot;preparation-in-each-node-&quot;&gt;&lt;strong&gt;Preparation in each node:&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;The following commands prepare the OS for installing external components like container runtimes (Docker/containerd) and Kubernetes tools (kubeadm, kubelet, kubectl).&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt upgrade -y
sudo apt install -y curl apt-transport-https ca-certificates software-properties-common
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Enabling cgroup for CPU and memory&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Hence, we are using Raspberry Pi, we can simply edit the cmdline.txt file in the firmware location to enable the cGroups.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo nano /boot/firmware/cmdline.txt

#add the following content
ipv6.disable=1 cgroup_enable=cpuset cgroup_enable=memory cgroup_memory=1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Now disable swap in all nodes.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Disabling swap on Kubernetes nodes is a common practice due to its potential to negatively impact performance and predictability, especially for containerized workloads. Swap, while it allows a system to use more memory than available RAM, accesses data from the disk, which is significantly slower than RAM. This can lead to performance degradation, increased latency, and unpredictable behaviour, particularly under load.&lt;/p&gt;
&lt;p&gt;Disabling swap is mandatory because Kubernetes requires it for stable and predictable performance:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Kubernetes relies on the kubelet to manage resources, assuming memory is fully available.&lt;/li&gt;
&lt;li&gt;Swap can cause nodes to become slow or unresponsive as critical processes get swapped out.&lt;/li&gt;
&lt;li&gt;Disabling swap ensures the scheduler and kubelet have accurate memory availability data.&lt;/li&gt;
&lt;li&gt;Kubernetes officially fails to start if swap is enabled unless explicitly configured otherwise (which is not recommended).&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;sudo dphys-swapfile swapoff
sudo dphys-swapfile uninstall
sudo systemctl disable dphys-swapfile

#or

sudo swapoff -a
sudo apt purge -y dphys-swapfile

#verify using 
free -mh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Enable required kernel modules:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Enabling overlay and br_netfilter kernel modules is essential for Kubernetes networking:&lt;/p&gt;
&lt;p&gt;overlay: Supports overlay network drivers like Calico or Flannel, enabling pods across different nodes to communicate through virtual networks.&lt;/p&gt;
&lt;p&gt;br_netfilter: Allows Linux bridge firewall rules to inspect and filter bridged network traffic, which is crucial for Kubernetes network policies and packet forwarding.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo modprobe overlay
sudo modprobe br_netfilter    
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Adding overlay and br_netfilter to /etc/modules-load.d/k8s.conf&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Adding overlay and br_netfilter to /etc/modules-load.d/k8s.conf ensures these kernel modules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Load automatically at boot time&lt;/li&gt;
&lt;li&gt;Maintain persistent availability without manual intervention after reboot&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This guarantees Kubernetes networking components relying on these modules are always operational, avoiding network failures after node restarts.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vi /etc/modules-load.d/k8s.conf

overlay
br_netfilter
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/555/1*xbnkBGXVcVpfqoP51sDlXA.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;k8s.conf&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Configure sysctl:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The sysctl settings in /etc/sysctl.d/k8s.conf enable essential kernel networking features for Kubernetes:&lt;/p&gt;
&lt;p&gt;&lt;em&gt;net.bridge.bridge-nf-call-ip6tables = 1&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Ensures IPv6 bridged network traffic is passed through ip6tables for filtering and firewall rules.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;net.bridge.bridge-nf-call-iptables = 1&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Ensures IPv4 bridged network traffic is processed by iptables, allowing Kubernetes to enforce network policies and firewall rules on pod traffic.&lt;/p&gt;
&lt;p&gt;_net.ipv4.ip_forward = 1_&lt;/p&gt;
&lt;p&gt;Enables IP forwarding at the kernel level, allowing packets to be routed between different network interfaces — critical for pod-to-pod communication across nodes.&lt;/p&gt;
&lt;p&gt;These settings are required for Kubernetes networking to function properly and to enforce network security policies.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cat &amp;lt;&amp;lt;EOF | sudo tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-ip6tables = 1
net.bridge.bridge-nf-call-iptables = 1
net.ipv4.ip_forward = 1
EOF
sudo sysctl - system
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/582/1*ffQeIAi6ZZo355e3bM_6pQ.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;sysctl.d/k8s.confg&lt;/em&gt;&lt;/p&gt;
&lt;h4 id=&quot;2-hostname-and-ip-configuration-in-the-router-&quot;&gt;&lt;strong&gt;2. Hostname and IP configuration in the router.&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;To ensure network stability within the Kubernetes cluster, it’s essential to assign static IP addresses to all nodes. In my setup, I have four Raspberry Pi devices connected to my home router via Wi-Fi.&lt;/p&gt;
&lt;p&gt;While it’s widely recommended to use wired (RJ45) Ethernet connections for Kubernetes clusters, mainly for improved reliability and lower latency, I’ve opted to use Wi-Fi. Despite the general concerns, I’ve tested this configuration extensively under various conditions and, so far, have encountered no issues.&lt;/p&gt;
&lt;p&gt;To maintain consistent IP addressing, I reserved the IPs for each Raspberry Pi in the router’s DHCP settings. This ensures the cluster nodes remain discoverable and reachable, even after reboots or network interruptions.&lt;/p&gt;
&lt;p&gt;I am using a Netlink Router at home. Please find my current configuration in the following screenshot. If you are using similar models, you can refer to the following configuration:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*zlmPaPh79fvRSPogxlqR7Q.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;Once this is configured, we have to edit the host file in each node and add the following content:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vi /etc/hosts

#add the following
192.168.1.200 master1
192.168.1.201 master2
192.168.1.202 worker1
192.168.1.203 worker2
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;3-installing-container-runtime-&quot;&gt;3. Installing Container Runtime.&lt;/h4&gt;
&lt;p&gt;In Kubernetes, the smallest deployable unit is the Pod, which usually runs one or more containers. To run these containers, Kubernetes relies on a container runtime.&lt;/p&gt;
&lt;p&gt;Initially, Kubernetes was designed to work closely with Docker, which was the most widely adopted container engine at the time. However, over time, Kubernetes evolved, and the need to support multiple container runtimes became clear.&lt;/p&gt;
&lt;p&gt;To address this, the Open Container Initiative (OCI) was introduced — a set of industry standards that define how containers should be built and executed. In line with OCI, Kubernetes implemented the Container Runtime Interface (CRI), which allows it to interact with any OCI-compliant runtime, such as containerd or CRI-O, in a standardised way.&lt;/p&gt;
&lt;p&gt;Today, Docker is no longer a single, unified project. It consists of several components, including Docker-CLI, Docker-Compose, Docker Network, runc, and containerd. While containerd complies with the OCI standard and is capable of running containers, it does not natively support the CRI that Kubernetes requires.&lt;/p&gt;
&lt;p&gt;To bridge this gap, Kubernetes initially provided a compatibility layer called Docker shim, allowing it to communicate with the Docker Engine. However, Dockershim has now been deprecated, and its role is continued by the community-maintained project CRI-Dockerd.&lt;/p&gt;
&lt;p&gt;In this project, we are using the Docker Engine as the container runtime for Kubernetes, integrated via CRI-Dockerd.&lt;/p&gt;
&lt;p&gt;Let’s move on and see how to install Docker and configure it as a container runtime for Kubernetes using CRI-Dockerd. Currently, Mirantis is managing the CRI-Dockerd project, and I followed their official documentation for configuring CRI-Dockerd on my Raspberry Pi. Please note that we have to configure the container runtime in all nodes for setting up the cluster successfully.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Installing Docker on all nodes&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.docker.com/engine/install/debian/&quot;&gt;Debian&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;We’ll begin by installing Docker on all machines in the cluster. If you’re using a Debian-based system (such as Ubuntu or Raspberry Pi OS), you can use the following commands regardless of the system architecture:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for pkg in docker.io docker-doc docker-compose podman-docker containerd runc; do sudo apt-get remove $pkg; done

# Add Docker's official GPG key:
sudo apt-get update
sudo apt-get install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Add the repository to Apt sources:
echo \
  &quot;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
  $(. /etc/os-release &amp;amp;&amp;amp; echo &quot;$VERSION_CODENAME&quot;) stable&quot; | \
  sudo tee /etc/apt/sources.list.d/docker.list &amp;gt; /dev/null
sudo apt-get update

sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once Docker installation is completed, run the following command to verify the installation is proper and working fine.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker run hello-world
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The following is the expected output;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/781/1*ESEaoH4EO2n7YMCYbBpNUg.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;docker run hello-world&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Installing CRI-Dockerd on all nodes&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You can refer to the following documentation for more details regarding CRI-Dockerd configuration:&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://mirantis.github.io/cri-dockerd/usage/install/&quot;&gt;Install&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;In this setup, I’m using Raspberry Pi devices, which are based on the ARM64 architecture. Instead of using the default package manager, I’m downloading the Docker binaries directly from the official release page to ensure compatibility and control over the installation version.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Mirantis/cri-dockerd/releases&quot;&gt;Releases · Mirantis/cri-dockerd&lt;/a&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;wget https://github.com/Mirantis/cri-dockerd/releases/download/v0.3.17/cri-dockerd-0.3.17.arm64.tgz

#after downloading extract and move it to local binary

tar -xvf cri-dockerd-0.3.17.arm64.tgz
cd cri-dockerd
sudo mv cri-dockerd /usr/local/bin/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once the installation is completed, run the following command to verify the installation;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl status cri-docker.service
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the configuration is correct, running the systemctl command should return an output indicating that the service is active and running.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/954/1*GgL_7d2yDrTbyXRKcZlK0Q.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;node1&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/969/1*ZMCPXp5Jq7XxYwJYtH1dnQ.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;node2 (master)&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/955/1*5j7pnwZhDN3M5o_yFabzvg.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;node 3&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/964/1*VGo0B83bxOfH1rGaXCejZg.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;node 4&lt;/em&gt;&lt;/p&gt;
&lt;h4 id=&quot;3-installing-kubeadm&quot;&gt;3. Installing Kubeadm&lt;/h4&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/install-kubeadm/&quot;&gt;Installing kubeadm&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;In this setup, we will be using Kubeadm version 1.29. This specific version is chosen intentionally, as we are planning a follow-up blog focused on Kubeadm upgrade procedures. Starting with v1.29 provides a practical baseline for demonstrating version upgrades.&lt;/p&gt;
&lt;p&gt;If you prefer to install the latest available version, you can refer to the official Kubernetes documentation linked above. Otherwise, to proceed with Kubeadm v1.29, follow the version-specific installation steps documented here.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://v1-29.docs.kubernetes.io/docs/setup/production-environment/tools/kubeadm/install-kubeadm/&quot;&gt;Installing kubeadm&lt;/a&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo apt-get update
# apt-transport-https may be a dummy package; if so, you can skip that package
sudo apt-get install -y apt-transport-https ca-certificates curl gpg
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.29/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.29/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update
sudo apt-get install -y kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl

#if you are facing any issues with the installation and you want to reinstall the packages, please unhold them first.

sudo apt-mark unhold kubelet kubeadm kubectl
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*3TVLF766tuaf8YlvxzL6Lw.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;kubeadm, kubelet and kubectl installation completed&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Follow the installation process on all nodes. Once the installation is completed, enable the kubelet on all nodes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl enable --now kubelet
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The kubelet is now restarting every few seconds, as it waits in a crashloop for kubeadm to tell it what to do.&lt;/p&gt;
&lt;h4 id=&quot;4-cgroup-driver-configuration&quot;&gt;4. Cgroup Driver Configuration&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Why Cgroup Driver Configuration Matters in Kubeadm Installation?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;During a Kubernetes cluster setup with Kubeadm, it is essential to ensure that the cgroup driver used by the container runtime (e.g., Docker, containerd) matches the driver expected by Kubelet, the Kubernetes node agent.&lt;/p&gt;
&lt;p&gt;Cgroups (control groups) are used by the Linux kernel to limit and isolate resource usage (CPU, memory, etc.) of process groups. Kubernetes relies on this mechanism to enforce resource limits on containers.&lt;/p&gt;
&lt;p&gt;There are two common cgroup drivers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;cgroupfs — a native driver provided by the Linux kernel&lt;/li&gt;
&lt;li&gt;systemd — integrates with the systemd init system for resource management&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Why this matters:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If the cgroup drivers between Docker/containerd and Kubelet are mismatched, Kubelet will fail to start, or you may encounter node stability issues.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Best practice:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Kubernetes now recommends using the systemd cgroup driver for better compatibility and integration with modern Linux distributions.&lt;/p&gt;
&lt;p&gt;You can verify and configure the cgroup driver to ensure consistency before initialising the cluster with kubeadm. Refer to the following documentation for more details. In our case, it is not required.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://v1-29.docs.kubernetes.io/docs/tasks/administer-cluster/kubeadm/configure-cgroup-driver/&quot;&gt;Configuring a cgroup driver&lt;/a&gt;&lt;/p&gt;
&lt;h4 id=&quot;5-initialising-master-node-&quot;&gt;5. Initialising master-node.&lt;/h4&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/create-cluster-kubeadm/&quot;&gt;Creating a cluster with kubeadm&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;When you run kubeadm init, Kubernetes performs several critical actions to bootstrap the control plane:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Generates PKI Certificates&lt;/strong&gt;&lt;br&gt;
Secure communication between components is enabled by generating TLS certificates under /etc/kubernetes/pki.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bootstrap the Kubeconfig Files.&lt;/strong&gt;&lt;br&gt;
Configuration files required by kubectl and system components are created under /etc/kubernetes/ and $HOME/.kube/config.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Start Control Plane Components&lt;/strong&gt;&lt;br&gt;
These pods run on the master node and are defined under /etc/kubernetes/manifests/. The following static pods are created and managed by the kubelet  &lt;ul&gt;
&lt;li&gt;kube-apiserver  &lt;/li&gt;
&lt;li&gt;kube-controller-manager  &lt;/li&gt;
&lt;li&gt;kube-scheduler&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Initialises the etcd Database&lt;/strong&gt;&lt;br&gt;
An embedded or external etcd key-value store is initialised to persist cluster state.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sets up the Bootstrap Token and Join Configuration&lt;/strong&gt;&lt;br&gt;
A token and join command are generated to allow worker nodes to securely join the cluster.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Outputs Post-Initialisation Instructions&lt;/strong&gt;&lt;br&gt;
The command displays steps to:  &lt;ul&gt;
&lt;li&gt;Configure kubectl access  &lt;/li&gt;
&lt;li&gt;Install a Pod network add-on (required before scheduling workloads)  &lt;/li&gt;
&lt;li&gt;Join worker nodes using the kubeadm join command&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Now run the following command. After initialising the control plane with kubeadm, you need to configure kubectl to communicate with the cluster. The following commands handle that setup:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config

export KUBECONFIG=/etc/kubernetes/admin.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The export command sets the environment variable so that kubectl uses the admin configuration directly from the system path. Now run the following command to initialize the master node;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo kubeadm init --cri-socket=unix:///var/run/cri-dockerd.sock
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the control plane initialisation is successful, you’ll see a message similar to the one shown in the screenshot below. This message confirms that the Kubernetes control plane is up and running.&lt;/p&gt;
&lt;p&gt;Additionally, a bootstrap token will be generated. This token, along with the control plane IP and certificate hash, is required for joining worker nodes to the cluster.&lt;/p&gt;
&lt;p&gt;You’ll also receive instructions to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Set up your kubectl config&lt;/li&gt;
&lt;li&gt;Install a pod network add-on&lt;/li&gt;
&lt;li&gt;Use the kubeadm join command on worker nodes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*Z3OVvRTm3bHP3h6XIJdlIQ.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;kubeadm init&lt;/em&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; You must use the corresponding sockets depending on the &lt;strong&gt;container runtime version&lt;/strong&gt; you are usi&lt;/p&gt;
&lt;/blockquote&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/12999/raspberry-pi-k8s-cluster-setup-for-home-lab-with-cilium</guid>
<pubDate>Tue, 17 Mar 2026 11:01:15 +0000</pubDate>
</item>
</channel>
</rss>