<?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: adeutou</title>
    <description>The latest articles on DEV Community by adeutou (@adeutou).</description>
    <link>https://dev.to/adeutou</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%2F678844%2Ffa818fd6-159d-4b74-872b-5464fb1b32dc.jpeg</url>
      <title>DEV Community: adeutou</title>
      <link>https://dev.to/adeutou</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/adeutou"/>
    <language>en</language>
    <item>
      <title>Electricity Planning Engine: An Hourly Arbitrage API for Batteries and Solar, Plus the Timezone Bug That Almost Broke It</title>
      <dc:creator>adeutou</dc:creator>
      <pubDate>Wed, 22 Jul 2026 10:14:12 +0000</pubDate>
      <link>https://dev.to/adeutou/electricity-planning-engine-an-hourly-arbitrage-api-for-batteries-and-solar-plus-the-timezone-bug-2fp6</link>
      <guid>https://dev.to/adeutou/electricity-planning-engine-an-hourly-arbitrage-api-for-batteries-and-solar-plus-the-timezone-bug-2fp6</guid>
      <description>&lt;p&gt;A grid that pays you to consume electricity is not a thought experiment. In Germany, wholesale power prices have gone negative often enough that it barely makes headlines anymore: on a windy, sunny afternoon, there is simply more electricity than the grid can absorb, and someone has to be paid to take it. Meanwhile, in most homes, none of that signal reaches the washing machine, the home battery, or the EV charger. They run on habit, not on price.&lt;/p&gt;

&lt;p&gt;That gap between "electricity has an hourly price" and "almost nothing reacts to it" is what I set out to close, at a small scale, with the &lt;strong&gt;Electricity Planning Engine&lt;/strong&gt;: a Laravel API that decides, hour by hour over a 24 to 72 hour horizon, whether a household should draw from the grid, charge or discharge a battery, or export solar surplus, based on its energy contract and the market price.&lt;/p&gt;

&lt;p&gt;Full source is on GitHub: &lt;strong&gt;&lt;a href="https://github.com/adeutou/electricity-planning-engine" rel="noopener noreferrer"&gt;github.com/adeutou/electricity-planning-engine&lt;/a&gt;&lt;/strong&gt;. This post walks through why the problem matters, then how the solution is actually built, with enough real code that you can follow along or lift pieces of it for your own project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why hourly prices are not a gimmick
&lt;/h2&gt;

&lt;p&gt;Germany is the best possible example for this, because it is living the consequences of both extremes at once.&lt;/p&gt;

&lt;p&gt;On the supply side, negative prices are not an anomaly, they are the visible symptom of an engineering problem: when solar and wind output exceeds demand, the grid has to either store the surplus, curtail it, or pay someone to consume it. Every megawatt-hour that gets curtailed is clean energy that was already generated and still got wasted, while a gas peaker plant somewhere ramps up a few hours later for the evening spike.&lt;/p&gt;

&lt;p&gt;On the demand side, Germany has been pushing dynamic, hourly-priced tariffs into the mainstream, with providers like Tibber or aWATTar built entirely around the idea. Grid operators also already have mechanisms to throttle controllable devices like heat pumps and EV chargers during congestion, in exchange for reduced grid fees. The infrastructure for "your appliances should react to the grid" already exists. What is missing, in most homes, is the software that makes the decision.&lt;/p&gt;

&lt;p&gt;And then there is &lt;em&gt;Dunkelflaute&lt;/em&gt;, the German word for days with neither wind nor sun: an arbitrage engine cannot just chase cheap hours, it has to hold reserve for the peak it can see coming.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building it: the domain first
&lt;/h2&gt;

&lt;p&gt;I did not start with the arbitrage algorithm. I started with the domain, because if the domain model is wrong, the cleverest algorithm on top of it is still wrong. The rule I followed throughout: &lt;code&gt;app/Domain&lt;/code&gt; never imports a single Laravel or Eloquent class. If you can &lt;code&gt;use Illuminate\...&lt;/code&gt; in a file, it does not belong in the domain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contracts are strategies, not prices
&lt;/h3&gt;

&lt;p&gt;An &lt;code&gt;EnergyContract&lt;/code&gt; never computes its own price. It delegates to a &lt;code&gt;PricingStrategyInterface&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;PricingStrategyInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;priceForHour&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;DateTimeImmutable&lt;/span&gt; &lt;span class="nv"&gt;$hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;?PriceSeries&lt;/span&gt; &lt;span class="nv"&gt;$marketPrices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$marketPrices&lt;/code&gt; is nullable and ignored by most implementations. Only a contract indexed on the wholesale market actually needs it, which is exactly the case a German Tibber-style dynamic tariff maps onto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DynamicSpotPricingStrategy&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;PricingStrategyInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;Money&lt;/span&gt; &lt;span class="nv"&gt;$supplierFeePerKwh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;Percentage&lt;/span&gt; &lt;span class="nv"&gt;$supplierMargin&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;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;priceForHour&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;DateTimeImmutable&lt;/span&gt; &lt;span class="nv"&gt;$hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;?PriceSeries&lt;/span&gt; &lt;span class="nv"&gt;$marketPrices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Money&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="nv"&gt;$marketPrices&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&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="nc"&gt;DomainException&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;because&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'DynamicSpotPricingStrategy requires market prices to compute a rate.'&lt;/span&gt;
            &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$wholesalePrice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$marketPrices&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;priceAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$hour&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$wholesalePrice&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;multiply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;supplierMargin&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toFraction&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;supplierFeePerKwh&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three other strategies implement the same interface: a flat fixed rate, a peak/off-peak schedule with seasonal rates and daily time windows (midnight wraparound handled explicitly), and a colored-day tariff modeled after EDF's French Tempo scheme, where whole days get reclassified rather than fixed hourly windows. None of them know the others exist. The arbitrage engine only ever calls &lt;code&gt;EnergyContract::priceForHour()&lt;/code&gt;, so adding a fifth strategy for a different market never touches the engine, the API, or the other three strategies.&lt;/p&gt;

&lt;h3&gt;
  
  
  A battery that refuses to lie about its own limits
&lt;/h3&gt;

