<?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: Tristan Kilhwan Chai</title>
    <description>The latest articles on DEV Community by Tristan Kilhwan Chai (@tristan_kilhwanchai_5954).</description>
    <link>https://dev.to/tristan_kilhwanchai_5954</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%2F4021942%2F5a933d9d-733e-4e72-95e8-f0b9ff7eb1c8.png</url>
      <title>DEV Community: Tristan Kilhwan Chai</title>
      <link>https://dev.to/tristan_kilhwanchai_5954</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tristan_kilhwanchai_5954"/>
    <language>en</language>
    <item>
      <title>[Homelab AI Project] Ep.8 - Building a High-Concurrency Node.js RSS Collector under 38MB of RAM</title>
      <dc:creator>Tristan Kilhwan Chai</dc:creator>
      <pubDate>Thu, 20 Aug 2026 06:06:57 +0000</pubDate>
      <link>https://dev.to/tristan_kilhwanchai_5954/homelab-ai-project-ep8-building-a-high-concurrency-nodejs-rss-collector-under-38mb-of-ram-376k</link>
      <guid>https://dev.to/tristan_kilhwanchai_5954/homelab-ai-project-ep8-building-a-high-concurrency-nodejs-rss-collector-under-38mb-of-ram-376k</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fobqvi4polj2z90y4a0uz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fobqvi4polj2z90y4a0uz.png" alt=" " width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Background: "28 Seconds of Latency? Let's Save the Idle CPU Cycles"
&lt;/h3&gt;

&lt;p&gt;With the monorepo foundation established (Episode 7), the next priority was implementing the data ingestion layer: &lt;code&gt;packages/collector&lt;/code&gt;. The objective is simple: fetch technological news from various global sites, parse the XML payloads, and persist them cleanly into PostgreSQL.&lt;/p&gt;

&lt;p&gt;A naive implementation typically loops through feed URLs sequentially:&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="c1"&gt;// Anti-pattern: Sequential Blocking Loop&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;feedUrl&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;feedList&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;xml&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;feedUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// CPU idles waiting for the network&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseXml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xml&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveToDb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&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 glaring flaw here is &lt;strong&gt;Network Latency&lt;/strong&gt;. A single remote request takes 1 to 1.5 seconds. Multiplied by 20 feeds, the batch run consumes nearly 30 seconds. During this window, the CPU does nothing but wait for network packet events.&lt;/p&gt;

&lt;p&gt;Deploying a heavy JVM-based scheduling engine (e.g., Spring Batch) would easily resolve this by spawning threads. However, it would persistently hog &lt;strong&gt;300MB+ of RAM&lt;/strong&gt; while idling for 59 minutes of every hour. Node.js is uniquely qualified for this task: its asynchronous event loop delegates I/O calls to the OS kernel, allowing massive concurrency without spawning heavy physical threads.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. Design Strategy: Asynchronous Chunking &amp;amp; Idempotent Database Ingestion
&lt;/h3&gt;

&lt;p&gt;The collector architecture relies on three primary design rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Promise.all&lt;/code&gt; Concurrency&lt;/strong&gt;: Network fetch requests are dispatched concurrently. This overlaps network waiting states, reducing total runtime to matching the latency of the slowest host (roughly 2 seconds).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fast-xml-parser&lt;/code&gt;&lt;/strong&gt;: A low-overhead parser that bypasses heavy DOM-tree structures in memory, serializing raw feed buffers to JS objects instantly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database-Level Idempotency (&lt;code&gt;ON CONFLICT&lt;/code&gt;)&lt;/strong&gt;: To prevent duplicate article ingestion across repetitive scheduled runs, we skip pre-ingestion database &lt;code&gt;SELECT&lt;/code&gt; checks. By applying a unique constraint on the &lt;code&gt;link&lt;/code&gt; column, we handle deduplication via a single PostgreSQL &lt;code&gt;INSERT ... ON CONFLICT DO NOTHING&lt;/code&gt; statement.&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  3. Implementation: Scaffolding the Scraper
&lt;/h3&gt;

&lt;p&gt;Here is the technical outline of the ingestion logic inside &lt;code&gt;packages/collector&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  (1) Low-Overhead Parser (&lt;code&gt;packages/collector/src/rss-parser.ts&lt;/code&gt;)
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;XMLParser&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fast-xml-parser&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NewsItemDTO&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@ai-news/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parser&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;XMLParser&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;ignoreAttributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;attributeNamePrefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;RSSFeedSource&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;url&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="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;parseRSSFeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RSSFeedSource&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="nx"&gt;NewsItemDTO&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&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;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;User-Agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AI-News-Bot/1.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// Strict 5-second timeout&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`HTTP error! status: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;xmlData&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&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;jsonObj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xmlData&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;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonObj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rss&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;jsonObj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&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;normalizedItems&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;items&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;normalizedItems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="na"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&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="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No Title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;source&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;rawContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;publishedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pubDate&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;Date&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;catch &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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[ERROR] Failed to fetch or parse feed [&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;source&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="s2"&gt;]:`&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="c1"&gt;// Return empty array to isolate failure&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;h4&gt;
  
  
  (2) Batch Processing Core (&lt;code&gt;packages/collector/src/index.ts&lt;/code&gt;)
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;parseRSSFeed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RSSFeedSource&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./rss-parser.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NewsItemDTO&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@ai-news/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dbPool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;connectionString&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;DATABASE_URL&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;FEED_SOURCES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RSSFeedSource&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hacker News&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://news.ycombinator.com/rss&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TechCrunch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://techcrunch.com/feed/&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;collectAllNews&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[INFO] Starting batch news collection...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// 1. Dispatch parallel network operations&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fetchPromises&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;FEED_SOURCES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;parseRSSFeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fetchPromises&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;allNewsItems&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NewsItemDTO&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flat&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[INFO] Fetched &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;allNewsItems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; articles. Persisting...`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Perform Idempotent Bulk Transaction&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;insertCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&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;await&lt;/span&gt; &lt;span class="nx"&gt;dbPool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BEGIN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;allNewsItems&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;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&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;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
        INSERT INTO news (title, link, source, raw_content, published_at)
        VALUES ($1, $2, $3, $4, $5)
        ON CONFLICT (link) DO NOTHING
      `&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;result&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rawContent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publishedAt&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="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowCount&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;insertCount&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;COMMIT&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;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ROLLBACK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[CRITICAL] Transaction rolled back due to error:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release&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;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[SUCCESS] Ingested &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;insertCount&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="nx"&gt;allNewsItems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; news in &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s.`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;collectAllNews&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&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;dbPool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&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="nx"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[FATAL] Script crashed:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  5. Benchmark Performance Metrics: Sequential vs. Event-Loop Concurrency
&lt;/h3&gt;

&lt;p&gt;We tested sequential processing against the parallel event-loop script harvesting &lt;strong&gt;20 RSS feeds&lt;/strong&gt; (translating to ~350 distinct news articles):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Benchmark&lt;/th&gt;
&lt;th&gt;Sequential Loop&lt;/th&gt;
&lt;th&gt;Asynchronous Concurrency&lt;/th&gt;
&lt;th&gt;Improvement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ingestion Run Duration&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;28.4s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;2.1s&lt;/strong&gt; (slowest-feed bounded)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-92.6% (Fast Ingestion)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Max Memory Usage (RSS)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;35 MiB&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;38 MiB&lt;/strong&gt; (negligible runtime delta)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Equivalent&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Peak CPU Load&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~1.2% (prolonged idle wait)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;~4.8%&lt;/strong&gt; (short burst, immediate sleep)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Better Efficiency&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DB Connection Lock Duration&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Persistent 28.0s hold&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.3s&lt;/strong&gt; (single pooled transaction)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-98.9% DB Lock Save&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;By abandoning multi-threading and relying entirely on asynchronous event multiplexing, the task finishes in &lt;strong&gt;2.1 seconds&lt;/strong&gt; while operating comfortably within &lt;strong&gt;38MB of RAM&lt;/strong&gt;. Additionally, database lock duration was reduced to just &lt;strong&gt;0.3s&lt;/strong&gt; via single-transaction bundling, eliminating connection pool starvation hazards.&lt;/p&gt;




&lt;h3&gt;
  
  
  6. Next Up
&lt;/h3&gt;

&lt;p&gt;Although the RSS pipeline handles public XML endpoints efficiently, how do we query commercial endpoints with strict rate limiting without triggering automatic IP bans? &lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;Episode 9&lt;/strong&gt;, we will explore: &lt;strong&gt;Defending Hardware and API Budgets — Implementing Adaptive Exponential Backoff Algorithms&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>node</category>
      <category>python</category>
      <category>docker</category>
    </item>
    <item>
      <title>[Homelab AI Project] Ep.7 - Designing a Lightweight Monorepo Architecture with Node.js and Python</title>
      <dc:creator>Tristan Kilhwan Chai</dc:creator>
      <pubDate>Tue, 28 Jul 2026 04:24:31 +0000</pubDate>
      <link>https://dev.to/tristan_kilhwanchai_5954/homelab-ai-project-ep7-designing-a-lightweight-monorepo-architecture-with-nodejs-and-python-4djj</link>
      <guid>https://dev.to/tristan_kilhwanchai_5954/homelab-ai-project-ep7-designing-a-lightweight-monorepo-architecture-with-nodejs-and-python-4djj</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqzcerg30zf8gfx7tjky4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqzcerg30zf8gfx7tjky4.png" alt=" " width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Background: "Spring Boot is Great, but My 16GB RAM is Priceless"
&lt;/h3&gt;

&lt;p&gt;After wrapping up the frontend optimization with Astro and React (Episode 6), it was time to design the backend services.&lt;/p&gt;

&lt;p&gt;When designing multi-module architectures, the default choice is often &lt;strong&gt;Java and Spring Boot (Gradle Multi-Module)&lt;/strong&gt;. With strong typing, mature DI containers, and structured layering, it is undeniably a solid framework.&lt;/p&gt;

&lt;p&gt;However, my mini-PC homelab (Ryzen 5500U, 16GB RAM) runs under strict resource constraints. The core database and utility containers already consume a substantial portion of memory:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL 16 + pgvector&lt;/strong&gt; (Capped at 4GB for caching vector indexing structures)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis 7.2 Alpine&lt;/strong&gt; (Capped at 1GB for queuing and caching layers)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prometheus + Grafana + Loki&lt;/strong&gt; (~1GB for the APM and logging stack)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gitea + Actions Runner&lt;/strong&gt; (~200MB for CI/CD pipelines)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In this environment, booting separate Spring Boot instances for the news collector, AI processor, and mail sender would instantly swallow &lt;strong&gt;300MB to 500MB of RAM per instance&lt;/strong&gt;. Running three JVM processes would eat up 1.5GB of memory before processing a single news article, inviting OS OOM (Out Of Memory) crashes during compute-intensive vector operations.&lt;/p&gt;

&lt;p&gt;Furthermore, implementing RAG processing requires Python library bindings (like numpy or sentence-transformers). Choosing Spring Boot meant I would eventually have to launch a separate Python runner anyway, doubling the memory footprint.&lt;/p&gt;

&lt;p&gt;Therefore, I chose a resource-optimized stack (ADR-002):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Node.js (TypeScript) Workspaces&lt;/strong&gt;: Running high-concurrency async I/O jobs under 50MB of RAM per instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python RAG Core&lt;/strong&gt;: Constructing the embedding calculations and pgvector pipelines directly in Python.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monorepo Structure&lt;/strong&gt;: Housing both languages inside a unified repository to keep pipeline configurations clean.&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  2. The Solution: Node.js &amp;amp; Python Monorepo Layout
&lt;/h3&gt;

&lt;p&gt;My multi-module layout (ADR-002) is organized as follows:&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;# Monorepo directory structure&lt;/span&gt;
AI_News/
├── packages/
│   ├── core/           &lt;span class="c"&gt;# [Node.js/TS] Shared database configurations, entities, and schemas&lt;/span&gt;
│   ├── collector/      &lt;span class="c"&gt;# [Node.js] Asynchronous RSS news collector (batch job)&lt;/span&gt;
│   ├── ai-engine/      &lt;span class="c"&gt;# [Python] pgvector + OpenRouter RAG summarizer&lt;/span&gt;
│   ├── sender/         &lt;span class="c"&gt;# [Node.js] Redis Queue based mass email sender&lt;/span&gt;
│   └── frontend/       &lt;span class="c"&gt;# [Astro/React] Frontend analytics dashboard&lt;/span&gt;
├── infra/              &lt;span class="c"&gt;# Docker Compose configurations and environment files (.env)&lt;/span&gt;
├── docs/               &lt;span class="c"&gt;# Architecture Decision Records (ADRs) and blog drafts&lt;/span&gt;
├── package.json        &lt;span class="c"&gt;# Root npm workspaces configuration&lt;/span&gt;
└── tsconfig.json       &lt;span class="c"&gt;# Shared compiler options for TypeScript modules&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core component is &lt;code&gt;packages/core&lt;/code&gt;. Since the collector, sender, and frontend dashboard all belong to the Node.js/TypeScript stack, sharing common database entities, DTO schemas, and environment validators inside &lt;code&gt;core&lt;/code&gt; keeps code duplication to absolute zero.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. Implementation: Root Setup &amp;amp; Module Scaffolding
&lt;/h3&gt;

&lt;h4&gt;
  
  
  (1) Root Configuration (&lt;code&gt;package.json&lt;/code&gt;)
&lt;/h4&gt;

