<?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.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;/code&gt;&lt;/pre&gt;

&lt;/div&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;trisoft-ai-engine&lt;/code&gt;) as the domain destination, mapping host targets to &lt;code&gt;http://trisoft-ai-engine:8000&lt;/code&gt; to establish container-to-container connections.&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.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: "Zero RAM Left to Waste on Frontend Servers"
&lt;/h3&gt;

&lt;p&gt;After establishing my Gitea-based CI/CD pipeline in Episode 5, it was time to build the frontend dashboard to display my AI news pipeline stats. &lt;/p&gt;

&lt;p&gt;For modern web apps, the default tech stack is almost always &lt;strong&gt;Next.js (App Router)&lt;/strong&gt;. While Next.js provides excellent developer velocity, it suffers from a massive architectural drawback when deployed on hardware-constrained homelabs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Persistent Node.js Runtime Footprint&lt;/strong&gt;: Running a Next.js app in SSR/ISR mode requires a continuous Node.js server instance running in the background. This instantly swallows &lt;strong&gt;150MB to 300MB&lt;/strong&gt; of idle RAM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Threat of the OOM Killer&lt;/strong&gt;: In my homelab (Ryzen 5500U, 16GB RAM), memory must be preserved for PostgreSQL page caches, Redis queues, and intensive Python/RAG embedding operations. Wasting 200MB+ on a frontend server would invite out-of-memory crashes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What about standard React SPAs (e.g., Vite builds)? They compile to static assets, allowing Nginx static serving (0MB constant runtime RAM). However, they enforce Client-Side Rendering (CSR), meaning users must download hundreds of kilobytes of JavaScript before anything is painted, leading to sluggish LCP and poor SEO crawlers indexation.&lt;/p&gt;

&lt;p&gt;I needed a system that shipped close to &lt;strong&gt;0 bytes of initial JavaScript&lt;/strong&gt;, loaded instantly, scored &lt;strong&gt;100/100 on Google Lighthouse SEO&lt;/strong&gt;, and still let me build interactive charts using the rich &lt;strong&gt;React&lt;/strong&gt; ecosystem.&lt;/p&gt;

&lt;p&gt;This is why I chose &lt;strong&gt;Astro&lt;/strong&gt; and &lt;strong&gt;React's Island Architecture&lt;/strong&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. The Solution: Island Architecture (Astro + React)
&lt;/h3&gt;

&lt;p&gt;Astro is a static-first framework. At build time, it processes components and outputs pure static HTML/CSS. Since I serve these files using Nginx, I allocate &lt;strong&gt;0MB constant runtime memory&lt;/strong&gt; for a Node.js frontend process.&lt;/p&gt;

&lt;p&gt;To support dynamic interfaces (like my analytics charts), I use &lt;strong&gt;Island Architecture&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;graph TD
    subgraph HTML_Page["Astro Static Page (HTML / No JS)"]
        Hero["Hero Section (Static HTML)"]
        Arch["Architecture Diagram (Static SVG/HTML)"]

        subgraph React_Island_1["React Island (Hydrated)"]
            ChartComponent["Live Demo Chart (React + Chart.js)"]
        end

        subgraph React_Island_2["React Island (Hydrated)"]
            LogViewer["Live Log Streamer (React)"]
        end

        Journal["Journal Cards (Static HTML)"]
    end

    HTML_Page --&amp;gt; Hero
    HTML_Page --&amp;gt; Arch
    HTML_Page --&amp;gt; React_Island_1
    HTML_Page --&amp;gt; React_Island_2
    HTML_Page --&amp;gt; Journal

    style React_Island_1 fill:#61dafb,stroke:#333,stroke-width:2px
    style React_Island_2 fill:#61dafb,stroke:#333,stroke-width:2px
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The layout and static descriptions render instantly as plain HTML. Interactive widgets are marked as independent &lt;strong&gt;Islands&lt;/strong&gt; (e.g. &lt;code&gt;&amp;lt;LiveDemoChart client:visible /&amp;gt;&lt;/code&gt;). The browser only requests the React bundle and hydrating JavaScript when the scroll position hits the visible chart viewport, bypassing massive initial payloads.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. Implementation: Packages &amp;amp; Configuration
&lt;/h3&gt;

&lt;h4&gt;
  
  
  (1) Package Directory Layout
&lt;/h4&gt;