&lt;p&gt;The battery is the one piece of the domain where getting the physics slightly wrong would make every downstream decision wrong. It is an immutable entity: &lt;code&gt;charge()&lt;/code&gt; and &lt;code&gt;discharge()&lt;/code&gt; return a new instance rather than mutating state, which matters later when the advanced engine needs to reason about several possible futures without side effects.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Energy&lt;/span&gt; &lt;span class="nv"&gt;$gridEnergyIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nv"&gt;$hours&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;maxChargeableEnergy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$hours&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="nv"&gt;$gridEnergyIn&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isGreaterThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$limit&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="nc"&gt;DomainException&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;because&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s2"&gt;"Cannot charge &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$gridEnergyIn&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: exceeds max chargeable energy of &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$limit&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$hours&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;h."&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$storedIncrease&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$gridEnergyIn&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;multiply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chargeEfficiency&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withSoc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;soc&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;soc&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;level&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$storedIncrease&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth calling out. First, &lt;code&gt;chargeEfficiency()&lt;/code&gt; is &lt;code&gt;sqrt($this-&amp;gt;roundTripEfficiency)&lt;/code&gt;, applied on both the charge and discharge side, so a 90% round-trip efficiency becomes roughly 94.9% on each leg rather than 90% twice, which is the standard way to model it without separate manufacturer figures for each direction. Second, exceeding a physical limit throws, it does not silently clamp. A caller that asks a battery to accept more energy than it has headroom for has a bug, and clamping the value would just hide it a few requests later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two engines, one lesson in greed
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;V1 is greedy, on purpose.&lt;/strong&gt; For every hour: let solar cover consumption first, then decide what to do with whatever is left over.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$horizon&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;iterateHours&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$hourIndex&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$hourStart&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$priceNow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$pricesByHour&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$hourIndex&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nv"&gt;$pvProduction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$pv&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;productionAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$hourStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$hourIndex&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$demand&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$consumption&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;consumptionAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$hourStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$hourIndex&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$consumptionFromPv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Energy&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$pvProduction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$demand&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$surplus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$pvProduction&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;subtract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$consumptionFromPv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$deficit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$demand&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;subtract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$consumptionFromPv&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="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$surplus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isZero&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// charge the battery if the price now beats the average of the&lt;/span&gt;
        &lt;span class="c1"&gt;// next few hours, otherwise export the surplus&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$battery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$batteryCharge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$exportToGrid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handleSurplus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&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;elseif&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$deficit&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isZero&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// discharge if the price now beats the average of the past few&lt;/span&gt;
        &lt;span class="c1"&gt;// hours, otherwise pull from the grid&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$battery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$batteryDischarge&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handleDeficit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// ... build the HourlyDecision for this hour and move on&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is simple, it is correct, and it has one specific weakness: a battery can get spent on an hour that only &lt;em&gt;looks&lt;/em&gt; expensive relative to its immediate neighbors, and sit empty when the real daily peak shows up later, outside that local comparison window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;V2 fixes exactly that.&lt;/strong&gt; Instead of comparing each hour to its local neighborhood, it ranks &lt;em&gt;every&lt;/em&gt; hour in the whole horizon by price in a first pass, cheapest surplus hours and priciest deficit hours first, and accumulates until the battery's available headroom would be filled. That produces two global price thresholds. A second pass then replays the horizon hour by hour, gating charge and discharge decisions on those global thresholds instead of a rolling average.&lt;/p&gt;

&lt;p&gt;I did not want to just assert V2 is smarter, so I wrote a test that proves it: a price series with a moderate bump early in the day, enough to trip V1's local rule, and the real peak much later. V1 spends its battery early and has nothing left for the real peak. V2 recognizes the later peak is the better opportunity and holds capacity for it. Total cost on that scenario: &lt;strong&gt;12.5% lower with V2&lt;/strong&gt;. Same battery, same prices, just a different notion of "when."&lt;/p&gt;

&lt;h3&gt;
  
  
  The bug that lied to me for a day
&lt;/h3&gt;

&lt;p&gt;The most interesting bug in this project was not in the arbitrage logic. It was in a class that looked almost too simple to break: &lt;code&gt;PriceSeries&lt;/code&gt;, which indexes hourly prices so the engine can look one up by timestamp.&lt;/p&gt;

&lt;p&gt;The original version indexed points by an ISO-8601 string that included the UTC offset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;DateTimeImmutable&lt;/span&gt; &lt;span class="nv"&gt;$hour&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$hour&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;DATE_ATOM&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// e.g. "2026-07-18T14:00:00+02:00"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two &lt;code&gt;DateTimeImmutable&lt;/code&gt; instances representing the exact same instant, but constructed in different timezones, produce two different strings for that instant, and therefore two different keys. The first time I round-tripped a plan through PostgreSQL, this bit me for real: the contract was built in &lt;code&gt;Europe/Berlin&lt;/code&gt;, the database returned everything in UTC, and a price that was unambiguously there suddenly came back as "not found."&lt;/p&gt;

&lt;p&gt;The cause was subtle because nothing about it looked wrong locally. The contract logic was right. The database mapping was right. It was the equality check itself, hiding one layer below both of them, comparing string representations of time instead of the instant they represented. The fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;DateTimeImmutable&lt;/span&gt; &lt;span class="nv"&gt;$hour&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$hour&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTimestamp&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Unix timestamp does not care what timezone produced it. I paired the fix with normalizing every timestamp to UTC at the Eloquent persistence boundary, and with a permanent regression test that constructs the same instant in two different timezones and asserts the lookup still succeeds, because this is exactly the kind of bug that comes back quietly if nothing is watching for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/adeutou/electricity-planning-engine.git
&lt;span class="nb"&gt;cd &lt;/span&gt;electricity-planning-engine
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That starts the API, PostgreSQL, and Redis. Then ask it to plan a day for a flat-rate contract with a 6 kWc solar system and a 10 kWh battery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8000/api/simulate &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "contract": {"country_code":"DE","zone":"DE","contract_type":"fixed","pricing_config":{"price_per_kwh":0.30}},
    "horizon": {"start":"2026-07-20T00:00:00+02:00","end":"2026-07-21T00:00:00+02:00"},
    "mode": "advanced",
    "pv": {"peak_power_kwc": 6},
    "battery": {"capacity_kwh": 10, "initial_soc_percent": 50}
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response includes an &lt;code&gt;id&lt;/code&gt;. &lt;code&gt;GET /api/plans/{id}/chart.svg&lt;/code&gt; renders the resulting plan as a chart, server-side, no JavaScript involved, straight in a browser tab.&lt;/p&gt;

