<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jules</title>
    <description>The latest articles on DEV Community by Jules (@_juliantellez).</description>
    <link>https://dev.to/_juliantellez</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F740350%2F901fe29e-7f8a-40b6-8f86-0ae855a38861.jpg</url>
      <title>DEV Community: Jules</title>
      <link>https://dev.to/_juliantellez</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/_juliantellez"/>
    <language>en</language>
    <item>
      <title>Typing While the Agent Thinks</title>
      <dc:creator>Jules</dc:creator>
      <pubDate>Fri, 31 Jul 2026 18:18:13 +0000</pubDate>
      <link>https://dev.to/_juliantellez/typing-while-the-agent-thinks-7ac</link>
      <guid>https://dev.to/_juliantellez/typing-while-the-agent-thinks-7ac</guid>
      <description>&lt;p&gt;I asked Claude Code to refactor a module and then I sat there.&lt;/p&gt;

&lt;p&gt;Not for long. Long enough to open a browser tab, read half a thread about something I did not care about, and lose the thing I had been holding in my head. When the run finished I had to read my own prompt again to remember what I had asked for.&lt;/p&gt;

&lt;p&gt;That happens many times a day now. I never measured it properly and I am not going to pretend I did, but the shape is obvious: a run is rarely under a minute, it is sometimes five, and the gap is exactly the wrong length. Too long to stare at. Too short to start anything.&lt;/p&gt;

&lt;p&gt;So I went looking for something to put in the hole, and everything I tried was wrong in the same way. A typing trainer feeds you scrambled words and you get faster at hitting keys. That is it. You spend twenty minutes a day at it and the only thing that improves is the muscle. Meanwhile the actual bottleneck in my day is not how fast my fingers move. It is how long it takes me to write a clear pull request description, a ticket, a paragraph like this one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The best two minutes to practise writing are the two you spend waiting for a machine to finish writing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The constraint
&lt;/h2&gt;

&lt;p&gt;It has to be over the moment the agent is. That is the whole design problem in one sentence.&lt;/p&gt;

&lt;p&gt;Everything falls out of it. No login, because a login costs more than the gap. No network, because a spinner in a two minute window is the same as a failure. No session you have to finish, because the thing you were actually doing comes back at an unpredictable moment and you need to be able to walk away mid sentence with nothing lost. And the unit of work has to be one sentence, not one paragraph, because one sentence is what fits.&lt;/p&gt;

&lt;p&gt;One sentence turns out to be enough to teach something, as long as you pick the sentence on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;It is called wordwright. You type a sentence, it measures you, and then it tells you why the sentence is built the way it is.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  wordwright                                        intermediate  ·  drill 3/8
  ────────────────────────────────────────────────────────────────────────────

  ▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░   58 wpm   97% accurate   3.0s

  The deploy finished at two; nobody noticed until the morning.

  semicolon · clause-joining

  esc quit    ctrl+r restart    tab skip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That semicolon is the point of the exercise. You type the sentence, you see your speed, and then you get two or three sentences on why a semicolon can join those two halves and a comma cannot. Every sentence in the corpus is there to carry one pattern: an appositive that defines a noun in passing, a dangling modifier, the difference between fewer and less.&lt;/p&gt;

&lt;p&gt;Wrong characters stay on the screen in red rather than being rejected. A trainer that refuses the wrong key teaches you to hammer a key until it gives, and it quietly hides the mistake from the report. Here the miss lands, you can see it, and you decide whether to go back for it. Correcting it still costs you accuracy, because it happened.&lt;/p&gt;

&lt;p&gt;Then there is the other mode, which is the one I actually built the tool for. It gives you a brief and you write the sentence yourself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  score      ▓▓▓▓▓▓▓▓▓▓▓░░░░░  2/3 checks

  yours      the outage was caused by an expired certificate

  checks     ✗ Still passive. Put the actor in front of the verb: something
               did something.
             ✓ 12 words or fewer
             ✓ used "certificate"

  mechanics  Open with a capital letter.

  one way    An expired certificate caused the outage.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no model in the loop. Not one call, not one token. Each check is a rule sitting in a JSON file that you can open and read: this pattern must appear, this one must not, stay under twelve words, use this word. That is a long way short of a human editor and it buys three things I was not willing to give up. The verdict arrives in the same frame as the keypress. It is identical every time, so a rule you failed yesterday fails the same way today. And it works on a train.&lt;/p&gt;

&lt;p&gt;Grading is split in two on purpose. Mechanics are the rules that hold for anything you write, so a capital at the front and a stop at the end. The checks are the actual lesson, and each one fails with a hint that tells you what to do rather than what you did. "Still passive, put the actor in front of the verb" is useful. "Incorrect" is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; wordwright
wordwright
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node 20.11 or newer, no runtime dependencies, nothing to configure. If you would rather not install anything, &lt;code&gt;npx wordwright&lt;/code&gt; runs it once and leaves nothing behind.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;wordwright                     pick a level and a mode, then practise
wordwright drill               type sentences, measured in words per minute
wordwright compose             write to a brief, graded on grammar and intent
wordwright stats               speed, accuracy, streak, and your weakest patterns
wordwright packs               every pack loaded, and anything that failed to load
wordwright add my-pack.json    install a pack of your own
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bare &lt;code&gt;wordwright&lt;/code&gt; asks you for a level and a mode and then runs eight exercises, which lands somewhere around two minutes. &lt;code&gt;--count 3&lt;/code&gt; makes it shorter. &lt;code&gt;--level advanced&lt;/code&gt; skips the first menu. Escape leaves at any point and keeps everything you finished.&lt;/p&gt;

&lt;p&gt;There are three levels. Beginner is the ground you stand on, so its and it's, subject and verb agreement, the comma before and. Intermediate is the joins: semicolons, comma splices, active voice, parallel structure. Advanced is rhythm and precision, which means appositives, the short sentence that lands after a long one, cutting the hedges, and the words that compress a whole clause into one.&lt;/p&gt;

&lt;p&gt;Your history is a single file at &lt;code&gt;~/.wordwright/progress.json&lt;/code&gt;. Every number the tool shows is derived from it when you ask, so nothing goes stale, and nothing is sent anywhere because there is nowhere to send it to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it picks next
&lt;/h2&gt;

&lt;p&gt;A trainer that serves random sentences trains what you are already good at.&lt;/p&gt;

&lt;p&gt;Every exercise carries focus tags, 84 of them across the corpus, so &lt;code&gt;semicolon&lt;/code&gt;, &lt;code&gt;active-voice&lt;/code&gt;, &lt;code&gt;its-vs-its&lt;/code&gt;, &lt;code&gt;rhythm&lt;/code&gt;. Each attempt you finish scores the tags it touched, weighted so a recent attempt counts for more than an old one, and the next session leans towards the tags you are worst at and the exercises you have not seen in a while.&lt;/p&gt;

&lt;p&gt;Two guards stop it becoming a punishment loop. An exercise you did in the last hour gets pushed down hard, and a little jitter goes into every priority, so your three worst sentences do not come back on a loop until you resent them. Practice, not detention.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the interface is put together
&lt;/h2&gt;

&lt;p&gt;This is where the interesting part is, and it is not the terminal drawing.&lt;/p&gt;

&lt;p&gt;The shape is one rule: exactly one file is allowed to know that a terminal exists. Everything else is a pure function.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
  subgraph impure["app.ts, the only impure file"]
    T["Terminal: raw mode, alternate screen, the clock"]
  end
  subgraph pure["pure, and therefore testable"]
    K["keys.ts: bytes to key presses"]
    E["engine: typing, grading, selection"]
    R["render.ts: state to an array of lines"]
  end
  T --&amp;gt;|"stdin chunk"| K
  K --&amp;gt;|"key"| E
  E --&amp;gt;|"state"| R
  R --&amp;gt;|"lines"| T
  E --&amp;gt;|"attempt"| P[("~/.wordwright/progress.json")]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The typing engine holds an immutable state and never reads the clock. The current time comes in as an argument to every function that needs it, which is the only reason the timing tests are not flaky. A test hands it three keystrokes and a fake stopwatch and asserts on the words per minute, with no waiting and no tolerance window.&lt;/p&gt;

&lt;p&gt;The key parser takes a string and returns key presses, and it exists because typing fast is messier than it looks. Several characters arrive in a single chunk when your hands are moving. An arrow key arrives as three bytes and must not be read as somebody typing a bracket. Because it is pure, a test can drive an entire session by feeding it a string, with no terminal attached anywhere.&lt;/p&gt;

&lt;p&gt;The renderers take state and return an array of lines. They do not write to standard output, they do not read the clock, and they know the width of your window only as a number somebody passed them. So the tests assert on what you would see, which for a typing trainer is most of what is worth asserting on. Every colour goes through one &lt;code&gt;paint&lt;/code&gt; function that takes a &lt;code&gt;plain&lt;/code&gt; flag, so every screen has a colourless twin. That is what &lt;code&gt;--plain&lt;/code&gt; gives you, and it is also what the tests read.&lt;/p&gt;

&lt;p&gt;Two details in the impure file earned their place.&lt;/p&gt;

&lt;p&gt;The first is that a frame is drawn by moving the cursor home and repainting, erasing each line as it goes and then erasing whatever is left below it. The obvious version clears the screen and then writes, and the obvious version flickers, because for one frame there is genuinely nothing there. Nobody notices this until they type fast.&lt;/p&gt;

&lt;p&gt;The second is that the restore is registered before the screen is entered, not after. Raw mode and the alternate screen are things you borrow from somebody's terminal. If the process falls over between borrowing and giving back, they are left with a terminal that does not echo what they type, and their only move is to close the tab. So the handlers for exit, interrupt and terminate go on first, they all run the same restore, and the restore is guarded so that calling it twice is harmless.&lt;/p&gt;

&lt;p&gt;There is one timer in the whole thing. It repaints every 120 milliseconds while a drill is running, and it is there for one reason: the elapsed seconds have to keep moving when your hands are not. Keystrokes paint their own frame.&lt;/p&gt;

&lt;p&gt;The result is 3,476 lines of TypeScript, of which 1,134 are tests. There are 127 of them, on Node's own test runner, with no framework and no dependencies, and they run in about 130 milliseconds. One of those tests is worth stealing for any project that ships content: every model answer in every shipped pack has to pass its own checks. An exercise whose own answer would be marked wrong is a bug, and it gets caught long before it reaches you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that bit me
&lt;/h2&gt;

&lt;p&gt;The repository runs straight off TypeScript with no build step, because Node strips the types for you now. That is a genuinely nice way to work, and it stops working the moment somebody installs your package: Node refuses to strip types for anything under &lt;code&gt;node_modules&lt;/code&gt;. The first version I packed would have failed on every machine that installed it.&lt;/p&gt;

&lt;p&gt;So the build compiles to JavaScript, and continuous integration installs the packed tarball on Node 20, 22 and 24, on Linux and macOS, and runs the command for real before anything is published. Type checking proves the code compiles. Only installing it the way a stranger would proves that it runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not do
&lt;/h2&gt;

&lt;p&gt;It does not know whether your sentence is good. It knows whether it is passive, whether it used the word, whether it stayed under twelve words, and whether the mechanics hold. It will not teach you to type if you have never touched a keyboard, so learn the home row somewhere else and come back.&lt;/p&gt;

&lt;p&gt;And the corpus is 81 exercises. That is weeks of daily practice and it is nowhere near enough forever, which is exactly why the pack format is the first thing documented in the README. A pack is a JSON file you drop in &lt;code&gt;~/.wordwright/packs/&lt;/code&gt;, &lt;code&gt;wordwright template&lt;/code&gt; prints the skeleton, and &lt;code&gt;wordwright add&lt;/code&gt; validates one before it goes in. If you write a pack for the mistakes you actually repeat, I would like to see it.&lt;/p&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/juliantellez/wordwright" rel="noopener noreferrer"&gt;github.com/juliantellez/wordwright&lt;/a&gt;, it is MIT, and it is on npm.&lt;/p&gt;