&lt;p&gt;I configured &lt;code&gt;npm workspaces&lt;/code&gt; in the root JSON file to manage local dependency paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;package.json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(root)&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;"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;"@ai-news/root"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"private"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"workspaces"&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="s2"&gt;"packages/core"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"packages/collector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"packages/sender"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"packages/frontend"&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;"scripts"&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;"dev:frontend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm run dev -w @ai-news/frontend"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build:frontend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm run build -w @ai-news/frontend"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"start:collector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm start -w @ai-news/collector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"start:sender"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm start -w @ai-news/sender"&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;h4&gt;
  
  
  (2) Shared TypeScript Rules (&lt;code&gt;tsconfig.json&lt;/code&gt;)
&lt;/h4&gt;

&lt;p&gt;I established standard compiler options at the root, allowing submodules to extend the configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tsconfig.json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(root)&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;"compilerOptions"&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;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ES2022"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NodeNext"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"moduleResolution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NodeNext"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"esModuleInterop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"strict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"skipLibCheck"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"forceConsistentCasingInFileNames"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"declaration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sourceMap"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  (3) The Core Package (&lt;code&gt;packages/core&lt;/code&gt;)
&lt;/h4&gt;

&lt;p&gt;Below is the scaffolding setup for the shared TypeScript package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;packages/core/package.json&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;"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;"@ai-news/core"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.1.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"private"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"main"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/index.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"types"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/index.d.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&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;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tsc"&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/core/src/types/news.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;NewsItemDTO&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&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="nl"&gt;title&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="nl"&gt;link&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="nl"&gt;source&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="nl"&gt;rawContent&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="nl"&gt;publishedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;SummaryDTO&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;newsId&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="nl"&gt;summaryText&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="nl"&gt;modelUsed&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="nl"&gt;costIncurred&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;processedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="err"&gt;####&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Frontend &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Astro&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;Deployment&lt;/span&gt; &lt;span class="nx"&gt;Design&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="nx"&gt;MB&lt;/span&gt; &lt;span class="nx"&gt;RAM&lt;/span&gt; &lt;span class="nx"&gt;Saving&lt;/span&gt; &lt;span class="nx"&gt;via&lt;/span&gt; &lt;span class="nx"&gt;Nginx&lt;/span&gt; &lt;span class="nx"&gt;Docker&lt;/span&gt; &lt;span class="nx"&gt;Multi&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;Stage&lt;/span&gt; &lt;span class="nx"&gt;Build&lt;/span&gt;
&lt;span class="nx"&gt;For&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="nx"&gt;portfolio&lt;/span&gt; &lt;span class="nf"&gt;dashboard &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="nx"&gt;be&lt;/span&gt; &lt;span class="nx"&gt;introduced&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;Episode&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;running&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="nx"&gt;persistent&lt;/span&gt; &lt;span class="nx"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;js&lt;/span&gt; &lt;span class="nx"&gt;web&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;unnecessary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nx"&gt;We&lt;/span&gt; &lt;span class="nx"&gt;designed&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="nx"&gt;lightweight&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="nx"&gt;Astro&lt;/span&gt; &lt;span class="nx"&gt;Nginx&lt;/span&gt; &lt;span class="nx"&gt;Container&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nx"&gt;that&lt;/span&gt; &lt;span class="nx"&gt;builds&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt; &lt;span class="nx"&gt;and&lt;/span&gt; &lt;span class="nx"&gt;serves&lt;/span&gt; &lt;span class="nx"&gt;them&lt;/span&gt; &lt;span class="nx"&gt;via&lt;/span&gt; &lt;span class="nx"&gt;Nginx&lt;/span&gt; &lt;span class="nx"&gt;inside&lt;/span&gt; &lt;span class="nx"&gt;our&lt;/span&gt; &lt;span class="nx"&gt;docker&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;compose&lt;/span&gt; &lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;

&lt;span class="nx"&gt;First&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;we&lt;/span&gt; &lt;span class="nx"&gt;structure&lt;/span&gt; &lt;span class="s2"&gt;`packages/frontend/Dockerfile`&lt;/span&gt; &lt;span class="nx"&gt;using&lt;/span&gt; &lt;span class="nx"&gt;multi&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;stage&lt;/span&gt; &lt;span class="nx"&gt;builds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
dockerfile&lt;/p&gt;
&lt;h1&gt;
  
  
  Stage 1: Build static assets
&lt;/h1&gt;

&lt;p&gt;FROM node:22-slim AS builder&lt;br&gt;
WORKDIR /usr/src/app&lt;br&gt;
COPY package*.json ./&lt;br&gt;
RUN npm install&lt;br&gt;
COPY . .&lt;br&gt;
RUN npm run build&lt;/p&gt;
&lt;h1&gt;
  
  
  Stage 2: Serve via Nginx
&lt;/h1&gt;

&lt;p&gt;FROM nginx:alpine&lt;br&gt;
COPY --from=builder /usr/src/app/dist /usr/share/nginx/html&lt;br&gt;
EXPOSE 80&lt;br&gt;
CMD ["nginx", "-g", "daemon off;"]&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
By mapping the service inside `infra/docker-compose.yml` and configuring Gitea Actions to rebuild both the collector and frontend services concurrently (`docker compose up -d --build`), we enforce a single-click deployment structure with minimal system overhead.

---

### 4. Troubleshooting &amp;amp; Debugging Logs

#### 🚨 Issue 1: npm Workspaces Hoisting and Missing Local Build Files
After running `npm install`, trying to boot sub-packages threw compile-time errors: `Module not found` when referencing `@ai-news/core`.

* **Cause**:
  npm workspaces hoist third-party packages to the root node directory. Because the local core package `@ai-news/core` was not built into compiled `.js` assets (`dist/`), referencing submodules crashed during runtime resolve steps.
* **Resolution**:
  I configured package pipeline triggers inside the root script runner, guaranteeing `@ai-news/core` is built prior to booting any dependent workspaces:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
json&lt;br&gt;
  // package.json (root)&lt;br&gt;
  "scripts": {&lt;br&gt;
    "build:core": "npm run build -w @ai-news/core",&lt;br&gt;
    "predev:frontend": "npm run build:core",&lt;br&gt;
    "prestart:collector": "npm run build:core"&lt;br&gt;
  }&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


#### 🚨 Issue 2: Docker Bridge DNS Resolving Failures (Connection Refused)
Routing API calls from the Node.js collector to the Python AI engine via `http://localhost:8000/summarize` resulted in crash errors: `ECONNREFUSED` inside container task runners.

* **Cause**:
  In containerized spaces, `localhost` points to the isolated loopback interface of that specific container. To communicate with external containers, processes must resolve network domains using Docker's internal bridge DNS resolver.
* **Resolution**:
  I targeted the Docker Compose service name (`ainews-ai-engine`) as the domain destination, mapping host targets to `http://ainews-ai-engine:8000` to establish container-to-container connections.

#### 🚨 Issue 3: Monorepo Dependency Resolution Failures during Individual Docker Builds
Building docker images with the context set directly to the subdirectory (`packages/collector`) failed with `ENOENT` errors because the build container could not resolve dependencies or reference the shared `@ai-news/core` package.
* **Cause**:
  In an npm workspaces monorepo, dependencies and shared modules are hoisted to the root directory. If the build context is restricted to the package folder, the Docker daemon cannot see files in parent or sibling directories.
* **Resolution**:
  We adjusted the Docker Compose build configuration to set the **build context to the repository root (`..`)** instead of the individual package directories. We then structured the Dockerfiles to copy the root `package.json` and `package-lock.json` first, install dependencies, and then selectively copy package sources, resolving the hoisting boundary issue.

---

### 5. Results &amp;amp; Metrics: Spring Boot vs. Node.js/Python

I measured the server memory footprint (RSS) against a simulated Java/Spring Boot multi-module deployment under idle conditions:

| Metric | Spring Boot (JVM, 3 instances) | Node.js (2 instances) + Python (1 instance) | Improvement |
|------|-------------------------|---------------------------------------------|------|
| **Constant RAM Footprint** | **~720 MiB** | **~124 MiB** (Node: 44MB, Python: 80MB) | **-82.7% (RAM Saved)** |
| **Cold Start Duration** | ~8.2s | **~0.9s** | **-89.0%** |
| **16GB Host RAM Saved** | ~95.5% conserved | **~99.2% conserved (600MB preserved)** | **+3.7% headroom** |

By deploying lightweight Node.js script runtimes for collectors and senders, I freed up **600MB of RAM**. This memory buffer was funneled into the pgvector similarity index caches, enabling faster semantic processing pipelines.

---

### 6. Next Up
With the Monorepo skeleton verified, Episode 8 will dive into our data harvesting layers: **Building a Node.js RSS News Collector Scheduler — Pipeline Steps and DB storage**.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>homelab</category>
      <category>docker</category>
      <category>devops</category>
      <category>architecture</category>
    </item>
    <item>
      <title>[Homelab AI Project] Ep.6 - Aiming for a 0MB Memory Footprint on Frontend Servers with Astro and React Islands</title>
      <dc:creator>Tristan Kilhwan Chai</dc:creator>
      <pubDate>Wed, 22 Jul 2026 08:01:09 +0000</pubDate>
      <link>https://dev.to/tristan_kilhwanchai_5954/homelab-ai-project-ep6-aiming-for-a-0mb-memory-footprint-on-frontend-servers-with-astro-and-h0n</link>
      <guid>https://dev.to/tristan_kilhwanchai_5954/homelab-ai-project-ep6-aiming-for-a-0mb-memory-footprint-on-frontend-servers-with-astro-and-h0n</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2y7fce4ti9tf7ylvnwm5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2y7fce4ti9tf7ylvnwm5.png" alt=" " width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Background: "Spring Boot is Great, but My 16GB RAM is Priceless"
&lt;/h3&gt;

&lt;p&gt;After wrapping up the frontend optimization with Astro and React (Episode 6), it was time to design the backend services.&lt;/p&gt;

&lt;p&gt;When designing multi-module architectures, the default choice is often &lt;strong&gt;Java and Spring Boot (Gradle Multi-Module)&lt;/strong&gt;. With strong typing, mature DI containers, and structured layering, it is undeniably a solid framework.&lt;/p&gt;

&lt;p&gt;However, my mini-PC homelab (Ryzen 5500U, 16GB RAM) runs under strict resource constraints. The core database and utility containers already consume a substantial portion of memory:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL 16 + pgvector&lt;/strong&gt; (Capped at 4GB for caching vector indexing structures)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis 7.2 Alpine&lt;/strong&gt; (Capped at 1GB for queuing and caching layers)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prometheus + Grafana + Loki&lt;/strong&gt; (~1GB for the APM and logging stack)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gitea + Actions Runner&lt;/strong&gt; (~200MB for CI/CD pipelines)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In this environment, booting separate Spring Boot instances for the news collector, AI processor, and mail sender would instantly swallow &lt;strong&gt;300MB to 500MB of RAM per instance&lt;/strong&gt;. Running three JVM processes would eat up 1.5GB of memory before processing a single news article, inviting OS OOM (Out Of Memory) crashes during compute-intensive vector operations.&lt;/p&gt;

&lt;p&gt;Furthermore, implementing RAG processing requires Python library bindings (like numpy or sentence-transformers). Choosing Spring Boot meant I would eventually have to launch a separate Python runner anyway, doubling the memory footprint.&lt;/p&gt;

&lt;p&gt;Therefore, I chose a resource-optimized stack (ADR-002):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Node.js (TypeScript) Workspaces&lt;/strong&gt;: Running high-concurrency async I/O jobs under 50MB of RAM per instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python RAG Core&lt;/strong&gt;: Constructing the embedding calculations and pgvector pipelines directly in Python.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monorepo Structure&lt;/strong&gt;: Housing both languages inside a unified repository to keep pipeline configurations clean.&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  2. The Solution: Node.js &amp;amp; Python Monorepo Layout
&lt;/h3&gt;

&lt;p&gt;My multi-module layout (ADR-002) is organized as follows:&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;# Monorepo directory structure&lt;/span&gt;
AI_News/
├── packages/
│   ├── core/           &lt;span class="c"&gt;# [Node.js/TS] Shared database configurations, entities, and schemas&lt;/span&gt;
│   ├── collector/      &lt;span class="c"&gt;# [Node.js] Asynchronous RSS news collector (batch job)&lt;/span&gt;
│   ├── ai-engine/      &lt;span class="c"&gt;# [Python] pgvector + OpenRouter RAG summarizer&lt;/span&gt;
│   ├── sender/         &lt;span class="c"&gt;# [Node.js] Redis Queue based mass email sender&lt;/span&gt;
│   └── frontend/       &lt;span class="c"&gt;# [Astro/React] Frontend analytics dashboard&lt;/span&gt;
├── infra/              &lt;span class="c"&gt;# Docker Compose configurations and environment files (.env)&lt;/span&gt;
├── docs/               &lt;span class="c"&gt;# Architecture Decision Records (ADRs) and blog drafts&lt;/span&gt;
├── package.json        &lt;span class="c"&gt;# Root npm workspaces configuration&lt;/span&gt;
└── tsconfig.json       &lt;span class="c"&gt;# Shared compiler options for TypeScript modules&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core component is &lt;code&gt;packages/core&lt;/code&gt;. Since the collector, sender, and frontend dashboard all belong to the Node.js/TypeScript stack, sharing common database entities, DTO schemas, and environment validators inside &lt;code&gt;core&lt;/code&gt; keeps code duplication to absolute zero.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. Implementation: Root Setup &amp;amp; Module Scaffolding
&lt;/h3&gt;