&lt;h3&gt;
  
  
  The pipeline behind it
&lt;/h3&gt;

&lt;p&gt;Every push runs the full test suite on PHP 8.2 and 8.3 (the range declared in &lt;code&gt;composer.json&lt;/code&gt;) and validates the Docker build. Nothing gets published on an ordinary push, a release is a deliberate action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v*.*.*'&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;   &lt;span class="c1"&gt;# create the GitHub Release&lt;/span&gt;
  &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;   &lt;span class="c1"&gt;# publish to ghcr.io&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;tests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/workflows/tests.yml&lt;/span&gt;

  &lt;span class="na"&gt;publish-image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tests&lt;/span&gt;
    &lt;span class="c1"&gt;# ... build and push ghcr.io/adeutou/electricity-planning-engine:&amp;lt;version&amp;gt;&lt;/span&gt;

  &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;publish-image&lt;/span&gt;
    &lt;span class="c1"&gt;# ... create the GitHub Release with generated notes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pushing &lt;code&gt;git tag v1.0.0 &amp;amp;&amp;amp; git push origin v1.0.0&lt;/code&gt; is the only thing that triggers a publish. Everything else, every ordinary commit, only has to prove it still builds and passes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters beyond the demo
&lt;/h2&gt;

&lt;p&gt;This project is a technical demonstration, but the problem underneath it is not a toy one. As more homes add solar panels, batteries, and EVs, and as the grid leans harder into intermittent generation, "when should this device consume, store, or sell" stops being a nice-to-have and becomes both a household economics question and a grid stability one. Germany is not a special case here, it is just further along the curve, which makes it the clearest place to see what everyone else is heading toward.&lt;/p&gt;

&lt;p&gt;Writing this kind of decision logic properly, with a domain model that actually holds up and algorithms whose behavior you can prove with a test instead of just hoping for, is exactly the kind of backend engineering work I want to keep doing.&lt;/p&gt;

&lt;p&gt;Source, tests, and the full architecture write-up: &lt;strong&gt;&lt;a href="https://github.com/adeutou/electricity-planning-engine" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/strong&gt;. MIT licensed, issues and forks welcome.&lt;/p&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>laravel</category>
      <category>docker</category>
    </item>
    <item>
      <title>Self-Hosting Like a Pro, Part 4: Monitoring with Prometheus, Grafana and Loki, plus a CI/CD Pipeline That Actually Works</title>
      <dc:creator>adeutou</dc:creator>
      <pubDate>Wed, 22 Jul 2026 03:55:20 +0000</pubDate>
      <link>https://dev.to/adeutou/self-hosting-like-a-pro-part-4-monitoring-with-prometheus-grafana-and-loki-plus-a-cicd-4p2e</link>
      <guid>https://dev.to/adeutou/self-hosting-like-a-pro-part-4-monitoring-with-prometheus-grafana-and-loki-plus-a-cicd-4p2e</guid>
      <description>&lt;p&gt;The stack from &lt;a href="https://blog.adeutou.com/self-hosting-like-a-pro-part-3-namespace-isolation-databases-and-shipping-a-real-app-on-k3s/" rel="noopener noreferrer"&gt;Part 3&lt;/a&gt; is live: multiple domains, isolated namespaces, TLS everywhere. But a production system you cannot observe is a production system you do not control. In this part we install a full observability stack on a modest 2 vCPU, 8 GB VPS, then wire up a GitHub Actions pipeline so that a git push becomes a deployment. Both came with surprises. I will share all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The monitoring stack and its budget
&lt;/h2&gt;

&lt;p&gt;The classic trio:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prometheus&lt;/strong&gt; scrapes and stores metrics from every pod and from the node itself&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grafana&lt;/strong&gt; turns those metrics into dashboards&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loki with Promtail&lt;/strong&gt; aggregates logs from every container in the cluster&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On a small VPS the default Helm values of these charts will eat your memory alive. Here is the budget that works for me, about 1.2 GB total for the whole namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheusSpec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;retention&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15d&lt;/span&gt;
    &lt;span class="na"&gt;retentionSize&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8GB"&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;100m&lt;/span&gt;
        &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;256Mi&lt;/span&gt;
      &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;500m&lt;/span&gt;
        &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512Mi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Retention matters more than people think. Fifteen days of metrics on an 8 GB size cap keeps the disk predictable on a 100 GB volume. For logs, seven days is plenty for a personal infrastructure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;loki&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;limits_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;retention_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;168h&lt;/span&gt;
    &lt;span class="na"&gt;compactor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;retention_enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One K3s specific detail: disable the components that do not exist on a lightweight distribution, otherwise Prometheus fills up with permanently failing targets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;kubeEtcd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;kubeControllerManager&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;kubeScheduler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;kubeProxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Three installation traps on K3s
&lt;/h2&gt;

&lt;p&gt;The Helm install of kube-prometheus-stack failed for me three times, each for a different reason. Documenting them here so you skip the debugging session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap one: the admission webhook job hangs.&lt;/strong&gt; The chart runs a pre install job that generates TLS certificates for its admission webhooks. On my cluster that job ran for nineteen minutes without finishing and blocked everything behind a "timed out waiting for the condition" error. On a single node personal cluster the admission webhooks are dispensable. Disable them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm &lt;span class="nb"&gt;install &lt;/span&gt;monitoring prometheus-community/kube-prometheus-stack &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; ns-monitoring &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--values&lt;/span&gt; monitoring-values.yaml &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; prometheusOperator.admissionWebhooks.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; prometheusOperator.admissionWebhooks.patch.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; prometheusOperator.tls.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Trap two: half measures crash loop.&lt;/strong&gt; My first attempt disabled the webhook job but not the TLS mount that depended on it. The operator pod then crash looped on a missing secret called monitoring-kube-prometheus-admission. If you disable the webhooks, disable the TLS block too. And if the release ends up in a broken state, uninstall it cleanly and reinstall rather than patching a corpse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap three: quotas versus third party charts.&lt;/strong&gt; This is the lesson from Part 3 striking again. My ResourceQuota on the monitoring namespace required every container to declare resources, but the Grafana chart ships init containers without any. Result: zero pods, zero error messages at the Deployment level, and the real reason buried in the ReplicaSet events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error creating: pods "monitoring-grafana-..." is forbidden:
failed quota: must specify limits.cpu for: init-chown-data, ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pragmatic fix on a personal cluster is to drop the quota on the monitoring namespace. The purist fix is to override the resources of every sub chart container. I chose pragmatism.&lt;/p&gt;

