<?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>nx-safe-suite: A Deep Dive Into Five Production-Grade Next.js Packages</title>
      <dc:creator>adeutou</dc:creator>
      <pubDate>Wed, 12 Aug 2026 13:23:08 +0000</pubDate>
      <link>https://dev.to/adeutou/nx-safe-suite-a-deep-dive-into-five-production-grade-nextjs-packages-om2</link>
      <guid>https://dev.to/adeutou/nx-safe-suite-a-deep-dive-into-five-production-grade-nextjs-packages-om2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;How each package works, why it is designed the way it is, and what it replaces.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;This is the second article in a two-part series. &lt;a href="https://blog.adeutou.com/i-built-five-npm-packages-to-solve-the-five-problems-every-next-js-backend-ignores/" rel="noopener noreferrer"&gt;The first&lt;/a&gt; covers the architecture and the reasoning behind the project. This one goes into the implementation of each package.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  @nx-safe-suite/env: Validation That Stops the Process
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The problem
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;process.env&lt;/code&gt; is a &lt;code&gt;Record&amp;lt;string, string | undefined&amp;gt;&lt;/code&gt;. Every access is potentially undefined. The standard response to this is to sprinkle &lt;code&gt;??&lt;/code&gt; and &lt;code&gt;!&lt;/code&gt; operators throughout the codebase, which does not make the code safer. It makes it longer and harder to read while the underlying risk remains.&lt;/p&gt;

&lt;p&gt;The real problem is that environment validation happens too late. By the time a missing variable causes an error, the request is already in flight, the user is already waiting, and the stack trace points somewhere unrelated.&lt;/p&gt;

&lt;h3&gt;
  
  
  The solution
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;@nx-safe-suite/env&lt;/code&gt; validates the entire environment at startup, before the application does anything else. If validation fails, the process exits with a readable report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ Invalid environment variables:

  DATABASE_URL
    → Required
  API_SECRET
    → String must contain at least 10 character(s)

2 variables failed validation. Fix your .env file and restart.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No stack trace. No cryptic undefined error three function calls deep. Just the problem, described in terms of the configuration, not the code.&lt;/p&gt;

&lt;h3&gt;
  
  
  The design decision worth noting
&lt;/h3&gt;

&lt;p&gt;The package separates &lt;code&gt;server&lt;/code&gt; and &lt;code&gt;client&lt;/code&gt; schemas. This is not cosmetic. Next.js exposes &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; variables to the browser bundle. Any variable not prefixed with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; is stripped at build time. If you define a schema for a non-prefixed variable under &lt;code&gt;client&lt;/code&gt;, the package throws synchronously at startup, before the application runs, because you have described a variable as browser-accessible that Next.js will never expose to the browser.&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;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createEnv&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;API_SECRET&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;NEXT_PUBLIC_API_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;runtimeEnv&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The return value is a frozen, fully typed object. &lt;code&gt;env.DATABASE_URL&lt;/code&gt; is a &lt;code&gt;string&lt;/code&gt;, not a &lt;code&gt;string | undefined&lt;/code&gt;. TypeScript knows this. Your editor knows this. The runtime guarantees it.&lt;/p&gt;

&lt;p&gt;One additional detail: importing &lt;code&gt;env.ts&lt;/code&gt; from &lt;code&gt;next.config.js&lt;/code&gt; means a misconfigured environment fails the build, not just the boot. The error appears in CI before any code is deployed.&lt;/p&gt;




&lt;h2&gt;
  
  
  @nx-safe-suite/api-response: A Contract Your Frontend Can Rely On
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The problem
&lt;/h3&gt;

&lt;p&gt;In a codebase with multiple developers and multiple API routes, response shapes drift. One route returns &lt;code&gt;{ data: [...] }&lt;/code&gt;. Another returns &lt;code&gt;{ results: [...] }&lt;/code&gt;. A third returns the array directly. Error responses are strings, objects, or HTTP status codes alone, depending on who wrote the route and when.&lt;/p&gt;

&lt;p&gt;Frontend developers work around this with defensive parsing, optional chaining, and condition checks at every call site. The problem compounds over time.&lt;/p&gt;