&lt;h4&gt;
  
  
  (1) Root Configuration (&lt;code&gt;package.json&lt;/code&gt;)
&lt;/h4&gt;

&lt;p&gt;I configured &lt;code&gt;npm workspaces&lt;/code&gt; in the root JSON file to manage local dependency paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;package.json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(root)&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;"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;"@ai-news/root"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"private"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"workspaces"&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="s2"&gt;"packages/core"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"packages/collector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"packages/sender"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"packages/frontend"&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;"scripts"&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;"dev:frontend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm run dev -w @ai-news/frontend"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build:frontend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm run build -w @ai-news/frontend"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"start:collector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm start -w @ai-news/collector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"start:sender"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm start -w @ai-news/sender"&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;h4&gt;
  
  
  (2) Shared TypeScript Rules (&lt;code&gt;tsconfig.json&lt;/code&gt;)
&lt;/h4&gt;

&lt;p&gt;I established standard compiler options at the root, allowing submodules to extend the configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tsconfig.json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(root)&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;"compilerOptions"&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;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ES2022"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NodeNext"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"moduleResolution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NodeNext"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"esModuleInterop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"strict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"skipLibCheck"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"forceConsistentCasingInFileNames"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"declaration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sourceMap"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  (3) The Core Package (&lt;code&gt;packages/core&lt;/code&gt;)
&lt;/h4&gt;

&lt;p&gt;Below is the scaffolding setup for the shared TypeScript package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;packages/core/package.json&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;"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;"@ai-news/core"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.1.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"private"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"main"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/index.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"types"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/index.d.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&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;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tsc"&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/core/src/types/news.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;NewsItemDTO&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&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="nl"&gt;title&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="nl"&gt;link&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="nl"&gt;source&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="nl"&gt;rawContent&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="nl"&gt;publishedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;SummaryDTO&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;newsId&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="nl"&gt;summaryText&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="nl"&gt;modelUsed&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="nl"&gt;costIncurred&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;processedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&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;h4&gt;
  
  
  (4) Frontend (Astro) Deployment Design: 0MB RAM Saving via Nginx Docker Multi-Stage Build
&lt;/h4&gt;

&lt;p&gt;For the portfolio dashboard (to be introduced in Episode 6), running a persistent Node.js web process is unnecessary. We designed a lightweight &lt;strong&gt;Astro Nginx Container&lt;/strong&gt; that builds static files and serves them via Nginx inside our docker-compose stack.&lt;/p&gt;

&lt;p&gt;First, we structure &lt;code&gt;packages/frontend/Dockerfile&lt;/code&gt; using multi-stage builds:&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="c"&gt;# Stage 1: Build static assets&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-slim&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /usr/src/app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package*.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build

&lt;span class="c"&gt;# Stage 2: Serve via Nginx&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; nginx:alpine&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /usr/src/app/dist /usr/share/nginx/html&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 80&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["nginx", "-g", "daemon off;"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By mapping the service inside &lt;code&gt;infra/docker-compose.yml&lt;/code&gt; and configuring Gitea Actions to rebuild both the collector and frontend services concurrently (&lt;code&gt;docker compose up -d --build&lt;/code&gt;), we enforce a single-click deployment structure with minimal system overhead.&lt;/p&gt;




&lt;h3&gt;
  
  
  4. Troubleshooting &amp;amp; Debugging Logs
&lt;/h3&gt;

&lt;h4&gt;
  
  
  🚨 Issue 1: npm Workspaces Hoisting and Missing Local Build Files
&lt;/h4&gt;

&lt;p&gt;After running &lt;code&gt;npm install&lt;/code&gt;, trying to boot sub-packages threw compile-time errors: &lt;code&gt;Module not found&lt;/code&gt; when referencing &lt;code&gt;@ai-news/core&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cause&lt;/strong&gt;:
npm workspaces hoist third-party packages to the root node directory. Because the local core package &lt;code&gt;@ai-news/core&lt;/code&gt; was not built into compiled &lt;code&gt;.js&lt;/code&gt; assets (&lt;code&gt;dist/&lt;/code&gt;), referencing submodules crashed during runtime resolve steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution&lt;/strong&gt;:
I configured package pipeline triggers inside the root script runner, guaranteeing &lt;code&gt;@ai-news/core&lt;/code&gt; is built prior to booting any dependent workspaces:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;package.json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(root)&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&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;"build:core"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm run build -w @ai-news/core"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"predev:frontend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm run build:core"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"prestart:collector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm run build:core"&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;h4&gt;
  
  
  🚨 Issue 2: Docker Bridge DNS Resolving Failures (Connection Refused)
&lt;/h4&gt;

&lt;p&gt;Routing API calls from the Node.js collector to the Python AI engine via &lt;code&gt;http://localhost:8000/summarize&lt;/code&gt; resulted in crash errors: &lt;code&gt;ECONNREFUSED&lt;/code&gt; inside container task runners.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cause&lt;/strong&gt;:
In containerized spaces, &lt;code&gt;localhost&lt;/code&gt; points to the isolated loopback interface of that specific container. To communicate with external containers, processes must resolve network domains using Docker's internal bridge DNS resolver.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution&lt;/strong&gt;:
I targeted the Docker Compose service name (&lt;code&gt;ainews-ai-engine&lt;/code&gt;) as the domain destination, mapping host targets to &lt;code&gt;http://ainews-ai-engine:8000&lt;/code&gt; to establish container-to-container connections.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  🚨 Issue 3: Monorepo Dependency Resolution Failures during Individual Docker Builds
&lt;/h4&gt;

&lt;p&gt;Building docker images with the context set directly to the subdirectory (&lt;code&gt;packages/collector&lt;/code&gt;) failed with &lt;code&gt;ENOENT&lt;/code&gt; errors because the build container could not resolve dependencies or reference the shared &lt;code&gt;@ai-news/core&lt;/code&gt; package.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cause&lt;/strong&gt;:
In an npm workspaces monorepo, dependencies and shared modules are hoisted to the root directory. If the build context is restricted to the package folder, the Docker daemon cannot see files in parent or sibling directories.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution&lt;/strong&gt;:
We adjusted the Docker Compose build configuration to set the &lt;strong&gt;build context to the repository root (&lt;code&gt;..&lt;/code&gt;)&lt;/strong&gt; instead of the individual package directories. We then structured the Dockerfiles to copy the root &lt;code&gt;package.json&lt;/code&gt; and &lt;code&gt;package-lock.json&lt;/code&gt; first, install dependencies, and then selectively copy package sources, resolving the hoisting boundary issue.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  5. Results &amp;amp; Metrics: Spring Boot vs. Node.js/Python
&lt;/h3&gt;

&lt;p&gt;I measured the server memory footprint (RSS) against a simulated Java/Spring Boot multi-module deployment under idle conditions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Spring Boot (JVM, 3 instances)&lt;/th&gt;
&lt;th&gt;Node.js (2 instances) + Python (1 instance)&lt;/th&gt;
&lt;th&gt;Improvement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Constant RAM Footprint&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~720 MiB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;~124 MiB&lt;/strong&gt; (Node: 44MB, Python: 80MB)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-82.7% (RAM Saved)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cold Start Duration&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~8.2s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~0.9s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-89.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;16GB Host RAM Saved&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~95.5% conserved&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~99.2% conserved (600MB preserved)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+3.7% headroom&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;By deploying lightweight Node.js script runtimes for collectors and senders, I freed up &lt;strong&gt;600MB of RAM&lt;/strong&gt;. This memory buffer was funneled into the pgvector similarity index caches, enabling faster semantic processing pipelines.&lt;/p&gt;




&lt;h3&gt;
  
  
  6. Next Up
&lt;/h3&gt;

&lt;p&gt;With the Monorepo skeleton verified, Episode 8 will dive into our data harvesting layers: &lt;strong&gt;Building a Node.js RSS News Collector Scheduler — Pipeline Steps and DB storage&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>docker</category>
      <category>devops</category>
      <category>architecture</category>
    </item>
    <item>
      <title>[Homelab AI Project] Ep.5 - Building a Lightweight Self-Hosted CI/CD Pipeline with Gitea and Act Runner</title>
      <dc:creator>Tristan Kilhwan Chai</dc:creator>
      <pubDate>Wed, 22 Jul 2026 06:24:23 +0000</pubDate>
      <link>https://dev.to/tristan_kilhwanchai_5954/homelab-ai-project-ep5-building-a-lightweight-self-hosted-cicd-pipeline-with-gitea-and-act-1l4h</link>
      <guid>https://dev.to/tristan_kilhwanchai_5954/homelab-ai-project-ep5-building-a-lightweight-self-hosted-cicd-pipeline-with-gitea-and-act-1l4h</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3bw1yqdwqimjdckq62mq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3bw1yqdwqimjdckq62mq.png" alt=" " width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The Challenge: Keeping Git and CI/CD under 150MB of RAM
&lt;/h3&gt;

&lt;p&gt;In Episode 4, we decoupled our PostgreSQL database, Redis task queue, and RSS crawler module into isolated Docker containers with strict memory ceilings. However, a manual deployment loop—SSHing to the server, pulling source code, and running rebuild commands—violates basic principles of engineering efficiency. &lt;/p&gt;

&lt;p&gt;We needed a local, self-hosted Git server and an automated CI/CD pipeline. The industry standard, GitLab, comes with a steep recommendation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"GitLab requires at least 4GB of RAM (8GB recommended) to function smoothly."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Allocating &lt;strong&gt;25% of our entire 16GB homelab memory&lt;/strong&gt; to a tool that simply hosts repos and triggers pipelines is a critical FinOps failure. That 4GB of RAM is better utilized feeding our database page cache or Python RAG pipeline.&lt;/p&gt;

&lt;p&gt;Our target was to achieve a git-triggered deployment pipeline with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Under &lt;strong&gt;150MB&lt;/strong&gt; active RAM consumption.&lt;/li&gt;
&lt;li&gt;Support for GitHub Actions YAML syntax (to preserve portable configuration).&lt;/li&gt;
&lt;li&gt;Direct container control to rolling-update the crawler service.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The solution: &lt;strong&gt;Gitea&lt;/strong&gt; paired with &lt;strong&gt;Gitea Actions Runner (Act Runner)&lt;/strong&gt;. Written in Go, Gitea runs natively in lightweight environments, and its Act Runner executes standard GitHub Actions workflow files seamlessly.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. Topology: Internal Network Bridging and Docker Daemon Delegation
&lt;/h3&gt;

&lt;p&gt;To deploy this without exposing raw ports to the internet or introducing unnecessary routing overheads, we set up two integration boundaries:&lt;/p&gt;

&lt;h4&gt;
  
  
  ① Host Docker Daemon Binding (&lt;code&gt;/var/run/docker.sock&lt;/code&gt;)
&lt;/h4&gt;

&lt;p&gt;The Act Runner container maps the host's &lt;code&gt;/var/run/docker.sock&lt;/code&gt; file. When a build job triggers a container update, it passes commands directly to the host's Docker daemon, allowing the runner container to remain lightweight.&lt;/p&gt;

&lt;h4&gt;
  
  
  ② Loopback and Internal DNS Direct Routing
&lt;/h4&gt;