&lt;p&gt;Now go and give your agent something slow to do.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This was originally published on &lt;a href="https://juliantellez.com/blog/typing-while-the-agent-thinks" rel="noopener noreferrer"&gt;juliantellez.com&lt;/a&gt;. It is part of my newsletter on video, streaming, and building reliable systems from scratch: the architecture, the tradeoffs, and the occasional 3am incident.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If it was useful, &lt;a href="https://juliantellez.com/#newsletter" rel="noopener noreferrer"&gt;subscribe for the next one&lt;/a&gt;. No spam, unsubscribe anytime. If you are building something similar and want to compare notes, &lt;a href="mailto:hello@juliantellez.com"&gt;say hello&lt;/a&gt;. I read every message.&lt;/p&gt;

&lt;p&gt;Built from scratch, like everything here.&lt;/p&gt;

</description>
      <category>cli</category>
      <category>typescript</category>
      <category>writing</category>
      <category>ai</category>
    </item>
    <item>
      <title>Who needs a VPN anyway?</title>
      <dc:creator>Jules</dc:creator>
      <pubDate>Wed, 29 Jul 2026 13:03:34 +0000</pubDate>
      <link>https://dev.to/_juliantellez/who-needs-a-vpn-anyway-f62</link>
      <guid>https://dev.to/_juliantellez/who-needs-a-vpn-anyway-f62</guid>
      <description>&lt;p&gt;As &lt;a href="https://juliantellez.com/blog/what-is-a-vpn" rel="noopener noreferrer"&gt;previously&lt;/a&gt; discussed; the internet is a collection of interconnected computers, commonly referred as nodes. When your computer requests data the sender must dispatch it (over the wire) through many intermediate nodes until it reaches you. This strategy for dispatching data is commonly referred to as the &lt;a href="https://en.wikipedia.org/wiki/Store_and_forward" rel="noopener noreferrer"&gt;store and forward model&lt;/a&gt;. But how do all these intermediate nodes know where to redirect the data so it gets to you? Well, there are a &lt;a href="https://en.wikipedia.org/wiki/Internet_Protocol" rel="noopener noreferrer"&gt;few rules to this game&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="/blog-media/who-needs-a-vpn/store-and-forward.gif" class="article-body-image-wrapper"&gt;&lt;img src="/blog-media/who-needs-a-vpn/store-and-forward.gif" alt="Data hopping between intermediate nodes A, B, C and D" width="{503}" height="{356}"&gt;&lt;/a&gt;&lt;/p&gt;&lt;br&gt;A,B,C,D represent middlemen nodes. From &lt;a href="https://en.wikipedia.org/wiki/Store_and_forward" rel="noopener noreferrer"&gt;Wikipedia&lt;/a&gt;
  &lt;p&gt;&lt;/p&gt;

&lt;p&gt;There is a limit to how big the sent data can be, this is because there's an actual physical limit which is why an optimal size has been established. To overcome this limitation, data is split into smaller packets. Each packet or &lt;a href="https://en.wikipedia.org/wiki/IP_packet" rel="noopener noreferrer"&gt;IP Packet&lt;/a&gt; contains a &lt;strong&gt;source&lt;/strong&gt; and a &lt;strong&gt;destination&lt;/strong&gt; address. Sounds familiar? This is how a postal service may implement their dispatching model.&lt;/p&gt;

&lt;p&gt;&lt;a href="/blog-media/who-needs-a-vpn/cover.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="/blog-media/who-needs-a-vpn/cover.jpeg" alt="Rows of post office boxes" width="{1400}" height="{933}"&gt;&lt;/a&gt;&lt;/p&gt;&lt;br&gt;Photo by &lt;a href="https://unsplash.com/@suehughes" rel="noopener noreferrer"&gt;sue hughes&lt;/a&gt; on &lt;a href="https://unsplash.com/" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;
  &lt;p&gt;&lt;/p&gt;

&lt;p&gt;The relay between sender to receiver may involve several intermediate nodes, where each node will inspect the packet and forward it closer to its final destination. When the requested data finally arrives to you it has been inspected by many different nodes.&lt;/p&gt;

&lt;p&gt;But why should we care about middlemen glancing over our data? As we mentioned in our &lt;a href="https://juliantellez.com/blog/what-is-a-vpn" rel="noopener noreferrer"&gt;previous article&lt;/a&gt;; The internet isn't trusted because of its architecture, but because of its users. Allowing third parties to inspect even just the route of your data can have several ripple effects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gain commercial advantage&lt;/strong&gt;. If a third party or ISP is affiliated to a certain vendor and you purchase from its competitor, said party may slow down ('throttle') or even block access to it. Since your overall experience with the vendor was poor, next time you purchase the same or similar item you will consider its competitor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build a commercial profile&lt;/strong&gt;. By analysing your data, a third party may build a profile of your preferences, correlate them with your local peers and sell the findings to a vendor. Ever wondered why everybody gets the same kind of adverts in the same area?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Censoring Information.&lt;/strong&gt; A third party could have an affinity for a certain topic and could therefore block access to the opposition.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Small misdirections such as throttling would ultimately change your behaviour.&lt;/p&gt;

&lt;p&gt;I will leave you with this reflection: 20% of the causes are responsible for 80% of the effects. The law of the vital few or &lt;a href="https://en.wikipedia.org/wiki/Pareto_principle" rel="noopener noreferrer"&gt;Pareto Principle&lt;/a&gt; can be traced down to human behaviour in the form of habits. Allowing others to determine how you interact with the world will ultimately change you, only you won't be able to notice.&lt;/p&gt;

&lt;p&gt;There are other reasons to use a VPN, such as privacy (not secrecy) and net-neutrality. I will be writing about them as they all deserve attention, until then. 👋&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://medium.com/freemiumpn/who-needs-a-vpn-anyway-32c879e91e36" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This was originally published on &lt;a href="https://juliantellez.com/blog/who-needs-a-vpn" rel="noopener noreferrer"&gt;juliantellez.com&lt;/a&gt;. It is part of my newsletter on video, streaming, and building reliable systems from scratch: the architecture, the tradeoffs, and the occasional 3am incident.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If it was useful, &lt;a href="https://juliantellez.com/#newsletter" rel="noopener noreferrer"&gt;subscribe for the next one&lt;/a&gt;. No spam, unsubscribe anytime. If you are building something similar and want to compare notes, &lt;a href="mailto:hello@juliantellez.com"&gt;say hello&lt;/a&gt;. I read every message.&lt;/p&gt;

&lt;p&gt;Built from scratch, like everything here.&lt;/p&gt;

</description>
      <category>vpn</category>
      <category>networking</category>
      <category>privacy</category>
    </item>
    <item>
      <title>What is a VPN?</title>
      <dc:creator>Jules</dc:creator>
      <pubDate>Wed, 29 Jul 2026 13:02:58 +0000</pubDate>
      <link>https://dev.to/_juliantellez/what-is-a-vpn-4mii</link>
      <guid>https://dev.to/_juliantellez/what-is-a-vpn-4mii</guid>
      <description>&lt;p&gt;&lt;a href="/blog-media/what-is-a-vpn/cover.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="/blog-media/what-is-a-vpn/cover.jpeg" alt="Looking down a long lit tunnel" width="{1400}" height="{933}"&gt;&lt;/a&gt;&lt;/p&gt;&lt;br&gt;Photo by &lt;a href="https://unsplash.com/@jakobsoeby" rel="noopener noreferrer"&gt;Jakob Søby&lt;/a&gt; on &lt;a href="https://unsplash.com/s/photos/tunnel" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;
  &lt;p&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://en.wikipedia.org/wiki/Virtual_private_network" rel="noopener noreferrer"&gt;VPN&lt;/a&gt; stands for Virtual Private Network. From a very high level, a network is simply a group of computers (commonly referred as nodes) that share resources, such as pictures, media, data, etc. These networks are said to be private when no external nodes have access to its resources.&lt;/p&gt;

&lt;p&gt;You can create a small network at home by connecting two computers to each other with say, an ethernet cable. In fact, you can connect as many nodes as you like; you will need a &lt;a href="https://en.wikipedia.org/wiki/Network_switch" rel="noopener noreferrer"&gt;network switch&lt;/a&gt; and a lot of patience. It's quiet fun! provided you don't mind thousands of cables lying around the house.&lt;/p&gt;

&lt;p&gt;&lt;a href="/blog-media/what-is-a-vpn/home-network.jpg" class="article-body-image-wrapper"&gt;&lt;img src="/blog-media/what-is-a-vpn/home-network.jpg" alt="A tangle of network cables" width="{564}" height="{657}"&gt;&lt;/a&gt;&lt;/p&gt;&lt;br&gt;From Pinterest
  &lt;p&gt;&lt;/p&gt;

&lt;p&gt;As you can imagine, having every node hard-wired to its peers alone just doesn't scale. What happens when you run out of room? what if you would like to connect thousands, millions or even billions of nodes? How about making their resources accessible in different locations? To overcome this problem, nodes connect to an specialised node called a &lt;a href="https://en.wikipedia.org/wiki/Router_(computing)" rel="noopener noreferrer"&gt;router&lt;/a&gt;, thanks &lt;a href="https://en.wikipedia.org/wiki/William_Yeager" rel="noopener noreferrer"&gt;William 'Bill' Yeager&lt;/a&gt;! 🚀&lt;/p&gt;

&lt;p&gt;Since routers are nodes themselves, they can connect smaller networks; allowing the parent network to scale infinitely. Sounds familiar? This is a very simplified version of how the &lt;a href="https://en.wikipedia.org/wiki/Internet" rel="noopener noreferrer"&gt;internet&lt;/a&gt; works.&lt;/p&gt;

&lt;p&gt;&lt;a href="/blog-media/what-is-a-vpn/router-diagram.png" class="article-body-image-wrapper"&gt;&lt;img src="/blog-media/what-is-a-vpn/router-diagram.png" alt="Diagram of nodes connecting through routers" width="{600}" height="{563}"&gt;&lt;/a&gt;&lt;/p&gt;&lt;br&gt;From developer.mozilla.org
  &lt;p&gt;&lt;/p&gt;

&lt;p&gt;When your computer connects to the internet, it becomes accessible on the network. In order to retrieve and send information on the web, it has to go through many routers and the information flowing back and forth isn't guaranteed to be private. The internet is an untrusted network, of course safety measures have been placed, such as &lt;a href="https://en.wikipedia.org/wiki/Transport_Layer_Security" rel="noopener noreferrer"&gt;SSL&lt;/a&gt;; However the internet isn't trusted because of its network architecture, but because of its users.&lt;/p&gt;

&lt;p&gt;So we know that for networks to be private, no external nodes should have access to its resources. But what about the word virtual? what are we referring to? Well, a virtual network is a sub network of geographically unrelated nodes; connected through the parent network, such as the internet. It simply means that these nodes aren't connected physically.&lt;/p&gt;

&lt;p&gt;We now know what it means to be Virtual, to be Private and what a Network is.&lt;/p&gt;