&lt;p&gt;The general debugging rule that emerged: when a Deployment shows desired pods but zero current pods, run kubectl describe on the ReplicaSet. Quota rejections live there and nowhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exposing Grafana safely
&lt;/h2&gt;

&lt;p&gt;Grafana lives on its own subdomain behind Traefik, with two layers of authentication: a basic auth middleware at the proxy level, then the Grafana login itself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Middleware&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana-auth&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;ns-monitoring&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;basicAuth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana-basic-auth&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generate the htpasswd value, base64 encode it, store it in a Secret, reference it from the middleware. Two minutes of work for a serious reduction of exposed attack surface on a service that holds every metric of your infrastructure.&lt;/p&gt;

&lt;p&gt;With Loki registered as a Grafana datasource, the payoff is immediate: one interface where you can jump from a CPU spike on a dashboard straight to the logs of the pod that caused it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CI/CD pipeline
&lt;/h2&gt;

&lt;p&gt;Manual deployment was fine while building. It stops being fine the third time you type the same six commands. The target workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;git push main
  detect which apps changed
  lint and test only those apps
  build Docker images, push to GitHub Container Registry
  SSH into the VPS, update the image, wait for the rollout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Path filtering keeps the pipeline fast in a monorepo. Touching only the frontend must not rebuild the blog image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dorny/paths-filter@v3&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;web:&lt;/span&gt;
        &lt;span class="s"&gt;- 'apps/web/**'&lt;/span&gt;
      &lt;span class="s"&gt;worker:&lt;/span&gt;
        &lt;span class="s"&gt;- 'apps/crosspost-worker/**'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Images are tagged twice, latest and the short commit SHA. The SHA tag is your rollback button: redeploying any previous version is one kubectl set image away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five pipeline failures and their fixes
&lt;/h2&gt;

&lt;p&gt;My pipeline went red five times before going green. Every failure was a lesson.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure one: pnpm version conflict.&lt;/strong&gt; The package.json declared a packageManager field while the workflow pinned a version on the pnpm setup action. The action refuses ambiguity and aborts. Fix: remove the version from the workflow and let packageManager be the single source of truth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure two: module level SDK instantiation.&lt;/strong&gt; The contact route created its Resend client at import time. Next.js executes route modules while collecting page data during the build, where no API key exists, so the build crashed. Fix: lazy initialization.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Resend&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getClient&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;Resend&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Resend&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;RESEND_API_KEY&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;client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wider rule: never instantiate SDK clients at module scope in a Next.js app. Always create them inside the request handler path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure three: wrong Docker build context.&lt;/strong&gt; In a monorepo the lockfile lives at the root. Building with the app folder as context means the Dockerfile cannot copy it. The build must run from the repository root with the Dockerfile referenced by path, and the standalone output of Next.js lands in a nested folder, which changes the runtime paths too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app/apps/web/.next/standalone ./&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app/apps/web/.next/static ./apps/web/.next/static&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "apps/web/server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Failure four: registry permissions.&lt;/strong&gt; The first image push was done manually with a personal access token, which made me the package owner. The automatic GITHUB_TOKEN of the workflow then received a 403 on push. Fix: in the package settings on GitHub, grant the repository write access under Manage Actions access. One click per package, easy to forget, guaranteed 403 if you do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure five: a corrupted SSH secret.&lt;/strong&gt; The deploy step failed with "error in libcrypto", which is OpenSSH speak for "this private key file is malformed". The key had been copied by hand into the GitHub secret and lost its line breaks along the way. Fix: pipe the file straight into the clipboard and paste it without touching it. On Windows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-Content&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;USERPROFILE&lt;/span&gt;&lt;span class="s2"&gt;\.ssh\deploy_key"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Raw&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Set-Clipboard&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The bonus bug: a page frozen in time
&lt;/h2&gt;

&lt;p&gt;With the pipeline green, one ghost remained. The portfolio kept showing placeholder articles even though the blog API returned the real ones. Environment variables were correct inside the pod. Fetching from inside the pod worked. No errors anywhere.&lt;/p&gt;

&lt;p&gt;The cause was subtle. The articles component guards its fetch: if the Ghost credentials are missing, it returns demo content without fetching. During the Docker build no credentials exist, so no fetch ran during prerendering. And Next.js only marks a page as revalidating if it actually observes a fetch with a revalidate option during the build. No fetch observed means fully static page, cached forever, never regenerated. My revalidate option inside the fetch call was correct and completely unused.&lt;/p&gt;

&lt;p&gt;The fix is one line at the route level, which forces incremental static regeneration regardless of what happens during the build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;revalidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the next deployment, the first visit still serves the stale page but triggers regeneration in the background, and the second visit shows real data. Every future article now appears on the portfolio within five minutes, no redeploy needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we stand
&lt;/h2&gt;

&lt;p&gt;The infrastructure is now complete:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Full metrics and log visibility through Grafana on a protected subdomain&lt;/li&gt;
&lt;li&gt;Alerting foundation in place with Alertmanager&lt;/li&gt;
&lt;li&gt;A pipeline where git push main lints, tests, builds, publishes and deploys only what changed&lt;/li&gt;
&lt;li&gt;Rollback to any previous version in one command thanks to SHA tagged images&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole system, applications and observability included, runs within 8 GB of RAM on a single VPS. Self hosting like a pro is not about big hardware. It is about isolation, budgets, and automating yourself out of the deployment loop.&lt;/p&gt;

&lt;p&gt;Next up: backups and disaster recovery, because an infrastructure you cannot restore is an infrastructure you do not own.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>cicd</category>
      <category>monitoring</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>Self-Hosting Like a Pro, Part 2: Docker, K3s and Traefik v3 with Automatic HTTPS</title>
      <dc:creator>adeutou</dc:creator>
      <pubDate>Wed, 22 Jul 2026 02:47:16 +0000</pubDate>
      <link>https://dev.to/adeutou/self-hosting-like-a-pro-part-2-docker-k3s-and-traefik-v3-with-automatic-https-1kpn</link>
      <guid>https://dev.to/adeutou/self-hosting-like-a-pro-part-2-docker-k3s-and-traefik-v3-with-automatic-https-1kpn</guid>
      <description>&lt;p&gt;This is Part 2 of a four-part series where I document how I turned a 10€/month VPS into a production-grade platform hosting multiple projects. In &lt;a href="https://blog.adeutou.com/self-hosting-like-a-pro-part-1-hardening-a-fresh-ubuntu-vps/" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; we hardened a fresh Ubuntu server. Today we install the actual platform: a container runtime, a lightweight Kubernetes distribution, a modern reverse proxy, and automatic TLS certificates. By the end of this article, deploying an app with HTTPS will take one YAML file and zero manual certificate handling.*&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Kubernetes on a single VPS?