&lt;p&gt;We bind both Gitea and Act Runner to the internal docker bridge network (&lt;code&gt;ainews-internal&lt;/code&gt;). The runner polls Gitea using the internal DNS address (&lt;code&gt;http://ainews-git:3000&lt;/code&gt;), ensuring build traffic never loops through external routers or Cloudflare edge servers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;graph TD
    subgraph Host_OS ["Rocky Linux 10.1 (Host)"]
        subgraph Int_Net ["ainews-internal Network (Isolated)"]
            Gitea["Gitea Server (ainews-git) &amp;lt;br&amp;gt; [Port: 3000, 2222]"]
            Runner["Act Runner (ainews-runner)"]
        end

        Docker_Sock["/var/run/docker.sock &amp;lt;br&amp;gt; (Host Docker Socket)"]
        Target_Cont["ainews-collector &amp;lt;br&amp;gt; (Collector Container)"]
    end

    Local_Dev["Local Developer PC &amp;lt;br&amp;gt; (git push)"] -- "Cloudflare Tunnel &amp;lt;br&amp;gt; (https://git.your-domain.com)" --&amp;gt; Gitea
    Runner -- "Polling &amp;lt;br&amp;gt; (http://ainews-git:3000)" --&amp;gt; Gitea
    Runner -- "docker compose up -d --build &amp;lt;br&amp;gt; (Control Daemon)" --&amp;gt; Docker_Sock
    Docker_Sock --&amp;gt; Target_Cont
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  3. Setup: infra/docker-compose.gitea.yml &amp;amp; Workflow
&lt;/h3&gt;

&lt;h4&gt;
  
  
  📄 &lt;code&gt;infra/docker-compose.gitea.yml&lt;/code&gt;
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.8'&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ainews-internal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ainews-git&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea/gitea:1.22&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ainews-git&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;USER_UID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;USER_GID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__database__DB_TYPE=postgres&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__database__HOST=ainews-db:5432&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__database__NAME=giteadb&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__database__USER=tristan&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA__database__PASSWD=super-secret-password-change-me&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./volumes/gitea:/data:z&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/timezone:/etc/timezone:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/localtime:/etc/localtime:ro&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000:3000"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2222:22"&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ainews-internal&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cpus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0.5'&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;512M&lt;/span&gt;

  &lt;span class="na"&gt;ainews-runner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea/act_runner:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ainews-runner&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ainews-git&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CONFIG_FILE=/config.yaml&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA_INSTANCE_URL=http://ainews-git:3000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA_RUNNER_REGISTRATION_TOKEN=your_runner_registration_token_here&lt;/span&gt;
      &lt;span class="c1"&gt;# Map custom label for our Rocky Linux environment instead of the default ubuntu&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA_RUNNER_LABELS=rocky-linux:docker://node:22-slim&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./volumes/act_runner:/data:z&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/timezone:/etc/timezone:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/localtime:/etc/localtime:ro&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ainews-internal&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cpus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1.0'&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;512M&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  📄 &lt;code&gt;.gitea/workflows/deploy.yml&lt;/code&gt;
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AI News Auto Deploy&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rocky-linux&lt;/span&gt; &lt;span class="c1"&gt;# Custom runner label configured in config.yaml / runner labels&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checkout Source Code&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Rebuild &amp;amp; Restart Target Service&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;echo "Start deploying AI News application..."&lt;/span&gt;
          &lt;span class="s"&gt;docker compose -f infra/docker-compose.yml up -d --build ainews-collector&lt;/span&gt;
          &lt;span class="s"&gt;echo "Deployment finished successfully!"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  4. Real-World Troubleshooting
&lt;/h3&gt;

&lt;h4&gt;
  
  
  🚨 Issue 1: Runner Loopback Failure via External Domain Addresses
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Symptom&lt;/strong&gt;: Pointing &lt;code&gt;GITEA_INSTANCE_URL&lt;/code&gt; to &lt;code&gt;https://git.your-domain.com&lt;/code&gt; threw socket timeout and connection refused errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cause&lt;/strong&gt;: Requests from the runner routed out through the WAN and back into Cloudflare Tunnel. Lacking NAT loopback translation rules inside the local router, internal packets were dropped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution&lt;/strong&gt;: Keep traffic inside the bridge network by utilizing Gitea's internal container alias (&lt;code&gt;http://ainews-git:3000&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  🚨 Issue 2: Docker Socket Bind Permission Denied (GID Mismatch)
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Symptom&lt;/strong&gt;: Runner container threw &lt;code&gt;Permission denied: failed to connect to /var/run/docker.sock&lt;/code&gt; during deployment steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cause&lt;/strong&gt;: The host's socket requires &lt;code&gt;root&lt;/code&gt; or &lt;code&gt;docker&lt;/code&gt; group membership. The runner's inner container user (UID 1000) was not mapped to the host's &lt;code&gt;docker&lt;/code&gt; GID (often 999 or 992).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution&lt;/strong&gt;: Configured GID group delegation by granting the runner process elevated socket access inside the Docker config, or configuring group mapping settings to line up internal UIDs with the host's Docker socket groups.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  🚨 Issue 3: Missing 'Actions' Repository Settings UI
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Symptom&lt;/strong&gt;: Repository landing page did not display the Actions configuration tab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cause&lt;/strong&gt;: Out of resource safety concerns, Gitea disables actions processing on initial startup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution&lt;/strong&gt;: Appended configuration flags to Gitea's custom configurations file:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;  &lt;span class="c"&gt;# edit volumes/gitea/gitea/conf/app.ini
&lt;/span&gt;  &lt;span class="nn"&gt;[actions]&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A quick container restart loaded the configurations, activating the menu.&lt;/p&gt;

&lt;h4&gt;
  
  
  🚨 Issue 4: Disappearing Host Port Bindings (3000) leading to 502 Bad Gateway
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Symptom&lt;/strong&gt;: Gitea container failed to resolve external connections, routing to a 502 Bad Gateway error. Running &lt;code&gt;docker ps&lt;/code&gt; revealed host port bindings (&lt;code&gt;0.0.0.0:3000-&amp;gt;3000/tcp&lt;/code&gt;) were missing, showing only raw container ports (&lt;code&gt;22/tcp, 3000/tcp&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cause&lt;/strong&gt;: The Gitea service was connected solely to the &lt;code&gt;infra_ainews-internal&lt;/code&gt; network. By default, the Docker daemon drops and blocks all host port forwarding rules for containers isolated purely inside &lt;code&gt;internal: true&lt;/code&gt; bridge networks for strict access segregation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution&lt;/strong&gt;: Map both &lt;code&gt;ainews-internal&lt;/code&gt; and the public bridge network &lt;code&gt;ainews-external&lt;/code&gt; to Gitea in &lt;code&gt;docker-compose.gitea.yml&lt;/code&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ainews-internal&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ainews-external&lt;/span&gt;  &lt;span class="c1"&gt;# &amp;lt;- Allow host port forwarding by joining public network&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restarting the stack created the correct iptables forwarding rules, resolving the bad gateway immediately.&lt;/p&gt;

&lt;h4&gt;
  
  
  🚨 Issue 5: 'token is empty' Registration Failure for Gitea Act Runner
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Symptom&lt;/strong&gt;: The runner container looped on startup, throwing &lt;code&gt;Error: token is empty&lt;/code&gt; even though we mapped the token in the configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cause&lt;/strong&gt;: Depending on the specific image tag, Gitea Act Runner expects either &lt;code&gt;GITEA_RUNNER_TOKEN&lt;/code&gt; or &lt;code&gt;GITEA_RUNNER_REGISTRATION_TOKEN&lt;/code&gt;. Environment parser mismatch passed an empty string inside the final configuration layout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution&lt;/strong&gt;: Map both environment variables to the registration token inside &lt;code&gt;docker-compose.gitea.yml&lt;/code&gt;:
&lt;/li&gt;
&lt;/ul&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="s"&gt;GITEA_RUNNER_TOKEN=your_token_here&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA_RUNNER_REGISTRATION_TOKEN=your_token_here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enforcing both variables bypasses image version compatibility issues, registering the runner instantly.&lt;/p&gt;

&lt;h4&gt;
  
  
  🚨 Issue 6: Container Loop Crash due to Missing package.json under DooD Bind Mounting
&lt;/h4&gt;

&lt;p&gt;After integrating automated deployment, triggering a git push caused the &lt;code&gt;ainews-collector&lt;/code&gt; container to crash in a &lt;code&gt;Restarting (254)&lt;/code&gt; loop, throwing &lt;code&gt;npm error enoent Could not read package.json&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cause&lt;/strong&gt;: 
Our Gitea Act Runner operates in a DooD (Docker-out-of-Docker) architecture by sharing &lt;code&gt;/var/run/docker.sock&lt;/code&gt;. When the runner triggers &lt;code&gt;docker compose up --build&lt;/code&gt;, the host's Docker daemon resolves the bind-mount &lt;code&gt;volumes: - ./collector:/usr/src/app&lt;/code&gt; relative to the &lt;em&gt;host&lt;/em&gt; OS path, not the runner's internal directory. Since the host directory was empty, the container started with an empty working directory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution&lt;/strong&gt;: 
We removed the host volume bind-mount entirely to eliminate physical file system coupling. Instead, we introduced a local &lt;code&gt;Dockerfile&lt;/code&gt; under &lt;code&gt;packages/collector&lt;/code&gt; that uses &lt;code&gt;COPY&lt;/code&gt; instructions to package source files directly into the image. Now, the Gitea Actions builder packs the latest commit into the image itself, allowing standalone container rollouts independent of host folder states.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  🚨 Issue 7: Missing Gitea Actions Workflows due to &lt;code&gt;.gitignore&lt;/code&gt; Exclusions
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Symptom&lt;/strong&gt;: After the first successful push, the Actions configuration tab was empty, displaying &lt;code&gt;There are no workflows yet.&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cause&lt;/strong&gt;: The &lt;code&gt;.gitignore&lt;/code&gt; configuration mistakenly contained a rule to exclude the &lt;code&gt;.gitea/&lt;/code&gt; directories. Consequently, our deployment scripts under &lt;code&gt;.gitea/workflows/deploy.yml&lt;/code&gt; were silently ignored during git staging.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution&lt;/strong&gt;: Removed the &lt;code&gt;.gitea/&lt;/code&gt; exclusion rule from &lt;code&gt;.gitignore&lt;/code&gt;, staged the directories using &lt;code&gt;git add .gitea/&lt;/code&gt;, and re-committed the workspace to activate the workflow pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  🚨 Issue 8: 404 API Fallback Checkout Failure via Slim Images
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Symptom&lt;/strong&gt;: The pipeline failed on the &lt;code&gt;Checkout Source Code&lt;/code&gt; step, throwing a &lt;code&gt;404 page not found&lt;/code&gt; error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cause&lt;/strong&gt;: The runner's default build environment (&lt;code&gt;node:22-slim&lt;/code&gt;) lacked &lt;code&gt;git&lt;/code&gt; binaries. Lacking git command support, &lt;code&gt;actions/checkout&lt;/code&gt; defaulted to using the Gitea REST API to fetch a zip archive (/tarball). However, since Gitea's internal &lt;code&gt;ROOT_URL&lt;/code&gt; did not align with internal network header routing, the API call returned a secure 404 Not Found error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution&lt;/strong&gt;: Updated &lt;code&gt;docker-compose.gitea.yml&lt;/code&gt; to specify the standard &lt;code&gt;node:22&lt;/code&gt; image (which has git pre-installed) for the runner labels. Reset the runner's credentials by deleting &lt;code&gt;.runner&lt;/code&gt; and restarted the container. The checkout step then executed a direct git clone successfully.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  🚨 Issue 9: Missing Docker CLI in Build Environment and 3-Second Static Binary Fix
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Symptom&lt;/strong&gt;: The build step threw &lt;code&gt;docker: command not found&lt;/code&gt; (exitcode 127).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cause&lt;/strong&gt;: While the host's &lt;code&gt;/var/run/docker.sock&lt;/code&gt; was mounted, the runner's container lacked the &lt;code&gt;docker&lt;/code&gt; CLI command binary. Running a full &lt;code&gt;apt-get&lt;/code&gt; installation for docker tools on every build adds 30+ seconds of overhead, violating our FinOps optimization principles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution&lt;/strong&gt;: Configured a lightweight setup step in &lt;code&gt;deploy.yml&lt;/code&gt; that fetches official Docker and Compose static binaries via &lt;code&gt;curl&lt;/code&gt;. Setting up the CLI takes only 3 seconds, keeping the build pipeline exceptionally fast.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  🚨 Issue 10: Missing &lt;code&gt;.env&lt;/code&gt; Configurations on Compose Mount Initialization
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Symptom&lt;/strong&gt;: The compose execution failed with &lt;code&gt;invalid spec: empty section between colons&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cause&lt;/strong&gt;: To protect private keys, the &lt;code&gt;.env&lt;/code&gt; configuration file was excluded from git. Lacking environment mappings in the build container, the host docker engine received empty values for compose volume paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution&lt;/strong&gt;: Encrypted and saved the &lt;code&gt;.env&lt;/code&gt; settings inside Gitea's repository Secrets. Added a setup step in &lt;code&gt;.gitea/workflows/deploy.yml&lt;/code&gt; to dynamically recreate the &lt;code&gt;infra/.env&lt;/code&gt; config right before rebuilding the containers.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  5. Results &amp;amp; Metrics
&lt;/h3&gt;

&lt;p&gt;Here are the resource footprint improvements compared to GitLab:&lt;/p&gt;

&lt;h4&gt;
  
  
  Idle Hardware Resource Usage Comparison
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;GitLab CE&lt;/th&gt;
&lt;th&gt;Gitea (PostgreSQL)&lt;/th&gt;
&lt;th&gt;Gitea Act Runner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Idle Memory Footprint&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.2 GiB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;118 MiB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;28 MiB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Idle CPU Utilization&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~2.5%&lt;/td&gt;
&lt;td&gt;~0.05%&lt;/td&gt;
&lt;td&gt;~0.01%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Active Build Memory&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5.0+ GiB&lt;/td&gt;
&lt;td&gt;~180 MiB&lt;/td&gt;
&lt;td&gt;~210 MiB (Host Socket)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;96.5% Memory Savings&lt;/strong&gt;: Instead of dedicating 4.2GB to GitLab, Gitea and its runner require only &lt;strong&gt;146MB&lt;/strong&gt;, freeing up over 4GB of raw hardware capacity for database index caching and memory-intensive Python vector searches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;7-Second Deployment Lead Time&lt;/strong&gt;: Pushing code updates triggers the local agent immediately, completing target rebuilds in &lt;strong&gt;7.0 seconds&lt;/strong&gt; with zero human interaction, thanks to our 3-second Docker CLI binary setup.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  6. Next Up
&lt;/h3&gt;

&lt;p&gt;With container structures and CI/CD pipelines isolated, Episode 6 explores client interfaces. We will configure our frontend using &lt;strong&gt;Astro&lt;/strong&gt; and &lt;strong&gt;React's Island Architecture&lt;/strong&gt; to maintain high rendering speeds and minimal Javascript runtimes.&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>docker</category>
      <category>devops</category>
      <category>architecture</category>
    </item>
    <item>
      <title>[Homelab AI Project] Ep.4 - Isolating the Service Layer with Docker Compose</title>
      <dc:creator>Tristan Kilhwan Chai</dc:creator>
      <pubDate>Thu, 16 Jul 2026 06:46:48 +0000</pubDate>
      <link>https://dev.to/tristan_kilhwanchai_5954/homelab-ai-project-episode-4-isolating-the-service-layer-with-docker-compose-2cih</link>
      <guid>https://dev.to/tristan_kilhwanchai_5954/homelab-ai-project-episode-4-isolating-the-service-layer-with-docker-compose-2cih</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkpfknqiouckzwidjemk2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkpfknqiouckzwidjemk2.png" alt=" " width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Series: Building a Personalized AI News Pipeline (4/23)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Executive Summary
&lt;/h3&gt;

&lt;p&gt;This post details how to design a multi-service container infrastructure on a single homelab server (Ryzen 5500U, 16GB RAM) using Docker Compose. We implement hardware resource caps via cgroups (allocating 4GB to PostgreSQL and 1GB to Redis) and establish a dual-network topology (Internal vs. External) to prevent resource contention and secure data stores. We also share troubleshooting lessons on enabling Linux memory controllers, Docker DNS search ordering, and environment parser edge cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;: docker-compose resource limits docs / Linux cgroups documentation&lt;/p&gt;




&lt;h3&gt;
  
  
  1. The Challenge: Monolithic Mess vs. Virtualization Overhead
&lt;/h3&gt;

&lt;p&gt;Our AI News Pipeline consists of four distinct architectural components:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;RSS Collector (Node.js): Constantly crawls tech feeds.&lt;/li&gt;
&lt;li&gt;AI Processing Engine (Python): Handles embedding generation, Vector DB querying (RAG), and LLM orchestration.&lt;/li&gt;
&lt;li&gt;Database (PostgreSQL + pgvector): Stores articles, metadata, and high-dimensional vectors.&lt;/li&gt;
&lt;li&gt;Queue &amp;amp; Cache (Redis 7.2 Alpine): Orchestrates tasks and caches API requests.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In a naive setup, one might install these services directly on the host OS (dnf install postgresql-server redis). However, in a production-grade setting, this leads to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Resource Contention: Heavy embedding computation in Python can spike CPU and memory, triggering the host OS's Out-Of-Memory (OOM) Killer to terminate PostgreSQL.&lt;/li&gt;
&lt;li&gt;Dependency Conflicts: Upgrades to system libraries for one service can break dependencies for another.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To avoid this, we could use hypervisor-level virtualization (such as Proxmox or ESXi) to allocate resources statically. But on a mini PC with only 16GB of RAM, running multiple VM operating systems introduces unacceptable virtualization memory overhead. &lt;/p&gt;

&lt;p&gt;The optimal solution is &lt;strong&gt;containerization with Docker Compose&lt;/strong&gt; -- providing lightweight process isolation, strict resource control, and seamless portability from local environment to our production Rocky Linux server.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. The Architecture: Cgroups and Network Segmentation
&lt;/h3&gt;

&lt;p&gt;To adapt enterprise-grade network security and resource scheduling principles to our homelab, we implemented three key design patterns:&lt;/p&gt;

&lt;h4&gt;
  
  
  1. Resource Constraints via cgroups
&lt;/h4&gt;

&lt;p&gt;Docker utilizes Linux kernel cgroups to restrict container workloads. We enforced hard limits to protect host OS stability:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PostgreSQL (ainews-db): Limit to &lt;strong&gt;4GB RAM, 2.0 CPUs&lt;/strong&gt;. Enough for pgvector indexing and cache buffers, but capped to prevent host OS starvation.&lt;/li&gt;
&lt;li&gt;Redis (ainews-queue): Limit to &lt;strong&gt;1GB RAM, 1.0 CPU&lt;/strong&gt;. Activates eviction policies if memory usage exceeds this cap.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  2. Dual-Network Partitioning
&lt;/h4&gt;

&lt;p&gt;Containers should not share a single flat network. We separated traffic into two custom bridge networks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ainews-internal: Only contains PostgreSQL (ainews-db) and Redis (ainews-queue). It has no outbound internet route (internal: true) and is inaccessible from the outside world.&lt;/li&gt;
&lt;li&gt;ainews-external: Contains the public-facing Frontend (Astro), the Collector (which needs outbound internet access to fetch RSS feeds), and the Cloudflare Tunnel agent (cloudflared).&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  3. Complete Configuration Decoupling
&lt;/h4&gt;

&lt;p&gt;All secrets, ports, and volume paths are abstracted into a local .env file, allowing us to keep configuration separate from the core stack declaration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;graph TD
    subgraph Host_OS ["Rocky Linux 10.1 (Host)"]
        subgraph Ext_Net ["ainews-external Network"]
            CF_Tunnel["Cloudflare Tunnel (cloudflared)"]
            Astro_FE["Astro + React Frontend"]
            Collector["Node.js RSS Collector"]
            AI_Engine["Python AI Engine"]
        end

        subgraph Int_Net ["ainews-internal Network"]
            Postgres["PostgreSQL + pgvector (ainews-db) &amp;lt;br&amp;gt; [cgroups limit: 4GB]"]
            Redis["Redis 7.2 Alpine (ainews-queue) &amp;lt;br&amp;gt; [cgroups limit: 1GB]"]
        end
    end

    CF_Tunnel --- Astro_FE
    Astro_FE --- Postgres
    Collector --- Postgres
    Collector --- Redis
    AI_Engine --- Postgres
    AI_Engine --- Redis

    style Postgres fill:#336791,stroke:#fff,stroke-width:2px,color:#fff
    style Redis fill:#D82C20,stroke:#fff,stroke-width:2px,color:#fff
    style CF_Tunnel fill:#F38020,stroke:#fff,stroke-width:2px,color:#fff
    style Ext_Net fill:#e1f5fe,stroke:#0288d1,stroke-width:1px,stroke-dasharray: 5 5
    style Int_Net fill:#efebe9,stroke:#5d4037,stroke-width:1px,stroke-dasharray: 5 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  3. Setup Files: infra/docker-compose.yml &amp;amp; .env.example
&lt;/h3&gt;

&lt;h4&gt;
  
  
  infra/.env.example
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PROJECT_NAME=ai-news

POSTGRES_USER=tristan
POSTGRES_PASSWORD=super-secret-password-change-me
POSTGRES_DB=ainews
POSTGRES_PORT=5432
DB_VOLUME_PATH=./volumes/postgres

REDIS_PORT=6379
REDIS_VOLUME_PATH=./volumes/redis

NODE_ENV=production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  infra/docker-compose.yml
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.8'&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ainews-internal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bridge&lt;/span&gt;
    &lt;span class="na"&gt;internal&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;ainews-external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bridge&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# 1. PostgreSQL DB with pgvector&lt;/span&gt;
  &lt;span class="na"&gt;ainews-db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ainews-db&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_USER}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_DB}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;${DB_VOLUME_PATH}:/var/lib/postgresql/data:z&lt;/span&gt; &lt;span class="c1"&gt;# :z is required for SELinux context&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${POSTGRES_PORT}:5432"&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ainews-internal&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cpus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;2.0'&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;4096M&lt;/span&gt;

  &lt;span class="c1"&gt;# 2. Redis Cache &amp;amp; Queue&lt;/span&gt;
  &lt;span class="na"&gt;ainews-queue&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:7.2-alpine&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ainews-queue&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-server --appendonly yes --requirepass ${POSTGRES_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;${REDIS_VOLUME_PATH}:/data:z&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${REDIS_PORT}:6379"&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ainews-internal&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cpus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1.0'&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;1024M&lt;/span&gt;

  &lt;span class="c1"&gt;# 3. Node.js RSS Collector&lt;/span&gt;
  &lt;span class="na"&gt;ainews-collector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node:22-slim&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ainews-collector&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;working_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/src/app&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;NODE_ENV&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${NODE_ENV}&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@ainews-db:5432/${POSTGRES_DB}      REDIS_URL&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis://:${POSTGRES_PASSWORD}@ainews-queue:6379/0    networks&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ainews-internal&lt;/span&gt; &lt;span class="c1"&gt;# Primary network first to prioritize internal DNS resolution&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ainews-external&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cpus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1.0'&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;512M&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  4. Real-World Troubleshooting
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Issue 1: Cgroup Memory Limits Ignored under Standalone Compose
&lt;/h4&gt;

&lt;p&gt;After starting the stack, running docker stats revealed that resource caps were not being enforced; the memory limits showed the host PC's full memory allocation (16GB).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cause: On certain installations of modern RHEL/Rocky Linux environments running cgroups v2, memory controllers may not automatically map limits down to containers unless the system boot parameters explicitly delegate memory swapping and namespace control.&lt;/li&gt;
&lt;li&gt;Resolution: Update the GRUB kernel parameters using grubby to ensure the memory cgroup controller is active at boot:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  &lt;span class="c"&gt;# Check if memory controller is enabled in cgroups&lt;/span&gt;
  &lt;span class="nb"&gt;cat&lt;/span&gt; /proc/cgroups | &lt;span class="nb"&gt;grep &lt;/span&gt;memory

  &lt;span class="c"&gt;# If disabled, enable memory and swap accounting via grubby&lt;/span&gt;
  &lt;span class="nb"&gt;sudo &lt;/span&gt;grubby &lt;span class="nt"&gt;--update-kernel&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ALL &lt;span class="nt"&gt;--args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"cgroup_enable=memory swapaccount=1"&lt;/span&gt;
  &lt;span class="nb"&gt;sudo &lt;/span&gt;reboot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After restarting, docker stats correctly recognized the limits (4GiB for DB, 1GiB for queue).&lt;/p&gt;

&lt;h4&gt;
  
  
  Issue 2: DNS Resolution Failures inside Dual-Network Containers
&lt;/h4&gt;

&lt;p&gt;The collector container crashed immediately on startup, failing to connect to the DB. The logs outputted:&lt;br&gt;
Error: getaddrinfo ENOTFOUND ainews-db&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cause: When a container is attached to multiple bridge networks (in this case, both ainews-internal and ainews-external), Docker's internal DNS resolver configures the search path based on the sequence in which networks are declared. If the external network is registered first, DNS requests for internal hosts (like ainews-db) may get routing priority on the external network gateway, failing to resolve the internal hostname.&lt;/li&gt;
&lt;li&gt;Resolution: Reorder the networks block under the service definition in docker-compose.yml, placing the private network (ainews-internal) at the top of the array:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ainews-internal&lt;/span&gt;  &lt;span class="c1"&gt;# Prioritize private network DNS&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ainews-external&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This forces the container's resolver to query the internal bridge first, resolving internal hostnames instantaneously.&lt;/p&gt;

&lt;h4&gt;
  
  
  Issue 3: Docker Compose .env Parsing Failure with Secret Special Characters
&lt;/h4&gt;

&lt;p&gt;The database container failed to launch, reporting authentication syntax errors even though the password matched our .env configuration.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cause: We generated a strong password containing a # character. The standard Docker Compose environment parser reads .env lines literally and interprets # as the beginning of a comment. Consequently, everything after the # character was stripped out, leaving PostgreSQL with a truncated password.&lt;/li&gt;
&lt;li&gt;Resolution: Wrap any variable values containing special characters in double quotes (") inside the .env file to escape the parser:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  POSTGRES_PASSWORD="my#secret$password"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  5. Results &amp;amp; Metrics
&lt;/h3&gt;

&lt;p&gt;Here are the resource usage statistics captured under active crawling load:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Container&lt;/th&gt;
&lt;th&gt;Memory Limit&lt;/th&gt;
&lt;th&gt;Memory (Idle)&lt;/th&gt;
&lt;th&gt;Memory (Peak)&lt;/th&gt;
&lt;th&gt;CPU Limit&lt;/th&gt;
&lt;th&gt;CPU (Peak Load)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ainews-db&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4096 MiB&lt;/td&gt;
&lt;td&gt;~120 MiB&lt;/td&gt;
&lt;td&gt;1.8 GiB (RAG Queries)&lt;/td&gt;
&lt;td&gt;2.0 Cores&lt;/td&gt;
&lt;td&gt;~45%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ainews-queue&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1024 MiB&lt;/td&gt;
&lt;td&gt;~12 MiB&lt;/td&gt;
&lt;td&gt;~85 MiB (High Queue Load)&lt;/td&gt;
&lt;td&gt;1.0 Core&lt;/td&gt;
&lt;td&gt;~5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ainews-collector&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;512 MiB&lt;/td&gt;
&lt;td&gt;~65 MiB&lt;/td&gt;
&lt;td&gt;~180 MiB (Parallel Parsing)&lt;/td&gt;
&lt;td&gt;1.0 Core&lt;/td&gt;
&lt;td&gt;~35%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;Zero Resource Contention: No matter how computationally intensive our RAG indexing operations get, ainews-db is strictly locked out of hogging the entire host memory, preserving a safe overhead of 1.5GB for host OS systemd tasks and the Cloudflare Tunnel daemon.&lt;/li&gt;
&lt;li&gt;Port Scans Mitigated: Because our public-facing ports are bound internally and exposed strictly via cloudflared edge routing, external port scanning attempts against our home IP register a complete drop (closed status), keeping server audit logs clean of noise.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  6. Next Up
&lt;/h3&gt;

&lt;p&gt;With our backend services isolated and secured in Docker Compose, the next challenge is managing deployments. In Episode 5, we will set up Gitea (a lightweight 100MB self-hosted Git server) and Act Runner to configure a seamless, local CI/CD pipeline on push events.&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>docker</category>
      <category>devops</category>
      <category>architecture</category>
    </item>
    <item>
      <title>[Homelab AI Project] Ep.3 - Zero Inbound Ports. Full Remote Dev Access. The Cloudflare Tunnel Architecture.</title>
      <dc:creator>Tristan Kilhwan Chai</dc:creator>
      <pubDate>Tue, 14 Jul 2026 00:44:29 +0000</pubDate>
      <link>https://dev.to/tristan_kilhwanchai_5954/homelab-ai-episode-3-zero-inbound-ports-full-remote-dev-access-the-cloudflare-tunnel-36en</link>
      <guid>https://dev.to/tristan_kilhwanchai_5954/homelab-ai-episode-3-zero-inbound-ports-full-remote-dev-access-the-cloudflare-tunnel-36en</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F12laegg8x7pjfjkkhlqj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F12laegg8x7pjfjkkhlqj.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;This post is Episode 3 of my &lt;a href="https://velog.io/@tris0703" rel="noopener noreferrer"&gt;Homelab AI Project series&lt;/a&gt;, where I document building an AI news automation pipeline on a mini PC homelab server instead of the cloud.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Korean version available on &lt;a href="https://velog.io/@tris0703/%ED%99%88%EB%9E%A9-AI-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-3%ED%99%94-Cloudflare-Tunnel%EB%A1%9C-%EA%B3%B5%EC%9D%B8-IP-%EC%97%86%EC%9D%B4-%EC%99%B8%EB%B6%80-%EC%A0%91%EC%86%8D" rel="noopener noreferrer"&gt;Velog&lt;/a&gt;&lt;/em&gt;---## Executive Summary&lt;/p&gt;

&lt;p&gt;This post details how to build a &lt;strong&gt;Zero-Trust, zero-port-forwarding remote development infrastructure&lt;/strong&gt; for a homelab server using &lt;strong&gt;Cloudflare Tunnel (cloudflared)&lt;/strong&gt; and &lt;strong&gt;Cloudflare Access OTP&lt;/strong&gt;. We'll cover the complete architecture - from OS-native systemd installation on the server side, to ProxyCommand-based VS Code Remote-SSH configuration on the client side - plus the real-world troubleshooting you won't find in the official docs.&lt;/p&gt;


&lt;h2&gt;
  
  
  1. The Security Anti-Pattern: Why Port Forwarding Is a Liability
&lt;/h2&gt;

&lt;p&gt;Opening inbound ports via router port forwarding is the default "quick fix" for homelab remote access. The consequences are immediate and measurable.&lt;/p&gt;

&lt;p&gt;Here's real Fail2ban log data captured &lt;strong&gt;12 hours after enabling port 22 forwarding&lt;/strong&gt; as a controlled test on our Rocky Linux 10.1 node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# /var/log/fail2ban.log - 12 hours post-exposure
2026-07-10 02:14:38 fail2ban.actions: NOTICE [sshd] Ban 185.224.128.x  (CN)
2026-07-10 02:17:12 fail2ban.actions: NOTICE [sshd] Ban 45.83.65.x     (RU)
2026-07-10 03:01:55 fail2ban.actions: NOTICE [sshd] Ban 91.240.118.x   (RO)
...
# Total unique IPs banned in 12 hours:      247
# Total failed login attempts logged: 12,481
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Over &lt;strong&gt;12,000 automated brute-force attempts&lt;/strong&gt; in half a day. Each blocked attempt still consumes SSH daemon processing cycles and triggers Fail2ban's iptables rule updates - a persistent, invisible tax on server resources.&lt;/p&gt;

&lt;p&gt;Our architecture mandate: &lt;strong&gt;Zero public IP exposure. Zero inbound ports open.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The Architecture: Outbound-Only Tunnel Model
&lt;/h2&gt;

&lt;p&gt;Cloudflare Tunnel (&lt;code&gt;cloudflared&lt;/code&gt;) inverts the traditional connection model entirely.&lt;/p&gt;

&lt;p&gt;Instead of opening inbound ports to accept connections, we run a lightweight &lt;code&gt;cloudflared&lt;/code&gt; daemon inside our private network that establishes a &lt;strong&gt;persistent outbound HTTPS/TCP connection&lt;/strong&gt; to Cloudflare's global edge network (321+ PoPs as of 2026).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The key security property&lt;/strong&gt;: Malicious packets - port scans, brute-force attempts, vulnerability probes - never reach the homelab server. They terminate at Cloudflare's edge and are silently dropped before any traffic is forwarded into the private network.&lt;/p&gt;

&lt;p&gt;The connection flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;cloudflared&lt;/code&gt; agent (homelab) -&amp;gt; establishes persistent outbound tunnel to Cloudflare Edge&lt;/li&gt;
&lt;li&gt;Developer (laptop) -&amp;gt; connects to &lt;code&gt;ssh.your-domain.com&lt;/code&gt; (Cloudflare Edge)&lt;/li&gt;
&lt;li&gt;Cloudflare Edge -&amp;gt; issues OTP challenge, verifies identity&lt;/li&gt;
&lt;li&gt;Cloudflare Edge -&amp;gt; forwards verified traffic through existing tunnel&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cloudflared&lt;/code&gt; -&amp;gt; proxies to local SSH daemon (port 22)&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  3. Server-Side Implementation: OS-Native systemd Installation
&lt;/h2&gt;

&lt;p&gt;A critical architectural decision must be made upfront: &lt;strong&gt;do not run &lt;code&gt;cloudflared&lt;/code&gt; as a Docker container.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the Docker daemon crashes or fails to restart after an update, the &lt;code&gt;cloudflared&lt;/code&gt; container dies with it - taking your entire SSH tunnel access down. You cannot diagnose what went wrong because the tunnel that would provide SSH access is itself inside the broken Docker stack. This is the equivalent of storing a lifeboat inside the hull of the ship.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cloudflared&lt;/code&gt; must live at the &lt;strong&gt;OS systemd layer&lt;/strong&gt;, independent of Docker entirely. If Docker goes down, the systemd-managed tunnel remains alive, allowing SSH access to diagnose and recover the situation.&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. Install cloudflared binary via RPM (Rocky Linux / RHEL-based)&lt;/span&gt;
curl &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; cloudflared.rpm &lt;span class="se"&gt;\&lt;/span&gt;
  https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-x86_64.rpm
&lt;span class="nb"&gt;sudo &lt;/span&gt;rpm &lt;span class="nt"&gt;-i&lt;/span&gt; cloudflared.rpm

&lt;span class="c"&gt;# 2. Verify installation&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;cloudflared &lt;span class="nt"&gt;--version&lt;/span&gt;
cloudflared version 2026.5.1 &lt;span class="o"&gt;(&lt;/span&gt;built 2026-05-01-1234 UTC&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# 3. Register as systemd service using tunnel token from Cloudflare Zero Trust dashboard&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;cloudflared service &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nv"&gt;$TUNNEL_TOKEN&lt;/span&gt;

&lt;span class="c"&gt;# 4. Enable service and configure for automatic start at boot&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; cloudflared

&lt;span class="c"&gt;# 5. Verify status and measure resource consumption&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;systemctl status cloudflared
&lt;span class="k"&gt;*&lt;/span&gt; cloudflared.service - Cloudflare Tunnel
     Loaded: loaded &lt;span class="o"&gt;(&lt;/span&gt;/etc/systemd/system/cloudflared.service&lt;span class="p"&gt;;&lt;/span&gt; enabled&lt;span class="o"&gt;)&lt;/span&gt;
     Active: active &lt;span class="o"&gt;(&lt;/span&gt;running&lt;span class="o"&gt;)&lt;/span&gt; since 2026-07-10 09:12:34 KST&lt;span class="p"&gt;;&lt;/span&gt; 3 days ago
   Main PID: 1842 &lt;span class="o"&gt;(&lt;/span&gt;cloudflared&lt;span class="o"&gt;)&lt;/span&gt;
      Tasks: 8
     Memory: 21.4M
        CPU: 1.234s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;*&lt;em&gt;Memory: 21.4 MB. CPU accumulated: 1.234 seconds over 3 days of uptime*This post is Episode 3 of my &lt;a href="https://velog.io/@tris0703" rel="noopener noreferrer"&gt;Homelab AI Project series&lt;/a&gt;, where I document building an AI news automation pipeline on a mini PC homelab server instead of the cloud.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Korean version available on &lt;a href="https://velog.io/@tris0703/%ED%99%88%EB%9E%A9-AI-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-3%ED%99%94-Cloudflare-Tunnel%EB%A1%9C-%EA%B3%B5%EC%9D%B8-IP-%EC%97%86%EC%9D%B4-%EC%99%B8%EB%B6%80-%EC%A0%91%EC%86%8D" rel="noopener noreferrer"&gt;Velog&lt;/a&gt;&lt;/em&gt;---## Executive Summary&lt;/p&gt;

&lt;p&gt;This post details how to build a &lt;strong&gt;Zero-Trust, zero-port-forwarding remote development infrastructure&lt;/strong&gt; for a homelab server using &lt;strong&gt;Cloudflare Tunnel (cloudflared)&lt;/strong&gt; and &lt;strong&gt;Cloudflare Access OTP&lt;/strong&gt;. We'll cover the complete architecture - from OS-native systemd installation on the server side, to ProxyCommand-based VS Code Remote-SSH configuration on the client side - plus the real-world troubleshooting you won't find in the official docs.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The Security Anti-Pattern: Why Port Forwarding Is a Liability
&lt;/h2&gt;

&lt;p&gt;Opening inbound ports via router port forwarding is the default "quick fix" for homelab remote access. The consequences are immediate and measurable.&lt;/p&gt;

&lt;p&gt;Here's real Fail2ban log data captured &lt;strong&gt;12 hours after enabling port 22 forwarding&lt;/strong&gt; as a controlled test on our Rocky Linux 10.1 node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# /var/log/fail2ban.log - 12 hours post-exposure
2026-07-10 02:14:38 fail2ban.actions: NOTICE [sshd] Ban 185.224.128.x  (CN)
2026-07-10 02:17:12 fail2ban.actions: NOTICE [sshd] Ban 45.83.65.x     (RU)
2026-07-10 03:01:55 fail2ban.actions: NOTICE [sshd] Ban 91.240.118.x   (RO)
...
# Total unique IPs banned in 12 hours:      247
# Total failed login attempts logged: 12,481
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Over &lt;strong&gt;12,000 automated brute-force attempts&lt;/strong&gt; in half a day. Each blocked attempt still consumes SSH daemon processing cycles and triggers Fail2ban's iptables rule updates - a persistent, invisible tax on server resources.&lt;/p&gt;

&lt;p&gt;Our architecture mandate: &lt;strong&gt;Zero public IP exposure. Zero inbound ports open.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The Architecture: Outbound-Only Tunnel Model
&lt;/h2&gt;

&lt;p&gt;Cloudflare Tunnel (&lt;code&gt;cloudflared&lt;/code&gt;) inverts the traditional connection model entirely.&lt;/p&gt;

&lt;p&gt;Instead of opening inbound ports to accept connections, we run a lightweight &lt;code&gt;cloudflared&lt;/code&gt; daemon inside our private network that establishes a &lt;strong&gt;persistent outbound HTTPS/TCP connection&lt;/strong&gt; to Cloudflare's global edge network (321+ PoPs as of 2026).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The key security property&lt;/strong&gt;: Malicious packets - port scans, brute-force attempts, vulnerability probes - never reach the homelab server. They terminate at Cloudflare's edge and are silently dropped before any traffic is forwarded into the private network.&lt;/p&gt;

&lt;p&gt;The connection flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;cloudflared&lt;/code&gt; agent (homelab) -&amp;gt; establishes persistent outbound tunnel to Cloudflare Edge&lt;/li&gt;
&lt;li&gt;Developer (laptop) -&amp;gt; connects to &lt;code&gt;ssh.your-domain.com&lt;/code&gt; (Cloudflare Edge)&lt;/li&gt;
&lt;li&gt;Cloudflare Edge -&amp;gt; issues OTP challenge, verifies identity&lt;/li&gt;
&lt;li&gt;Cloudflare Edge -&amp;gt; forwards verified traffic through existing tunnel&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cloudflared&lt;/code&gt; -&amp;gt; proxies to local SSH daemon (port 22)&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  3. Server-Side Implementation: OS-Native systemd Installation
&lt;/h2&gt;

&lt;p&gt;A critical architectural decision must be made upfront: &lt;strong&gt;do not run &lt;code&gt;cloudflared&lt;/code&gt; as a Docker container.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the Docker daemon crashes or fails to restart after an update, the &lt;code&gt;cloudflared&lt;/code&gt; container dies with it - taking your entire SSH tunnel access down. You cannot diagnose what went wrong because the tunnel that would provide SSH access is itself inside the broken Docker stack. This is the equivalent of storing a lifeboat inside the hull of the ship.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cloudflared&lt;/code&gt; must live at the &lt;strong&gt;OS systemd layer&lt;/strong&gt;, independent of Docker entirely. If Docker goes down, the systemd-managed tunnel remains alive, allowing SSH access to diagnose and recover the situation.&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. Install cloudflared binary via RPM (Rocky Linux / RHEL-based)&lt;/span&gt;
curl &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; cloudflared.rpm &lt;span class="se"&gt;\&lt;/span&gt;
  https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-x86_64.rpm
&lt;span class="nb"&gt;sudo &lt;/span&gt;rpm &lt;span class="nt"&gt;-i&lt;/span&gt; cloudflared.rpm

&lt;span class="c"&gt;# 2. Verify installation&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;cloudflared &lt;span class="nt"&gt;--version&lt;/span&gt;
cloudflared version 2026.5.1 &lt;span class="o"&gt;(&lt;/span&gt;built 2026-05-01-1234 UTC&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# 3. Register as systemd service using tunnel token from Cloudflare Zero Trust dashboard&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;cloudflared service &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nv"&gt;$TUNNEL_TOKEN&lt;/span&gt;

&lt;span class="c"&gt;# 4. Enable service and configure for automatic start at boot&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; cloudflared
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 5. Verify status and measure resource consumption&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;systemctl status cloudflared
&lt;span class="k"&gt;*&lt;/span&gt; cloudflared.service - Cloudflare Tunnel
     Loaded: loaded &lt;span class="o"&gt;(&lt;/span&gt;/etc/systemd/system/cloudflared.service&lt;span class="p"&gt;;&lt;/span&gt; enabled&lt;span class="o"&gt;)&lt;/span&gt;
     Active: active &lt;span class="o"&gt;(&lt;/span&gt;running&lt;span class="o"&gt;)&lt;/span&gt; since 2026-07-10 09:12:34 KST&lt;span class="p"&gt;;&lt;/span&gt; 3 days ago
   Main PID: 1842 &lt;span class="o"&gt;(&lt;/span&gt;cloudflared&lt;span class="o"&gt;)&lt;/span&gt;
      Tasks: 8
     Memory: 21.4M
        CPU: 1.234s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Memory: 21.4 MB. CPU accumulated: 1.234 seconds over 3 days of uptime&lt;/strong&gt; - effectively idle. The OS boots &lt;code&gt;cloudflared&lt;/code&gt; before Docker even starts, and it continues running regardless of Docker's health.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Zero-Trust Access Policy Configuration
&lt;/h2&gt;

&lt;p&gt;Within the Cloudflare Zero Trust Dashboard (Access -&amp;gt; Applications), we define two types of access policies:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Web Service Routes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;git.your-domain.com&lt;/code&gt; -&amp;gt; internal Gitea container (&lt;code&gt;gitea:3000&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dashboard.your-domain.com&lt;/code&gt; -&amp;gt; internal Astro dashboard (&lt;code&gt;astro:4321&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;SSH Service Route with MFA Guardrail:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ssh.your-domain.com&lt;/code&gt; -&amp;gt; local SSH daemon (&lt;code&gt;localhost:22&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policy&lt;/strong&gt;: &lt;code&gt;Include&lt;/code&gt; -&amp;gt; Email -&amp;gt; &lt;code&gt;your-email@your-domain.com&lt;/code&gt;- &lt;strong&gt;Auth Method&lt;/strong&gt;: One-time PIN (OTP) - enforced at edge before any tunnel traffic is forwarded&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The policy enforcement model is critical: unauthenticated connection attempts to &lt;code&gt;ssh.your-domain.com&lt;/code&gt; are &lt;strong&gt;terminated at the Cloudflare edge&lt;/strong&gt;. The OTP challenge is issued before a single packet reaches the homelab server.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Client-Side Configuration: Bridging the Tunnel Locally
&lt;/h2&gt;

&lt;p&gt;The developer's external laptop requires a local &lt;code&gt;cloudflared&lt;/code&gt; binary to function as the SSH proxy client.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Install cloudflared on the client machine&lt;/strong&gt;&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;# Windows&lt;/span&gt;
winget &lt;span class="nb"&gt;install &lt;/span&gt;Cloudflare.cloudflared

&lt;span class="c"&gt;# macOS&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;cloudflare/cloudflare/cloudflared
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2: SSH Config with ProxyCommand&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;# ~/.ssh/config
Host home-server
    HostName ssh.your-domain.com
    User your-username
    Port 22
    ProxyCommand cloudflared access ssh --hostname %h
    # Windows: use absolute path if ProxyCommand cannot resolve PATH
    # ProxyCommand "C:\Tools\cloudflared.exe" access ssh --hostname %h
    IdentityFile ~/.ssh/id_rsa_homelab
    TCPKeepAlive yes
    ServerAliveInterval 60
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3: VS Code Remote-SSH connection&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;F1&lt;/code&gt; -&amp;gt; &lt;code&gt;SSH: Connect to Host...&lt;/code&gt; -&amp;gt; &lt;code&gt;home-server&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;On first connection, a browser window opens to the Cloudflare Access OTP verification page. After email OTP verification, the tunnel session is established and VS Code's status bar shows &lt;code&gt;[SSH: home-server]&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Real-World Troubleshooting
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Issue 1: &lt;code&gt;ProxyCommand&lt;/code&gt; fails silently on Windows when the binary path contains spaces
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;winget&lt;/code&gt; installs &lt;code&gt;cloudflared.exe&lt;/code&gt; to a path containing the Windows username (e.g., &lt;code&gt;C:\Users\YourUsername\...&lt;/code&gt;). SSH config's &lt;code&gt;ProxyCommand&lt;/code&gt; parser splits on spaces, causing the command to fail with a misleading error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ssh: connect to host ssh.your-domain.com port 22: No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: Copy &lt;code&gt;cloudflared.exe&lt;/code&gt; to a space-free path (e.g., &lt;code&gt;C:\Tools\cloudflared.exe&lt;/code&gt;) and reference it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ProxyCommand "C:\Tools\cloudflared.exe" access ssh --hostname %h
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Issue 2: First connection hangs without triggering the OTP browser flow
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;ProxyCommand&lt;/code&gt; silently times out on the first connection attempt if no local Cloudflare Access session token exists. You must initialize the local token cache explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cloudflared access login https://ssh.your-domain.com
&lt;span class="c"&gt;# Browser opens -&amp;gt; OTP verification -&amp;gt; token cached to ~/.cloudflared/&lt;/span&gt;
&lt;span class="c"&gt;# Subsequent SSH connections via ProxyCommand reuse this cached token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Issue 3: &lt;code&gt;cloudflared service install&lt;/code&gt; embeds TUNNEL_TOKEN in plaintext
&lt;/h3&gt;

&lt;p&gt;Running &lt;code&gt;sudo cloudflared service install $TUNNEL_TOKEN&lt;/code&gt; writes the token directly into the &lt;code&gt;ExecStart&lt;/code&gt; line of &lt;code&gt;/etc/systemd/system/cloudflared.service&lt;/code&gt;. The token is also visible via &lt;code&gt;ps aux&lt;/code&gt; and &lt;code&gt;systemctl show&lt;/code&gt;. A leaked tunnel token enables full tunnel hijacking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: Use an &lt;code&gt;EnvironmentFile&lt;/code&gt; for secret injection:&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. Isolate the token in a root-only env file (chmod 600)&lt;/span&gt;
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/cloudflared
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/cloudflared/env &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
TUNNEL_TOKEN=your-actual-token-here
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 /etc/cloudflared/env
&lt;span class="nb"&gt;sudo chown &lt;/span&gt;root:root /etc/cloudflared/env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 2. Override the service file&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl edit cloudflared
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/cloudflared.service.d/override.conf
&lt;/span&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/cloudflared tunnel run&lt;/span&gt;
&lt;span class="py"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/etc/cloudflared/env&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart cloudflared
&lt;span class="nv"&gt;$ &lt;/span&gt;ps aux | &lt;span class="nb"&gt;grep &lt;/span&gt;cloudflared
root 1842  0.0  0.1 cloudflared tunnel run  &lt;span class="c"&gt;# Token absent from args&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  7. Architecture Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Port Forwarding&lt;/th&gt;
&lt;th&gt;Cloudflare Tunnel (OS Native)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Inbound ports exposed&lt;/td&gt;
&lt;td&gt;SSH 22, HTTP 80/443&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public IP visibility&lt;/td&gt;
&lt;td&gt;Exposed in DNS A records&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Hidden (Cloudflare IPs only)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brute-force attack surface&lt;/td&gt;
&lt;td&gt;Reaches server directly&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Terminated at edge&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication layers&lt;/td&gt;
&lt;td&gt;SSH key only&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Edge OTP + SSH key (2-factor)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic IP handling&lt;/td&gt;
&lt;td&gt;Requires DDNS&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Handled automatically&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSH access if Docker fails&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;[OK] Survives - OS layer is independent&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly cost&lt;/td&gt;
&lt;td&gt;$0 (but attack surface)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0 (Free Tier)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server resource overhead&lt;/td&gt;
&lt;td&gt;CPU ~3% (Fail2ban)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Memory 21 MB, CPU ~0 (idle)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  8. Next Episode
&lt;/h2&gt;

&lt;p&gt;In &lt;strong&gt;Episode 4&lt;/strong&gt;, we design the multi-service Docker Compose architecture - separating PostgreSQL, Redis, the Node.js collector, and the AI engine into isolated Docker networks with controlled inter-service communication.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Follow the series: &lt;a href="https://velog.io/@tris0703" rel="noopener noreferrer"&gt;Velog (Korean)&lt;/a&gt; | &lt;a href="https://dev.to/tristan_kilhwanchai_5954"&gt;Dev.to&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




</description>
      <category>homelab</category>
      <category>devops</category>
      <category>cloudflare</category>
      <category>security</category>
    </item>
    <item>
      <title>[Homelab AI Project] Ep.2 - Rocky Linux 10.x OS Hardening &amp; Kernel Tuning</title>
      <dc:creator>Tristan Kilhwan Chai</dc:creator>
      <pubDate>Mon, 13 Jul 2026 07:48:35 +0000</pubDate>
      <link>https://dev.to/tristan_kilhwanchai_5954/homelab-ai-project-ep2-rocky-linux-10x-os-hardening-kernel-tuning-m5m</link>
      <guid>https://dev.to/tristan_kilhwanchai_5954/homelab-ai-project-ep2-rocky-linux-10x-os-hardening-kernel-tuning-m5m</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyrq73wqrb6detb908oys.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyrq73wqrb6detb908oys.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  [Core] Executive Summary
&lt;/h3&gt;

&lt;p&gt;An in-depth systems engineering guide to turning a budget Mini PC running bare-metal &lt;strong&gt;Rocky Linux 10.x&lt;/strong&gt; into a resilient, high-concurrency database/message-queue node. This post covers optimizing base OS RAM usage &lt;strong&gt;under 150MB&lt;/strong&gt;, securing ports with &lt;strong&gt;Fail2ban&lt;/strong&gt;, configuring physical disk partition isolation, installing base host runtimes (Docker Engine &amp;amp; Docker Compose), and tuning core kernel parameters for Redis and PostgreSQL.&lt;/p&gt;




&lt;h3&gt;
  
  
  1. Introduction: Between Cloud Mazes and Terminal Dark Worlds
&lt;/h3&gt;

&lt;p&gt;Deploying virtualized servers on public clouds like AWS is the industry standard for production environments. However, that convenience comes with a cost: staring at five browser tabs, hunting for hidden options, and clicking dropdown selectors like you're playing a hidden-object game in the AWS console.&lt;/p&gt;

&lt;p&gt;To escape that, we retreat to the terminal--an unforgiving, dark space where a single typo can reduce your entire system to ashes. In the end, it's just about what risks you are willing to own and manage. The moment that lone cursor starts blinking, the reality of bare-metal homelabs hits you with a barrage of systems-level constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;IP Instability (DHCP)&lt;/strong&gt;: The home router changing the server IP on a whim, breaking all internal service links.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Immediate Port Scans&lt;/strong&gt;: Automated bots knocking on Port 22 within minutes of bringing the server online.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage Disasters&lt;/strong&gt;: Docker logging and DB data accumulating until the root filesystem hits 100% capacity, triggering a fatal OS kernel panic and locking you out of SSH.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If we don't isolate and secure these physical hardware risks at the OS layer, whatever shiny Node.js pipeline or RAG orchestration we build on top will just be a house of cards. Here is how I constructed the resilient foundation for my homelab server--along with a single script to automate the entire process.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. Operating System Selection: "Why I Chose Rocky Linux 10.x Over Ubuntu"
&lt;/h3&gt;

&lt;p&gt;Ubuntu Server is the absolute standard. It has the largest ecosystem, the most resources, and is a rock-solid choice for both home and enterprise production. I love Ubuntu and use it frequently.&lt;/p&gt;

&lt;p&gt;However, for this project, I set aside Ubuntu's cozy ecosystem. Driven by personal familiarity, pure control, and a bit of extreme "hunger spirit" for efficiency, I opted for &lt;strong&gt;Rocky Linux 10.x (Minimal ISO)&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Speed of Familiarity&lt;/strong&gt;: You troubleshoot faster when you use tools that match your muscle memory. The robust directory layout of RHEL-family distributions and the strictness of its package manager make me feel more in control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extreme Memory Diet&lt;/strong&gt;: By installing the Minimal ISO, I stripped out every unnecessary daemon and GUI component (like Gnome). The base RAM consumption at boot is a mere &lt;strong&gt;140MiB&lt;/strong&gt;.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;free &lt;span class="nt"&gt;-h&lt;/span&gt;
               total        used        free      shared  buff/cache   available
Mem:            15Gi       140Mi        14Gi       8.0Mi       480Mi        15Gi
Swap:          2.0Gi          0B       2.0Gi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This minimal footprint ensures that almost 99% of my Mini PC's 16GB RAM is reserved entirely for memory-intensive PostgreSQL (pgvector) calculations and the Redis 7.2 queue.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. Storage Design: Split Partition Isolation (Disk A vs. Disk B)
&lt;/h3&gt;

&lt;p&gt;My Mini PC has two 512GB SSDs. Simply merging them into a single logical volume (LVM) was out of the question. To prevent a storage bottleneck from taking down the entire system, I isolated the partitions physically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;graph TD
    subgraph Mini_PC["Mini PC (Rocky Linux 10.x)"]
        subgraph Hardware["Physical Hardware"]
            SSD_A["SSD A (512GB)"]
            SSD_B["SSD B (512GB)"]
        end
        subgraph OS_Layer["OS Layer (RAM &amp;lt; 200MB)"]
            Kernel["Kernel (vm.overcommit_memory=1, vm.swappiness=10, net.core.somaxconn=1024)"]
            Firewall["Firewalld (White-listed IPs)"]
            Fail2ban["Fail2ban (Bans brute-force IPs)"]
            SSH["SSH Daemon (Port xxxx, No Root Login)"]
        end
        subgraph Storage_Mounts["Storage Mounts"]
            RootMount["/ (Root Filesystem)"]
            DataMount["/data (Data Filesystem)"]
        end
        subgraph Docker_Layer["Docker Container Layer (SELinux :z context)"]
            PostgreSQL["PostgreSQL 16 + pgvector"]
            Redis["Redis 7.2 (Queue/Cache)"]
            Apps["Collector, AI Engine, Sender"]
        end
    end

    SSD_A --&amp;gt; RootMount
    SSD_B --&amp;gt; DataMount
    RootMount --&amp;gt; OS_Layer
    DataMount -.-&amp;gt;|Volume Bind Mount :z| Docker_Layer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Disk A (SSD 1 - 512GB)&lt;/strong&gt;: Mounted at &lt;code&gt;/&lt;/code&gt; (Root). Contains only system configuration files and packages. Its size remains static.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk B (SSD 2 - 512GB)&lt;/strong&gt;: Mounted at &lt;code&gt;/data&lt;/code&gt;.

&lt;ul&gt;
&lt;li&gt;High-frequency read/write operations (Gitea repositories, workspace, PostgreSQL pgvector data directory, Redis AOF/RDB snapshots) are exiled to this &lt;code&gt;/data&lt;/code&gt; mount.
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt;
Filesystem      Size  Used Avail Use% Mounted on
/dev/sda1        492G  2.1G  465G   1% /
/dev/sdb1        492G   24K  467G   1% /data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The benefit is clear:&lt;/strong&gt;&lt;br&gt;
If a database log overflows or backup files consume 100% of the &lt;code&gt;/data&lt;/code&gt; partition, the OS on Disk A stays alive. Essential system logs (syslog, journald) and system processes continue to execute, ensuring SSH access remains available for root administrator recovery. If the OS completely crashes, I can wipe Disk A, reinstall the OS, remount Disk B, and restore the service with zero data loss.&lt;/p&gt;


&lt;h3&gt;
  
  
  4. Network Configuration &amp;amp; Security Hardening
&lt;/h3&gt;

&lt;p&gt;First, I set up a static IP within the local network using NetworkManager CLI (&lt;code&gt;nmcli&lt;/code&gt;):&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;# nmcli manual static IP assignment&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nmcli connection modify eth0 ipv4.addresses &lt;span class="s2"&gt;"192.168.0.100/24"&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nmcli connection modify eth0 ipv4.gateway &lt;span class="s2"&gt;"192.168.0.1"&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nmcli connection modify eth0 ipv4.dns &lt;span class="s2"&gt;"1.1.1.1,8.8.8.8"&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nmcli connection modify eth0 ipv4.method manual
&lt;span class="nb"&gt;sudo &lt;/span&gt;nmcli connection up eth0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To block brute-force scanners, I disabled Root login and changed the default SSH port in &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Port xxxx
PermitRootLogin no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, I deployed &lt;code&gt;Fail2ban&lt;/code&gt; to act as a sentinel. Any IP that fails login 3 times is banned for 24 hours:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/fail2ban/jail.local
&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;port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;xxxx&lt;/span&gt;
&lt;span class="py"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;sshd&lt;/span&gt;
&lt;span class="py"&gt;logpath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/var/log/secure&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;span class="py"&gt;findtime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;600&lt;/span&gt;
&lt;span class="py"&gt;bantime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;86400  # 24-hour ban&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Security Note:&lt;/strong&gt; This firewall hardening is just a baseline protection for the local network. Opening port forwarding on your router to expose this SSH port directly to the public internet will still fill your Fail2ban logs with thousands of blocks. The proper way to connect remotely without exposing any ports is through Cloudflare Tunnel, which we will set up in Episode 3.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  5. Kernel Optimizations
&lt;/h3&gt;

&lt;p&gt;Configure &lt;code&gt;/etc/sysctl.d/99-sysctl.conf&lt;/code&gt; to handle high-concurrency requests and Redis database snapshots.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# 1. Prevent background persistence failures in Redis (BGSAVE)
vm.overcommit_memory = 1

# 2. Prevent latency spikes by limiting aggressive swapping
vm.swappiness = 10

# 3. Prevent packet drops under high-concurrency requests
net.core.somaxconn = 1024
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply immediately:&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;sysctl &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/sysctl.d/99-sysctl.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  [Warning] Today's Major Gotcha: SELinux vs Docker
&lt;/h4&gt;

&lt;p&gt;Rocky Linux enables SELinux in &lt;code&gt;Enforcing&lt;/code&gt; mode by default. If you attempt a standard Docker volume mount (&lt;code&gt;- /data/postgres:/var/lib/postgresql/data&lt;/code&gt;), the container process will throw a &lt;code&gt;Permission Denied&lt;/code&gt; error when writing to the host directory.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Fix&lt;/strong&gt;: Do not disable SELinux. Instead, append the &lt;code&gt;:z&lt;/code&gt; flag in your Docker Compose volume bindings to trigger automatic SELinux security context labeling (e.g., &lt;code&gt;- /data/postgres:/var/lib/postgresql/data:z&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  6. Initialization Script (&lt;code&gt;setup.sh&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;To ensure this entire environment setup--including installing base host runtimes (Docker Engine, Docker Compose Plugin) and applying kernel/security hardening--is reproducible and safe from typos, use the following bash initialization script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Rocky Linux 10.x Initialization &amp;amp; Optimization Script&lt;/span&gt;
&lt;span class="c"&gt;# Execution: sudo bash setup.sh&lt;/span&gt;

&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[+] Rocky Linux 10.x Optimization Script Started"&lt;/span&gt;

&lt;span class="c"&gt;# 1. DNF Optimization&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[+] Optimizing DNF package manager..."&lt;/span&gt;
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/dnf/dnf.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
max_parallel_downloads=10
fastestmirror=True
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# 2. Update and Utilities Installation&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[+] Updating system packages &amp;amp; installing utilities..."&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf update &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; epel-release dnf-plugins-core
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; fail2ban git curl policycoreutils-python-utils

&lt;span class="c"&gt;# 3. Docker Engine &amp;amp; Docker Compose Plugin Installation&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[+] Installing Docker Engine &amp;amp; Docker Compose Plugin..."&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf config-manager &lt;span class="nt"&gt;--add-repo&lt;/span&gt; https://download.docker.com/linux/centos/docker-ce.repo
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; docker

&lt;span class="c"&gt;# 4. Physical Partition Isolation Check&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[+] Checking physical partition isolation..."&lt;/span&gt;
&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"/data"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[!] WARNING: /data mount point not detected. Ensure SSD B is mounted before running containers."&lt;/span&gt;

&lt;span class="c"&gt;# 5. Firewall Configuration&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[+] Configuring firewalld for secure access..."&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; firewalld
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--new-zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;homelab-zone &lt;span class="nt"&gt;--permanent&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;homelab-zone &lt;span class="nt"&gt;--add-source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;192.168.0.0/24 &lt;span class="nt"&gt;--permanent&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;homelab-zone &lt;span class="nt"&gt;--add-port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;xxxx/tcp &lt;span class="nt"&gt;--permanent&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;homelab-zone &lt;span class="nt"&gt;--add-masquerade&lt;/span&gt; &lt;span class="nt"&gt;--permanent&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--reload&lt;/span&gt;

&lt;span class="c"&gt;# 6. SSH Daemon Hardening&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[+] Hardening SSH daemon settings (Port xxxx, No Root)..."&lt;/span&gt;
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/#Port 22/Port xxxx/'&lt;/span&gt; /etc/ssh/sshd_config
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/#PermitRootLogin yes/PermitRootLogin no/'&lt;/span&gt; /etc/ssh/sshd_config
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/PermitRootLogin yes/PermitRootLogin no/'&lt;/span&gt; /etc/ssh/sshd_config

&lt;span class="c"&gt;# Register custom port to SELinux&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[+] Registering custom SSH port to SELinux..."&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;semanage port &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; ssh_port_t &lt;span class="nt"&gt;-p&lt;/span&gt; tcp xxxx &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;semanage port &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; ssh_port_t &lt;span class="nt"&gt;-p&lt;/span&gt; tcp xxxx
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart sshd

&lt;span class="c"&gt;# 7. Fail2ban Setup&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[+] Setting up Fail2ban brute-force protection..."&lt;/span&gt;
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/fail2ban/jail.local &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
[sshd]
enabled = true
port = xxxx
filter = sshd
logpath = /var/log/secure
maxretry = 3
findtime = 600
bantime = 86400
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; fail2ban

&lt;span class="c"&gt;# 8. Kernel Optimizations&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[+] Injecting Kernel parameter optimizations..."&lt;/span&gt;
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sysctl.d/99-sysctl.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
vm.overcommit_memory = 1
vm.swappiness = 10
net.core.somaxconn = 1024
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/sysctl.d/99-sysctl.conf

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[+] System configuration successfully completed!"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  7. Next Episode
&lt;/h3&gt;

&lt;p&gt;Now we have a solid bare-metal platform running on under 150MB of idle RAM, hardened with a firewall, and isolated at the disk level. But coding strictly within the local home network is a bottleneck. We need a way to access and develop on this server securely from cafes or external workspaces.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;Episode 3&lt;/strong&gt;, we will establish external access without port forwarding using &lt;strong&gt;Cloudflare Tunnel (Zero-Trust)&lt;/strong&gt;. We will cover both HTTPS routing for web applications and a secure SSH tunneling architecture using Cloudflare Access OTP and VS Code Remote-SSH.&lt;/p&gt;



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

&lt;/div&gt;

</description>
      <category>homelab</category>
      <category>architecture</category>
      <category>infrastructure</category>
      <category>linux</category>
    </item>
    <item>
      <title>[Homelab AI Project] Ep.01 - Dragging an Idle Mini PC into the Production Game (Prologue)</title>
      <dc:creator>Tristan Kilhwan Chai</dc:creator>
      <pubDate>Thu, 09 Jul 2026 00:46:19 +0000</pubDate>
      <link>https://dev.to/tristan_kilhwanchai_5954/homelab-ai-ep01-dragging-an-idle-mini-pc-into-the-production-game-prologue-40p</link>
      <guid>https://dev.to/tristan_kilhwanchai_5954/homelab-ai-ep01-dragging-an-idle-mini-pc-into-the-production-game-prologue-40p</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvai0v9eiruy5k8ci6o1s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvai0v9eiruy5k8ci6o1s.png" alt=" " width="799" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  0. Dragging an Idle Mini PC into the Game
&lt;/h3&gt;

&lt;p&gt;Today, everyone talks about AI by throwing massive cloud budgets at heavy GPU instances. But my architect instinct always asks a different question: &lt;br&gt;
"Do we really need a massive cloud budget to build a resilient, production-grade enterprise system?"&lt;/p&gt;

&lt;p&gt;That's when an idle mini PC collecting dust in my room caught my eye. A modest AMD Ryzen 5500U with DDR4 16GB RAM—the kind of low-spec hardware you easily find on second-hand markets—is now my official server.&lt;/p&gt;

&lt;p&gt;On this limited hardware, I'm spinning up Rocky Linux simply because it feels way more comfortable to me than Ubuntu. Without spending a single penny on cloud hosting—well, except for my home electricity bill... wait, anyway—I am kicking off a comprehensive series documenting how to build a production-grade AI Messaging and RAG Platform from scratch.&lt;/p&gt;




&lt;h3&gt;
  
  
  1. The Architectural Blueprint
&lt;/h3&gt;

&lt;p&gt;This is not a simple tutorial or a casual toy project. To extract maximum efficiency out of this limited hardware, we will implement high-level engineering patterns used in real enterprise environments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Resource Isolation: Setting up Docker containers on Rocky Linux 10.1 with strict CPU/Memory limits to completely prevent resource contention.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Zero-Trust Private Networking: Utilizing Cloudflare Tunnel to expose services securely to the edge without opening any router ports or risking public IP exposure.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;High-Throughput Async Pipelines: Designing a multi-module Node.js and Python backend controlled by Redis Queue to manage event-driven throttling and backpressure during heavy data ingestion.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;FinOps-Focused AI &amp;amp; RAG: Integrating a local Vector DB (pgvector) to de-duplicate data and implementing hybrid AI routing algorithms to keep LLM token costs under absolute control.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  2. Why Am I Doing This?
&lt;/h3&gt;

&lt;p&gt;Because I firmly believe that true engineering excellence is defined by architectural density, not the size of your capital. This series is dedicated to indie developers, startup architects, and tech enthusiasts who want to build high-performance systems under tight budget realities.&lt;/p&gt;

&lt;p&gt;I wanted to use this mini PC as a personal playground to realize, organize, and test years of accumulated knowledge in backend optimization, network troubleshooting, and infrastructure security.&lt;/p&gt;

&lt;p&gt;The stage is set. In the next episode, we open the terminal to tackle the core foundation: Rocky Linux 10.1 environment configuration and strict Docker isolation design. &lt;/p&gt;

&lt;p&gt;If you are ready to explore the boundaries of software efficiency, follow along. Let's build.&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>architecture</category>
      <category>backend</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