&lt;p&gt;To scale into a monorepo later, I organized the frontend code under &lt;code&gt;packages/frontend/&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;# packages/frontend/&lt;/span&gt;
├── public/
├── src/
│   ├── components/       &lt;span class="c"&gt;# Astro &amp;amp; React Components&lt;/span&gt;
│   │   ├── Header.astro
│   │   ├── Hero.astro
│   │   └── LiveDemoChart.jsx &lt;span class="o"&gt;(&lt;/span&gt;React&lt;span class="o"&gt;)&lt;/span&gt;
│   ├── layouts/          &lt;span class="c"&gt;# Layout Templates&lt;/span&gt;
│   │   └── Layout.astro
│   ├── pages/            &lt;span class="c"&gt;# File-based Routing&lt;/span&gt;
│   │   └── index.astro
│   └── styles/           &lt;span class="c"&gt;# Vanilla CSS Styling&lt;/span&gt;
│       └── global.css
├── astro.config.mjs
├── package.json
└── tsconfig.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  (2) Astro Configuration (&lt;code&gt;astro.config.mjs&lt;/code&gt;)
&lt;/h4&gt;

&lt;p&gt;I enabled the React integration and enforced strict static file compilation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&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;astro/config&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;react&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;@astrojs/react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;integrations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;react&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;static&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Forces build-time static HTML file generation&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;file&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Generates concrete physical files (index.html)&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.5) Essential Node.js Package Configuration (&lt;code&gt;package.json&lt;/code&gt; &amp;amp; &lt;code&gt;tsconfig.json&lt;/code&gt;)
&lt;/h4&gt;

&lt;p&gt;We initialized modular dependencies for Astro and React, and configured TypeScript parameters:&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/frontend/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/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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"module"&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;"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"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"astro dev"&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"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"astro dev"&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;"astro build"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"preview"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"astro preview"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"astro"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"astro"&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;"dependencies"&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;"@astrojs/react"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^3.0.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;"astro"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^4.0.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;"chart.js"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^4.4.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;"react"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^18.2.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;"react-dom"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^18.2.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;"react-chartjs-2"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^5.2.0"&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;"devDependencies"&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;"@types/react"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^18.2.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;"@types/react-dom"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^18.2.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;"typescript"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^5.0.0"&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 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/frontend/tsconfig.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;"extends"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"astro/tsconfigs/strict"&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;"jsx"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"react-jsx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"jsxImportSource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"react"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"baseUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"paths"&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;"@/*"&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="s2"&gt;"src/*"&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="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) Lightweight CSS Design Tokens (&lt;code&gt;global.css&lt;/code&gt;)
&lt;/h4&gt;

&lt;p&gt;To keep builds clean and eliminate dependencies, I shunned CSS frameworks (like Tailwind) and built a custom dark theme using CSS variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--font-sans&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;'Outfit'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;'Inter'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;-apple-system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c"&gt;/* Harmonious Dark Palette */&lt;/span&gt;
  &lt;span class="py"&gt;--bg-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;224&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;25%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;12%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--bg-secondary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;224&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;25%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;16%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--bg-tertiary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;224&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;25%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;20%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--border-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;224&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;15%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;25%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="py"&gt;--text-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;210&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;40%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;98%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--text-secondary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;215&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;20%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;75%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c"&gt;/* Accent Colors */&lt;/span&gt;
  &lt;span class="py"&gt;--accent-cyan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;180&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--accent-purple&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;270&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;65%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--gradient-accent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;135deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--accent-cyan&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--accent-purple&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="py"&gt;--shadow-lg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;10px&lt;/span&gt; &lt;span class="m"&gt;30px&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--transition-smooth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;all&lt;/span&gt; &lt;span class="m"&gt;0.3s&lt;/span&gt; &lt;span class="n"&gt;cubic-bezier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--bg-primary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--text-primary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--font-sans&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;line-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.card-glass&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;36&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;44&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;backdrop-filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;blur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--border-color&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--shadow-lg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--transition-smooth&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.card-glass&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;border-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--accent-cyan&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-2px&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) React Island Live Chart Component (&lt;code&gt;LiveDemoChart.jsx&lt;/code&gt;)
&lt;/h4&gt;