&lt;/h2&gt;

&lt;p&gt;Fair question. Docker Compose would work, and for a single project I would recommend it without hesitation. But my server hosts several unrelated projects: a portfolio blog, a university group website, and a SaaS platform. I want:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Isolation&lt;/strong&gt;: a memory leak in one project must not take down the others.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource quotas&lt;/strong&gt;: each project gets a defined slice of the 8 GB RAM, enforced by the platform, not by hope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Declarative deployments&lt;/strong&gt;: the entire infrastructure lives in Git as YAML. If the server burns down, I can rebuild it in an hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One ingress to rule them all&lt;/strong&gt;: a single reverse proxy routes all domains and handles all certificates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full Kubernetes (kubeadm) would eat half the server's resources just to run itself. &lt;strong&gt;K3s&lt;/strong&gt; is a certified Kubernetes distribution by SUSE that fits in a ~512 MB memory footprint. Same API, same YAML, a fraction of the overhead. It is the sweet spot for a single-node setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The hardened Ubuntu VPS from Part 1 (2 vCPU / 8 GB RAM or more recommended)&lt;/li&gt;
&lt;li&gt;A domain name with DNS you control&lt;/li&gt;
&lt;li&gt;45 minutes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before starting, create a DNS &lt;strong&gt;A record&lt;/strong&gt; pointing a test subdomain to your server, for example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;whoami.yourdomain.com  →  YOUR_SERVER_IP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DNS propagation can take a few minutes, so doing it now means it will be ready when we need it in Step 6.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Install Docker
&lt;/h2&gt;

&lt;p&gt;K3s uses its own container runtime (containerd), so strictly speaking Docker is not required to &lt;em&gt;run&lt;/em&gt; the cluster. We install it anyway, because it is invaluable for building images locally on the server and for quick debugging.&lt;/p&gt;

&lt;p&gt;Use the official convenience script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://get.docker.com | &lt;span class="nb"&gt;sudo &lt;/span&gt;sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add your user to the docker group so you can run it without sudo:&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;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;
newgrp docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; hello-world
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One important detail on a hardened server: Docker manipulates iptables directly and can &lt;strong&gt;bypass UFW rules&lt;/strong&gt; when you publish ports with &lt;code&gt;-p&lt;/code&gt;. Since all our public traffic will go through K3s and Traefik, avoid publishing Docker ports on this machine. If you ever need a quick test container, bind it to localhost only: &lt;code&gt;-p 127.0.0.1:8080:80&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Install K3s
&lt;/h2&gt;

&lt;p&gt;One command, about thirty seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sfL&lt;/span&gt; https://get.k3s.io | sh &lt;span class="nt"&gt;-s&lt;/span&gt; - &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disable&lt;/span&gt; traefik &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--write-kubeconfig-mode&lt;/span&gt; 644
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two flags deserve an explanation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--disable traefik&lt;/code&gt;: K3s bundles Traefik, but we want to install and control our own Traefik v3 via Helm, with our own configuration. Letting K3s manage it means fighting its defaults later.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--write-kubeconfig-mode 644&lt;/code&gt;: makes the kubeconfig readable by non-root users, convenient on a single-user server. On a shared machine, skip this flag and copy the kubeconfig to your user manually.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check that the node is ready:&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 nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;NAME        STATUS   ROLES                  AGE   VERSION
srv-xxxxx   Ready    control-plane,master   30s   v1.33.x+k3s1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make kubectl work in every session by pointing it at the K3s kubeconfig:&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="s1"&gt;'export KUBECONFIG=/etc/rancher/k3s/k3s.yaml'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc
&lt;span class="nb"&gt;source&lt;/span&gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Install Helm
&lt;/h2&gt;

&lt;p&gt;Helm is the package manager for Kubernetes. We will use it to install Traefik and cert-manager with pinned, reproducible configurations.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
helm version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Install Traefik v3
&lt;/h2&gt;

&lt;p&gt;Traefik is our front door: it receives all HTTP/HTTPS traffic on ports 80 and 443, routes requests to the right application based on the domain name, and terminates TLS.&lt;/p&gt;

&lt;p&gt;Add the official Helm repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add traefik https://traefik.github.io/charts
helm repo update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a values file so the configuration lives in Git, not in your shell history:&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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/infra/traefik &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; nano ~/infra/traefik/values.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ~/infra/traefik/values.yaml&lt;/span&gt;
&lt;span class="na"&gt;deployment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;

&lt;span class="na"&gt;service&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;LoadBalancer&lt;/span&gt;   &lt;span class="c1"&gt;# K3s ships with ServiceLB (Klipper), this binds ports 80/443 on the node&lt;/span&gt;

&lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8000&lt;/span&gt;
    &lt;span class="na"&gt;exposedPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
    &lt;span class="na"&gt;redirections&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;entryPoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;websecure&lt;/span&gt;
        &lt;span class="na"&gt;scheme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https&lt;/span&gt;
        &lt;span class="na"&gt;permanent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;websecure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8443&lt;/span&gt;
    &lt;span class="na"&gt;exposedPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;443&lt;/span&gt;
    &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kubernetesCRD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;kubernetesIngress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;logs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;general&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;INFO&lt;/span&gt;
  &lt;span class="na"&gt;access&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;100m&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;128Mi&lt;/span&gt;
  &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;256Mi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;redirections&lt;/code&gt; block is doing quiet but important work: every HTTP request is permanently redirected to HTTPS at the entrypoint level, so no application ever needs to think about it.&lt;/p&gt;