&lt;p&gt;&lt;a href="/blog-media/what-is-a-vpn/virtual.gif" class="article-body-image-wrapper"&gt;&lt;img src="/blog-media/what-is-a-vpn/virtual.gif" alt="Mind blown" width="{320}" height="{320}"&gt;&lt;/a&gt;&lt;/p&gt;&lt;br&gt;From giphy.com
  &lt;p&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://en.wikipedia.org/wiki/Virtual_private_network" rel="noopener noreferrer"&gt;VPN&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.wikipedia.org/wiki/Network_switch" rel="noopener noreferrer"&gt;Network switch&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.wikipedia.org/wiki/Router_(computing)" rel="noopener noreferrer"&gt;Router&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.wikipedia.org/wiki/William_Yeager" rel="noopener noreferrer"&gt;William 'Bill' Yeager&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.wikipedia.org/wiki/Internet" rel="noopener noreferrer"&gt;Internet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.wikipedia.org/wiki/Transport_Layer_Security" rel="noopener noreferrer"&gt;SSL&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://medium.com/freemiumpn/what-is-a-vpn-15a0089b93c1" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This was originally published on &lt;a href="https://juliantellez.com/blog/what-is-a-vpn" rel="noopener noreferrer"&gt;juliantellez.com&lt;/a&gt;. It is part of my newsletter on video, streaming, and building reliable systems from scratch: the architecture, the tradeoffs, and the occasional 3am incident.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If it was useful, &lt;a href="https://juliantellez.com/#newsletter" rel="noopener noreferrer"&gt;subscribe for the next one&lt;/a&gt;. No spam, unsubscribe anytime. If you are building something similar and want to compare notes, &lt;a href="mailto:hello@juliantellez.com"&gt;say hello&lt;/a&gt;. I read every message.&lt;/p&gt;

&lt;p&gt;Built from scratch, like everything here.&lt;/p&gt;

</description>
      <category>vpn</category>
      <category>networking</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Handling complexity in lambda functions</title>
      <dc:creator>Jules</dc:creator>
      <pubDate>Wed, 29 Jul 2026 13:02:22 +0000</pubDate>
      <link>https://dev.to/_juliantellez/handling-complexity-in-lambda-functions-4db</link>
      <guid>https://dev.to/_juliantellez/handling-complexity-in-lambda-functions-4db</guid>
      <description>&lt;p&gt;&lt;a href="/blog-media/handling-complexity-in-lambda-functions/cover.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="/blog-media/handling-complexity-in-lambda-functions/cover.jpeg" alt="A rocket launch against a dark sky" width="{1400}" height="{932}"&gt;&lt;/a&gt;&lt;/p&gt;&lt;br&gt;Photo by &lt;a href="https://unsplash.com/@nasa" rel="noopener noreferrer"&gt;NASA&lt;/a&gt; on &lt;a href="https://unsplash.com/" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;
  &lt;p&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TLDR;
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Middlewares can handle the complexity of your lambdas while isolating business logic and cross-cutting concerns in reusable components that can be modelled by event cycles.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I have had the luck and pleasure of working with lambda functions recently and boy I am having fun! The idea of having an event-driven execution environment is both daring and exciting. If you are new to the FaaS world, don't worry, the community has already prepared a curated list of reads for you &lt;a href="https://github.com/anaibol/awesome-serverless" rel="noopener noreferrer"&gt;here&lt;/a&gt; and &lt;a href="https://github.com/pmuens/awesome-serverless" rel="noopener noreferrer"&gt;here&lt;/a&gt;. Have a browse, drink some coffee. ☕&lt;/p&gt;