&lt;h3&gt;
  
  
  The solution
&lt;/h3&gt;

&lt;p&gt;A small set of typed helpers that always produce the same shapes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Success responses&lt;/strong&gt; follow a consistent envelope:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Albert"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-07T12:00:00Z"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"links"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"self"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/users/123"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Error responses&lt;/strong&gt; follow RFC 9457, the IETF standard for HTTP problem details, extended with a &lt;code&gt;code&lt;/code&gt; field for machine-readable business errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"about:blank"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Not Found"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"User 123 was not found."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"instance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/users/123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USER_NOT_FOUND"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;code&lt;/code&gt; field is what makes error handling tractable on the frontend. Instead of parsing status codes or error message strings, client code can switch on a stable, documented identifier.&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&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;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;notFound&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;USER_NOT_FOUND&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`User &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; was not found.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/api/users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;links&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;self&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/api/users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pagination, both offset and cursor, is handled as a &lt;code&gt;pagination&lt;/code&gt; option on any success helper. The response &lt;code&gt;meta&lt;/code&gt; block is extended automatically with the computed values (&lt;code&gt;totalPages&lt;/code&gt;, &lt;code&gt;hasNextPage&lt;/code&gt;, &lt;code&gt;hasPrevPage&lt;/code&gt;). No manual calculation at the call site.&lt;/p&gt;




&lt;h2&gt;
  
  
  @nx-safe-suite/route-guard: Authentication Without the Boilerplate
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The problem
&lt;/h3&gt;

&lt;p&gt;A typical Next.js API route that requires authentication, role checking, and input validation looks like this before any business logic runs:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getServerSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authOptions&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;session&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unauthorized&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&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;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Forbidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&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;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid JSON&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;CreateProjectSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&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;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;422&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// business logic begins here&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is forty lines before any actual work is done. It is also inconsistent across routes, because every developer writes their version of this slightly differently.&lt;/p&gt;

&lt;h3&gt;
  
  
  The solution
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;createGuard&lt;/code&gt; produces a configured &lt;code&gt;withGuard&lt;/code&gt; wrapper. The cross-cutting concerns, auth, roles, rate limiting, and validation, are declared as configuration. The handler receives a typed context object containing only what it needs.&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;const&lt;/span&gt; &lt;span class="nx"&gt;guard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createGuard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;secret&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;JWT_SECRET&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;rateLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;window&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1m&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;POST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withGuard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// user is GuardUser: typed, verified&lt;/span&gt;
    &lt;span class="c1"&gt;// body is { name: string }: validated&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;project&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;created&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;links&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;self&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/api/projects/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="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 RBAC check accepts either a string array, where any match grants access, or an async function, which enables attribute-based access control without a separate library:&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="nx"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;membership&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;membership&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findFirst&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;membership&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;owner&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Server Actions&lt;/strong&gt; are supported via &lt;code&gt;withAction&lt;/code&gt;. The same auth and RBAC pipeline runs without a real &lt;code&gt;Request&lt;/code&gt; object, which Server Actions do not have, and the action receives a typed context containing the resolved user and the input.&lt;/p&gt;

&lt;p&gt;The rate limiter is an in-memory LRU store by default. The interface is pluggable: swap it for Upstash or ioredis in a distributed deployment by implementing four methods. The default is good enough for single-instance deployments and zero-dependency prototyping.&lt;/p&gt;




&lt;h2&gt;
  
  
  @nx-safe-suite/server-cache: Multi-Tier Caching That Composes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The problem
&lt;/h3&gt;

&lt;p&gt;Next.js has excellent built-in caching primitives. They are also tightly coupled to the deployment model. The &lt;code&gt;unstable_cache&lt;/code&gt; API and &lt;code&gt;revalidateTag&lt;/code&gt; work well on Vercel. On a self-hosted Node.js server, the behavior is less predictable. On a multi-instance deployment, in-memory caches diverge immediately.&lt;/p&gt;

&lt;p&gt;The deeper problem is that most caching implementations choose either simplicity, a single in-memory map, or power, Redis configured manually per use case. There is rarely a middle layer that handles the transition between them.&lt;/p&gt;

&lt;h3&gt;
  
  
  The solution
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;createCache&lt;/code&gt; returns a function that wraps any async operation with configurable caching behavior. Layers are provided as an ordered array, fastest first.&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;const&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createCache&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;layers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MemoryStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;                          &lt;span class="c1"&gt;// L1: in-process LRU&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RedisStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Redis&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;REDIS_URL&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;       &lt;span class="c1"&gt;// L2: distributed&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;defaultTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&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="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`user:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a cache miss, the source function is called. The result propagates to all layers. On a cache hit in L2, the result is back-filled to L1 so the next request for the same key is served from memory, without a Redis round-trip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tag-based invalidation&lt;/strong&gt; works across all layers simultaneously. When a user is updated, a single call clears every cache entry associated with that user, regardless of which layer holds it:&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;await&lt;/span&gt; &lt;span class="nx"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`user:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Stampede protection&lt;/strong&gt; is built in. When dozens of concurrent requests trigger a cache miss for the same key at the same time, a common scenario after a cache expiry under load, only one call to the source function is made. All other callers receive the same Promise and resolve together when the single fetch completes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stale-while-revalidate&lt;/strong&gt; allows returning a cached value immediately, even if it is stale, while refreshing it in the background. The next request gets the fresh value with no latency penalty.&lt;/p&gt;




&lt;h2&gt;
  
  
  @nx-safe-suite/audit-log: Logging That Survives Compliance
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The problem
&lt;/h3&gt;

&lt;p&gt;Audit logging is the feature that gets added after the first compliance review, built hastily, and never quite right. The common failure modes are: logs that contain passwords or PII in plaintext, logs that block the main request thread because they write synchronously to a database, and logs in a format that neither humans nor machines can parse reliably.&lt;/p&gt;

&lt;h3&gt;
  
  
  The solution
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;createAuditLog&lt;/code&gt; returns a logger configured with transports, a service name, and a list of sensitive fields to mask. Every entry sent to &lt;code&gt;audit.log()&lt;/code&gt; is enriched with a timestamp, service name, and default status, then masked, then dispatched to all transports in parallel.&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;audit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createAuditLog&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;serviceName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;my-saas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;sensitiveFields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;password&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ssn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;creditCard&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;silent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// transport failures never break the main request path&lt;/span&gt;
  &lt;span class="na"&gt;transports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ConsoleTransport&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stdout&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;    &lt;span class="c1"&gt;// structured JSON to stdout&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PrismaTransport&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auditLog&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;   &lt;span class="c1"&gt;// persisted to DB&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 PII masking is recursive and case-insensitive. It operates on a shallow clone so the original object is never mutated. A payload containing &lt;code&gt;{ email: "albert@example.com", name: "Albert" }&lt;/code&gt; arrives at the transport as &lt;code&gt;{ email: "[REDACTED]", name: "Albert" }&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Three transports ship with the package. &lt;code&gt;ConsoleTransport&lt;/code&gt; writes newline-delimited JSON to stdout or stderr, compatible with any log aggregation pipeline that parses structured stdout. &lt;code&gt;HttpTransport&lt;/code&gt; posts to a webhook endpoint with configurable retry logic and timeout. &lt;code&gt;PrismaTransport&lt;/code&gt; writes to any Prisma model, with a &lt;code&gt;mapEntry&lt;/code&gt; option to transform the entry shape to match your schema exactly.&lt;/p&gt;

&lt;p&gt;The transport interface is two lines:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AuditTransport&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AuditEntry&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;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;Implement those two lines and any destination works: Axiom, Datadog, Loki, a custom HTTP sink.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Tests&lt;/th&gt;
&lt;th&gt;Bundle (ESM)&lt;/th&gt;
&lt;th&gt;Peer deps&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@nx-safe-suite/env&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;~3KB&lt;/td&gt;
&lt;td&gt;zod&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@nx-safe-suite/api-response&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;td&gt;~3KB&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@nx-safe-suite/route-guard&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;~6KB&lt;/td&gt;
&lt;td&gt;zod, jose (optional)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@nx-safe-suite/server-cache&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;~5KB&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@nx-safe-suite/audit-log&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;~5KB&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;115 tests total. All passing. Strict TypeScript throughout.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Note on What This Demonstrates
&lt;/h2&gt;

&lt;p&gt;If you are reading this as someone evaluating my engineering judgment rather than as someone looking to use these packages, here is what I would point to.&lt;/p&gt;

&lt;p&gt;The decision to make transport errors non-fatal by default in &lt;code&gt;audit-log&lt;/code&gt; via &lt;code&gt;silent: true&lt;/code&gt; reflects an understanding of production priorities: a logging failure should never degrade the user experience.&lt;/p&gt;

&lt;p&gt;The decision to use Promise deduplication in &lt;code&gt;server-cache&lt;/code&gt; rather than a lock reflects an understanding of the Node.js event loop: locks are unnecessary when you can share the Promise itself.&lt;/p&gt;

&lt;p&gt;The decision to validate client variable prefixes eagerly in &lt;code&gt;env&lt;/code&gt;, before schema validation runs, reflects an understanding of where configuration mistakes come from: the schema definition, not the environment values.&lt;/p&gt;

&lt;p&gt;These are not clever tricks. They are the kind of decisions that come from having debugged the failure modes they prevent.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;strong&gt;&lt;a href="https://github.com/adeutou/nx-safe-suite" rel="noopener noreferrer"&gt;github.com/adeutou/nx-safe-suite&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Documentation: &lt;strong&gt;&lt;a href="https://adeutou.github.io/nx-safe-suite" rel="noopener noreferrer"&gt;adeutou.github.io/nx-safe-suite&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;npm: &lt;strong&gt;&lt;a href="https://www.npmjs.com/org/nx-safe-suite" rel="noopener noreferrer"&gt;@nx-safe-suite&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Questions, feedback, or pull requests are welcome.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>npm</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Built Five npm Packages to Solve the Five Problems Every Next.js Backend Ignores</title>
      <dc:creator>adeutou</dc:creator>
      <pubDate>Wed, 12 Aug 2026 11:48:00 +0000</pubDate>
      <link>https://dev.to/adeutou/i-built-five-npm-packages-to-solve-the-five-problems-every-nextjs-backend-ignores-3p5j</link>
      <guid>https://dev.to/adeutou/i-built-five-npm-packages-to-solve-the-five-problems-every-nextjs-backend-ignores-3p5j</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A production-grade toolkit for Next.js backends, and the architectural thinking behind it.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;After years of building internal tools and SaaS products with Next.js, I kept copying the same five things across every project. Not because they were hard to write, but because no one had packaged them together in a way that was coherent, composable, and genuinely production-ready.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;nx-safe-suite&lt;/strong&gt;: a monorepo of five independently installable TypeScript packages that handle the foundational concerns of any serious Next.js backend.&lt;/p&gt;

&lt;p&gt;This article covers the architecture and the reasoning. The next one goes deep on each package.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem With "Just Use process.env"
&lt;/h2&gt;

&lt;p&gt;Every Next.js tutorial ends the same way: &lt;code&gt;process.env.DATABASE_URL&lt;/code&gt;. No validation. No type safety. No guarantee that the variable exists.&lt;/p&gt;

&lt;p&gt;In production, this means one of two things happens. Either you catch the error immediately as a hard crash at startup, or worse, you do not. The variable is undefined, it flows silently through your application, and something breaks three layers deep in a way that takes an hour to trace back to its source.&lt;/p&gt;

&lt;p&gt;This is not a Next.js problem. It is a discipline problem. And it is exactly the kind of problem that a well-designed package should eliminate at the boundary, not paper over with optional chaining and defensive null checks scattered throughout the codebase.&lt;/p&gt;

&lt;p&gt;The same pattern repeats for error responses, authentication, caching, and audit logging. Every team solves them. Almost no team solves them consistently.&lt;/p&gt;




&lt;h2&gt;
  
  
  What nx-safe-suite Is
&lt;/h2&gt;

&lt;p&gt;Five packages. Five problems. Each one installable on its own.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Problem it solves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@nx-safe-suite/env&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Environment variables that are validated, typed, and fail loudly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@nx-safe-suite/api-response&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;API responses that are consistent, RFC-compliant, and predictable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@nx-safe-suite/route-guard&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Authentication, authorization, and validation without boilerplate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@nx-safe-suite/server-cache&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Multi-tier caching with invalidation that actually works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@nx-safe-suite/audit-log&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Structured audit logging that survives compliance reviews&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;They compose naturally. None of them requires the others. You can adopt one this week and the rest over the following months.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture Decision: Why Not a Framework?
&lt;/h2&gt;

&lt;p&gt;The obvious alternative was a framework: a single opinionated abstraction that wraps all five concerns. I chose not to build one, and the reason is worth explaining.&lt;/p&gt;

&lt;p&gt;Frameworks impose a contract. When you adopt a framework, you adopt its assumptions about your data model, your auth strategy, your deployment target. Those assumptions are often correct for the common case and deeply wrong for your specific case.&lt;/p&gt;

&lt;p&gt;Individual composable packages impose an interface. They define what goes in and what comes out. You decide what happens in between.&lt;/p&gt;

&lt;p&gt;This distinction matters at scale. When a framework assumption breaks down, and it always eventually does, you are refactoring across the entire abstraction. When a package interface breaks down, you swap the package.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Monorepo Structure
&lt;/h2&gt;

&lt;p&gt;All five packages live in a single repository, managed with &lt;strong&gt;pnpm workspaces&lt;/strong&gt; and &lt;strong&gt;Turborepo&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nx-safe-suite/
├── packages/
│   ├── env/
│   ├── api-response/
│   ├── route-guard/
│   ├── server-cache/
│   └── audit-log/
└── apps/
    └── docs/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turborepo handles the build graph. When &lt;code&gt;route-guard&lt;/code&gt; depends on the error shape from &lt;code&gt;api-response&lt;/code&gt;, Turborepo ensures the latter is always built first. The cache means that on a clean run, only changed packages rebuild. A build that would take 22 seconds takes 43 milliseconds on the second pass.&lt;/p&gt;

&lt;p&gt;Versioning and publishing are handled by &lt;strong&gt;Changesets&lt;/strong&gt;. Each meaningful change includes a changeset file describing the impact as patch, minor, or major. The release workflow on GitHub Actions assembles those into version bumps and publishes to npm automatically when merged to &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Dependency Philosophy
&lt;/h2&gt;

&lt;p&gt;The five packages collectively have zero mandatory runtime dependencies on third-party libraries. Every dependency that exists is either a &lt;code&gt;devDependency&lt;/code&gt; (TypeScript, Vitest, tsup) or a &lt;code&gt;peerDependency&lt;/code&gt; that the consuming project almost certainly already has.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@nx-safe-suite/env&lt;/code&gt; lists &lt;code&gt;zod&lt;/code&gt; as a peer. You bring your own Zod.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@nx-safe-suite/route-guard&lt;/code&gt; lists both &lt;code&gt;zod&lt;/code&gt; and &lt;code&gt;jose&lt;/code&gt; as peers. The &lt;code&gt;jose&lt;/code&gt; dependency is optional and only needed if you use the built-in JWT verification mode.&lt;/p&gt;

&lt;p&gt;The rest have nothing.&lt;/p&gt;

&lt;p&gt;This matters for two reasons. First, it avoids version conflicts: you do not end up with two copies of Zod at different versions in your bundle. Second, it keeps the surface area honest. These packages solve specific structural problems. They do not replace your existing toolchain.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quality Signals
&lt;/h2&gt;

&lt;p&gt;Every package ships with a full test suite written in Vitest. The numbers across the five packages: &lt;strong&gt;115 unit tests&lt;/strong&gt;, all passing, covering success paths, failure paths, edge cases, and integration behavior between layers.&lt;/p&gt;

&lt;p&gt;The TypeScript configuration is strict throughout: &lt;code&gt;strict: true&lt;/code&gt;, &lt;code&gt;noUncheckedIndexedAccess: true&lt;/code&gt;, &lt;code&gt;noImplicitOverride: true&lt;/code&gt;. These are not cosmetic. They are the settings that catch the bugs TypeScript is designed to catch.&lt;/p&gt;

&lt;p&gt;The CI pipeline runs on Node 20, 22, and 24. Packages that produce CJS and ESM builds are tested against both module systems.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Library Mindset
&lt;/h2&gt;

&lt;p&gt;Building a library forces a different kind of thinking than building an application. An application can tolerate internal inconsistency. A library cannot, because its surface area is a contract with every developer who installs it. Every public type, every default value, every error message is a decision that has consequences for someone else's code.&lt;/p&gt;

&lt;p&gt;The decisions visible in nx-safe-suite are not incidental. The pluggable interfaces, the peer dependency choices, the RFC 9457 compliance, the Changesets workflow, the sub-42ms Turborepo cache hit: all of them reflect a specific way of thinking about software. Constraints are features. Composability beats comprehensiveness. A well-designed system makes the wrong thing hard to do by default.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where to Find It
&lt;/h2&gt;

&lt;p&gt;The code is public: &lt;strong&gt;&lt;a href="https://github.com/adeutou/nx-safe-suite" rel="noopener noreferrer"&gt;github.com/adeutou/nx-safe-suite&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The documentation is live at: &lt;strong&gt;&lt;a href="https://adeutou.github.io/nx-safe-suite" rel="noopener noreferrer"&gt;adeutou.github.io/nx-safe-suite&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The packages are on npm under the &lt;code&gt;@nx-safe-suite&lt;/code&gt; scope.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://blog.adeutou.com/nx-safe-suite-a-deep-dive-into-five-production-grade-next-js-packages/" rel="noopener noreferrer"&gt;next article&lt;/a&gt; goes into each package individually: the problem it solves, the API design, and the implementation choices that are not obvious from the README.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>npm</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Electricity Planning Engine, part 2: A Reader Comment Found a Real Gap in My Test Suite (and How I Fixed It)</title>
      <dc:creator>adeutou</dc:creator>
      <pubDate>Mon, 27 Jul 2026 00:17:12 +0000</pubDate>
      <link>https://dev.to/adeutou/electricity-planning-engine-part-2-a-reader-comment-found-a-real-gap-in-my-test-suite-and-how-i-414m</link>
      <guid>https://dev.to/adeutou/electricity-planning-engine-part-2-a-reader-comment-found-a-real-gap-in-my-test-suite-and-how-i-414m</guid>
      <description>&lt;p&gt;I wrote about the &lt;a href="https://dev.to/adeutou/electricity-planning-engine-an-hourly-arbitrage-api-for-batteries-and-solar-plus-the-timezone-bug-2fp6"&gt;Electricity Planning Engine&lt;/a&gt; a little while back, including a timezone bug that made a correct price look "not found" after a database round trip. A few days later, &lt;a href="https://dev.to/alexshev"&gt;Alex Shev&lt;/a&gt; left this comment:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Timezone bugs are brutal in planning engines because the result can look mathematically correct while being operationally wrong. Energy workflows especially need tests around boundaries, not just averages.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a genuinely sharp way to put it, and it is not just a comment about the bug I already wrote about. It is a comment about how I test the project in general, and I did not like how well it applied once I went and checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that stung a little
&lt;/h2&gt;

&lt;p&gt;"Looks mathematically correct while being operationally wrong" is exactly what the original timezone bug was. &lt;code&gt;PriceSeries::priceAt()&lt;/code&gt; threw a clean "price not found" error, which is arguably the good version of that failure mode: loud, easy to catch, hard to ship. A quieter version of the same class of mistake, off by one hour instead of missing entirely, would not throw anything. It would just return a plan that looks completely reasonable and is wrong the entire time it runs.&lt;/p&gt;

&lt;p&gt;Alex's second point, boundaries over averages, is the one I actually had to go check rather than just agree with in the abstract. So I opened &lt;code&gt;tests/Unit/Domain/Contract/PricingStrategyTest.php&lt;/code&gt; and looked at every hour used in every peak/off-peak assertion:&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;new&lt;/span&gt; &lt;span class="nc"&gt;DateTimeImmutable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-07-18 14:00:00'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// peak&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DateTimeImmutable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-07-18 23:00:00'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// off-peak&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DateTimeImmutable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-07-18 05:00:00'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// off-peak&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;14:00, 23:00, 05:00. Every single one comfortably inside its window. None of them anywhere near the actual transition. The off-peak slot in the config is &lt;code&gt;22:00&lt;/code&gt; to &lt;code&gt;06:00&lt;/code&gt;, and the comparison behind that lives in &lt;code&gt;TimeSlot::contains()&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="c1"&gt;// wraparound slot, e.g. 22:00 -&amp;gt; 06:00&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$minuteOfDay&lt;/span&gt; &lt;span class="o"&gt;&amp;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;startMinuteOfDay&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$minuteOfDay&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;endMinuteOfDay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;&amp;gt;=&lt;/code&gt; versus &lt;code&gt;&amp;lt;&lt;/code&gt; is exactly the kind of one-character decision that determines whether 22:00:00 itself is off-peak or not, and whether 06:00:00 itself is off-peak or already peak again. Nothing in the suite exercised either instant. A boundary mistake here would not crash, would not warn, would just silently bill one hour a day at the wrong rate, forever, until someone happened to notice their bill looked slightly off. That is Alex's point, precisely, and it was sitting in my own repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&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;test_peak_off_peak_strategy_resolves_the_exact_boundary_minute_correctly&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$strategy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PricingStrategyFactory&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fromConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ContractType&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;PeakOffPeak&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'off_peak_slots'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="s1"&gt;'start'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'22:00'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'end'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'06:00'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
        &lt;span class="s1"&gt;'seasons'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;
            &lt;span class="s1"&gt;'label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'year_round'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'months'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'rates'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'slot'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'peak'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price_per_kwh'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.27&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'slot'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'off_peak'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price_per_kwh'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;]],&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="c1"&gt;// Just before the off-peak window opens: still peak.&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$strategy&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;priceForHour&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DateTimeImmutable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-07-18 21:59:00'&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;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.27&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
    &lt;span class="c1"&gt;// The window opens exactly at 22:00:00: start is inclusive.&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$strategy&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;priceForHour&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DateTimeImmutable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-07-18 22:00:00'&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;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.20&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
    &lt;span class="c1"&gt;// Just before the off-peak window closes: still off-peak.&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$strategy&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;priceForHour&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DateTimeImmutable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-07-19 05:59:00'&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;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.20&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
    &lt;span class="c1"&gt;// The window closes exactly at 06:00:00: end is exclusive, already peak.&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$strategy&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;priceForHour&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DateTimeImmutable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-07-19 06:00:00'&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;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.27&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;Four instants instead of three comfortable ones: one minute before the window opens, the exact opening second, one minute before it closes, the exact closing second. That is the whole idea of testing boundaries instead of averages, written down as assertions instead of just agreed with in a comment thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving the test actually tests something
&lt;/h2&gt;

&lt;p&gt;A boundary test that would pass against a broken implementation is worse than no test, it is a false sense of safety. So before trusting this one, I broke the code on purpose: flipped the wraparound comparison from &lt;code&gt;&amp;gt;=&lt;/code&gt; / &lt;code&gt;&amp;lt;&lt;/code&gt; to &lt;code&gt;&amp;gt;&lt;/code&gt; / &lt;code&gt;&amp;lt;=&lt;/code&gt;, one character each, and reran just this test.&lt;/p&gt;

&lt;p&gt;It failed immediately, on the 22:00:00 assertion, exactly where it should:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failed asserting that false is true.
at tests/Unit/Domain/Contract/PricingStrategyTest.php:73
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I reverted the one-character change and ran the full suite: 104 tests, 752 assertions, green. That failure-then-pass cycle is the only way I trust a new test is doing its job rather than just decorating the file with more green checkmarks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thanks, &lt;a href="https://dev.to/alexshev"&gt;Alex&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;None of this was a bug in production, it was a gap in coverage that a bug could have hidden in later. Comments like Alex's are exactly how that gap gets found before the bug does instead of after. If you have opinions on where else this project's tests are testing averages instead of edges, the &lt;a href="https://github.com/adeutou/electricity-planning-engine" rel="noopener noreferrer"&gt;repo&lt;/a&gt; is open, and so are the issues.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>testing</category>
    </item>
    <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>