&lt;p&gt;This component is designed to render asynchronously on the client-side once hydration occurs, bypassing SSR &lt;code&gt;window is not defined&lt;/code&gt; errors. It utilizes Chart.js to map news parsing and AI summarization statistics.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/frontend/src/components/LiveDemoChart.jsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&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;react&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;Chart&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ChartJS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;CategoryScale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;LinearScale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;PointElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;LineElement&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;Tooltip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;Legend&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;Filler&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;chart.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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Line&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;react-chartjs-2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;ChartJS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;CategoryScale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;LinearScale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;PointElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;LineElement&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;Tooltip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;Legend&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;Filler&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;LiveDemoChart&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;chartData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setChartData&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Safely run inside the client environment&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;labels&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;Mon&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;Tue&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;Wed&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;Thu&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;Fri&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;Sat&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;Sun&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;datasets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Collected 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;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;65&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;78&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;89&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;110&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
          &lt;span class="na"&gt;borderColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgb(0, 242, 254)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;backgroundColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgba(0, 242, 254, 0.1)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;tension&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;label&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 Summaries&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
          &lt;span class="na"&gt;borderColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgb(186, 85, 211)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;backgroundColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgba(186, 85, 211, 0.1)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;tension&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nf"&gt;setChartData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="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;chartData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;300px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;alignItems&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;center&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;justifyContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;center&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;var(--text-secondary)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        Loading chart...
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;responsive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;maintainAspectRatio&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;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;legend&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgb(210, 220, 240)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;font&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Outfit&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="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;scales&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgba(255, 255, 255, 0.05)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;ticks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgb(215, 220, 235)&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;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgba(255, 255, 255, 0.05)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;ticks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgb(215, 220, 235)&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="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;300px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Line&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;chartData&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






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

&lt;h4&gt;
  
  
  🚨 Issue 1: &lt;code&gt;window is not defined&lt;/code&gt; Build Failure
&lt;/h4&gt;

&lt;p&gt;Compiling the project with &lt;code&gt;npm run build&lt;/code&gt; failed instantly with a compilation error:&lt;br&gt;
&lt;code&gt;ReferenceError: window is not defined&lt;/code&gt; inside the React chart component.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cause&lt;/strong&gt;: Astro attempts to prerender all components on the server side (SSR) to spit out clean HTML during compilation. Because Node.js lacks browser globals like &lt;code&gt;window&lt;/code&gt; or &lt;code&gt;document&lt;/code&gt;, libraries parsing canvas dimensions crash during static rendering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution&lt;/strong&gt;: Force Astro to skip SSR compilation for client-dependent modules by applying the &lt;strong&gt;&lt;code&gt;client:only="react"&lt;/code&gt;&lt;/strong&gt; directive:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  &amp;lt;!-- Bypasses server-side build render; mounts directly in the browser --&amp;gt;
  &amp;lt;LiveDemoChart client:only="react" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alternatively, wrapping browser API declarations inside React's &lt;code&gt;useEffect&lt;/code&gt; hook ensures code only runs after hydration.&lt;/p&gt;

&lt;h4&gt;
  
  
  🚨 Issue 2: Style Flash (FOUC) during Page Reloads
&lt;/h4&gt;

&lt;p&gt;Refreshing the dashboard caused a flash of unstyled content (FOUC)—showing raw black-on-white text for ~100ms before snapping to our dark theme.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cause&lt;/strong&gt;: Styles scoped within React Islands were asynchronously loaded and parsed only after the React bundles hydrated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution&lt;/strong&gt;: Decoupled the styling from React component scopes. Imported global styles (&lt;code&gt;global.css&lt;/code&gt;) directly in the static Astro layout header (&lt;code&gt;Layout.astro&lt;/code&gt;), allowing the browser to parse all CSS classes instantly alongside the initial HTML stream.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  🚨 Issue 3: Missing Node.js/npm Runtime on the Homelab Server
&lt;/h4&gt;

&lt;p&gt;After SSHing into my Rocky Linux server, I attempted to install dependencies using &lt;code&gt;npm install&lt;/code&gt; only to be greeted by &lt;code&gt;bash: npm: command not found&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cause&lt;/strong&gt;: 
In the excitement of building my homelab infrastructure, I forgot to provision the Node.js/npm runtime on the host OS. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution&lt;/strong&gt;: 
Installed NVM (Node Version Manager) and ran &lt;code&gt;nvm install --lts&lt;/code&gt; to neatly implement the latest LTS release. NVM is preferred on homelabs to avoid global permission friction.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  🚨 Issue 4: Empty Boilerplate &lt;code&gt;package.json&lt;/code&gt; and &lt;code&gt;EJSONPARSE&lt;/code&gt; Failures
&lt;/h4&gt;