&lt;p&gt;As you can see, (because you checked at least one of the links, didn't you?) A huge weight has been lifted. We can now develop, run and manage applications without the intricacy of building and maintaining infrastructure. But with new beginnings, new challenges arise. One of such problems and the reason for this blog is function complexity.&lt;/p&gt;

&lt;h2&gt;
  
  
  How complex is too complex?
&lt;/h2&gt;

&lt;p&gt;Generally speaking, when writing lambda functions you would organise them into a series of dedicated modules following the &lt;a href="https://en.wikipedia.org/wiki/Single_responsibility_principle" rel="noopener noreferrer"&gt;single responsibility principle&lt;/a&gt;. If you would rather do otherwise, please read this excellent blog comparing &lt;a href="https://hackernoon.com/aws-lambda-should-you-have-few-monolithic-functions-or-many-single-purposed-functions-8c3872d4338f" rel="noopener noreferrer"&gt;monolithic vs single purposed functions&lt;/a&gt;. Isolating application from business logic reduces code complexity, simplifies reasoning and will help you debug your code more efficiently. Your colleagues and your future self will thank you.&lt;/p&gt;

&lt;p&gt;Take for example some sort of CRUD API, we would like to make sure that a payload is properly formatted before handling any data. The payload validation could emit an error that would communicate its status to the consumer. If the validation was successful it would then move onto executing some business logic. For both scenarios, we would also want to set up some percentile tracing and error logging. While all of these sound great, we have essentially mapped out a request lifecycle with various &lt;a href="https://en.wikipedia.org/wiki/Cross-cutting_concern" rel="noopener noreferrer"&gt;cross-cutting concerns&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Data validation.&lt;/li&gt;
&lt;li&gt;Error detection and correction.&lt;/li&gt;
&lt;li&gt;Logging.&lt;/li&gt;
&lt;li&gt;Monitoring.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="/blog-media/handling-complexity-in-lambda-functions/thinking.gif" class="article-body-image-wrapper"&gt;&lt;img src="/blog-media/handling-complexity-in-lambda-functions/thinking.gif" alt="Thinking" width="{300}" height="{300}"&gt;&lt;/a&gt;&lt;/p&gt;&lt;br&gt;I know what you are thinking… (gif from giphy)
  &lt;p&gt;&lt;/p&gt;

&lt;p&gt;Following the serverless mantra, we should outsource anything that isn't related to the core business, so wouldn't it be great if we could just focus on the business logic and outsource these application-specific problems too? Of course, this isn't a new concept and we have been using it in web frameworks like &lt;a href="http://hapijs.com/" rel="noopener noreferrer"&gt;hapijs&lt;/a&gt; and &lt;a href="https://expressjs.com/" rel="noopener noreferrer"&gt;express&lt;/a&gt; for quite some time now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lambda Middleware
&lt;/h2&gt;

&lt;p&gt;A lambda &lt;a href="https://en.wikipedia.org/wiki/Middleware" rel="noopener noreferrer"&gt;middleware&lt;/a&gt; is essentially a function that contributes to managing cross-cutting concerns in a consistent manner. It provides a clear separation of application and business logic and outlines an easy to reason configuration cycle. It is the glue that when adopted by everyone in a team becomes the lingua franca that supports and creates features. But do we really need a middleware? Well, look at the current state of your lambdas and ask yourself a few questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What does the code look like?&lt;/li&gt;
&lt;li&gt;Can you envision anyone supporting it in at least 2 years?&lt;/li&gt;
&lt;li&gt;How much business logic expertise do you need? (if specialised then separation is imperative)&lt;/li&gt;
&lt;li&gt;Is logic intertwined and hard to reason about?&lt;/li&gt;
&lt;li&gt;Does the project have a clear path or do you see it going in different directions?&lt;/li&gt;
&lt;li&gt;How will the project look like the day the expert leaves?&lt;/li&gt;
&lt;li&gt;Are best-practices enforced and maintained?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="/blog-media/handling-complexity-in-lambda-functions/jack-nicholson.gif" class="article-body-image-wrapper"&gt;&lt;img src="/blog-media/handling-complexity-in-lambda-functions/jack-nicholson.gif" alt="Jack Nicholson nodding in approval" width="{300}" height="{229}"&gt;&lt;/a&gt;&lt;/p&gt;&lt;br&gt;Jack Nicholson approves. (gif from giphy)
  &lt;p&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Enter Lambcycle
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/juliantellez/lambcycle" rel="noopener noreferrer"&gt;Lambcycle&lt;/a&gt; 🐑 🛵 is a declarative middleware for lambda functions. It defines a configurable event cycle and allows you to focus on your application's logic. It has a "Feature as Plugin" approach, so you can easily create your own plugins or reuse your favourite packages with very little effort.&lt;/p&gt;

&lt;p&gt;In practice there is no such thing as an un-opinionated framework, every idea is an opinion and a way of working with your application. Lambcycle favours configuration over code and believes that business logic should be as isolated as possible.&lt;/p&gt;

&lt;p&gt;&lt;a href="/blog-media/handling-complexity-in-lambda-functions/lambcycle-flow-chart.png" class="article-body-image-wrapper"&gt;&lt;img src="/blog-media/handling-complexity-in-lambda-functions/lambcycle-flow-chart.png" alt="Lambcycle event cycle flow chart" width="{380}" height="{514}"&gt;&lt;/a&gt;&lt;/p&gt;&lt;br&gt;Labmcycle flow chart.
  &lt;p&gt;&lt;/p&gt;

&lt;p&gt;Lambcycle enhances lambda functions with a few extension points (see graph), each of which can be used to interact with the event in a decomposed manner.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The first extension point is &lt;code&gt;Request&lt;/code&gt; which occurs immediately after the lambda is called. You can use this step for parsing, validation, etc...&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note: If you are thinking of auth, please consider a &lt;a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html" rel="noopener noreferrer"&gt;lambda authoriser&lt;/a&gt; instead.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;Pre Handler&lt;/code&gt; extension comes in handy when you need to adapt data to fit an interface. It is also a great place for fetching secrets.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;Handler&lt;/code&gt;, where your beautiful business logic lives.&lt;/li&gt;
&lt;li&gt;Next up is the &lt;code&gt;Post Handler&lt;/code&gt;, use this extension to validate and/or cache the output of your business logic.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Error&lt;/code&gt; is an implicit extension for logging and tracing.&lt;/li&gt;
&lt;li&gt;And finally &lt;code&gt;Pre Response&lt;/code&gt;, your chance to format a response to the consumer (which could be data or an error).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Using the middleware
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Joi&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;joi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;lambcycle&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lambcycle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;joiPlugin&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lambcycle/dist/plugin-joi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;bodyParser&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lambcycle/dist/plugin-body-parser&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;realTimeEventTracker&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./bugFreeBizLogic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;businessLogic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;realTimeEventTracker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Joi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;assetId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Joi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;competition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Joi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;sport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Joi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;tournamentCalendar&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Joi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lambcycle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;businessLogic&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nf"&gt;bodyParser&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;joiPlugin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The example above leverages &lt;a href="https://github.com/expressjs/body-parser" rel="noopener noreferrer"&gt;express' body parser&lt;/a&gt; and &lt;a href="https://github.com/hapijs/joi" rel="noopener noreferrer"&gt;hapi's joi validation&lt;/a&gt;. We have configured both plugins with a parsing type and a validation schema respectively. By abstracting these two we have rid of complexity and shift the focus to the business logic. Note that a more robust lambda would include error tracing, and response plugins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Crafting a Plugin
&lt;/h2&gt;

&lt;p&gt;The possibilities are endless when it comes to plugins! Do you have something in mind? &lt;a href="https://github.com/juliantellez/lambcycle/blob/develop/contributing.md" rel="noopener noreferrer"&gt;Contributions&lt;/a&gt; are more than welcome ❤️&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Sentry&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@sentry/node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;MyAwesomeIntegration&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./MyAwesomeIntegration&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sentryPlugin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;@sentry.io/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;integrations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MyAwesomeIntegration&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;plugin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;onPreResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handlerWrapper&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;captureMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;some percentile log perhaps?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="na"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handlerWrapper&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;captureException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handlerWrapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;sentryPlugin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In your lambda ….&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;lambcycle&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lambcycle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;sentryPlugin&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./sentryPlugin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;myApplicationLogic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;someLogic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lambcycle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;myApplicationLogic&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nf"&gt;sentryPlugin&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SENTRY_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SENTRY_PROJECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  DX
&lt;/h2&gt;

&lt;p&gt;Lambcycle has been built with &lt;a href="https://hackernoon.com/the-best-practices-for-a-great-developer-experience-dx-9036834382b0" rel="noopener noreferrer"&gt;developer experience "DX"&lt;/a&gt; in mind and ships with &lt;a href="https://www.typescriptlang.org/" rel="noopener noreferrer"&gt;type&lt;/a&gt; definitions, for consistency and auto-completion 🚀 (VScode only).&lt;/p&gt;

&lt;p&gt;&lt;a href="/blog-media/handling-complexity-in-lambda-functions/lambcycle-dx.gif" class="article-body-image-wrapper"&gt;&lt;img src="/blog-media/handling-complexity-in-lambda-functions/lambcycle-dx.gif" alt="Lambcycle type definitions providing auto-completion in the editor" width="{830}" height="{352}"&gt;&lt;/a&gt;&lt;/p&gt;&lt;br&gt;Lambcycle DX.
  &lt;p&gt;&lt;/p&gt;

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

&lt;p&gt;It is a brave new world and serverless is here to stay! Promoting reusable components and consistent error handling will help you and your team create and support features in a more controlled and organised fashion. Lambcycle's main goals are to shift the conversation around a predictable unidirectional cycle and encouraging component reusability across features. Its worth mentioning that for more complex scenarios you should be looking at &lt;a href="https://docs.aws.amazon.com/step-functions/latest/dg/welcome.html" rel="noopener noreferrer"&gt;Step functions&lt;/a&gt;. Let me know if you would like to know more about them in the comments, in the meantime check out these &lt;a href="https://aws.amazon.com/step-functions/use-cases/" rel="noopener noreferrer"&gt;use cases&lt;/a&gt;!&lt;/p&gt;

&lt;p&gt;Special thanks to the legendary &lt;a href="https://theburningmonk.com/" rel="noopener noreferrer"&gt;burning monk&lt;/a&gt; for the in-depth review of this article.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published in the DAZN Engineering publication on &lt;a href="https://medium.com/dazn-tech/handling-complexity-in-lambda-functions-e7acfbeb920a" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This was originally published on &lt;a href="https://juliantellez.com/blog/handling-complexity-in-lambda-functions" rel="noopener noreferrer"&gt;juliantellez.com&lt;/a&gt;. It is part of my newsletter on video, streaming, and building reliable systems from scratch: the architecture, the tradeoffs, and the occasional 3am incident.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If it was useful, &lt;a href="https://juliantellez.com/#newsletter" rel="noopener noreferrer"&gt;subscribe for the next one&lt;/a&gt;. No spam, unsubscribe anytime. If you are building something similar and want to compare notes, &lt;a href="mailto:hello@juliantellez.com"&gt;say hello&lt;/a&gt;. I read every message.&lt;/p&gt;

&lt;p&gt;Built from scratch, like everything here.&lt;/p&gt;

</description>
      <category>serverless</category>
      <category>aws</category>
      <category>lambda</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Talking to Claude Code through an Alexa</title>
      <dc:creator>Jules</dc:creator>
      <pubDate>Wed, 29 Jul 2026 13:01:45 +0000</pubDate>
      <link>https://dev.to/_juliantellez/talking-to-claude-code-through-an-alexa-1k71</link>
      <guid>https://dev.to/_juliantellez/talking-to-claude-code-through-an-alexa-1k71</guid>
      <description>&lt;p&gt;This was an afternoon experiment, so read it in that spirit. The goal was to start real coding work by talking to a speaker: not "set a timer", but "work on the board script", and have an agent plan it with me and go and do it. No laptop open.&lt;/p&gt;

&lt;p&gt;I gave myself a few hours, which means there are shortcuts everywhere. The backend is literally my laptop. The public entry point is a free tunnel. There is exactly one user, me, and no accounts. None of that would survive contact with real users, and I will come back to what a grown-up version looks like at the end. But the core turned out to be more interesting than I expected, because the main constraint forced a better design than the obvious one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wall everything runs into
&lt;/h2&gt;

&lt;p&gt;Alexa cuts off a skill's response after about eight seconds. That is the whole design problem in one sentence.&lt;/p&gt;

&lt;p&gt;A coding agent does not fit in eight seconds. Even just asking Claude to &lt;em&gt;think of a few options&lt;/em&gt; for a task takes anywhere from four to twenty-plus seconds. So you cannot do the obvious thing, which is take what the person said, ask Claude about it, and speak the answer. Alexa will have hung up long before Claude replies.&lt;/p&gt;

&lt;p&gt;Once you accept that nothing useful finishes in the window, the design falls out of it: &lt;strong&gt;acknowledge instantly, think in the background, and surface the thinking somewhere the eight seconds do not apply.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A planning conversation, not a command
&lt;/h2&gt;

&lt;p&gt;Here is what actually happens when I use it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;me:    "Alexa, open assistant"
claude: "Claude ready. What should I work on?"

me:    "work on the board script"
claude: "Okay, work on the board script. I'm putting a few options on the
         dashboard. Tell me which one or describe what you want, then say go."

me:    "the second one"
claude: "Here's the plan: add a --since flag to the board script. Say go to
         start, or add more."

me:    "go"
claude: "Starting now: add a --since flag to the board script. Watch the
         dashboard, and ask me for the result."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The moment I describe a task, the server does two things at once. It replies immediately with a short acknowledgement, well inside the window, and it spawns a detached job that asks Claude to turn my vague sentence into two or three concrete, short options. Those options land on a dashboard, and the next thing I say is resolved against them. "The second one", "option two", and "three" all map to a choice; anything else is folded in as more detail.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sequenceDiagram
  actor You
  participant Echo as "Echo / Alexa cloud"
  participant Mac as "alexa-claude (my Mac)"
  participant Claude
  You-&amp;gt;&amp;gt;Echo: "work on the board script"
  Echo-&amp;gt;&amp;gt;Mac: signed request
  Mac--&amp;gt;&amp;gt;Echo: ack in under a second
  Mac-&amp;gt;&amp;gt;Claude: generate options (background)
  Claude--&amp;gt;&amp;gt;Mac: options appear on dashboard
  You-&amp;gt;&amp;gt;Echo: "the second one" ... "go"
  Echo-&amp;gt;&amp;gt;Mac: signed request
  Mac-&amp;gt;&amp;gt;Claude: run task in a tmux session
  Mac--&amp;gt;&amp;gt;Echo: "starting, ask me for status"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the latency that made the naive design impossible becomes invisible: by the time I have heard the acknowledgement and decided what I want, the options are already there. The prompt gets built up across a couple of turns, and "go" commits it.&lt;/p&gt;

&lt;p&gt;(The skill is invoked as "assistant" rather than "claude", because Amazon will not let you claim a single well-known word as a custom invocation name. It just introduces itself as Claude.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The jobs are real sessions
&lt;/h2&gt;

&lt;p&gt;When I say "go", the task does not run as some opaque background process. It starts in its own &lt;strong&gt;tmux session&lt;/strong&gt;, which means it shows up in the same &lt;code&gt;sessions&lt;/code&gt; tool I use for every other Claude session on the machine, and I can attach and watch it work live:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sessions go &amp;lt;pid&amp;gt;   &lt;span class="c"&gt;# drop into the running voice-started job&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That mattered more than I expected. A voice command that spawns invisible work is unnerving. A voice command that spawns a session I can see, attach to, and read line by line is just a faster way to start something I already understand.&lt;/p&gt;

&lt;h2&gt;
  
  
  A dashboard that stays home
&lt;/h2&gt;

&lt;p&gt;Voice is a poor channel for output, so the real surface is a small web dashboard the server renders. It polls every couple of seconds and shows the prompt being built right now with its options, the jobs with running, done, and failed badges, and a log of voice turns so I can see what it heard and what it said back.&lt;/p&gt;

&lt;p&gt;The one decision I am quietly pleased with: the dashboard is &lt;strong&gt;refused on the public URL.&lt;/strong&gt; The tunnel that lets Amazon reach my Mac would also let anyone who found it load my activity dashboard. But tunnelled requests carry an &lt;code&gt;X-Forwarded-For&lt;/code&gt; header and direct localhost requests do not, so the server serves the dashboard only to local requests and returns a 403 to anything that arrived over the tunnel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hearing the answer
&lt;/h2&gt;

&lt;p&gt;Long work reports back through whatever channel suits. The job streams to its log and its tmux pane the whole time. When I want the result out loud, I ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;me:    "Alexa, ask assistant for the status"
claude: "Done, add a --since flag to the board script. &amp;lt;reads the tail of the
         output, kept short enough to be pleasant&amp;gt;"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the machine has Telegram configured it also pushes the result to my phone, but that is optional sugar now rather than the main path. Short questions are the one thing answered inline: "ask assistant to tell me X" runs Claude read-only with a few seconds of grace, and speaks the answer if it finishes in time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it safe enough
&lt;/h2&gt;

&lt;p&gt;Even an afternoon toy that starts a coding agent from a public URL needs a lock, so every request has to prove itself. The server follows Amazon's verification for self-hosted endpoints: the certificate chain has to be a genuine Amazon address, the certificate has to be valid and issued for &lt;code&gt;echo-api.amazon.com&lt;/code&gt;, the body has to verify against its signature, and the timestamp has to be recent so a captured request cannot be replayed. The skill id is a second lock on top. Questions run read-only; tasks run with a permission mode that edits files but still stops before risky commands.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a real product would look like
&lt;/h2&gt;

&lt;p&gt;Everything above is held together with the cheapest possible parts, and that is the right call for a few hours of play. But it is worth being honest about where the shortcuts are and what you would actually build instead.&lt;/p&gt;

&lt;p&gt;The shortcuts: the backend is my Mac, so it only works while the machine is awake with the server and tunnel running. The entry point is a personal free tunnel, not a hosted endpoint. There is one user and no authentication, so "lock it to my skill id" is doing a lot of heavy lifting. And the agent runs directly on my real machine with access to my real files, which is fine for me and unthinkable for anyone else.&lt;/p&gt;

&lt;p&gt;A product version inverts most of that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
  Echo["Echo"] --&amp;gt; Alexa["Alexa cloud"]
  Alexa --&amp;gt;|"account linking (OAuth)"| GW["API Gateway"]
  GW --&amp;gt; Fn["Skill function: validate + enqueue"]
  Fn --&amp;gt; Q["Queue"]
  Fn --&amp;gt; DB[("Jobs / sessions store")]
  Q --&amp;gt; W["Sandboxed agent workers"]
  W --&amp;gt; DB
  W --&amp;gt;|result| Push["Proactive notifications / companion app"]
  Push --&amp;gt; Echo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The skill endpoint becomes a hosted, stateless function behind an API gateway, so there is no laptop to keep awake and no tunnel to babysit. Its only jobs are to verify the request, look up the user, and drop work on a queue, all comfortably inside the eight seconds. &lt;strong&gt;Account linking&lt;/strong&gt; turns the single-user assumption into real multi-tenancy: each person links their own identity, and the skill id stops being the only thing standing between a stranger and a shell.&lt;/p&gt;

&lt;p&gt;The actual agent work moves onto &lt;strong&gt;sandboxed workers&lt;/strong&gt;, one isolated environment per user, so a task can never touch anyone else's files or the host. Job and conversation state lives in a small &lt;strong&gt;database&lt;/strong&gt; rather than on one machine's disk, which is what lets a result reach you later, on a different device, after the voice session is long gone. And the results come back through Alexa's &lt;strong&gt;proactive notifications&lt;/strong&gt; or a companion app, so "ask me for the status" becomes "your phone buzzes when it is done."&lt;/p&gt;

&lt;p&gt;None of that is exotic. It is the boring, well-trodden shape of any async job system. The interesting part, the planning-conversation-around-latency, stays exactly the same. The afternoon version just runs it on a laptop instead of a fleet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it taught me
&lt;/h2&gt;

&lt;p&gt;I went in thinking the hard part would be the voice. It was not; Amazon's speech to text is good and free. The hard part, and the fun part, was that the eight second wall made the naive design impossible and pushed me toward a better one: a system that never pretends to be fast, acknowledges instantly, thinks out of band, and gives you real attachable sessions and a live dashboard instead of a single hopeful reply.&lt;/p&gt;

&lt;p&gt;It stopped being "an agent I sit in front of" and became "an agent I can start a conversation with from across the room, then go and watch it work." For an afternoon's wiring, that is a surprisingly nice way to begin a piece of work.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built from scratch, like everything in this newsletter. Questions about the build? &lt;a href="https://www.linkedin.com/in/juliantellez" rel="noopener noreferrer"&gt;Say hello&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This was originally published on &lt;a href="https://juliantellez.com/blog/talking-to-claude-code-through-alexa" rel="noopener noreferrer"&gt;juliantellez.com&lt;/a&gt;. It is part of my newsletter on video, streaming, and building reliable systems from scratch: the architecture, the tradeoffs, and the occasional 3am incident.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If it was useful, &lt;a href="https://juliantellez.com/#newsletter" rel="noopener noreferrer"&gt;subscribe for the next one&lt;/a&gt;. No spam, unsubscribe anytime. If you are building something similar and want to compare notes, &lt;a href="mailto:hello@juliantellez.com"&gt;say hello&lt;/a&gt;. I read every message.&lt;/p&gt;

&lt;p&gt;Built from scratch, like everything here.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>alexa</category>
      <category>homelab</category>
      <category>automation</category>
    </item>
    <item>
      <title>Stop Letting AI Mark Its Own Homework</title>
      <dc:creator>Jules</dc:creator>
      <pubDate>Wed, 29 Jul 2026 13:01:09 +0000</pubDate>
      <link>https://dev.to/_juliantellez/stop-letting-ai-mark-its-own-homework-4p07</link>
      <guid>https://dev.to/_juliantellez/stop-letting-ai-mark-its-own-homework-4p07</guid>
      <description>&lt;p&gt;Last month I watched Claude Code write a beautiful authentication module. Clean abstractions, sensible error handling, well named functions. It looked like something a developer would write. Then I ran it. It failed immediately. The module was calling a method that didn't exist on the object it was importing. Claude hadn't made a logic error. It had made a confidence error. It wrote code that read correctly, explained correctly, and looked like it would work. But nobody had asked it to prove it. I stared at the screen for a minute, fixed the bug manually, and then thought: how many times will this happen again? Who is checking if all this code actually works?&lt;/p&gt;

&lt;h2&gt;
  
  
  The false confidence problem
&lt;/h2&gt;

&lt;p&gt;Here's what I've noticed after a year of building with AI coding tools: the failure mode isn't that the code is bad. It's that the code is almost right. Close enough to pass a glance. Close enough that Claude will confidently explain why it works. Close enough that you could ship it, and if you don't have guards in place bugs will show up in production.&lt;/p&gt;

&lt;p&gt;This isn't a Claude problem per se, it's an LLM problem. These models are trained to produce plausible output. And plausible code is the most dangerous kind of code, because it doesn't look wrong. The traditional answer to this has always been testing. Write the test first, write the implementation, run the test, see green. TDD isn't new. But here's what changed: the developer writing the code is now also an AI, and if the same AI writes the code and decides whether the code works, we've got a circular trust problem.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Reviewing your own code is basically marking your own homework. AI is no different.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The search for a solution
&lt;/h2&gt;

&lt;p&gt;So I went looking for answers. The Claude Code ecosystem has some genuinely brilliant tools. GSD, short for Get Shit Done, stood out immediately. It interviews you about what you're building, creates atomic plans, and executes in phases using fresh subagents with clean context windows. It solves a great part of the context rot problem. If you haven't tried it, you should.&lt;/p&gt;

&lt;p&gt;But after a few weeks, I kept hitting the same wall. GSD manages complexity and it's rather good at it. The planning is structured, the execution is disciplined, the main context stays clean. But when it comes to verification, the same agent that writes the code also checks the code. It basically asks "what must be TRUE for this to work?", which is a sensible question. But the agent is answering a question about its own output.&lt;/p&gt;

&lt;p&gt;GSD was great at planning, but we also needed a trust boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the boundary
&lt;/h2&gt;

&lt;p&gt;The idea is simple: go back to basics and embrace TDD. What if the agent that writes the test is not allowed to write the implementation? That one constraint changes the flow. Once you enforce that separation, the agent can't fake confidence. The tester doesn't know how the code will work. The coder can't change the tests. And the thing that decides pass or fail isn't AI at all, it's your pytest, jest, go test. A mechanical process with no opinions and no ego.&lt;/p&gt;

&lt;p&gt;I called it Greenlight. Green means done. Not "the agent thinks it's done." Actually done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introducing Greenlight
&lt;/h2&gt;

&lt;p&gt;It has agents, each with hard boundaries. Here is the core team:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Architect: defines what needs to be built, system design, constraints, scope.&lt;/li&gt;
&lt;li&gt;The Tester: writes the (failing) tests based on the design. It can't see any implementation. It only knows what the code should do.&lt;/li&gt;
&lt;li&gt;The Developer: receives the contracts and tests and writes code to fulfil the requirements. It is not done until both the requirements and tests pass. It can see the tests but cannot modify them.&lt;/li&gt;
&lt;li&gt;The SecOps: reviews everything independently for vulnerabilities, injection risks, auth issues, and security antipatterns.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The test runner sits in the middle and judges. Not Claude. Not any AI. The machine. If tests pass, we move forward. If they don't, the coder keeps working. The project lives at &lt;a href="https://github.com/atlantic-blue/greenlight" rel="noopener noreferrer"&gt;github.com/atlantic-blue/greenlight&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Greenlight
&lt;/h2&gt;

&lt;p&gt;Claude Code is powerful but permissive. It writes code, reviews its own work, and moves on. There's no external verification. Greenlight fixes this by enforcing agent isolation: one agent writes tests from contracts, another implements until tests pass, a third scans for vulnerabilities, and the test runner is the only judge. No agent ever sees its own tests.&lt;/p&gt;

&lt;p&gt;This matters because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AI reviews its own code poorly. Separating test writing from implementation prevents Claude from gaming its own tests.&lt;/li&gt;
&lt;li&gt;TDD works better with AI. Contracts define WHAT, agents figure out HOW, and the test suite proves it works.&lt;/li&gt;
&lt;li&gt;Security can't be an afterthought. Every slice gets a security scan. Every vulnerability becomes a failing test.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What Greenlight is
&lt;/h3&gt;

&lt;p&gt;A set of Claude Code slash commands, agents, and engineering standards that enforce test-driven development:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;10 agents with strict isolation boundaries (designer, architect, test writer, implementer, security, verifier, debugger, codebase mapper, assessor, wrapper)&lt;/li&gt;
&lt;li&gt;16 slash commands (&lt;code&gt;/gl:init&lt;/code&gt;, &lt;code&gt;/gl:design&lt;/code&gt;, &lt;code&gt;/gl:slice&lt;/code&gt;, &lt;code&gt;/gl:ship&lt;/code&gt;, and so on) that orchestrate the workflow&lt;/li&gt;
&lt;li&gt;Engineering standards (CLAUDE.md) covering error handling, naming, security, API design, testing, and more&lt;/li&gt;
&lt;li&gt;Context degradation awareness, so agents stay under 50% context usage to maintain quality&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What Greenlight is not
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Not a framework, library, or runtime dependency. It installs config files and does nothing at build time.&lt;/li&gt;
&lt;li&gt;Not a replacement for your test framework. It orchestrates Claude Code to use whatever test runner your project already has.&lt;/li&gt;
&lt;li&gt;Not opinionated about language or stack. The contracts and standards are language agnostic.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why is this interesting?
&lt;/h2&gt;

&lt;p&gt;Claude Code just shipped Agent Teams, an experimental feature where multiple Claude sessions coordinate, message each other, and work in parallel. The agent roles already exist in Greenlight. The trust boundaries are already defined. What Agent Teams adds is concurrency and real-time coordination.&lt;/p&gt;

&lt;p&gt;Right now, Greenlight's agents run sequentially. With Agent Teams, picture the test writer and implementer running as parallel teammates. The security agent scanning code as it's written, not after. The architect coordinating as team lead. Slices without dependencies executing simultaneously. All communicating through Claude Code's native messaging, all respecting their information boundaries.&lt;/p&gt;

&lt;p&gt;The roles exist. The primitives exist. Wiring them together is the natural next step.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The real bottleneck isn't capability. The bottleneck is trust.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We're at a place in time where AI coding tools are good enough that capability isn't the limiting factor. Claude can write authentication modules, API gateways, database migrations, React components, infrastructure. The models will keep getting better. Costs will keep dropping. The bottleneck is trust.&lt;/p&gt;

&lt;p&gt;Can you trust that what it wrote actually works? Can you deploy it without manually reading every line? Can you sleep at night knowing unreviewed code is running in production? TDD has always been the answer to that question. It just needed to be adapted for a world where the developer is also an AI.&lt;/p&gt;

&lt;p&gt;Happy coding.&lt;/p&gt;




&lt;p&gt;If you're building with Claude Code and want to stop vibe coding, give it a go. Or tell me I'm wrong. I'd genuinely love to hear how others are solving the trust problem. Greenlight is open source, MIT licensed: &lt;a href="https://github.com/atlantic-blue/greenlight" rel="noopener noreferrer"&gt;github.com/atlantic-blue/greenlight&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;First published in my LinkedIn newsletter, &lt;a href="https://www.linkedin.com/newsletters/built-from-scratch-6973801137594343424/" rel="noopener noreferrer"&gt;Built from Scratch&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This was originally published on &lt;a href="https://juliantellez.com/blog/stop-letting-ai-mark-its-own-homework" rel="noopener noreferrer"&gt;juliantellez.com&lt;/a&gt;. It is part of my newsletter on video, streaming, and building reliable systems from scratch: the architecture, the tradeoffs, and the occasional 3am incident.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If it was useful, &lt;a href="https://juliantellez.com/#newsletter" rel="noopener noreferrer"&gt;subscribe for the next one&lt;/a&gt;. No spam, unsubscribe anytime. If you are building something similar and want to compare notes, &lt;a href="mailto:hello@juliantellez.com"&gt;say hello&lt;/a&gt;. I read every message.&lt;/p&gt;

&lt;p&gt;Built from scratch, like everything here.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>tdd</category>
      <category>greenlight</category>
    </item>
    <item>
      <title>Raspberry Pi Kubernetes Cluster: Start the Cluster (Issue #4)</title>
      <dc:creator>Jules</dc:creator>
      <pubDate>Wed, 29 Jul 2026 13:00:33 +0000</pubDate>
      <link>https://dev.to/_juliantellez/raspberry-pi-kubernetes-cluster-start-the-cluster-issue-4-35l</link>
      <guid>https://dev.to/_juliantellez/raspberry-pi-kubernetes-cluster-start-the-cluster-issue-4-35l</guid>
      <description>&lt;p&gt;Welcome to the Raspberry Pi Kubernetes home lab series. A bite sized informative guide to help you provision a cluster from scratch. By now we should have 3+ nodes running the latest version of Ubuntu and we should be able to SSH into them.&lt;/p&gt;

&lt;p&gt;Today's issue will walk you through installing Kubernetes into your newly created cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Requirements
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;At least 3 nodes with Ubuntu installed on them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The following 12 steps should be replicated on every node. We will set them up manually in this lab session, but will provide an automated way of setting up nodes via Ansible in a coming issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. SSH into the node
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh ubuntu@192.168.0.25
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Set up hostnames and static IPs
&lt;/h2&gt;

&lt;p&gt;Slave nodes need to provide a unique name to the master node upon registration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On each node&lt;/span&gt;
&lt;span class="nv"&gt;NODE_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;k8s-master &lt;span class="c"&gt;# k8s-node-1 etc..&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;hostnamectl set-hostname &lt;span class="nv"&gt;$NODE_NAME&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Edit the hosts file so it maps IP addresses to hostnames
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/hosts
192.168.0.25 k8s-master
192.168.0.26 k8s-node-1
192.168.0.24 k8s-node-2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Disable swap memory
&lt;/h2&gt;

&lt;p&gt;Swap is space on the host disk that is used when the amount of physical RAM is full. Even though swap support has been added to Kubernetes, our Raspberry Pis have limited resources and we should account for such limitations.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;swapoff &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt purge &lt;span class="nt"&gt;-y&lt;/span&gt; dphys-swapfile
&lt;span class="c"&gt;# Test swap has been disabled&lt;/span&gt;
free &lt;span class="nt"&gt;-m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. Edit the boot configuration
&lt;/h2&gt;

&lt;p&gt;Append the following config to the boot file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /boot/firmware/cmdline.txt
ipv6.disable&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;cgroup_enable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cpuset &lt;span class="nv"&gt;cgroup_enable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;memory &lt;span class="nv"&gt;cgroup_memory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  7. Set up a container runtime (CRI)
&lt;/h2&gt;

&lt;p&gt;Kubernetes has moved away from Docker. There are a few options out there (Docker included). We will be setting up containerd as our CRI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt dist-upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; containerd containernetworking-plugins
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  8. Create a config for containerd
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="err"&gt;sudo&lt;/span&gt; &lt;span class="err"&gt;mkdir&lt;/span&gt; &lt;span class="err"&gt;/etc/containerd&lt;/span&gt;
&lt;span class="err"&gt;cat&lt;/span&gt; &lt;span class="err"&gt;&amp;lt;&amp;lt;EOF&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="err"&gt;sudo&lt;/span&gt; &lt;span class="err"&gt;tee&lt;/span&gt; &lt;span class="err"&gt;/etc/containerd/config.toml&lt;/span&gt;
&lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="nn"&gt;[plugins]&lt;/span&gt;
  &lt;span class="nn"&gt;[plugins."io.containerd.grpc.v1.cri"]&lt;/span&gt;
    &lt;span class="nn"&gt;[plugins."io.containerd.grpc.v1.cri".containerd]&lt;/span&gt;
      &lt;span class="nn"&gt;[plugins."io.containerd.grpc.v1.cri".containerd.runtimes]&lt;/span&gt;

&lt;span class="nn"&gt;[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]&lt;/span&gt;
          &lt;span class="py"&gt;runtime_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"io.containerd.runc.v2"&lt;/span&gt;
          &lt;span class="nn"&gt;[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]&lt;/span&gt;
            &lt;span class="py"&gt;SystemdCgroup&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="err"&gt;EOF&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  9. Forward IPv4 and set up IP tables
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt; | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe overlay
&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe br_netfilter

&lt;span class="c"&gt;# sysctl params required by setup, params persist across reboots&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt; | sudo tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# Apply sysctl params without reboot&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  10. Install kubeadm
&lt;/h2&gt;

&lt;p&gt;Update the &lt;code&gt;apt&lt;/code&gt; package index and install packages needed to use the Kubernetes &lt;code&gt;apt&lt;/code&gt; repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; apt-transport-https ca-certificates curl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Download the Google Cloud public signing key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;curl &lt;span class="nt"&gt;-fsSLo&lt;/span&gt; /usr/share/keyrings/kubernetes-archive-keyring.gpg https://packages.cloud.google.com/apt/doc/apt-key.gpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the Kubernetes &lt;code&gt;apt&lt;/code&gt; repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"deb [signed-by=/usr/share/keyrings/kubernetes-archive-keyring.gpg] https://apt.kubernetes.io/ kubernetes-xenial main"&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/kubernetes.list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update the &lt;code&gt;apt&lt;/code&gt; package index, install kubelet, kubeadm and kubectl, and pin their version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; kubelet kubeadm kubectl
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-mark hold kubelet kubeadm kubectl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  11. Install a Container Network Interface (CNI)
&lt;/h2&gt;

&lt;p&gt;A CNI is required to implement the Kubernetes network model. We are going to install Flannel in this lab as it is compatible with MetalLB. Be sure to check out different networking models too, such as Calico.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wget https://github.com/flannel-io/flannel/releases/download/v0.19.2/flanneld-arm64
&lt;span class="nb"&gt;sudo chmod&lt;/span&gt; +x flanneld-arm64
&lt;span class="nb"&gt;sudo cp &lt;/span&gt;flanneld-arm64 /usr/local/bin/flanneld
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /var/lib/k8s/flannel/networks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We will install Flannel and apply its configuration once the master node is running.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Reboot (optional)
&lt;/h2&gt;

&lt;p&gt;Reboot the nodes and start fresh. It will ensure that the boot file from step 6 is loaded in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;reboot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Start the cluster
&lt;/h2&gt;

&lt;p&gt;SSH into the master node and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;kubeadm config images pull
&lt;span class="nb"&gt;sudo &lt;/span&gt;kubeadm init &lt;span class="nt"&gt;--pod-network-cidr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.244.0.0/16 &lt;span class="nt"&gt;--token-ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save the token and discovery token provided by the initialisation. You can always generate a new token later by doing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;KUBE_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kubeadm token generate&lt;span class="si"&gt;)&lt;/span&gt;
kubeadm token create &lt;span class="nv"&gt;$KUBE_TOKEN&lt;/span&gt; &lt;span class="nt"&gt;--print-join-command&lt;/span&gt; &lt;span class="nt"&gt;--ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Create the kube config
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nv"&gt;$HOME&lt;/span&gt;/.kube
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; /etc/kubernetes/admin.conf &lt;span class="nv"&gt;$HOME&lt;/span&gt;/.kube/config
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;:&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$HOME&lt;/span&gt;/.kube/config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Node verification
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get nodes
NAME       STATUS   ROLES  AGE VERSION
k8s-master NotReady master 55m v1.25.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the node to become ready we need to install our CNI from step 11.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apply the CNI config
&lt;/h2&gt;

&lt;p&gt;Assuming we are connected to our master node from the previous section, download Flannel's configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wget https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inspect the YAML configuration and replace the backend. More info is in the Flannel backend documentation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/vxlan/host-gw/'&lt;/span&gt; kube-flannel.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply the config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create &lt;span class="nt"&gt;-f&lt;/span&gt; kube-flannel.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Add slave nodes to the cluster
&lt;/h2&gt;

&lt;p&gt;SSH into the slave nodes (k8s-node-1, k8s-node-2, ...) and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;K8S_MASTER_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;192.168.0.25
&lt;span class="nb"&gt;sudo &lt;/span&gt;kubeadm &lt;span class="nb"&gt;join&lt;/span&gt; &lt;span class="nv"&gt;$K8S_MASTER_IP&lt;/span&gt;:6443 &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nt"&gt;--token&lt;/span&gt; &amp;lt;token here&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nt"&gt;--discovery-token-ca-cert-hash&lt;/span&gt; sha256:&amp;lt;&lt;span class="nb"&gt;hash &lt;/span&gt;value here&amp;gt;
&lt;span class="c"&gt;# Output:&lt;/span&gt;
This node has joined the cluster:
&lt;span class="k"&gt;*&lt;/span&gt; Certificate signing request was sent to apiserver and a response was received.
&lt;span class="k"&gt;*&lt;/span&gt; The Kubelet was informed of the new secure connection details.
Run &lt;span class="s1"&gt;'kubectl get nodes'&lt;/span&gt; on the control-plane to see this node &lt;span class="nb"&gt;join &lt;/span&gt;the cluster.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We are now ready to start inspecting and extending our cluster.&lt;/p&gt;

&lt;p&gt;Leave a comment if you have any questions. We will be automating this setup next. Happy coding.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;First published in my LinkedIn newsletter, &lt;a href="https://www.linkedin.com/newsletters/built-from-scratch-6973801137594343424/" rel="noopener noreferrer"&gt;Built from Scratch&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This was originally published on &lt;a href="https://juliantellez.com/blog/raspberry-pi-kubernetes-cluster-start-the-cluster" rel="noopener noreferrer"&gt;juliantellez.com&lt;/a&gt;. It is part of my newsletter on video, streaming, and building reliable systems from scratch: the architecture, the tradeoffs, and the occasional 3am incident.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If it was useful, &lt;a href="https://juliantellez.com/#newsletter" rel="noopener noreferrer"&gt;subscribe for the next one&lt;/a&gt;. No spam, unsubscribe anytime. If you are building something similar and want to compare notes, &lt;a href="mailto:hello@juliantellez.com"&gt;say hello&lt;/a&gt;. I read every message.&lt;/p&gt;

&lt;p&gt;Built from scratch, like everything here.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>raspberrypi</category>
    </item>
    <item>
      <title>Raspberry Pi Kubernetes Cluster: SSH into the Nodes (Issue #3)</title>
      <dc:creator>Jules</dc:creator>
      <pubDate>Wed, 29 Jul 2026 12:59:56 +0000</pubDate>
      <link>https://dev.to/_juliantellez/raspberry-pi-kubernetes-cluster-ssh-into-the-nodes-issue-3-7cm</link>
      <guid>https://dev.to/_juliantellez/raspberry-pi-kubernetes-cluster-ssh-into-the-nodes-issue-3-7cm</guid>
      <description>&lt;p&gt;Welcome to the Raspberry Pi Kubernetes home lab series. A bite sized informative guide to help you provision a cluster from scratch. By now you should have 3+ nodes running the latest version of Ubuntu. Refer to the &lt;a href="https://juliantellez.com/blog/raspberry-pi-kubernetes-cluster-os-installation" rel="noopener noreferrer"&gt;previous guide&lt;/a&gt; if you would like to know more.&lt;/p&gt;

&lt;p&gt;This issue walks you through communicating with your Raspberry Pi nodes through SSH.&lt;/p&gt;

&lt;h2&gt;
  
  
  Requirements
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Raspberry Pi node with the latest version of Ubuntu, connected to either your home router or an Ethernet switch&lt;/li&gt;
&lt;li&gt;SSH should be enabled on the node&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Install Nmap
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;nmap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nmap ("Network Mapper") is an open source tool for network exploration and security auditing, designed to rapidly scan networks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find the device's IP address
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Find the router's IP&lt;/span&gt;
netstat &lt;span class="nt"&gt;-nr&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;default

&lt;span class="c"&gt;# Inspect nodes on your local network&lt;/span&gt;
nmap &lt;span class="nt"&gt;-sP&lt;/span&gt; 192.168.0.1/24
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the snippet above, the nodes are on 192.168.0.[24-26].&lt;/p&gt;

&lt;h2&gt;
  
  
  SSH into nodes manually
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ssh into device, password is usually "ubuntu"&lt;/span&gt;
ssh ubuntu@192.168.0.26
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  SSH into nodes with private keys
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; rsa &lt;span class="nt"&gt;-b&lt;/span&gt; 4096
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the SSH key to the node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-copy-id &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.ssh/ansible ubuntu@192.168.0.26
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SSH with the RSA key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.ssh/ansible ubuntu@192.168.0.26
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;You are now ready to start your Kubernetes setup. It is important that you know how to access the nodes independently and that you can reach them both via SSH keys and as a root user.&lt;/p&gt;

&lt;p&gt;Leave a comment if you have any questions. The next step will be &lt;a href="https://juliantellez.com/blog/raspberry-pi-kubernetes-cluster-start-the-cluster" rel="noopener noreferrer"&gt;installing dependencies on the nodes&lt;/a&gt;. Happy coding.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;First published in my LinkedIn newsletter, &lt;a href="https://www.linkedin.com/newsletters/built-from-scratch-6973801137594343424/" rel="noopener noreferrer"&gt;Built from Scratch&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This was originally published on &lt;a href="https://juliantellez.com/blog/raspberry-pi-kubernetes-cluster-ssh-into-the-nodes" rel="noopener noreferrer"&gt;juliantellez.com&lt;/a&gt;. It is part of my newsletter on video, streaming, and building reliable systems from scratch: the architecture, the tradeoffs, and the occasional 3am incident.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If it was useful, &lt;a href="https://juliantellez.com/#newsletter" rel="noopener noreferrer"&gt;subscribe for the next one&lt;/a&gt;. No spam, unsubscribe anytime. If you are building something similar and want to compare notes, &lt;a href="mailto:hello@juliantellez.com"&gt;say hello&lt;/a&gt;. I read every message.&lt;/p&gt;

&lt;p&gt;Built from scratch, like everything here.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>raspberrypi</category>
    </item>
    <item>
      <title>Raspberry Pi Kubernetes Cluster: OS Installation (Issue #2)</title>
      <dc:creator>Jules</dc:creator>
      <pubDate>Wed, 29 Jul 2026 12:59:20 +0000</pubDate>
      <link>https://dev.to/_juliantellez/raspberry-pi-kubernetes-cluster-os-installation-issue-2-4o9f</link>
      <guid>https://dev.to/_juliantellez/raspberry-pi-kubernetes-cluster-os-installation-issue-2-4o9f</guid>
      <description>&lt;p&gt;Welcome to the Raspberry Pi Kubernetes home lab series. A bite sized informative guide to help you provision a cluster from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Install Ubuntu on a Raspberry Pi
&lt;/h2&gt;

&lt;p&gt;The Raspberry Pi nodes are provisioned using &lt;a href="https://ubuntu.com/download/raspberry-pi" rel="noopener noreferrer"&gt;Ubuntu 22.04 LTS&lt;/a&gt; for ARM. Download and unzip the image from the link.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Locate the SD card
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://ss64.com/osx/diskutil.html" rel="noopener noreferrer"&gt;Diskutil&lt;/a&gt; provides information about the formats of disks, which include disk images.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;diskutil list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Unmount the disk
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;diskutil unmountDisk /dev/disk2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unmount the disk so you can write onto it. Note that unmount is not the same as eject. Unmount means to remove the connection to the disk internally; eject means to physically remove it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. dd the image into the disk
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;path-to-image&amp;gt;/raspbian.img &lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/rdisk2 &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is important that you set the block size read/write buffering to optimise the overall time of the task.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Enable SSH
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;touch&lt;/span&gt; /Volumes/system-boot/ssh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SSH needs to be enabled so we can talk to the nodes later.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Unmount and eject/remove the card
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;diskutil unmountDisk /dev/disk2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leave a comment if you have any questions. We will be installing and preparing the nodes next, first manually and then in an automated fashion via Ansible. Happy coding.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;First published in my LinkedIn newsletter, &lt;a href="https://www.linkedin.com/newsletters/built-from-scratch-6973801137594343424/" rel="noopener noreferrer"&gt;Built from Scratch&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This was originally published on &lt;a href="https://juliantellez.com/blog/raspberry-pi-kubernetes-cluster-os-installation" rel="noopener noreferrer"&gt;juliantellez.com&lt;/a&gt;. It is part of my newsletter on video, streaming, and building reliable systems from scratch: the architecture, the tradeoffs, and the occasional 3am incident.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If it was useful, &lt;a href="https://juliantellez.com/#newsletter" rel="noopener noreferrer"&gt;subscribe for the next one&lt;/a&gt;. No spam, unsubscribe anytime. If you are building something similar and want to compare notes, &lt;a href="mailto:hello@juliantellez.com"&gt;say hello&lt;/a&gt;. I read every message.&lt;/p&gt;

&lt;p&gt;Built from scratch, like everything here.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>raspberrypi</category>
    </item>
    <item>
      <title>Raspberry Pi Kubernetes Cluster: NFS Storage (Issue #5)</title>
      <dc:creator>Jules</dc:creator>
      <pubDate>Wed, 29 Jul 2026 12:58:44 +0000</pubDate>
      <link>https://dev.to/_juliantellez/raspberry-pi-kubernetes-cluster-nfs-storage-issue-5-4fm0</link>
      <guid>https://dev.to/_juliantellez/raspberry-pi-kubernetes-cluster-nfs-storage-issue-5-4fm0</guid>
      <description>&lt;p&gt;Welcome to the Raspberry Pi Kubernetes home lab series. A bite sized informative guide to help you provision a cluster from scratch. By now we should have our &lt;a href="https://juliantellez.com/blog/raspberry-pi-kubernetes-cluster-start-the-cluster" rel="noopener noreferrer"&gt;k8s cluster up and running&lt;/a&gt;; great job everyone.&lt;/p&gt;

&lt;p&gt;Today's issue will walk you through installing and creating a &lt;a href="https://en.wikipedia.org/wiki/Network_File_System" rel="noopener noreferrer"&gt;Network File System&lt;/a&gt; (NFS) server so it can be used as our default cluster storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Requirements
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;1 &lt;a href="https://juliantellez.com/blog/raspberry-pi-kubernetes-cluster-start-the-cluster" rel="noopener noreferrer"&gt;k8s cluster&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;1 external hard drive&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Locate the hard drive
&lt;/h2&gt;

&lt;p&gt;Plug your hard drive into one of your slave nodes and &lt;a href="https://juliantellez.com/blog/raspberry-pi-kubernetes-cluster-ssh-into-the-nodes" rel="noopener noreferrer"&gt;SSH into it&lt;/a&gt; to find its location.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;lsblk &lt;span class="nt"&gt;-o&lt;/span&gt; UUID,NAME,FSTYPE,SIZE,MOUNTPOINT,LABEL,MODEL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Format the hard drive with mkfs.ext3
&lt;/h2&gt;

&lt;p&gt;We can see that our hard drive is located at /dev/sda2. Let's go ahead and format the hard drive's filesystem so it can be used by the Linux system. Other examples include /dev/sdb2.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;mkfs.ext3 &lt;span class="nt"&gt;-L&lt;/span&gt; k8s-volume /dev/sda2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Create a directory for the volume and mount it
&lt;/h2&gt;

&lt;p&gt;This could be in any location. We are going to "namespace" it under k8s-mount and label it k8s-volume-1.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /k8s-mount/k8s-volume-1
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount /dev/sda2 /k8s-mount/k8s-volume-1 &lt;span class="c"&gt;# mount disk&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Install the NFS server
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install &lt;/span&gt;nfs-kernel-server &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. Start the server
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start nfs-server
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status nfs-server &lt;span class="c"&gt;# verify it is running&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  7. Allocate permissions
&lt;/h2&gt;

&lt;p&gt;In order to have access to your NFS server we need to create some read/write permissions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# owner (read/write) group (read)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;find /k8s-mount/k8s-volume-1/ &lt;span class="nt"&gt;-type&lt;/span&gt; d &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;chmod &lt;/span&gt;755 &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="se"&gt;\;&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;find /k8s-mount/k8s-volume-1/ &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;chmod &lt;/span&gt;644 &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="se"&gt;\;&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;find /k8s-mount/k8s-volume-1/ &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;chmod &lt;/span&gt;777 &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="se"&gt;\;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  8. Set up remote access
&lt;/h2&gt;

&lt;p&gt;The line below will give permissions to all hosts in the network, so be careful!&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/exports
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/k8s-mount/k8s-volume-1/ *(rw,sync,no_subtree_check,insecure)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We can set the permissions to be restrictive per host. Let me know if you would like to see how this is done. In the meantime, here is a &lt;a href="https://pimylifeup.com/raspberry-pi-nfs/" rel="noopener noreferrer"&gt;walk through of this setup&lt;/a&gt; and the &lt;a href="https://linux.die.net/man/5/exports" rel="noopener noreferrer"&gt;official docs&lt;/a&gt; too.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Export the etc config
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;exportfs &lt;span class="nt"&gt;-ra&lt;/span&gt; &lt;span class="c"&gt;# export config&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;exportfs &lt;span class="nt"&gt;-v&lt;/span&gt;  &lt;span class="c"&gt;# see current config&lt;/span&gt;
showmount &lt;span class="nt"&gt;-e&lt;/span&gt;      &lt;span class="c"&gt;# see what is being exported&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  10. Install the NFS client on all the other worker nodes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ssh ubuntu@192.168.0.xx # WORKER_NODE&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;nfs-common &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  11. Create volume manifests
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# system-volume.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Namespace&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;system-volume&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;system-volume&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PersistentVolume&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pv-k8s-volume-1&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;k8s-volume-1&lt;/span&gt;
  &lt;span class="na"&gt;capacity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Ti&lt;/span&gt;
  &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ReadWriteMany&lt;/span&gt;
  &lt;span class="na"&gt;nfs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;192.168.0.26&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/k8s-mount/k8s-volume-1&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PersistentVolumeClaim&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pvc-k8s-volume-1&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;k8s-volume-1&lt;/span&gt;
  &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ReadWriteMany&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Ti&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  12. Apply the manifests
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-k&lt;/span&gt; path-to-manifest.yaml
kubectl &lt;span class="nt"&gt;--namespace&lt;/span&gt; system-volume get pv,pvc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bonus
&lt;/h2&gt;

&lt;p&gt;This isn't a required section but more of an educational addendum. Since we have given global access to our drive, we can now access it anywhere from within the network.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Find nodes
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nmap &lt;span class="nt"&gt;-sP&lt;/span&gt; 192.168.0.1/24 &lt;span class="c"&gt;# list nodes&lt;/span&gt;

&lt;span class="nv"&gt;WORKER_NODE_1&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;192.168.0.24
&lt;span class="nv"&gt;WORKER_NODE_2&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;192.168.0.26 &lt;span class="c"&gt;# hard drive lives here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Find the volume on the network
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh ubuntu@&lt;span class="nv"&gt;$WORKER_NODE_1&lt;/span&gt; &lt;span class="c"&gt;# a worker node&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;nfs-common

showmount &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;$WORKER_NODE_2&lt;/span&gt; &lt;span class="c"&gt;# see what is being exported&lt;/span&gt;

&lt;span class="c"&gt;# output&lt;/span&gt;
Export list &lt;span class="k"&gt;for &lt;/span&gt;192.168.0.26:
/k8s-mount/k8s-volume-1 &lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Access the hard drive
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /test
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount &lt;span class="nt"&gt;-t&lt;/span&gt; nfs &lt;span class="nv"&gt;$WORKER_NODE_2&lt;/span&gt;:/k8s-mount/k8s-volume-1 /test/
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-al&lt;/span&gt; /test/ &lt;span class="c"&gt;# inspect drive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. End of test
&lt;/h3&gt;

&lt;p&gt;Clear the test area by removing the files created previously.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;umount /test
&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We are now ready to start using our local storage. It is important that you know how to access the nodes independently and that you understand how to manipulate the NFS server across the network.&lt;/p&gt;

&lt;p&gt;Leave a comment if you have any questions. We will be integrating with &lt;a href="https://metallb.universe.tf/" rel="noopener noreferrer"&gt;MetalLB&lt;/a&gt;'s load balancer next. Happy coding.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;First published in my LinkedIn newsletter, &lt;a href="https://www.linkedin.com/newsletters/built-from-scratch-6973801137594343424/" rel="noopener noreferrer"&gt;Built from Scratch&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This was originally published on &lt;a href="https://juliantellez.com/blog/raspberry-pi-kubernetes-cluster-nfs-storage" rel="noopener noreferrer"&gt;juliantellez.com&lt;/a&gt;. It is part of my newsletter on video, streaming, and building reliable systems from scratch: the architecture, the tradeoffs, and the occasional 3am incident.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If it was useful, &lt;a href="https://juliantellez.com/#newsletter" rel="noopener noreferrer"&gt;subscribe for the next one&lt;/a&gt;. No spam, unsubscribe anytime. If you are building something similar and want to compare notes, &lt;a href="mailto:hello@juliantellez.com"&gt;say hello&lt;/a&gt;. I read every message.&lt;/p&gt;

&lt;p&gt;Built from scratch, like everything here.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>raspberrypi</category>
    </item>
    <item>
      <title>Raspberry Pi Kubernetes Cluster: Load Balancer (Issue #6)</title>
      <dc:creator>Jules</dc:creator>
      <pubDate>Wed, 29 Jul 2026 12:58:08 +0000</pubDate>
      <link>https://dev.to/_juliantellez/raspberry-pi-kubernetes-cluster-load-balancer-issue-6-241e</link>
      <guid>https://dev.to/_juliantellez/raspberry-pi-kubernetes-cluster-load-balancer-issue-6-241e</guid>
      <description>&lt;p&gt;Welcome to the Raspberry Pi Kubernetes home lab series. A bite sized informative guide to help you provision a cluster from scratch. Our cluster should now be up and running and we should be able to easily provision services, deployments, volumes, and so on.&lt;/p&gt;

&lt;p&gt;Today's issue will walk you through &lt;a href="https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types" rel="noopener noreferrer"&gt;exposing a service to an external IP address&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The challenge with bare metal
&lt;/h2&gt;

&lt;p&gt;Since our cluster is running on bare metal we don't have the luxury of provisioning an external load balancer. Kubernetes doesn't provide a direct implementation for network load balancers and instead comes with built-in functionality to integrate with different cloud providers (AWS, GCP, Azure, and so on). This is normally done by allowing Kubernetes to automatically create load balancers on your cloud provider. Without the ability to load balance traffic we are left with two options, "NodePort" and "ClusterIP", which are solutions better suited for internal network or cluster communication.&lt;/p&gt;

&lt;p&gt;In this lab we will be implementing &lt;a href="https://metallb.universe.tf/" rel="noopener noreferrer"&gt;MetalLB&lt;/a&gt;, a solution that offers network balancing so that external traffic and services can communicate with our cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Requirements
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;1 k8s cluster with a compatible CNI&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Preparation
&lt;/h2&gt;

&lt;p&gt;Please check the compatibility matrix before you implement MetalLB. If you are following this series you would have installed &lt;a href="https://github.com/flannel-io/flannel" rel="noopener noreferrer"&gt;Flannel&lt;/a&gt; and therefore are good to go! Kubernetes CNI integrations are very interesting and worth a read in their own right. &lt;a href="https://projectcalico.docs.tigera.io/about/about-networking" rel="noopener noreferrer"&gt;Calico&lt;/a&gt;, for example, has a really interesting implementation and is definitely worth exploring.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Installation
&lt;/h2&gt;

&lt;p&gt;Version 0.13.5 has come a long way; integrating with MetalLB is incredibly simple. We will resource this latest version and supply a configuration file along with it.&lt;/p&gt;

&lt;h3&gt;
  
  
  kustomization.yaml
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kustomize.config.k8s.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Kustomization&lt;/span&gt;
&lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;metallb-system&lt;/span&gt;
&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;github.com/metallb/metallb/config/native?ref=v0.13.5&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;config.yaml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MetalLB is set up as an L2 balancer by default and will be idle until configured. The easiest deployment is probably the Layer 2 configuration, which works by responding to &lt;a href="https://en.wikipedia.org/wiki/Address_Resolution_Protocol" rel="noopener noreferrer"&gt;ARP requests&lt;/a&gt; and providing the machine's MAC address to clients.&lt;/p&gt;

&lt;p&gt;As a bare minimum (no pun intended) it will expect a pool of available addresses in your network, and a reference for them. From the network perspective it is represented as if the cluster had multiple IPs assigned to its network interface. As you can see from the configuration below, we are assigning ports 100 to 250, which should be more than enough for our Raspberry Pi experiments.&lt;/p&gt;

&lt;h3&gt;
  
  
  config.yaml
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;metallb.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IPAddressPool&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;first-pool&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;metallb-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;addresses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;192.168.0.100-192.168.1.250&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;metallb.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;L2Advertisement&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;advertisment&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;metallb-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ipAddressPools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;first-pool&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply the &lt;a href="https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/" rel="noopener noreferrer"&gt;kustomization&lt;/a&gt; file and verify. MetalLB will create a daemon set "speaker" on every slave node, which will ensure the load balancer can reach our services, and a controller (responsible for address assignments).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Apply&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-k&lt;/span&gt; &amp;lt;kustomization_directory&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Test the load balancer
&lt;/h2&gt;

&lt;p&gt;Let's deploy an nginx service and assign a load balancer to it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl run nginx &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nginx
kubectl expose deploy nginx &lt;span class="nt"&gt;--port&lt;/span&gt; 80 &lt;span class="nt"&gt;--type&lt;/span&gt; LoadBalancer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's check that our service has indeed deployed and that it has been assigned an external IP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get service

&lt;span class="c"&gt;# Output&lt;/span&gt;
NAME            TYPE           CLUSTER-IP   EXTERNAL-IP
service/nginx   LoadBalancer   10.96.4.10   192.168.0.230
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And there we go, our service is now available to the network!&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;We are now ready to start assigning IPs to our services. It is important that you know how Layer 2 balancers work so you can extend the basic configuration for MetalLB.&lt;/p&gt;

&lt;p&gt;Leave a comment if you have any questions. We will be looking into provisioning Kubernetes secrets powered by &lt;a href="https://git-secret.io/" rel="noopener noreferrer"&gt;git-secret&lt;/a&gt; next. Happy coding.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;First published in my LinkedIn newsletter, &lt;a href="https://www.linkedin.com/newsletters/built-from-scratch-6973801137594343424/" rel="noopener noreferrer"&gt;Built from Scratch&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This was originally published on &lt;a href="https://juliantellez.com/blog/raspberry-pi-kubernetes-cluster-load-balancer" rel="noopener noreferrer"&gt;juliantellez.com&lt;/a&gt;. It is part of my newsletter on video, streaming, and building reliable systems from scratch: the architecture, the tradeoffs, and the occasional 3am incident.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If it was useful, &lt;a href="https://juliantellez.com/#newsletter" rel="noopener noreferrer"&gt;subscribe for the next one&lt;/a&gt;. No spam, unsubscribe anytime. If you are building something similar and want to compare notes, &lt;a href="mailto:hello@juliantellez.com"&gt;say hello&lt;/a&gt;. I read every message.&lt;/p&gt;

&lt;p&gt;Built from scratch, like everything here.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>raspberrypi</category>
    </item>
    <item>
      <title>Raspberry Pi Kubernetes Cluster: Equipment (Issue #1)</title>
      <dc:creator>Jules</dc:creator>
      <pubDate>Wed, 29 Jul 2026 12:56:17 +0000</pubDate>
      <link>https://dev.to/_juliantellez/raspberry-pi-kubernetes-cluster-equipment-issue-1-4la7</link>
      <guid>https://dev.to/_juliantellez/raspberry-pi-kubernetes-cluster-equipment-issue-1-4la7</guid>
      <description>&lt;p&gt;This is the first in a series on building a Kubernetes home lab from Raspberry Pi computers. Treat it as an informative guide rather than a tutorial. If you want the deep, step-by-step path, "Kubernetes the Hard Way" is the place to go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Requirements
&lt;/h2&gt;

&lt;p&gt;The usual Kubernetes prerequisites apply to each machine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A compatible Linux host&lt;/li&gt;
&lt;li&gt;At least 2GB of RAM per machine&lt;/li&gt;
&lt;li&gt;2 or more CPUs&lt;/li&gt;
&lt;li&gt;Full network connectivity between all nodes&lt;/li&gt;
&lt;li&gt;A unique hostname, MAC address and product UUID per node&lt;/li&gt;
&lt;li&gt;The required ports open between nodes&lt;/li&gt;
&lt;li&gt;Swap disabled, so the kubelet behaves correctly&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Equipment
&lt;/h2&gt;

&lt;p&gt;What I used to build the cluster:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Compute:&lt;/strong&gt; 4 x Raspberry Pi 4 (one master node and three worker nodes)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage:&lt;/strong&gt; 4 x 32GB microSD cards&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Networking:&lt;/strong&gt; an Ethernet switch, a router with DHCP, and a cable per node&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Power:&lt;/strong&gt; USB-C / micro USB cables and a multi-amp USB charger&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cooling:&lt;/strong&gt; a case with fans and heatsinks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional:&lt;/strong&gt; an external hard drive for extra storage&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;img src="/blog-media/raspberry-pi-kubernetes-cluster-equipment/raspberry-pi-4.jpeg" alt="Raspberry Pi 4"&amp;gt;
&amp;lt;figcaption&amp;gt;Raspberry Pi 4&amp;lt;/figcaption&amp;gt;


&amp;lt;img src="/blog-media/raspberry-pi-kubernetes-cluster-equipment/microsd-card.jpeg" alt="MicroSD card"&amp;gt;
&amp;lt;figcaption&amp;gt;MicroSD card&amp;lt;/figcaption&amp;gt;


&amp;lt;img src="/blog-media/raspberry-pi-kubernetes-cluster-equipment/ethernet-switch.jpeg" alt="Ethernet switch"&amp;gt;
&amp;lt;figcaption&amp;gt;Ethernet switch&amp;lt;/figcaption&amp;gt;


&amp;lt;img src="/blog-media/raspberry-pi-kubernetes-cluster-equipment/router.jpeg" alt="Router"&amp;gt;
&amp;lt;figcaption&amp;gt;Router&amp;lt;/figcaption&amp;gt;


&amp;lt;img src="/blog-media/raspberry-pi-kubernetes-cluster-equipment/ethernet-cables.jpeg" alt="Ethernet cables"&amp;gt;
&amp;lt;figcaption&amp;gt;Ethernet cables&amp;lt;/figcaption&amp;gt;


&amp;lt;img src="/blog-media/raspberry-pi-kubernetes-cluster-equipment/usb-c-cables.jpeg" alt="USB-C cables"&amp;gt;
&amp;lt;figcaption&amp;gt;USB-C cables&amp;lt;/figcaption&amp;gt;


&amp;lt;img src="/blog-media/raspberry-pi-kubernetes-cluster-equipment/usb-charger.jpeg" alt="USB charger"&amp;gt;
&amp;lt;figcaption&amp;gt;USB charger&amp;lt;/figcaption&amp;gt;


&amp;lt;img src="/blog-media/raspberry-pi-kubernetes-cluster-equipment/cluster-case.jpeg" alt="Cluster case"&amp;gt;
&amp;lt;figcaption&amp;gt;Cluster case&amp;lt;/figcaption&amp;gt;


&amp;lt;img src="/blog-media/raspberry-pi-kubernetes-cluster-equipment/external-hard-drive.jpeg" alt="External hard drive"&amp;gt;
&amp;lt;figcaption&amp;gt;External hard drive&amp;lt;/figcaption&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;A good case with proper cooling makes a real difference once the nodes are under load, so it is worth following an assembly guide for the heatsink and fan setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  What comes next
&lt;/h2&gt;

&lt;p&gt;Later issues in the series cover configuring the nodes, SSH access, initialising the cluster, storage, and load balancing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;First published in my LinkedIn newsletter, &lt;a href="https://www.linkedin.com/newsletters/built-from-scratch-6973801137594343424/" rel="noopener noreferrer"&gt;Built from Scratch&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This was originally published on &lt;a href="https://juliantellez.com/blog/raspberry-pi-kubernetes-cluster-equipment" rel="noopener noreferrer"&gt;juliantellez.com&lt;/a&gt;. It is part of my newsletter on video, streaming, and building reliable systems from scratch: the architecture, the tradeoffs, and the occasional 3am incident.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If it was useful, &lt;a href="https://juliantellez.com/#newsletter" rel="noopener noreferrer"&gt;subscribe for the next one&lt;/a&gt;. No spam, unsubscribe anytime. If you are building something similar and want to compare notes, &lt;a href="mailto:hello@juliantellez.com"&gt;say hello&lt;/a&gt;. I read every message.&lt;/p&gt;

&lt;p&gt;Built from scratch, like everything here.&lt;/p&gt;

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