&lt;p&gt;Install into its own namespace:&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 namespace traefik
helm &lt;span class="nb"&gt;install &lt;/span&gt;traefik traefik/traefik &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; traefik &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--values&lt;/span&gt; ~/infra/traefik/values.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify the pod is running and the ports are bound:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; traefik
kubectl get svc &lt;span class="nt"&gt;-n&lt;/span&gt; traefik
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service should show &lt;code&gt;EXTERNAL-IP&lt;/code&gt; equal to your server's IP, with ports &lt;code&gt;80&lt;/code&gt; and &lt;code&gt;443&lt;/code&gt; mapped. At this point, visiting &lt;code&gt;http://YOUR_SERVER_IP&lt;/code&gt; returns a 404 from Traefik — which is perfect: the front door is open, there is just nothing behind it yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Install cert-manager
&lt;/h2&gt;

&lt;p&gt;cert-manager automates the entire Let's Encrypt lifecycle: it requests certificates, answers the ACME challenges, stores the certs as Kubernetes secrets and renews them before expiry. Set it up once, never think about TLS again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add jetstack https://charts.jetstack.io
helm repo update

helm &lt;span class="nb"&gt;install &lt;/span&gt;cert-manager jetstack/cert-manager &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; cert-manager &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; crds.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait for the three pods to be ready:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; cert-manager
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now define &lt;strong&gt;two ClusterIssuers&lt;/strong&gt;: one for Let's Encrypt staging, one for production. Always test with staging first, because the production endpoint rate-limits you to 5 failed attempts per hour, and debugging DNS issues while rate-limited is a special kind of misery.&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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/infra/cert-manager &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; nano ~/infra/cert-manager/cluster-issuers.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cert-manager.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterIssuer&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;letsencrypt-staging&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;acme&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;https://acme-staging-v02.api.letsencrypt.org/directory&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;you@yourdomain.com&lt;/span&gt;
    &lt;span class="na"&gt;privateKeySecretRef&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;letsencrypt-staging-key&lt;/span&gt;
    &lt;span class="na"&gt;solvers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;http01&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik&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;cert-manager.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterIssuer&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;letsencrypt-prod&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;acme&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;https://acme-v02.api.letsencrypt.org/directory&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;you@yourdomain.com&lt;/span&gt;
    &lt;span class="na"&gt;privateKeySecretRef&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;letsencrypt-prod-key&lt;/span&gt;
    &lt;span class="na"&gt;solvers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;http01&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace the email (Let's Encrypt uses it for expiry warnings) and apply:&lt;br&gt;
&lt;/p&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;-f&lt;/span&gt; ~/infra/cert-manager/cluster-issuers.yaml
kubectl get clusterissuers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both issuers should show &lt;code&gt;READY: True&lt;/code&gt; after a few seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Deploy a test app with automatic HTTPS
&lt;/h2&gt;

&lt;p&gt;Time for the payoff. We deploy &lt;code&gt;whoami&lt;/code&gt;, a tiny web server that echoes request information, and expose it over HTTPS on the subdomain you configured earlier.&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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/infra/apps &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; nano ~/infra/apps/whoami.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="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;ns-test&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;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;whoami&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;ns-test&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;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;whoami&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;whoami&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;whoami&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik/whoami:latest&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;16Mi&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;32Mi&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;Service&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;whoami-svc&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;ns-test&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;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;whoami&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&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;cert-manager.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Certificate&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;whoami-cert&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;ns-test&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;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;whoami-tls&lt;/span&gt;
  &lt;span class="na"&gt;issuerRef&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;letsencrypt-prod&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;ClusterIssuer&lt;/span&gt;
  &lt;span class="na"&gt;dnsNames&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;whoami.yourdomain.com&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IngressRoute&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;whoami-route&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;ns-test&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;entryPoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;websecure&lt;/span&gt;
  &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Host(`whoami.yourdomain.com`)&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Rule&lt;/span&gt;
      &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;whoami-svc&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
  &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;whoami-tls&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;whoami.yourdomain.com&lt;/code&gt; with your actual subdomain (twice), then:&lt;br&gt;
&lt;/p&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;-f&lt;/span&gt; ~/infra/apps/whoami.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch the certificate being issued:&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 certificate &lt;span class="nt"&gt;-n&lt;/span&gt; ns-test &lt;span class="nt"&gt;-w&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Within a minute, &lt;code&gt;READY&lt;/code&gt; flips to &lt;code&gt;True&lt;/code&gt;. Now open &lt;code&gt;https://whoami.yourdomain.com&lt;/code&gt; in your browser: a valid Let's Encrypt certificate, automatic HTTP→HTTPS redirect, and the whoami output showing your request went through Traefik.&lt;/p&gt;

&lt;p&gt;Take a second to appreciate what just happened: you wrote one YAML file, and the platform handled deployment, routing, certificate issuance and TLS termination. Every future application follows the exact same pattern.&lt;/p&gt;

&lt;p&gt;If the certificate stays stuck in &lt;code&gt;READY: False&lt;/code&gt;, the usual suspects are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Check the ACME challenge status&lt;/span&gt;
kubectl describe certificaterequest &lt;span class="nt"&gt;-n&lt;/span&gt; ns-test
kubectl get challenges &lt;span class="nt"&gt;-n&lt;/span&gt; ns-test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine times out of ten it is DNS: the A record has not propagated yet, or points to the wrong IP. Verify with &lt;code&gt;dig whoami.yourdomain.com +short&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final checklist
&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;# 1. K3s node ready&lt;/span&gt;
kubectl get nodes
&lt;span class="c"&gt;# Expected: STATUS Ready&lt;/span&gt;

&lt;span class="c"&gt;# 2. Traefik running and bound to 80/443&lt;/span&gt;
kubectl get svc &lt;span class="nt"&gt;-n&lt;/span&gt; traefik
&lt;span class="c"&gt;# Expected: EXTERNAL-IP = your server IP, ports 80/443&lt;/span&gt;

&lt;span class="c"&gt;# 3. ClusterIssuers ready&lt;/span&gt;
kubectl get clusterissuers
&lt;span class="c"&gt;# Expected: both READY True&lt;/span&gt;

&lt;span class="c"&gt;# 4. Test app serving valid HTTPS&lt;/span&gt;
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://whoami.yourdomain.com | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;span class="c"&gt;# Expected: HTTP/2 200&lt;/span&gt;

&lt;span class="c"&gt;# 5. HTTP redirects to HTTPS&lt;/span&gt;
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; http://whoami.yourdomain.com | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; location
&lt;span class="c"&gt;# Expected: location: https://whoami.yourdomain.com/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once everything passes, clean up the test app if you like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl delete &lt;span class="nt"&gt;-f&lt;/span&gt; ~/infra/apps/whoami.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;We now have a hardened server running a Kubernetes platform with automatic HTTPS. In &lt;a href="https://blog.adeutou.com/self-hosting-like-a-pro-part-3-namespace-isolation-databases-and-shipping-a-real-app-on-k3s/" rel="noopener noreferrer"&gt;&lt;strong&gt;Part 3&lt;/strong&gt;&lt;/a&gt;, we make it truly multi-tenant: one namespace per project with enforced resource quotas, a shared PostgreSQL and Redis for the small projects, and Traefik middlewares for security headers, rate limiting and CORS. That is where the "several unrelated projects on one 8 GB server without them stepping on each other" promise gets fulfilled.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Questions or suggestions? Reach out, I answer everything. If your certificate refuses to issue and you have already sacrificed a rubber duck to the DNS gods, send me the output of &lt;code&gt;kubectl get challenges&lt;/code&gt; and we will figure it out.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>k3s</category>
      <category>traefik</category>
    </item>
    <item>
      <title>Self-Hosting Like a Pro, Part 1: Hardening a Fresh Ubuntu VPS</title>
      <dc:creator>adeutou</dc:creator>
      <pubDate>Mon, 06 Jul 2026 09:26:36 +0000</pubDate>
      <link>https://dev.to/adeutou/self-hosting-like-a-pro-part-1-hardening-a-fresh-ubuntu-vps-30gb</link>
      <guid>https://dev.to/adeutou/self-hosting-like-a-pro-part-1-hardening-a-fresh-ubuntu-vps-30gb</guid>
      <description>&lt;p&gt;&lt;em&gt;This is the first article in a four-part series where I document how I turned a 10€/month VPS into a production-grade platform hosting my portfolio, a university group webapplication and a SaaS product, all isolated from each other with Kubernetes. In this part, we take a fresh Ubuntu server and lock it down properly before installing anything else.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother with hardening?
&lt;/h2&gt;

&lt;p&gt;The moment your VPS gets a public IP address, it starts receiving attacks. Not "might receive", it &lt;em&gt;starts&lt;/em&gt;. Within minutes, automated bots will probe port 22, try &lt;code&gt;root:root&lt;/code&gt;, &lt;code&gt;admin:admin123&lt;/code&gt; and thousands of other credential combinations. If you skip this step and jump straight to deploying your apps, you are building on sand.&lt;/p&gt;

&lt;p&gt;The good news: an hour of work is enough to eliminate the vast majority of these threats. Here is what we will set up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A non-root user with sudo privileges&lt;/li&gt;
&lt;li&gt;SSH key authentication, with passwords and root login disabled&lt;/li&gt;
&lt;li&gt;UFW as a simple, effective firewall&lt;/li&gt;
&lt;li&gt;Fail2ban to ban brute-force attackers automatically&lt;/li&gt;
&lt;li&gt;Automatic security updates&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What you need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A fresh VPS running Ubuntu 24.04 LTS or newer. I use a Hostinger KVM 2 (2 vCPU, 8 GB RAM, 100 GB NVMe), but any provider works: Hetzner, DigitalOcean, OVH, Contabo.&lt;/li&gt;
&lt;li&gt;The root password or SSH key your provider gave you.&lt;/li&gt;
&lt;li&gt;A terminal on your local machine (macOS, Linux, or WSL on Windows).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Throughout this tutorial, replace &lt;code&gt;YOUR_SERVER_IP&lt;/code&gt; with your server's IP address and &lt;code&gt;deploy&lt;/code&gt; with the username you want to use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: First login and system update
&lt;/h2&gt;

&lt;p&gt;Connect as root for the first and last time:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Update everything before touching anything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
apt autoremove &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a kernel update was installed, reboot now:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Wait a minute, then reconnect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Create a non-root user
&lt;/h2&gt;

&lt;p&gt;Working as root is like driving without a seatbelt: fine until it isn't. One mistyped &lt;code&gt;rm -rf&lt;/code&gt; and the party is over. Create a dedicated user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adduser deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Choose a strong password (you will still need it for &lt;code&gt;sudo&lt;/code&gt;, even after we disable password SSH logins). Then grant sudo privileges:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify it works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;su - deploy
&lt;span class="nb"&gt;sudo whoami&lt;/span&gt;
&lt;span class="c"&gt;# Expected output: root&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: SSH key authentication
&lt;/h2&gt;

&lt;p&gt;Passwords can be brute-forced. SSH keys, in practice, cannot. If you don't have a key pair yet, generate one &lt;strong&gt;on your local machine&lt;/strong&gt; (not on the server):&lt;br&gt;
&lt;/p&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; ed25519 &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"deploy@YOUR_SERVER_IP"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ed25519 is the modern default: shorter keys, faster, and at least as secure as 4096-bit RSA.&lt;/p&gt;

&lt;p&gt;Now copy the public key to the server:&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 deploy@YOUR_SERVER_IP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test it from your local machine:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;If you log in without being asked for a password, the key works. &lt;strong&gt;Do not proceed to the next step until this works&lt;/strong&gt;, otherwise you will lock yourself out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Harden the SSH daemon
&lt;/h2&gt;

&lt;p&gt;Now we disable everything an attacker could exploit. Open the SSH configuration:&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/ssh/sshd_config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Find and change (or add) the following lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;PermitRootLogin&lt;/span&gt; &lt;span class="no"&gt;no&lt;/span&gt;
&lt;span class="k"&gt;PasswordAuthentication&lt;/span&gt; &lt;span class="no"&gt;no&lt;/span&gt;
&lt;span class="k"&gt;PubkeyAuthentication&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;
&lt;span class="k"&gt;MaxAuthTries&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
&lt;span class="k"&gt;ClientAliveInterval&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt;
&lt;span class="k"&gt;ClientAliveCountMax&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;span class="k"&gt;X11Forwarding&lt;/span&gt; &lt;span class="no"&gt;no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A quick explanation of each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PermitRootLogin no&lt;/code&gt;: root can no longer log in over SSH at all. Attackers love the root account because they don't need to guess the username.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PasswordAuthentication no&lt;/code&gt;: keys only. Brute-force attacks become pointless.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MaxAuthTries 3&lt;/code&gt;: three failed attempts and the connection is dropped.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ClientAliveInterval&lt;/code&gt; / &lt;code&gt;ClientAliveCountMax&lt;/code&gt;: idle sessions are closed after 10 minutes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;X11Forwarding no&lt;/code&gt;: we are running a server, not a remote desktop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On Ubuntu 24.04+, also check the drop-in directory, because cloud providers often override settings there:&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;ls&lt;/span&gt; /etc/ssh/sshd_config.d/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see a file like &lt;code&gt;50-cloud-init.conf&lt;/code&gt; containing &lt;code&gt;PasswordAuthentication yes&lt;/code&gt;, edit or remove it, otherwise it silently wins over your main config.&lt;/p&gt;

&lt;p&gt;Validate the configuration and restart the daemon:&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;sshd &lt;span class="nt"&gt;-t&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart ssh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Keep your current session open&lt;/strong&gt; and test the new rules from a second terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh deploy@YOUR_SERVER_IP        &lt;span class="c"&gt;# should work (key)&lt;/span&gt;
ssh root@YOUR_SERVER_IP          &lt;span class="c"&gt;# should be refused&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 5: Firewall with UFW
&lt;/h2&gt;

&lt;p&gt;UFW (Uncomplicated Firewall) lives up to its name. The strategy is simple: deny everything inbound, then open only what we need.&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;ufw default deny incoming
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw default allow outgoing
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow OpenSSH
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 80/tcp    &lt;span class="c"&gt;# HTTP&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 443/tcp   &lt;span class="c"&gt;# HTTPS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order matters: &lt;strong&gt;allow OpenSSH before enabling the firewall&lt;/strong&gt;, or you will cut the branch you are sitting on. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw &lt;span class="nb"&gt;enable
sudo &lt;/span&gt;ufw status verbose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Status: active
Default: deny (incoming), allow (outgoing), disabled (routed)

To                         Action      From
--                         ------      ----
OpenSSH                    ALLOW IN    Anywhere
80/tcp                     ALLOW IN    Anywhere
443/tcp                    ALLOW IN    Anywhere
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Ports 80 and 443 will later be handled by our reverse proxy (Traefik, in Part 2). Everything else, including databases, monitoring dashboards and internal services, stays invisible from the internet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Fail2ban
&lt;/h2&gt;

&lt;p&gt;Even with keys-only authentication, bots will keep hammering your SSH port and polluting your logs. Fail2ban watches the logs and bans IPs that misbehave.&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 &lt;span class="nb"&gt;install &lt;/span&gt;fail2ban &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never edit &lt;code&gt;jail.conf&lt;/code&gt; directly (it gets overwritten on updates). Create a local override instead:&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/fail2ban/jail.local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[DEFAULT]&lt;/span&gt;
&lt;span class="py"&gt;bantime&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
&lt;span class="py"&gt;findtime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
&lt;span class="py"&gt;maxretry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;5&lt;/span&gt;

&lt;span class="nn"&gt;[sshd]&lt;/span&gt;
&lt;span class="py"&gt;enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;bantime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;24h&lt;/span&gt;
&lt;span class="py"&gt;maxretry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Translation: any IP that fails SSH authentication 3 times within 10 minutes is banned for 24 hours. Enable and start the service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;fail2ban
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart fail2ban
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check that the SSH jail is active:&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;fail2ban-client status sshd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Come back after a day and run that command again. Seeing the list of banned IPs grow is oddly satisfying, and a good reminder of why this whole article exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Automatic security updates
&lt;/h2&gt;

&lt;p&gt;A hardened server that runs unpatched software is only hardened on paper. Ubuntu's &lt;code&gt;unattended-upgrades&lt;/code&gt; installs security patches automatically:&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 &lt;span class="nb"&gt;install &lt;/span&gt;unattended-upgrades &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dpkg-reconfigure &lt;span class="nt"&gt;-plow&lt;/span&gt; unattended-upgrades
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Select "Yes" in the dialog. To verify the configuration:&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;cat&lt;/span&gt; /etc/apt/apt.conf.d/20auto-upgrades
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;APT&lt;/span&gt;::&lt;span class="n"&gt;Periodic&lt;/span&gt;::&lt;span class="n"&gt;Update&lt;/span&gt;-&lt;span class="n"&gt;Package&lt;/span&gt;-&lt;span class="n"&gt;Lists&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt;;
&lt;span class="n"&gt;APT&lt;/span&gt;::&lt;span class="n"&gt;Periodic&lt;/span&gt;::&lt;span class="n"&gt;Unattended&lt;/span&gt;-&lt;span class="n"&gt;Upgrade&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt;;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default, only the security repository is enabled for automatic upgrades, which is exactly what we want on a server: security patches yes, surprise major version bumps no.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final checklist
&lt;/h2&gt;

&lt;p&gt;Before moving on, verify each item:&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;# 1. Root login refused&lt;/span&gt;
ssh root@YOUR_SERVER_IP
&lt;span class="c"&gt;# Expected: Permission denied&lt;/span&gt;

&lt;span class="c"&gt;# 2. Password auth refused (from a machine without your key)&lt;/span&gt;
ssh &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;PubkeyAuthentication&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no deploy@YOUR_SERVER_IP
&lt;span class="c"&gt;# Expected: Permission denied (publickey)&lt;/span&gt;

&lt;span class="c"&gt;# 3. Firewall active&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw status | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;span class="c"&gt;# Expected: Status: active&lt;/span&gt;

&lt;span class="c"&gt;# 4. Fail2ban running&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl is-active fail2ban
&lt;span class="c"&gt;# Expected: active&lt;/span&gt;

&lt;span class="c"&gt;# 5. Auto-updates configured&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;unattended-upgrade &lt;span class="nt"&gt;--dry-run&lt;/span&gt; &lt;span class="nt"&gt;--debug&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If all five checks pass, congratulations: your VPS just went from "free real estate for botnets" to a solid foundation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;In &lt;strong&gt;Part 2&lt;/strong&gt;, we install the actual platform: Docker, K3s (a lightweight Kubernetes distribution that fits comfortably in 8 GB of RAM), Traefik v3 as reverse proxy, and cert-manager for automatic Let's Encrypt certificates. By the end of it, any app we deploy will get HTTPS with zero manual certificate handling.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>linux</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