&lt;p&gt;With Node.js running, I triggered &lt;code&gt;npm install&lt;/code&gt; again, which immediately crashed with &lt;code&gt;npm error code EJSONPARSE: Unexpected end of JSON input&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cause&lt;/strong&gt;: 
While the folder structure was created, the core &lt;code&gt;package.json&lt;/code&gt; was left completely blank. The npm client crashes instantly when parsing empty or malformed JSON payloads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution&lt;/strong&gt;: 
Populated the template with essential Astro and React hooks (&lt;code&gt;@astrojs/react&lt;/code&gt;, &lt;code&gt;astro&lt;/code&gt;, &lt;code&gt;react&lt;/code&gt;, &lt;code&gt;react-dom&lt;/code&gt;) alongside a sound &lt;code&gt;tsconfig.json&lt;/code&gt; template, which restored the installation process.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  🚨 Issue 5: Astro's Host Header Restrictions over Cloudflare Tunnel
&lt;/h4&gt;

&lt;p&gt;Binding my server port (&lt;code&gt;4321&lt;/code&gt;) to a custom domain via Cloudflare Tunnel resulted in &lt;code&gt;400 Bad Request&lt;/code&gt; or host header rejection errors on external browsers.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cause&lt;/strong&gt;: 
Astro 4.x natively blocks unexpected host headers to prevent host header injection attacks. Additionally, bound loopback addresses (&lt;code&gt;127.0.0.1&lt;/code&gt;) do not receive tunneled proxy streams gracefully.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution&lt;/strong&gt;: 
We must run the dev server with &lt;code&gt;npm run dev -- --host 0.0.0.0&lt;/code&gt; and configure &lt;code&gt;server: { allowedHosts: ['ainews.tristan.team'] }&lt;/code&gt; in &lt;code&gt;astro.config.mjs&lt;/code&gt;.
However, during the static build verification stage, the &lt;code&gt;astro preview&lt;/code&gt; command ignored configuration file host configurations due to config-merging bugs between Astro and Vite, consistently displaying the &lt;code&gt;Blocked request. This host is not allowed&lt;/code&gt; warning page.
Since the core philosophy of Episode 6 is &lt;strong&gt;"rejecting persistent Node.js web servers and serving static compiled HTML via Nginx to keep RAM usage at 0MB,"&lt;/strong&gt; fighting with Vite's dev-only security checks on &lt;code&gt;astro preview&lt;/code&gt; was pointless. Instead, we can verify the output by launching a lightweight static web server (&lt;code&gt;npx serve dist -l 4321&lt;/code&gt;), which behaves exactly like the production Nginx static serving configuration.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  5. Results &amp;amp; Metrics: Next.js vs. Astro
&lt;/h3&gt;

&lt;p&gt;Here are the performance metrics compared to a default Next.js (App Router) project:&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;Next.js (App Router, blank page)&lt;/th&gt;
&lt;th&gt;Astro (Static + 1 React Island)&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;Server RAM Footprint&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;240 MiB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0 MiB&lt;/strong&gt; (Static Nginx serving)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-100% (No runtime node)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Initial JS Size&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~82.4 kB&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;7.2 kB&lt;/strong&gt; (Excluding React core)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-91.2%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Lighthouse Performance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;88 / 100&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100 / 100&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+12 points&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TTI (Time to Interactive)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.8s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.15s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-81.2%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;By using Astro, I saved &lt;strong&gt;240MB of memory&lt;/strong&gt; on my server and reduced the initial JS footprint down to a mere 7.2KB, securing a perfect &lt;strong&gt;100/100 Lighthouse Performance score&lt;/strong&gt; even on lower-end hardware configurations.&lt;/p&gt;




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

&lt;p&gt;With my frontend skeleton running efficiently, Episode 7 shifts focus to my core codebase. I will walk through configuring my monorepo architecture: &lt;strong&gt;Monorepo Structure Design — Decoupling Node.js &amp;amp; Python modules and sharing core modules&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;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;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;5-Second Deployment Lead Time&lt;/strong&gt;: Pushing code updates triggers the local agent immediately, completing target rebuilds in &lt;strong&gt;4.8 seconds&lt;/strong&gt; with zero human interaction.&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>
