[{"content":"Engineering the AI Gateway Identity, Guardrails, and Production Hardening in USS When you add AI to a product, the real engineering work isn\u2019t model selection.\nIt\u2019s boundary enforcement.\nUSS uses a Cloudflare Worker as a hardened AI gateway. This post breaks down how it works \u2014 and why each layer exists.\n1. The Worker Is Not a Proxy A naive implementation would look like:\nFrontend \u2192 Worker \u2192 OpenAI That\u2019s not what this is.\nThe Worker is a:\nPolicy engine Identity extractor Intent filter Cost guard Failure isolator It exists to ensure:\nOnly structured OpenAPI drafting requests reach the model.\n2. Identity via Cloudflare Access (JWT Extraction) Because the endpoint is protected with Cloudflare Access:\nEvery request contains Cf-Access-Jwt-Assertion The Worker extracts it Decodes payload Logs user identity Core concept:\nconst jwt = request.headers.get(&#34;Cf-Access-Jwt-Assertion&#34;) || &#34;&#34;; const payload = decodeJwtPayload(jwt); const identity = { email: payload.email, sub: payload.sub }; This gives:\nTraceability Abuse accountability Enterprise posture Zero anonymous usage No custom session handling required.\nThat\u2019s the power of edge identity.\n3. CORS + Access + Edge Headers This combination is subtle.\nBecause:\nBrowser enforces CORS\nAccess injects headers\nWorker must allow them explicitly\nCritical headers:\nAccess-Control-Allow-Origin Access-Control-Allow-Headers: Content-Type, Cf-Access-Jwt-Assertion If Cf-Access-Jwt-Assertion is not allowed:\nRequests silently fail before hitting your logic.\nDebugging this required inspecting:\nPreflight OPTIONS Response headers Access rules Worker response behavior This is where many edge integrations break.\n4. Prompt Validation Layer Before classification, I validate structure.\nChecks include:\nNon-empty input Minimum length threshold Maximum token guard API-related keyword presence No markdown chat-style indicators No conversational cues Reject early.\nReject cheaply.\nNever send garbage upstream.\nThis protects:\nToken burn Abuse loops Prompt injection attempts 5. Intent Classification Guard This is the most important engineering layer.\nThe goal:\nPrevent USS from becoming a general chatbot.\nThe Worker evaluates:\nDoes the prompt request:\nOpenAPI drafting? \u2705 API endpoint structure? \u2705 Resource + method definition? \u2705 Or:\nEssay writing? \u274c General programming help? \u274c Conversational response? \u274c If classification fails:\nReturn 400 with structured error.\nThis ensures product alignment.\nWithout this layer:\nCost increases. Brand dilutes. Scope drifts.\n6. Rate Limiting Strategy Rate limiting is implemented via Cloudflare security rules.\nCharacteristics:\nScoped to \/api\/ai\/* Identity-aware (via Access) Burst threshold Cooldown duration Temporary block on exceed This protects against:\nScripted abuse Accidental infinite loops Bot attacks Token floods Layering matters:\nFrontend does not enforce rate. Worker does not enforce rate. Edge WAF enforces rate.\nDefense in depth.\n7. Prompt Engineering Decisions The system prompt is structured and strict.\nCore constraints:\nMust output valid OpenAPI 3.0.3 Must produce YAML only No explanation text No markdown fences Deterministic structure Include info, paths, components if applicable I avoid:\nCreative tone Narrative responses Free-form explanation The model is treated as a structured generator \u2014 not a chat assistant.\n8. Failure Modes Considered AI systems fail in predictable ways.\nI accounted for:\n1. Upstream Timeout Return 502 with structured error.\n2. Model Refusal Return explicit refusal message.\n3. Non-YAML Output Reject and return validation error.\n4. Access Token Missing Return 401.\n5. Rate Limit Triggered Return 429.\n6. Unexpected Exception Return generic 500 without leaking internals.\nNever pass raw OpenAI errors directly to client.\n9. Observability Strategy Even without a traditional backend:\nYou still need signals.\nCurrent observability includes:\nWorker console logs (identity + status) Access logs WAF trigger monitoring Cloudflare analytics OpenAI usage dashboard Future improvements:\nStructured JSON logging Centralized log drain Per-user usage metrics Token consumption tracking Observability is what separates hobby AI from production AI.\n10. Scaling Considerations Right now:\nStateless edge execution No database dependency No session state Low latency Future scale concerns:\nToken cost management Caching safe prompt patterns Per-identity quotas Tiered rate limits Enterprise access segmentation The architecture already supports these expansions.\nThat was intentional.\n11. What Makes This Production-Ready Production-ready does not mean \u201cperfect.\u201d\nIt means:\nIdentity-aware Intent-aligned Abuse-resistant Rate-limited Failure-contained Observed The gateway is the boundary.\nAnd boundaries define software quality.\n12. The Philosophy Behind the Gateway This design reflects a belief:\nAI belongs behind policy layers.\nNot directly in browsers. Not publicly exposed. Not unbounded.\nIt should sit behind:\nIdentity Validation Guardrails Observability That is how AI becomes infrastructure.\n","permalink":"https:\/\/mumbleb.com\/posts\/uss-ai-gateway\/","summary":"<h1 id=\"engineering-the-ai-gateway\">Engineering the AI Gateway<\/h1>\n<h2 id=\"identity-guardrails-and-production-hardening-in-uss\">Identity, Guardrails, and Production Hardening in USS<\/h2>\n<p>When you add AI to a product, the real engineering work isn\u2019t model selection.<\/p>\n<p>It\u2019s boundary enforcement.<\/p>\n<p>USS uses a Cloudflare Worker as a hardened AI gateway. This post breaks down how it works \u2014 and why each layer exists.<\/p>\n<hr>\n<h2 id=\"1-the-worker-is-not-a-proxy\">1. The Worker Is Not a Proxy<\/h2>\n<p>A naive implementation would look like:<\/p>\n<pre tabindex=\"0\"><code class=\"language-Code\" data-lang=\"Code\">Frontend \u2192 Worker \u2192 OpenAI\n<\/code><\/pre><p>That\u2019s not what this is.<\/p>","title":"Engineering the AI Gateway \u2014 Identity, Guardrails, and Production Hardening in USS"},{"content":"From Tool to Platform Why I Added AI to Universal Schema Studio Universal Schema Studio didn\u2019t start as an AI product.\nIt started as a practical frustration.\nXSD to OpenAPI conversions Schema inspection Clean documentation workflows Developer-focused structure It was a tool.\nFocused. Deterministic. Static.\nThen something changed.\n1. Why Add AI at All? There are two types of AI integration:\nFeature-driven AI (\u201cbecause it\u2019s trending\u201d)\nWorkflow-driven AI (\u201cbecause it reduces friction\u201d)\nI was only interested in the second.\nThe real friction I observed:\nDevelopers staring at a blank OpenAPI file Translating business requirements into structure Rewriting similar API skeletons repeatedly Getting schema formatting wrong on first draft What AI does well:\nConvert description \u2192 structured output Produce valid YAML skeletons Accelerate the \u201cfirst 60%\u201d So the goal was not:\n\u201cAdd AI to USS.\u201d\nThe goal was:\n\u201cReduce friction in API design.\u201d\nThat distinction matters.\n2. User Value (Not Just Capability) The feature does one thing:\nYou describe your API in plain English \u2192 receive valid OpenAPI 3.0.3 YAML.\nBut the value is deeper:\nBefore\nBlank editor Manual scaffolding Syntax mistakes Structure second-guessing After\nStructured starting point Valid document Clear endpoints Editable baseline It shifts USS from:\nSchema viewer \/ editor\nto\nAPI drafting assistant\nThat\u2019s a different product category.\n3. Risk Analysis Before Writing Code AI features introduce real risks:\nToken cost explosions Abuse Prompt misuse General chatbot drift Security exposure Brand dilution I had to answer one question:\nDoes adding AI increase the long-term quality of USS?\nOnly if:\nIt stays aligned to API design It remains structured It does not become a generic chat interface It preserves developer intent So the integration had to be constrained.\nNot open-ended.\n4. Guardrails Define the Product The most important design decision was not model selection.\nIt was guardrails.\nThe AI endpoint:\nOnly accepts OpenAPI drafting prompts Blocks general Q&amp;A Enforces intent classification Requires identity via Cloudflare Access Enforces rate limiting Logs identity metadata This is deliberate.\nGuardrails do two things:\nProtect cost and abuse\nPreserve product identity\nWithout guardrails:\nUSS becomes:\n\u201cChatGPT with YAML formatting.\u201d\nWith guardrails:\nUSS remains:\nA structured API design tool.\n5. UX Decisions (What I Didn\u2019t Build) Equally important is what I chose not to do.\nI did not:\nAdd streaming chat bubbles Add conversational memory Add follow-up question chains Add prompt history Add multi-turn chat UX Why?\nBecause USS is not a chat product.\nIt is a document product.\nThe AI is a drafting engine \u2014 not a conversation engine.\nThis preserves clarity.\n6. Founder Mindset Shift This was the moment USS stopped being just a project.\nIt became infrastructure.\nWhen you:\nAdd identity enforcement Add rate limiting Add structured gateways Add intent filtering Add layered security You\u2019re not building a toy anymore.\nYou\u2019re building something that can survive production use.\nThe mindset changes from:\n\u201cDoes this work?\u201d\nto\n\u201cCan this be trusted?\u201d\n7. From Tool \u2192 Platform A tool solves a narrow problem.\nA platform creates a foundation others can build on.\nAI integration nudges USS toward:\nDraft generation Conversion workflows Structured exports Policy enforcement Developer productivity acceleration It starts forming layers:\nEditor layer AI layer Identity layer Security layer Export layer That is platform territory.\nNot in scale \u2014 but in structure.\n8. Enterprise Readiness Narrative Enterprise doesn\u2019t mean:\nBig servers Complex dashboards Expensive plans It means:\nIdentity-aware Rate-limited Logged Controlled Intent-aligned Even as a self-hosted project, USS now reflects:\nZero-trust thinking Layered security Clear boundaries Deterministic output expectations That is enterprise posture.\n9. What This Signals for USS Adding AI is not the end.\nIt signals future direction:\nStructured document intelligence Schema-assisted validation Context-aware linting AI-assisted refactoring AI-driven documentation summaries But always within boundaries.\nThe discipline is the differentiator.\n10. The Real Question The question is not:\n\u201cDoes USS have AI?\u201d\nThe question is:\n\u201cDoes USS use AI responsibly?\u201d\nThat\u2019s the long-term positioning.\nUSS is not chasing AI.\nIt is integrating AI as infrastructure.\nThat\u2019s a different story.\n","permalink":"https:\/\/mumbleb.com\/posts\/uss-from-tool-to-platform\/","summary":"<h1 id=\"from-tool-to-platform\">From Tool to Platform<\/h1>\n<h2 id=\"why-i-added-ai-to-universal-schema-studio\">Why I Added AI to Universal Schema Studio<\/h2>\n<p>Universal Schema Studio didn\u2019t start as an AI product.<\/p>\n<p>It started as a practical frustration.<\/p>\n<ul>\n<li>XSD to OpenAPI conversions<\/li>\n<li>Schema inspection<\/li>\n<li>Clean documentation workflows<\/li>\n<li>Developer-focused structure<\/li>\n<\/ul>\n<p>It was a tool.<\/p>\n<p>Focused.\nDeterministic.\nStatic.<\/p>\n<p>Then something changed.<\/p>\n<hr>\n<h2 id=\"1-why-add-ai-at-all\">1. Why Add AI at All?<\/h2>\n<p>There are two types of AI integration:<\/p>\n<ol>\n<li>\n<p>Feature-driven AI (\u201cbecause it\u2019s trending\u201d)<\/p>\n<\/li>\n<li>\n<p>Workflow-driven AI (\u201cbecause it reduces friction\u201d)<\/p>","title":"From Tool to Platform \u2014 Why I Added AI to Universal Schema Studio"},{"content":"Integrating AI into Universal Schema Studio Architecture &amp; Implementation Deep Dive Universal Schema Studio (USS) started as a static developer tool.\nHosted on GitHub Pages, fronted by Cloudflare, entirely client-side.\nThen I added something that fundamentally changes the risk profile:\nAI-powered OpenAPI 3.0.3 drafting.\nThis post breaks down exactly how I integrated AI without compromising:\nSecurity Cost control Identity Abuse resistance Production discipline This is the architecture-first breakdown.\n1. The Starting Constraint: USS Is Static USS runs as:\nStatic HTML + modular JavaScript Hosted on GitHub Pages Served via Cloudflare No backend server No database No session state Adding AI introduces a critical constraint:\nThe browser cannot call OpenAI directly.\nReasons:\nAPI key exposure No identity enforcement No abuse control No rate limiting No audit logging So the solution required a secure AI gateway layer.\n2. Why I Chose a Cloudflare Worker (Instead of Hosting an LLM) I evaluated three options:\nOption 1 \u2014 Host My Own LLM Rejected because:\nGPU cost Operational complexity Scaling concerns Model quality tradeoffs Option 2 \u2014 Traditional Backend Server Rejected because:\nAdds infrastructure to a static project Requires container hosting Increases attack surface Breaks the \u201cedge-first\u201d philosophy Option 3 \u2014 Cloudflare Worker (Chosen) Using a Cloudflare Worker gave me:\nEdge execution No server to manage Native integration with Cloudflare Access Built-in request metadata WAF integration Global low latency It preserves USS as a static app while adding a secure backend boundary.\n3. Final Production Architecture Here is the layered architecture:\n\ud83c\udf10 High-Level Flow User (Google SSO via Cloudflare Access) \u2193 USS Frontend (Static JS on GitHub Pages) \u2193 POST \/api\/ai\/draft-openapi \u2193 Cloudflare Access (JWT injected) \u2193 Cloudflare Worker (AI Gateway) \u2193 Intent Classification Guard \u2193 Prompt Validation \u2193 Rate Limiting (WAF) \u2193 OpenAI API \u2193 Validated OpenAPI 3.0.3 YAML \u2193 Return to USS Editor \ud83e\uddf1 Architecture Layers Explained 1\ufe0f\u20e3 Frontend (USS OpenAPI Editor) Modular JS architecture AI panel module Client-side YAML validation Monaco-based editing Controlled endpoint access The frontend never stores an API key.\nIt only talks to:\n\/api\/ai\/draft-openapi 2\ufe0f\u20e3 Cloudflare Access (Google SSO) I use Cloudflare Access with Google SSO to:\nEnforce identity Restrict endpoint usage Inject Cf-Access-Jwt-Assertion Identify the user without managing sessions Inside the Worker:\nconst jwt = request.headers.get(&#34;Cf-Access-Jwt-Assertion&#34;); I decode it to log:\nEmail Subject ID Access metadata This means:\nNo anonymous AI usage Traceability Enterprise alignment 3\ufe0f\u20e3 The Cloudflare Worker (AI Gateway) This is the core enforcement layer.\nIt performs:\nCORS handling JWT extraction Prompt validation Intent classification Abuse blocking Upstream OpenAI call It is not just a proxy.\nIt is a policy engine.\n4. Intent Classification Guard (Critical Design Decision) One major risk:\nUsers turning USS into:\nA general chatbot A coding assistant A prompt playground A token drain So I implemented an intent guard.\nStep 1: Validate Prompt Structure Must:\nDescribe an API Contain endpoint context Contain method or resource structure Be within size limits Step 2: Classification Check If prompt intent is:\nGeneral Q&amp;A \u274c Conversational \u274c Essay writing \u274c Code unrelated to OpenAPI \u274c It gets blocked.\nThis keeps the feature aligned with:\n\u201cDraft an OpenAPI 3.0.3 YAML specification.\u201d\nThis single layer prevents:\nAbuse Cost explosions Product drift 5. CORS Challenges (And Why It Was Tricky) Because:\nUSS is served from schema.mumbleb.com Worker runs on the same zone Cloudflare Access injects headers CORS required:\nAccess-Control-Allow-Origin: https:\/\/schema.mumbleb.com Access-Control-Allow-Headers: Content-Type, Cf-Access-Jwt-Assertion Without allowing Cf-Access-Jwt-Assertion, requests silently fail.\nThis was one of the trickiest parts.\nEdge security layers + browser CORS = subtle failure modes\n6. Rate Limiting (WAF Layer) I added a rate limiting rule via Cloudflare Security \u2192 Rate Limiting Rules.\nCharacteristics:\nApplied to \/api\/ai\/* Per user identity Burst + duration limit Temporary block on exceed This prevents:\nScripted abuse Accidental loops Token burn events This is critical when forwarding requests to OpenAI.\n7. Security Layering Model The final stack looks like this:\nLayer Responsibility Frontend UX + YAML validation Cloudflare Access Identity Worker Policy enforcement Intent Guard Product alignment WAF Rate limiting OpenAI Model inference This is deliberate defense-in-depth.\n8. Lessons Learned 1. Static Apps Can Be Enterprise-Grade\nYou don\u2019t need Kubernetes to be serious.\n2. Identity Changes Everything\nAnonymous AI endpoints are reckless.\n3. Intent Guard Is Mandatory\nWithout it, cost and scope drift happens immediately.\n4. CORS + Access + Workers = Non-trivial\nDebugging required careful header inspection.\n5. You Must Think Like an Adversary\nIf a feature can be abused, it will be.\n9. Tradeoffs Pros\nMinimal infrastructure Scales globally No server maintenance Clear boundary Secure by design Cons\nWorker execution limits Cold start considerations OpenAI dependency Vendor lock-in (Cloudflare edge) 10. Final Production Architecture (Current State) GitHub Pages (Static USS) \u2193 Cloudflare DNS + Proxy \u2193 Cloudflare Access (Google SSO) \u2193 Cloudflare Worker (AI Gateway) \u2193 Intent Guard + Prompt Validation \u2193 WAF Rate Limiting \u2193 OpenAI USS remains:\nStatic Modular Self-hosted Edge-protected Enterprise-aware Without becoming infrastructure-heavy.\n","permalink":"https:\/\/mumbleb.com\/posts\/integrate-ai-into-uss\/","summary":"<h1 id=\"integrating-ai-into-universal-schema-studio\">Integrating AI into Universal Schema Studio<\/h1>\n<h2 id=\"architecture--implementation-deep-dive\">Architecture &amp; Implementation Deep Dive<\/h2>\n<p>Universal Schema Studio (USS) started as a static developer tool.<\/p>\n<p>Hosted on GitHub Pages, fronted by Cloudflare, entirely client-side.<\/p>\n<p>Then I added something that fundamentally changes the risk profile:<\/p>\n<blockquote>\n<p>AI-powered OpenAPI 3.0.3 drafting.<\/p>\n<\/blockquote>\n<p>This post breaks down exactly how I integrated AI without compromising:<\/p>\n<ul>\n<li>Security<\/li>\n<li>Cost control<\/li>\n<li>Identity<\/li>\n<li>Abuse resistance<\/li>\n<li>Production discipline<\/li>\n<\/ul>\n<p>This is the architecture-first breakdown.<\/p>\n<hr>\n<h2 id=\"1-the-starting-constraint-uss-is-static\">1. The Starting Constraint: USS Is Static<\/h2>\n<p>USS runs as:<\/p>","title":"Integrating AI into Universal Schema Studio \u2014 Architecture & Implementation Deep Dive"},{"content":"Building a Self-Hosted Analytics System for Universal Schema Studio For a long time, I wanted a way to track which parts of my Universal Schema Studio (USS) were being used the most \u2014 YAML\/XSD Viewer, XML Viewer, OpenAPI Editor, etc.\nI didn\u2019t want Google Analytics, Matomo, or any heavy tooling.\nI wanted:\nFully self-hosted Fully private Runs in Docker on my Raspberry Pi Accessible via Cloudflare Tunnels Simple JSON storage A dashboard I can view anytime Pretty Shields.io-style badges I can embed anywhere A dashboard I could trust \u2014 running on my own hardware.\nThis post documents the full journey \u2014 mistakes, debugging, design decisions, and the final result.\n\ud83c\udfaf Goals I wanted the final system to:\nTrack per-page views Track daily and unique visitors Show real-time stats Use a JSON file as storage Run entirely in Docker Expose through Cloudflare Tunnel Provide clean Shields.io-style badges Embed easily into USS and my blog And now it does.\n\ud83c\udfd7\ufe0f Step 1: The Enhanced View Counter Backend The backend is built in Node.js using Express and fs-extra for JSON persistence.\nFiles are stored in:\n\/data\/views-enhanced.json\nEvery view increments:\ntotal count today&rsquo;s count unique visitors Each USS page sends a request like: \/increment\/universal-schema-studio\/&lt;slug&gt;\nWhere &lt;slug&gt; is automatically detected by viewTracker.js.\nJSON structure { &#34;universal-schema-studio&#34;: { &#34;total&#34;: { &#34;openapiEditor&#34;: 42, &#34;xmlViewer&#34;: 30 }, &#34;daily&#34;: { &#34;2025-11-13&#34;: { &#34;yamlxsdViewer&#34;: 10 } }, &#34;unique&#34;: { &#34;2025-11-13&#34;: [&#34;c4a91fc\u2026&#34;] } } } Why JSON instead of a database?\nBecause it&rsquo;s:\nfast low-overhead Pi-friendly trivial to back up human readable perfect for view-based logging \ud83d\udc33 Step 2: Dockerizing the Backend The backend runs in a container using:\nnode:18-alpine I originally tried Node 22, but Express+CJS caused issues:\nrequire() is not defined in ES module scope Downgrading to Node 18 solved everything.\nLesson Learned: Node version matters \u2014 stick with LTS (18) for simple CJS + Express apps.\n\ud83d\udd27 Step 3: Fixing Routing &amp; Static File Serving This part took some time.\nI mounted the dashboard with:\napp.use(&#34;\/dashboard&#34;, express.static(path.join(__dirname, &#34;dashboard&#34;), { index: &#34;dashboard.html&#34; })); But initially I hit:\n404 errors __dirname collisions &ldquo;Identifier declared already&rdquo; errors Express skipping routes The root cause: I mistakenly redeclared:\nconst __dirname = ... const path = ... And one wildcard route was placed above specific \/stats\/* routes:\napp.get(&#34;\/stats\/:app&#34;) &lt;-- This shadowed everything If \/stats\/top is called, Express thinks:\n:app = &#34;top&#34; \u2192 db[top] does not exist \u2192 404 Fix: Place \/stats\/:app at the bottom, after all specific routes.\nLesson Learned: Express routing order matters \u2014 wildcard routes must always be last.\n\ud83c\udfa8 Step 4: Shields.io-Style Dynamic Badges I replaced the minimal SVG with a professional badge that supports:\ngradient overlays dynamic width GitHub-ready look color scaling based on view count Examples: https:\/\/api.domain.com\/badge\/universal-schema-studio\/openapiEditor https:\/\/api.domain.com\/badge\/universal-schema-studio\/xmlViewer https:\/\/api.domain.com\/badge\/universal-schema-studio\/yamlxsdViewer These can be embedded anywhere:\n![XML Viewer](https:\/\/api.domain.com\/badge\/universal-schema-studio\/xmlViewer) Lesson Learned: Badges are a brilliant way to surface metrics without logging into a dashboard.\n\ud83e\udded Step 5: Front-End Tracking (viewTracker.js) All USS pages share one script:\n\/js\/viewTracker.js This extracts the page slug:\nconst path = window.location.pathname .replace(\/^\\\/|\\\/$\/g, &#34;&#34;) .replace(\/\\.html$\/, &#34;&#34;); const slug = path || &#34;yamlxsdViewer&#34;; So:\n\/ \u2192 yamlxsdViewer \/openapiEditor.html \u2192 openapiEditor \/xmlViewer.html \u2192 xmlViewer Lesson Learned: Slug detection must be deterministic \u2014 dashboards and badges rely on it.\n\ud83d\udcca Step 6: Building the Analytics Dashboard The dashboard shows:\nHealth status Uptime Memory usage CPU load averages Daily view chart Top pages Auto-refresh every 10 seconds It\u2019s served at:\nhttps:\/\/api.mumbleb.com\/dashboard\/ Issues solved along the way:\n\u274c CORS Fixed using:\napp.use(cors()); \u274c CSP blocking inline scripts\nMoved inline JS \u2192 dashboard.js.\n\u274c Favicon 404\nLater resolved by cleaning unused assets.\n\u274c Dashboard not loading\nStatic folder wasn&rsquo;t being copied into the Docker image \u2014 added:\nCOPY dashboard\/ .\/dashboard Lesson Learned: Always check that Docker COPY paths match your repo structure.\n\ud83e\uddf9 Step 7: Cleaning Up Old Image Assets One surprising issue was that I had leftover images from older experiments inside the dashboard folder. These were not needed and caused:\n404 noise extra weight static route clutter Removing them cleaned up:\nconsole logs devtools warnings broken favicon references Lesson Learned: Old assets in static folders can \u201cghost load\u201d and cause unexpected 404s.\n\ud83e\uddea What the Final System Delivers \u2714 Fully self-hosted analytics\n\u2714 Runs on Raspberry Pi\n\u2714 Dockerized\n\u2714 Realtime tracking\n\u2714 Dashboard\n\u2714 Badges\n\u2714 Daily view history\n\u2714 Unique visitors\n\u2714 Zero external dependencies\n\u2714 Zero tracking cookies\n\u2714 Cloudflare Tunnel + HTTPS\n\u2714 Reliable, fast, minimal\nThis is your own private Google Analytics, without Google.\n\ud83c\udf1f Final Thoughts This project turned out to be far more complex than I expected, but also extremely rewarding. The result is a system that:\nI fully control Runs on my own hardware Integrates beautifully with my USS Will help me understand usage patterns Can be extended later (event tracking, error tracking, etc.) The biggest lessons:\nUse Node 18 for Express apps Routing order matters Use a dedicated tracking script for all pages Docker COPY paths must match reality Clear the cache after pushing to GitHub Pages Static dashboards need external JS (no inline scripts under CSP) Removing unused assets prevents 404 clutter If you want to self-host analytics without bloat, this approach works incredibly well.\n","permalink":"https:\/\/mumbleb.com\/posts\/activity-dashboard\/activity-dashboard\/","summary":"A full walkthrough of how I built a self-hosted view counter, analytics backend, dashboard, and badges for Universal Schema Studio \u2014 running entirely on Docker, Cloudflared, and my Raspberry Pi.","title":"Building a Self-Hosted Analytics System for Universal Schema Studio"},{"content":"\ud83e\udde9 Building a Universal Schema Viewer \u2014 XSD, XML &amp; YAML in the Browser Author: Bernard \u201cmumblebaj\u201d Mumble\nTags: JavaScript, OpenAPI, XSD, XML, YAML, ReDoc, GitHub Pages, Cloudflare\n\ud83d\ude80 Introduction\nWhat began as a simple idea to visualize XSDs has evolved into a full Universal Schema Studio \u2014 an all-in-one web app that can parse and display XSD, XML, and YAML\/JSON OpenAPI documents right in your browser. It features dark\/light theming, live editing via the new OpenAPI Editor, and a polished ReDoc preview pane.\n\ud83d\udc49 Live Demo: schema.mumbleb.com \ud83e\udde9 Project Overview The app now supports three complementary viewers and one editor:\nXSD Viewer \u2014 Parses .xsd schemas, detects root elements, and converts them to OpenAPI specs. YAML\/JSON Viewer \u2014 Renders .yaml, .yml, or .json OpenAPI specs with ReDoc or collapsible JSON tree. XML Viewer \u2014 Parses .xml payloads, displaying structured content or mock schema previews. OpenAPI Editor \u2014 A dedicated editing environment for live validation, preview, and export. The system automatically detects the file type and mode, applies dual themes, and ensures smooth switching between schema views.\n\ud83d\uddc2\ufe0f Updated Project Structure project-root\/ \u251c\u2500\u2500 .github\/workflows\/ \u2502 \u2514\u2500\u2500 docs.yml \u251c\u2500\u2500 docs\/ \u2502 \u251c\u2500\u2500 index.html \u2502 \u251c\u2500\u2500 openapiEditor.html \u2502 \u251c\u2500\u2500 xmlViewer.html \u2502 \u251c\u2500\u2500 CNAME \u2502 \u251c\u2500\u2500 favicon.svg \u251c\u2500\u2500 css\/ \u2502 \u251c\u2500\u2500 style.css \u2502 \u2514\u2500\u2500 openapiEditor.css \u251c\u2500\u2500 js\/ \u2502 \u251c\u2500\u2500 yamlViewer.js \u2502 \u251c\u2500\u2500 xsdViewer.js \u2502 \u251c\u2500\u2500 xmlViewer.js \u2502 \u251c\u2500\u2500 openapiEditor.js \u2502 \u2514\u2500\u2500 swagger-client.browser.min.js \u2502 CHANGELOG.md \u2514\u2500\u2500 README.md \ud83d\udcf8 \ud83e\uddf1 The Core \u2014 index.html Now includes support for all three viewers and a button to launch the OpenAPI Editor.\n&lt;header&gt; &lt;h2&gt;Universal Schema Viewer&lt;\/h2&gt; &lt;div&gt; &lt;select id=&#34;viewer-mode&#34;&gt; &lt;option value=&#34;yaml&#34;&gt;YAML \/ JSON&lt;\/option&gt; &lt;option value=&#34;xsd&#34;&gt;XSD \/ XML Schema&lt;\/option&gt; &lt;option value=&#34;xml&#34;&gt;XML Document&lt;\/option&gt; &lt;\/select&gt; &lt;button id=&#34;theme-toggle&#34;&gt;\ud83c\udf19 Dark Mode&lt;\/button&gt; &lt;button id=&#34;editor-launch&#34;&gt;\ud83e\uddf0 OpenAPI Editor&lt;\/button&gt; &lt;\/div&gt; &lt;\/header&gt; \ud83d\udcf8 \ud83c\udfa8 Styling \u2014 css\/style.css Dual-theme design now covers all elements, including dropdowns, badges, and embedded ReDoc components.\nIn dark mode, previously white-on-white text (dropdowns, inline elements) now inherits proper foreground colors.\n\ud83d\udcd8 YAML \/ JSON Viewer \u2014 js\/yamlViewer.js The YAML viewer detects OpenAPI specs automatically and renders them via ReDoc.\nIt now includes:\n$ref navigation (clickable references) live theme awareness fallback to a collapsible JSON tree when the data isn\u2019t OpenAPI \ud83d\udcf8 \ud83e\udde9 XSD Viewer \u2014 js\/xsdViewer.js Significantly enhanced with dynamic root detection and robust OpenAPI conversion logic.\nIt now correctly detects multi-root schemas and supports simpleContent, choice, and annotation elements.\nThe DOM handling was reworked to ensure ReDoc containers rebuild cleanly on refresh.\n\ud83d\udcf8 \ud83d\udcc4 XML Viewer \u2014 js\/xmlViewer.js A brand-new addition that enables direct .xml visualization.\nIt parses any well-formed XML payload, detects hierarchical structure, and \u2014 when possible \u2014 displays it as a simplified OpenAPI representation.\nexport function initXmlViewer(dropzone, xmlViewer) { console.log(&#34;\ud83d\udcc4 XML Viewer initialized&#34;); \/\/ Parse and render XML tree or schema-like ReDoc view } \ud83d\udcf8 \ud83e\uddf0 OpenAPI Editor \u2014 openapiEditor.html \/ .js \/ .css The OpenAPI Editor lets you load or paste an API spec, validate it live with SwaggerParser, and instantly preview via ReDoc.\nKey highlights:\n\ud83d\udd0d Auto-validation after edits \ud83d\udcbe Export to YAML \/ JSON with timestamped filenames \ud83d\udd17 Clickable $ref navigation across components \ud83c\udf19 Dark\/Light mode synced with global theme await SwaggerParser.validate(parsed); window.Redoc.init(parsed, { scrollYOffset: 20 }, previewPane); \ud83d\udcf8 \ud83c\udf17 Dual-Theme Refinements Theming now applies consistently across all viewers and the editor, including:\ndropdown selects mode badges (YAML \/ XSD \/ XML) ReDoc-rendered sections and links Light and dark modes switch seamlessly, with persistent storage via localStorage.\n\u2699\ufe0f Hosting &amp; Deployment Updates The GitHub Pages + Cloudflare workflow remains lightweight but now includes:\nautomatic CNAME handling correct HTTPS propagation (avoid \u201cUnavailable for your site\u201d delays) clean docs\/ folder deployment via peaceiris\/actions-gh-pages@v4 - name: Copy CNAME and favicon run: | mkdir -p docs echo &#34;schema.mumbleb.com&#34; &gt; docs\/CNAME cp favicon.svg docs\/ \ud83e\udde0 Lessons Learned XML and XSD parsing both benefit from unified DOM logic. ReDoc remains the most resilient renderer for OpenAPI specs. Dual-theme design required deeper CSS isolation to override internal ReDoc styles. Cloudflare + GitHub Pages continues to be the simplest static hosting stack for this kind of web tool. \u2728 Final Thoughts This project has evolved from a simple schema renderer into a modular Universal Schema Studio, capable of handling YAML, JSON, XML, and XSD \u2014 all styled consistently and deployed effortlessly.\nStay tuned for the next iteration: enhanced cover image, improved editor UX, and schema diffing tools.\n\ud83d\udc49 Try it live: schema.mumbleb.com ","permalink":"https:\/\/mumbleb.com\/posts\/universal-xsd-openapi-viewer\/","summary":"A detailed guide on how I built a unified web-based schema viewer that converts XSD and XML files into OpenAPI YAML and renders them with ReDoc \u2014  complete with dark mode, drag-and-drop uploads, and live previews.","title":"Building a Universal XSD \u2192 OpenAPI \u2192 ReDoc Viewer"},{"content":"\ud83d\udee1\ufe0f ReconYa + Suricata Network Monitoring on Raspberry Pi 5 (with Daily Email Digest) This guide walks through setting up ReconYa for LAN device monitoring, Suricata for intrusion detection, and a daily digest email script so your inbox doesn&rsquo;t get spammed by all the Suricata alerts. All tested and tuned ona Raspberry Pi 5.\nInstall ReconYa (Local Networ Monitoring) ReconYa is a self-hosted dashboard for device monitoring. To install ReconYa, run the below git clone https:\/\/github.com\/Dyneteq\/reconya.git cd reconya npm run install At first npm run install gave me an error. I replaced it with just npm install. The script behind npm run install required a package called commander which got installed when I ran npm install. I then re-ran npm run install to ensure that all required dependencies are installed properly.\nThe above npm run install will:\nDetect the operating system (macOS, Windows, Debian, or Red Hat-based) Install all required dependencies (Go, Node.js, nmap) Configure nmap permissions for MAC address detection Set up the reconYa application Install all Node.js dependencies\nNote\nI originally planned to use Docker, but since Docker on Raspberry Pi 5 is still experimental in some builds, I went with the manual script.\nOnce installed, run ReconYa at boot via systemd: sudo nano \/etc\/systemd\/system\/reconya.service Add the following:\n[Unit] Description=reconYa network scanner After=network-online.target Wants=network-online.target [Service] Type=simple User=your-user-name WorkingDirectory=\/home\/your-user-name\/reconya ExecStart=\/usr\/bin\/node scripts\/start.js Restart=always RestartSec=5 Environment=PATH=\/usr\/bin:\/usr\/local\/bin Environment=NODE_ENV=production # Optional: log output to files StandardOutput=append:\/home\/your-user-name\/reconya\/logs\/systemd-out.log StandardError=append:\/home\/your-user-name\/reconya\/logs\/systemd-error.log [Install] WantedBy=multi-user.target Then run the following:\nsudo systemctl daemon-reload sudo systemctl enable reconya sudo systemctl start reconya This will enable reconYa as a daemon and ensure it starts automatically on system crash or restart.\nInstall arpwatch + mail tools The following section details the installation of arpwatch and the mail tools so that we can setup mail notifications for intrusion alerts.\nsudo apt-get update sudo apt-get install arpwatch msmtp msmtp-mta mailutils Configure SMTP for email sending Edit msmtp config:\nsudo nano \/etc\/msmtprc Example for Gmail:\ndefaults auth on tls on tls_trust_file \/etc\/ssl\/certs\/ca-certificates.crt logfile \/var\/log\/msmtp.log account default host smtp.gmail.com port 587 from your_email@gmail.com user your_email@gmail.com password your_app_password Note:\nIf using Gmail, you need to create an App Password under your Google Account Security Settings. For Outlook, change host to smtp.office365.com and adjust accordingly. Set Permissions:\nsudo chmod 600 \/etc\/msmtprc Test sending:\necho &#34;Test email from Pi&#34; | mail -s &#34;Pi Email Test&#34; your_email@gmail.com If it arrives, SMTP is working.\nConfigure arpwatch Edit \/etc\/default\/arpwatch:\nsudo nano \/etc\/default\/arpwatch Set interface (e.g., eth0) and email:\nINTERFACES=&#34;eth0&#34; OPTIONS=&#34;-m your_email@gmail.com&#34; Enable and start:\nsudo systemctl enable --now arpwatch Verify arpwatch When a new device connects, you should get an email like:\nhostname (MAC address) appeared on eth0 Installing Suricata (Intrusion Detection System) Suricata is a free and open-source network threat detection engine. It functions as an Intrusion Detection System (IDS), Intrusion Prevention System (IPS), and Network Security Monitoring (NSM) tool, providing real-time analysis of network traffic.\nsudo apt update sudo apt install suricata Edit \/etc\/suricata\/suricata.yaml:\nSet HOME_NET to your LAN, e.g.: vars: address-groups: HOME_NET: &#34;[192.168.0.0\/24]&#34; Set runmode to autofp for low load: runmode: autofp Enable and start: sudo systemctl enable --now suricata Test config: sudo suricata -T -c \/etc\/suricata\/suricata.yaml \ud83d\udca1 Failure &amp; Fix: Initial test failed with No rule files match the pattern \/etc\/suricata\/rules\/suricata.rules The fix was to install rules and disable broken ones:\nsudo suricata-update sudo nano \/etc\/suricata\/suricata.yaml # point to correct rules file Disabled noisy or duplicate TLS rules in \/etc\/suricata\/rules\/tls-events.rules.\nReducing Noise (Rule Tuning) Out of the box, Suricata will alert on anything and everything &ndash; including normal LAN traffic.\nExample of noisy alerts:\nSURICATA ICMPv4 unknown code ET INFO Session Traversal Utilities for NAT ET INFO Go-http-client User-Agent Observed Outbound I disabled these in the disable.conf file: sudo nano \/etc\/suricata\/disable.conf sid:2200025 sid:2016149 sid:2016150 sid:2060251 I ended with the following file to try and tune out as much noise as possible so that only legitimate alerts can be trapped. ############################################## # Home LAN noise filter for Suricata # Save as: \/etc\/suricata\/disable.conf # Then run: sudo suricata-update &amp;&amp; sudo systemctl restart suricata ############################################## # --- Known noisy Suricata built-in events (keep real attacks, mute benign anomalies) # ICMP ping\/odd codes (spam example) 2200025 2024897 2260002 2016149 2016150 2060251 2053281 2053282 2210046 2210045 2047122 2048911 2006380 2013504 re:(?i)ICMPv4 unknown code # Benign TCP handshake anomolies 2210007 re:(?i)SURICATA STREAM 3way handshake SYNACK with wrong ack # Generic \u201cdecoder\/protocol\u201d informational chatter re:(?i)Generic Protocol Command Decode re:(?i)SURICATA.*(applayer|app[- ]layer|protocol).*(error|invalid|truncated) re:(?i)SURICATA.*(stream|reassembly).*(gap|overlap|depth) re:(?i)SURICATA.*(TCP )?invalid checksum re:(?i)SURICATA TLS certificate invalid (subject|issuer|validity) re:(?i)SURICATA HTTP header (invalid|too long) re:(?i)SURICATA (bad|malformed) packet # DHCP\/SMB\/NFS \u201cevent\u201d noise typical on home Windows boxes re:(?i)\\bSMB\\b.*(event|negotiat|dialect|session) re:(?i)\\bNFS\\b.*(event|op) re:(?i)\\bDHCP\\b.*(event) # --- Policy\/Info families (super chatty, rarely useful at home) re:(?i)^ET INFO re:(?i)^ET POLICY # --- Common consumer apps you probably use (don\u2019t alert on them) re:(?i)dropbox re:(?i)teamviewer re:(?i)onedrive|office365 re:(?i)google( drive| docs| api)?|youtube re:(?i)windows( update| defender| telemetry)? re:(?i)apple.*update re:(?i)facebook|instagram|whatsapp # --- Optional: DNS\/DoH chatter that\u2019s usually benign at home # re:(?i)ET DNS # re:(?i)DoH|DNS over HTTPS # --- Leave these ENABLED (don\u2019t disable): # Port scans, brute force, exploit\/malware C2, suspicious DNS\/HTTP # (We\u2019re not disabling ET SCAN\/ET TROJAN\/ET EXPLOIT etc.) Reload rules: sudo suricata-update sudo systemctl restart suricata \ud83d\udca1 Lesson: Review your logs before disabling \u2014 some \u201cnoise\u201d could be useful in certain environments. Avoid Inbox Spam (Daily Digest) Instead of emailing every alert, I created \/usr\/local\/bin\/suricata-daily-digest.sh. Before tuning the above disable.conf, suricata triggered an alert for every event resulting in me exceeding my daily Gmail limit. To avoid this re-occuring I opted to make use of a daily digest instead.\nKey Features:\nCounts total alerts Shows Top 5 Source IPs Shows Top 5 SIDs with descriptions Sends email once a day Only clears logs if email sent successfully Keeps digest history for 7 days Prunes old digests automatically #!\/bin\/bash # Suricata Daily Digest Script (summary + top talkers + SID descriptions) EMAIL=&#34;your_email@gmail.com&#34; LOG_FILE=&#34;\/var\/log\/suricata\/fast.log&#34; RULES_FILE=&#34;\/var\/lib\/suricata\/rules\/suricata.rules&#34; DIG_DIR=&#34;\/var\/log\/suricata\/digests&#34; TMP_FILE=$(mktemp) SUMMARY_FILE=$(mktemp) TOP_SIDS_FILE=$(mktemp) # will hold &#34;sid count&#34; lines STAMP=$(date +&#34;%Y-%m-%d&#34;) DIG_FILE=&#34;$DIG_DIR\/digest-$STAMP.txt&#34; mkdir -p &#34;$DIG_DIR&#34; # Count total alerts and skip if none ALERT_COUNT=$(wc -l &lt; &#34;$LOG_FILE&#34; 2&gt;\/dev\/null | tr -d &#39; &#39;) if [ -z &#34;$ALERT_COUNT&#34; ] || [ &#34;$ALERT_COUNT&#34; -eq 0 ]; then exit 0 fi # Build summary (+ capture Top 5 SIDs into TOP_SIDS_FILE) awk -v stamp=&#34;$STAMP&#34; -v out_sids=&#34;$TOP_SIDS_FILE&#34; &#39; function inc(map, key){ map[key]++; } function topn(map, n, label, also_write_sids){ i=0; for (k in map){ i++; keys[i]=k; vals[i]=map[k]; } for (a=1; a&lt;=i; a++){ max=a; for (b=a+1; b&lt;=i; b++){ if (vals[b]&gt;vals[max]) max=b; } if (max!=a){ tmp=vals[a]; vals[a]=vals[max]; vals[max]=tmp; tmp=keys[a]; keys[a]=keys[max]; keys[max]=tmp; } } out=label &#34;\\n&#34;; limit = (i&lt;n?i:n); for (c=1; c&lt;=limit; c++){ out = out sprintf(&#34; %2d) %-20s %6d\\n&#34;, c, keys[c], vals[c]); if (also_write_sids) { printf(&#34;%s %d\\n&#34;, keys[c], vals[c]) &gt;&gt; out_sids; } } if (i==0) out = out &#34; (none)\\n&#34;; return out &#34;\\n&#34;; } { if (match($0, \/\\[[0-9]+:([0-9]+):[0-9]+\\]\/, m)) { sid=m[1]; inc(sids, sid); } if (match($0, \/\\}\\s+([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+):[0-9]+\\s+-&gt;\/, m2)) { sip=m2[1]; inc(srcs, sip); } } END{ total=&#39;&#34;$ALERT_COUNT&#34;&#39;; usrc=0; for (x in srcs) usrc++; usid=0; for (y in sids) usid++; print &#34;Summary for &#34; stamp &gt; &#34;&#39;&#34;$SUMMARY_FILE&#34;&#39;&#34;; print &#34;=======================================&#34; &gt;&gt; &#34;&#39;&#34;$SUMMARY_FILE&#34;&#39;&#34;; print &#34;Total alerts: &#34; total &gt;&gt; &#34;&#39;&#34;$SUMMARY_FILE&#34;&#39;&#34;; print &#34;Unique source IPs: &#34; usrc &gt;&gt; &#34;&#39;&#34;$SUMMARY_FILE&#34;&#39;&#34;; print &#34;Unique SIDs: &#34; usid &#34;\\n&#34; &gt;&gt; &#34;&#39;&#34;$SUMMARY_FILE&#34;&#39;&#34;; printf(&#34;%s&#34;, topn(srcs, 5, &#34;Top 5 Source IPs (by alert count):&#34;, 0)) &gt;&gt; &#34;&#39;&#34;$SUMMARY_FILE&#34;&#39;&#34;; printf(&#34;%s&#34;, topn(sids, 5, &#34;Top 5 SIDs (by alert count):&#34;, 1)) &gt;&gt; &#34;&#39;&#34;$SUMMARY_FILE&#34;&#39;&#34;; } &#39; &#34;$LOG_FILE&#34; # Build a SID\u2192description section by grepping the rules file SID_DESC_SECTION=&#34;Top 5 SIDs Details\\n------------------\\n&#34; if [ -r &#34;$RULES_FILE&#34; ] &amp;&amp; [ -s &#34;$TOP_SIDS_FILE&#34; ]; then i=0 while read -r SID COUNT; do [ -z &#34;$SID&#34; ] &amp;&amp; continue i=$((i+1)) # Find first matching rule, extract msg:&#34;...&#34; DESC=&#34;$(grep -m1 -E &#34;sid:${SID};&#34; &#34;$RULES_FILE&#34; | sed -n &#39;s\/.*msg:&#34;\\([^&#34;]*\\)&#34;.*\/\\1\/p&#39;)&#34; [ -z &#34;$DESC&#34; ] &amp;&amp; DESC=&#34;&lt;description not found&gt;&#34; SID_DESC_SECTION+=&#34; $i) SID $SID ($COUNT) \u2014 $DESC&#34;$&#39;\\n&#39; done &lt; &#34;$TOP_SIDS_FILE&#34; SID_DESC_SECTION+=$&#39;\\n&#39; else SID_DESC_SECTION+=&#34; (rules file not readable or no SIDs)\\n\\n&#34; fi # Compose full email body: summary + SID details + full alert list { cat &#34;$SUMMARY_FILE&#34; echo &#34;$SID_DESC_SECTION&#34; echo &#34;Full alert list&#34; echo &#34;===============&#34; cat &#34;$LOG_FILE&#34; } &gt; &#34;$TMP_FILE&#34; # Save a copy cp &#34;$TMP_FILE&#34; &#34;$DIG_FILE&#34; # Subject w\/ count (pluralize) SUBJECT=&#34;Suricata Daily Digest ($STAMP) \u2014 $ALERT_COUNT alert&#34; [ &#34;$ALERT_COUNT&#34; -ne 1 ] &amp;&amp; SUBJECT=&#34;${SUBJECT}s&#34; # Send email; only clear log if send succeeded if mail -s &#34;$SUBJECT&#34; &#34;$EMAIL&#34; &lt; &#34;$TMP_FILE&#34;; then truncate -s 0 &#34;$LOG_FILE&#34; || true fi # Prune digest files older than 7 days find &#34;$DIG_DIR&#34; -type f -name &#39;digest-*.txt&#39; -mtime +7 -delete # Cleanup rm -f &#34;$TMP_FILE&#34; &#34;$SUMMARY_FILE&#34; &#34;$TOP_SIDS_FILE&#34; Sample Email\nSummary for 2025-08-09 ======================================= Total alerts: 40 Unique source IPs: 4 Unique SIDs: 5 Top 5 Source IPs (by alert count): 1) 192.168.0.1 37 2) 152.53.246.82 1 3) 23.137.254.218 1 4) 160.187.1.243 1 Top 5 SIDs (by alert count): 1) 2033078 21 2) 2013504 16 3) 2522194 1 4) 2522207 1 5) 2522360 1 Top 5 SIDs Details\\n------------------\\n 1) SID 2033078 (21) \u2014 ET INFO Session Traversal Utilities for NAT (STUN Binding Request On Non-Standard High Port) 2) SID 2013504 (16) \u2014 ET POLICY GNU\/Linux APT User-Agent Outbound likely related to package management 3) SID 2522194 (1) \u2014 ET TOR Known Tor Relay\/Router (Not Exit) Node Traffic group 195 4) SID 2522207 (1) \u2014 ET TOR Known Tor Relay\/Router (Not Exit) Node Traffic group 208 5) SID 2522360 (1) \u2014 ET TOR Known Tor Relay\/Router (Not Exit) Node Traffic group 361 Full alert list =============== 08\/08\/2025-23:31:11.329567 [**] [1:2033078:4] ET INFO Session Traversal Utilities for NAT (STUN Binding Request On Non-Standard High Port) [**] [Classification: Misc activity] [Priority: 3] {UDP} 192.168.0.1:22000 -&gt; 192.0.2.42:9999 08\/08\/2025-23:31:11.429902 [**] [1:2033078:4] ET INFO Session Traversal Utilities for NAT (STUN Binding Request On Non-Standard High Port) [**] [Classification: Misc activity] [Priority: 3] {UDP} 192.168.0.1:22000 -&gt; 192.0.2.42:9999 08\/08\/2025-23:31:11.630818 [**] [1:2033078:4] ET INFO Session Traversal Utilities for NAT (STUN Binding Request On Non-Standard High Port) [**] [Classification: Misc activity] [Priority: 3] {UDP} 192.168.0.1:22000 -&gt; 192.0.2.42:9999 08\/08\/2025-23:31:12.031131 [**] [1:2033078:4] ET INFO Session Traversal Utilities for NAT (STUN Binding Request On Non-Standard High Port) [**] [Classification: Misc activity] [Priority: 3] {UDP} 192.168.0.1:22000 -&gt; 192.0.2.42:9999 08\/08\/2025-23:31:12.832148 [**] [1:2033078:4] ET INFO Session Traversal Utilities for NAT (STUN Binding Request On Non-Standard High Port) [**] [Classification: Misc activity] [Priority: 3] {UDP} 192.168.0.1:22000 -&gt; 192.0.2.42:9999 08\/08\/2025-23:31:14.433082 [**] [1:2033078:4] ET INFO Session Traversal Utilities for NAT (STUN Binding Request On Non-Standard High Port) [**] [Classification: Misc activity] [Priority: 3] {UDP} 192.168.0.1:22000 -&gt; 192.0.2.42:9999 08\/08\/2025-23:31:16.033476 [**] [1:2033078:4] ET INFO Session Traversal Utilities for NAT (STUN Binding Request On Non-Standard High Port) [**] [Classification: Misc activity] [Priority: 3] {UDP} 192.168.0.1:22000 -&gt; 192.0.2.42:9999 08\/08\/2025-23:31:17.633842 [**] [1:2033078:4] ET INFO Session Traversal Utilities for NAT (STUN Binding Request On Non-Standard High Port) [**] [Classification: Misc activity] [Priority: 3] {UDP} 192.168.0.1:22000 -&gt; 192.0.2.42:9999 08\/08\/2025-23:31:19.234401 [**] [1:2033078:4] ET INFO Session Traversal Utilities for NAT (STUN Binding Request On Non-Standard High Port) [**] [Classification: Misc activity] [Priority: 3] {UDP} 192.168.0.1:22000 -&gt; 192.0.2.42:9999 Cron setup:\nsudo crontab -e RANDOM_DELAY=15 0 6 * * * \/usr\/local\/bin\/suricata-daily-digest.sh This setup ensures that you only receive 1 daily digest email with all alerts and avoids getting email bombed.\nClearing Logs Safely If you want to reset Suricata logs manually:\nsudo truncate -s 0 \/var\/log\/suricata\/fast.log \ud83d\udca1 I clear them automatically in the digest script only after a successful send \u2014 so no lost data. Lessons Learned Start noisy, then tune &ndash; don&rsquo;t disable rules before seeing what fires Email limits matter &ndash; Gmail caps daily sends at 500; Suricata can eat that in minutes if untuned. Seperate signal from noise &ndash; home LAN rules differ from corporate IDS rules ReconYa + Suricata are complementary &ndash; ReconYa for device visibility, Suricata for packet-level threats Digest &gt; Real-time for home use &ndash; real-time alerting only makes sense for critical assets Issues Experienced after installation After installing Suricata &amp; reconYa, my OMV web interface started returning 403 Forbidden errors. The culprit? OMV was uninstalled during package changes \u2014 \/var\/www\/openmediavault\/index.php was missing.\nFIX:\nsudo apt install --reinstall openmediavault sudo systemctl restart nginx Access restored on:\nLAN: http:\/\/local-ip:81 \u2705 End Result: Suricata \u2192 daily digest emails, 7-day retention reconYa \u2192 scanning on startup, always running via systemd arpwatch \u2192 tracking MAC\/IP changes OMV \u2192 LAN-only access, containers unaffected All services back to normal after recovery ","permalink":"https:\/\/mumbleb.com\/posts\/network-monitoring\/","summary":"Step-by-step network monitoring setup","title":"Setting up ReconYa + Suricata Network Monitoring on Raspberri Pi 5 with a Daily Email Digest"},{"content":"In my dealings with MagicMirror and the work I have been doing there, I have seen a few people mention the word NAS and &ldquo;pulling&rdquo; their photos from their NAS into their MM setup for display. This triggered some research and the more I read about self-hosting it made sense to me. No reliance on any Cloud storage that you have to pay for and as for privacy, that was front of mind. This meant I had to setup my own NAS to bring my media back under my own control.\nThe Journey I only had a RPi 3B+ at hand and as I wanted to check this out, I decided to use that.\nAnd thus, the initial setup was as follows.\n\ud83d\udd27 What You\u2019ll Need: Raspberry Pi 3B+ microSD card (8GB minimum, 16GB+ recommended) USB external hard drive or SSD (or large USB flash drive) Raspberry Pi power supply Ethernet cable or Wi-Fi (Ethernet preferred for stability\/speed) Raspberry Pi OS installed (Lite version is fine if you&rsquo;re using SSH) \ud83e\ude9b Step-by-Step Setup Set Up Raspberry Pi OS Download Raspberry Pi OS from https:\/\/www.raspberrypi.com\/software Use Raspberry Pi Imager to flash it to your SD card. Boot the Pi and configure basics (sudo raspi-config), including hostname, timezone, SSH, etc. Update Your Pi sudo apt update &amp;&amp; sudo apt upgrade -y Connect Your Storage Drive Plug in your external USB drive Find the drive: lsblk Mount it (e.g., to \/mnt\/nasdrive: sudo mkdir \/mnt\/nasdrive sudo mount \/dev\/sda1 \/mnt\/nasdrive Make it mount on boot: sudo blkid # Get UUID of the drive Add to \/etc\/fstab: sudo nano \/etc\/fstab Add a line like: UUID=your-drive-uuid \/mnt\/nasdrive ext4 defaults,nofail 0 0 Install Samba (for Windows\/macOS access) sudo apt install samba samba-common-bin -y Configure Samba Share\nEdit config: sudo nano \/etc\/samba\/smb.conf Add at the bottom: [NAS] path = \/mnt\/nasdrive browseable = yes writeable = yes only guest = no create mask = 0777 directory mask = 0777 public = no Create Samba User sudo adduser yourusername sudo smbpasswd -a yourusername Restart Samba sudo systemctl restart smbd Accessing the NAS On Windows: open File Explorer and go to \\\\raspberrypi.local\\NAS or use the IP On macOS: in Finder &gt; Go &gt; Connect to Server: smb\/\/raspberrypi.local\/NAS Now, this works just fine if you want a simple setup and manually transferring you media to your NAS. This was not my ideal setup and I did a little more digging.\nThis lead me to OMV, OpenMediaVault. OMV, as it is known for short, is a full-featured NAS web interface. However, this is much too heavy to run on a Pi 3. I then decided to take the plunge and ordered an RPi5 8Gb. Had to wait a few days for this to arive.\nI initially looked at Synology. Now, some of you would ask what is Synology? Well,\nSynology is a Taiwanese corporation that specializes in Network Attached Storage (NAS) devices. The Synology DiskStation Manager (DSM) cannot run on a RasberryPi because:\nDSM is closed-source and designed only to run on Synology&rsquo;s own custom x86 and ARM-based hardware. It requires specific hardware drives and bootloaders that the Pi does not support Synology doesn&rsquo;t officially support DIY installations or non-Synology hardware. For the above reasons I decided to go with OMV.\nOpenMediaVault (OMV) is a free network-attached storage (NAS) solution based on Debian Linux. It&rsquo;s designed for home and small office environments which made it perfect for my scenario.\nNOTE I installed OMV on my Pi 3B and tried setting up my 1TB SSD but this seemed to keep failing and giving me endless issues. Reason being that the Pi 3B does not play well with that big storage.\n\u26a1 Power Issues Pi 3B+ has limited power, especially for 2.5&quot; HDDs or SSD&rsquo;s which causes it to continously disconnect. The above was one of the main motivators for me getting my Pi 5 as it has dedicated USB 3 ports which works well with the SSD.\nWhat I saw on the Pi3B was:\nThe driver for the USB controller dwc_otg_hcd does not support scatter-gather which is required by the UAS driver. This confirmed two important things:\nThe USB-to-SATA adaptor supports UAS (USB Attached SCI) &ndash; a faster, more efficient USB protocol used by many SSDs and modern enclosures. The Raspberry Pi 3B+&rsquo;s USB controller (dwc_otg_hcd) does not fully support UAS, and can&rsquo;t handle &ldquo;scatter-gather&rdquo; operations What follows is the setup of OMV on my RPi 5.\n\ud83d\udda5\ufe0f Raspberry Pi 5 NAS Setup with OpenMediaVault and External SSD This guide walks through installing OpenMediaVault (OMV) on a Raspberry Pi 5, adding an SSD for media storage, and preparing it for Docker-based media and sync services.\n\ud83e\uddf0 Prerequisites Raspberry Pi 5 (8GB recommended for media tasks) microSD card (32GB or larger) for boot USB 3.0 SSD or NVMe via USB adapter (for media storage) Stable 5V 5A USB-C power supply Ethernet connection (recommended for NAS) Another PC for flashing the OS 1\ufe0f\u20e3 Install Raspberry Pi OS (Lite Recommended) Download Raspberry Pi Imager:\nhttps:\/\/www.raspberrypi.com\/software\/\nSelect:\nRaspberry Pi 5 \u2192 Raspberry Pi OS Lite (64-bit)\n(Lite is preferred for servers; no desktop environment needed) Before writing, press the gear icon: Enable SSH Set username and password Configure Wi-Fi (if not using Ethernet) Set hostname (e.g., rpi-nas) 2\ufe0f\u20e3 Initial Configuration SSH into the Pi:\nssh pi@&lt;raspberrypi-ip&gt; Update the OS:\nsudo apt update &amp;&amp; sudo apt upgrade -y sudo reboot 3\ufe0f\u20e3 Install OpenMediaVault Download and run the official OMV install script: wget -O - https:\/\/github.com\/OpenMediaVault-Plugin-Developers\/installScript\/raw\/master\/install | sudo bash After the script finishes: Access OMV web UI: http:\/\/&lt;raspberrypi-ip&gt;\/ Default credentials: User: admin Password: openmediavault Immediately change the password in the Web UI. 4\ufe0f\u20e3 Connect and Mount Your SSD Plug in the SSD (USB 3.0 recommended) In OMV web UI: Go to Storage \u2192 Disks Verify your SSD is listed Wipe the SSD (if new or safe to erase): Storage \u2192 Disks \u2192 Wipe \u2192 Quick Create a Filesystem: Storage \u2192 File Systems \u2192 Create \u2192 EXT4 Mount the filesystem (OMV will mount it under \/srv\/dev-disk-by-uuid-xxxx) 5\ufe0f\u20e3 Create Shared Folders For media and sync services:\nAccess Rights Management \u2192 Shared Folders \u2192 Add Example folders: music videos photos appdata (for Docker persistent configs) Set Permissions: Set Owner: root Group: users Permissions: 775 with setgid if you want all new files to inherit the users group. 6\ufe0f\u20e3 Enable SMB\/NFS for Network Access (Optional) Services \u2192 SMB\/CIFS \u2192 Enable Add shared folders as SMB shares for easy access from Windows\/Mac. (Optional) Enable NFS for Linux clients. 7\ufe0f\u20e3 Install OMV-Extras and Docker OMV-Extras (gives Docker and Portainer support): wget -O - https:\/\/github.com\/OpenMediaVault-Plugin-Developers\/packages\/raw\/master\/install | sudo bash In the OMV web UI: Go to System \u2192 OMV-Extras Install Docker Install Portainer for container management. Services \u2192 Compose \u2192 Files and Click the plus button services: portainer: image: portainer\/portainer-ce:latest container_name: portainer restart: always ports: - &#34;9000:9000&#34; - &#34;8000:8000&#34; volumes: - \/var\/run\/docker.sock:\/var\/run\/docker.sock - portainer_data:\/data volumes: portainer_data: 8\ufe0f\u20e3 Map Docker Volumes to SSD When creating containers (Syncthing, Jellyfin, etc.) in Portainer:\nMap host folders from your mounted SSD, for example: Service\tHost Path\tContainer Path Syncthing\t\/srv\/dev-disk-by-uuid-xxxx\/music\t\/sync\/music Jellyfin\t\/srv\/dev-disk-by-uuid-xxxx\/music and \/srv\/dev-disk-by-uuid-xxxx\/videos\t\/media\/music &amp; \/media\/videos App Configs\t\/srv\/dev-disk-by-uuid-xxxx\/appdata\/&lt;service&gt;\t\/config 9\ufe0f\u20e3 Setup Syncthing for Phone \u2192 NAS Sync Deploy Syncthing via Docker: Map \/music and \/photos to container paths Use PUID=1000 and PGID=1000 for permission consistency Pair devices using Syncthing-Fork on Android Set Send-Only (Phone) \u2192 Receive-Only (NAS) for one-way sync services: syncthing: image: syncthing\/syncthing:latest container_name: syncthing restart: always ports: - &#34;8384:8384&#34; # Web UI - &#34;22000:22000&#34; # Sync traffic - &#34;21027:21027\/udp&#34; # Discovery volumes: - \/srv\/path-to-your-disk\/syncthing\/config:\/var\/syncthing - \/srv\/path-to-your-disk\/syncthing\/data:\/mnt\/sync \ud83d\udd1f Setup Jellyfin for Media Playback See my media-management blog post for a complete setup guide.\nDeploy Jellyfin via Docker Map \/music and \/videos folders to \/media\/music and \/media\/videos Add libraries in the Jellyfin dashboard 1\ufe0f\u20e31\ufe0f\u20e3 Enable Secure Remote Access with Tailscale (Optional) Install Tailscale: curl -fsSL https:\/\/tailscale.com\/install.sh | sh sudo tailscale up Sign in and access OMV\/Jellyfin securely from anywhere using: http:\/\/&lt;device-name&gt;.tailnet.ts.net \u2705 Final Notes Always use EXT4 for SSDs to avoid Linux permission issues Set proper permissions (775 with group users) for all media folders Keep your Pi cool if transcoding with Jellyfin Regularly backup \/srv\/dev-disk-by-uuid-xxxx\/appdata for Docker configs ","permalink":"https:\/\/mumbleb.com\/posts\/omv-setup\/","summary":"Step-by-step guide to setup OMV on your RPI5","title":"Set up OpenMediaVault on RPI5"},{"content":"AdGuard Home is a powerful DNS-based ad blocker that filters unwanted traffic across your entire network. By running it on my Raspberry Pi 5 with OpenMediaVault (OMV), I gained:\nNetwork-wide ad, malware and tracker blocking DNS-over-HTTPS (DoH) for encrypted queries Home Assistant integration for monitoring and control This guide walks through how I set it up on OMV using Docker and configured it for reliable home use.\n1. Deploy AdGuard Home via Docker Compose I used Portainer for stack deployment, but you can also use docker-compose.\ndocker-compose.yml:\nservices: adguardhome: container_name: adguardhome image: adguard\/adguardhome:latest restart: unless-stopped ports: - &#34;53:53\/tcp&#34; # DNS TCP - &#34;53:53\/udp&#34; # DNS UDP - &#34;80:80\/tcp&#34; # Web UI &amp; API - &#34;443:443\/tcp&#34; # Optional HTTPS for Web UI volumes: - \/srv\/dev-disk-by-uuid-xxxx\/adguard\/conf:\/opt\/adguardhome\/conf - \/srv\/dev-disk-by-uuid-xxxx\/adguard\/work:\/opt\/adguardhome\/work environment: - TZ=Africa\/Johannesburg 2. Free Port 53 OMV (Debian-based) uses systemd-resolved which binds port 53.\nTo let AdGuard Home run, I disabled the stub listener:\nsudo nano \/etc\/systemd\/resolved.conf Set:\nDNS=192.168.3.110 1.1.1.1 8.8.8.8 DNSStubListener=no Then:\nsudo systemctl restart systemd-resolved 3. Set Up Router DNS In my router:\nPrimary DNS: &lt;your-omv-ip&gt; Secondary DNS (Fallback): 1.1.1.1 (Cloudflare) This routes all network DNS requests through Adguard Home and falls back to Cloudflare in case the OMV is unavailable.\n4. Configure AdGuard Home Access the Web UI:\n4. http:\/\/&lt;your-omv-ip&gt;:3000 # This is for the initial setup. Thereafter you should be able to use port 80. Key settings:\nEnable Protection Upstream DNS: https:\/\/dns.quad9.net\/dns-query https:\/\/dns.cloudflare.com\/dns-query https:\/\/dns.google\/dns-query Enable Logging and Statistics: querylog: interval: 168h # keep 7 days of queries statistics: interval: 1h # refresh stats hourly 5. Enable API for Home Assistant In \/opt\/adguardhome\/conf\/AdGuardHome.yaml:\napi: enabled: true bind: 0.0.0.0:80 Then restart:\ndocker restart adguardhome Now Home Assistant can connect using:\nHost: http:&lt;your-omv-ip&gt; Port 80 User: Your AdGuard UI user 6. Verify Everything Works From any device: nslookup google.com 192.168.3.110 From OMV host: dig google.com @127.0.0.1 Check Web UI \u2192 Query Log \u2192 Should show live traffic 7. Key Wins \u2705 Network-wide ad blocking with minimal Pi load \u2705 Encrypted upstream DNS (DoH) \u2705 Integrated into Home Assistant \u2705 Real-time query logging and hourly statistics ","permalink":"https:\/\/mumbleb.com\/posts\/adguard-home-setup\/","summary":"Step-by-step guide to setup Adguard Home","title":"Adguard Home Setup on OMV with Docker and Pi"},{"content":"I recently set up a self-hosted media server on my OpenMediaVault (OMV) box using Portainer and Docker Compose, combining:\nImmich \u2192 Personal photo &amp; video backup (Google Photos alternative) Jellyfin \u2192 Movies, TV shows, music streaming (Plex\/Netflix alternative) Syncthing \u2192 File and photo syncing from devices to the server\nThis guide walks you through a comparison between them and also providing a working configuration to get you up and running quickly. The comparison Immich Focus: Photos and videos from personal cameras\/phones Use Case: A self-hosted alternative to Google Photos Media Type: Personal memories, travel photos, phone videos Key Features: Auto-upload from phone Albums, people\/facial recognition, map view AI tagging (optional), search by person\/place\/date Web and mobile app for browsing and backup Multi-user with private\/public libraries Jellyfin Focus: Movies, TV shows, music, audiobooks, and live TV Use Case: A self-hosted alternative to Plex or Netflix Media Type: Commercial media libraries (ripped DVDs, downloaded shows, etc.) Key Features: Beautiful metadata fetching for movies\/shows Season\/episode support Transcoding to stream on many devices DLNA, Chromecast, Kodi integration Plugins and live TV\/DVR features So:\nUse Immich if you want to back up and browse your own photos and videos taken with a phone\/camera.\nUse Jellyfin if you want to stream shows\/movies\/music to your devices like Netflix or Plex.\nAnd yes \u2014 you can run both together on the same server (e.g., via Docker), serving different roles.\nIf your goal is only photo\/video management:\nImmich alone is a great replacement for: Syncthing (for photo upload &ndash; Immich mobile app handles that automatically) Jellyfin (for photo viewing &ndash; Immich is better-suited UI-wise for browsing personal albums, people, locations, etc.) Advantages:\nImmich handles face recognition, map views, albums, and sharing much better than Jellyfin. Auto-upload is seamless &ndash; no need to manage Syncthing folders manually. Nice UI for family members or multi-user setup. If you also want to manage\/stream other media (TV, movies, music):\nStick with Jellyfin _ Syncthing (or another uploader like Nextcloud or PhotoPrism, depending on needs). Why?\nJellyfin shines at: Organizing TV shows\/movies Streaming music, audiobooks, live TV Woking with external metadata (IMDb, TheMovieDB, etc.) Syncthing offers general-purpose sync, not just for photos &ndash; so it&rsquo;s more flexible. Here is a Diagram: Unified Media Server Setup +---------------------+ | Mobile Devices | | (phones, tablets) | +----------+----------+ | [1] Auto Upload via Immich App | [2] Sync via Syncthing (optional) | +----------v----------+ | Media Server | | (e.g. Raspberry Pi 5)| +----------+----------+ | +-----------------------------+----------------------------+ | | | +----v----+ +------v------+ +------v-------+ | Immich | | Syncthing | | Jellyfin | | (Docker)| | (Docker\/OS) | | (Docker) | +---------+ +-------------+ +--------------+ | | | | [Reads\/Writes] | | +------------+-----------------+----------------------------+ | Media Shared Storage (e.g. \/mnt\/media) | Organized like this: | |-- \/mnt\/media |-- photos\/ (used by Immich + Jellyfin) |-- videos\/ (optional raw footage or home videos) |-- movies\/ (Jellyfin) |-- tv_shows\/ (Jellyfin) |-- music\/ (Jellyfin) |-- uploads\/ (incoming Syncthing folder) Key Points to take away:\nSyncthing: Optional if you want to keep syncing photos outside Immich, or sync other files. Immich: Handles photo upload, facial recognition, albums Best used for \/photos\/ directory Jellyfin: Reads from \/photos\/, \/movies\/, \/tv_shows\/, \/music\/. Can show photos too, but doesn&rsquo;t offer smart albums or facial tagging. Step 1: Media Folder Structure (on host) Make sure your folders look like this on your host machine:\n\/mnt\/media\/ \u251c\u2500\u2500 photos\/ # Immich and Jellyfin \u251c\u2500\u2500 movies\/ # Jellyfin \u251c\u2500\u2500 tv_shows\/ # Jellyfin \u251c\u2500\u2500 music\/ # Jellyfin \u2514\u2500\u2500 uploads\/ # Syncthing (optional staging folder) Step 2: Prepare the folders mkdir -p \\ \/srv\/...\/photos \\ \/srv\/...\/movies \\ \/srv\/...\/tvshows \\ \/srv\/...\/music \\ \/srv\/...\/syncthing \\ \/srv\/...\/jellyfin-config Step 2: Docker Compose Stack (for Portainer) Save this in a file called media-stack.yml, then import it into Portainer as a stack.\nservices: immich-server: image: ghcr.io\/immich-app\/immich-server:release container_name: immich-server restart: unless-stopped depends_on: - immich-db - immich-redis environment: DB_HOSTNAME: immich-db DB_USERNAME: immich DB_PASSWORD: 1mmich1705 DB_DATABASE_NAME: immich REDIS_HOSTNAME: immich-redis NODE_ENV: production IMMICH_SERVER_HOST: 0.0.0.0 IMMICH_SERVER_PORT: 2283 volumes: - \/srv\/...\/photos:\/usr\/src\/app\/upload - \/etc\/localtime:\/etc\/localtime:ro ports: - &#34;2283:2283&#34; immich-ml: image: ghcr.io\/immich-app\/immich-machine-learning:release container_name: immich-ml restart: unless-stopped depends_on: - immich-db - immich-redis environment: DB_HOSTNAME: immich-db DB_USERNAME: immich DB_PASSWORD: 1mmich1705 DB_DATABASE_NAME: immich REDIS_HOSTNAME: immich-redis volumes: - \/srv\/...\/photos:\/usr\/src\/app\/upload immich-redis: image: redis:6 container_name: immich-redis restart: unless-stopped immich-db: image: ghcr.io\/immich-app\/postgres:14-vectorchord0.3.0-pgvectors0.2.0 container_name: immich-db restart: unless-stopped environment: POSTGRES_USER: immich POSTGRES_PASSWORD: 1mmich1705 POSTGRES_DB: immich volumes: - immich-db:\/var\/lib\/postgresql\/data jellyfin: image: jellyfin\/jellyfin container_name: jellyfin restart: unless-stopped ports: - &#34;8096:8096&#34; volumes: - \/srv\/...\/photos:\/media\/photos - \/srv\/...\/movies:\/media\/movies - \/srv\/...\/tvshows:\/media\/tvshows - \/srv\/...\/music:\/media\/music - \/srv\/...\/jellyfin-config:\/config - jellyfin-cache:\/cache syncthing: image: syncthing\/syncthing:latest container_name: syncthing restart: unless-stopped ports: - &#34;8384:8384&#34; # Web UI - &#34;22000:22000\/tcp&#34; - &#34;22000:22000\/udp&#34; - &#34;21027:21027\/udp&#34; volumes: - \/srv\/...\/syncthing:\/var\/syncthing\/uploads - syncthing-config:\/var\/syncthing\/config volumes: immich-db: jellyfin-cache: syncthing-config: Step 4: Deploy in Portainer Open Portainer Go tp Stacks &gt; Add Stack. Name it (e.g., media-suite) Paste the media-stack.yml content into the editor. Deploy the stack Access URLs: Service\tURL Notes Immich\thttp:\/\/&lt;host&gt;:2283\tAuto photo upload via app too Jellyfin\thttp:\/\/&lt;host&gt;:8096\tSet up libraries from \/media\/... Syncthing\thttp:\/\/&lt;host&gt;:8384\tFor syncing files manually The beauty of this setup is that I now have the freedom of choice of which technology to use for which situation. I have found this to work best for my setup. ","permalink":"https:\/\/mumbleb.com\/posts\/media-management\/","summary":"Selecting the right media storage","title":"Selecting the right media management option for self-hosting is important"},{"content":"I have been an avid user of the MagicMirror\u00b2 framework for almost 6 years now. I have developed, and look after a range of modules. This document will detail these modules and their usage.\n\ud83e\uddf1 Features Based on debian:trixie for Pi 5 compatibility Installs MagicMirror\u00b2 v2.32+ Allows custom modules + volume persistence Exposed port for local display \ud83e\uddea Custom Modules MMM-Growatt for solar stats MMM-Growatt-Stats for combined stats MMM-MovieListings for movies MMM-NOAA3 for weather-forecast from different providers MMM-Reddit to display content from Reddit MMM-GasMonitor to track your gas levels MMM-NewsAPI custom news feed MMM-Rugby track world rugby rankings and tracks match results for specified leagues MMM-SweepClock a clssic Railway Clock MMM-EskomSePush tracks loadshedding schedule in South Africa MMM-WOTD a word of the day module which caters for multiple languages MMM-PhilipsHue displayes information and status of your Hue lights from your Hue Bridge \ud83e\ude9e MagicMirror\u00b2 in Docker \u2014 My Complete Setup I recently set up MagicMirror\u00b2 to run fully containerized in Docker with support for: Mounted custom modules, config.js, and custom.css Server-only mode for remote\/browser display Clean build \u2192 test \u2192 push to Docker Hub workflow Here\u2019s how I did it.\n1\ufe0f\u20e3 Prepare the Project Structure Create a working folder for your MagicMirror container:\nmkdir magicmirror-docker cd magicmirror-docker mkdir config custom-css my-modules touch config\/config.js touch custom-css\/custom.css config\/ \u2192 contains config.js custom-css\/ \u2192 contains custom.css my-modules\/ \u2192 where all your custom modules go 2\ufe0f\u20e3 Create the Dockerfile FROM debian:trixie-20250520 SHELL [&#34;\/bin\/bash&#34;, &#34;-euxo&#34;, &#34;pipefail&#34;, &#34;-c&#34;] ENV DEBIAN_FRONTEND=noninteractive # Create non-root user RUN useradd -ms \/bin\/bash pi USER root # Install system deps RUN apt-get update &amp;&amp; apt-get -y upgrade &amp;&amp; apt-get install -y --no-install-recommends \\ curl git nano procps tini ca-certificates xz-utils python3 gnupg build-essential \\ &amp;&amp; apt-get clean &amp;&amp; rm -rf \/var\/lib\/apt\/lists\/* # Dummy sudo for some npm postinstall scripts RUN echo -e &#39;#!\/bin\/bash\\nexec &#34;$@&#34;&#39; &gt; \/usr\/local\/bin\/sudo &amp;&amp; chmod +x \/usr\/local\/bin\/sudo # Install Node.js 22.x RUN curl -fsSL https:\/\/deb.nodesource.com\/setup_22.x | bash - &amp;&amp; \\ apt-get install -y nodejs &amp;&amp; node -v &amp;&amp; npm -v # Switch to non-root USER pi WORKDIR \/home\/pi # Clone MagicMirror and install dependencies RUN git clone https:\/\/github.com\/MagicMirrorOrg\/MagicMirror.git WORKDIR \/home\/pi\/MagicMirror RUN npm install &amp;&amp; npm run install-mm # Copy entrypoint script COPY entrypoint.sh \/home\/pi\/entrypoint.sh RUN chmod +x \/home\/pi\/entrypoint.sh EXPOSE 8080 ENTRYPOINT [&#34;\/home\/pi\/entrypoint.sh&#34;] 3\ufe0f\u20e3 Create the Entrypoint Script entrypoint.sh handles linking modules and loading config\/css:\n#!\/bin\/bash set -euxo pipefail MM_DIR=&#34;\/home\/pi\/MagicMirror&#34; MODULES_DIR=&#34;$MM_DIR\/modules&#34; CUSTOM_MODULES_DIR=&#34;$MM_DIR\/custom-modules&#34; CUSTOM_CONFIG=&#34;\/home\/pi\/MM-config\/config.js&#34; CUSTOM_CSS=&#34;\/home\/pi\/MM-css\/custom.css&#34; # Load config.js if provided if [ -f &#34;$CUSTOM_CONFIG&#34; ]; then cp &#34;$CUSTOM_CONFIG&#34; &#34;$MM_DIR\/config\/config.js&#34; echo &#34;\u2705 Loaded custom config.js&#34; fi # Load custom.css if provided if [ -f &#34;$CUSTOM_CSS&#34; ]; then cp &#34;$CUSTOM_CSS&#34; &#34;$MM_DIR\/css\/custom.css&#34; echo &#34;\u2705 Loaded custom.css&#34; fi # Link custom modules if [ -d &#34;$CUSTOM_MODULES_DIR&#34; ]; then for dir in &#34;$CUSTOM_MODULES_DIR&#34;\/*; do [ -d &#34;$dir&#34; ] || continue name=$(basename &#34;$dir&#34;) target=&#34;$MODULES_DIR\/$name&#34; if [ ! -L &#34;$target&#34; ]; then ln -s &#34;$dir&#34; &#34;$target&#34; echo &#34;\u2705 Linked custom module: $name&#34; fi done fi # Start MagicMirror in server-only mode cd &#34;$MM_DIR&#34; exec npm run server 4\ufe0f\u20e3 Docker Compose Setup docker-compose.yml mounts your local folders into the container:\nversion: &#34;3.8&#34; services: magicmirror: image: yourusername\/magicmirror:latest container_name: magicmirror ports: - &#34;8080:8080&#34; volumes: - .\/config:\/home\/pi\/MM-config - .\/custom-css:\/home\/pi\/MM-css - .\/my-modules:\/home\/pi\/MagicMirror\/custom-modules restart: unless-stopped 5\ufe0f\u20e3 Build, Test, and Run Locally docker-compose build --no-cache docker-compose up -d docker logs -f magicmirror Visit:\nhttp:\/\/localhost:8080 Notes:\nIf you are doing these in a Windows environment, you need to have docker-desktop installed. You may also want to do the above steps in a WSL environment 6\ufe0f\u20e3 Scripts for Automation build-local.sh (Test Locally)\n#!\/bin\/bash set -e docker-compose build --no-cache docker-compose up -d build-and-push.sh (Push to Docker Hub)\n#!\/bin\/bash set -e DOCKER_USERNAME=&#34;yourusername&#34; IMAGE_NAME=&#34;magicmirror&#34; docker build -t $DOCKER_USERNAME\/$IMAGE_NAME:latest . docker push $DOCKER_USERNAME\/$IMAGE_NAME:latest Make them executable:\nchmod +x build-local.sh build-and-push.sh 7\ufe0f\u20e3 Browser Display and Portrait Mode MagicMirror is running in server-only mode. Rotate the display on the viewing device (tablet, monitor, or laptop). On Raspberry Pi with a local screen, use \/boot\/config.txt to set display_rotate=1 \u2705 Final Notes All custom modules, config, and CSS live outside the container for easy updates. Core MagicMirror and default modules are baked into the image. Upgrading is as simple as: docker-compose down docker pull yourusername\/magicmirror:latest docker-compose up -d This setup gives you a clean, maintainable MagicMirror Docker workflow, from local testing to pushing your own Docker Hub image.\nSome screenshots of the modules under my guard Growatt Growatt-Stats EskomSePush Gas Monitor MovieListing NewsAPI NOAA3 PhilipsHue Reddit Rugby SweepClock WOTD ","permalink":"https:\/\/mumbleb.com\/posts\/magicmirror-docker\/","summary":"<p>I have been an avid user of the MagicMirror\u00b2 framework for almost 6 years now. I have developed, and look after  a range of modules. This document will detail these modules and their usage.<\/p>\n<h2 id=\"-features\">\ud83e\uddf1 Features<\/h2>\n<ul>\n<li>Based on <code>debian:trixie<\/code> for Pi 5 compatibility<\/li>\n<li>Installs MagicMirror\u00b2 v2.32+<\/li>\n<li>Allows custom modules + volume persistence<\/li>\n<li>Exposed port for local display<\/li>\n<\/ul>\n<h2 id=\"-custom-modules\">\ud83e\uddea Custom Modules<\/h2>\n<ul>\n<li><a href=\"https:\/\/github.com\/mumblebaj\/MMM-Growatt\">MMM-Growatt<\/a> for solar stats<\/li>\n<li><a href=\"https:\/\/github.com\/mumblebaj\/MMM-Growatt-Stats\">MMM-Growatt-Stats<\/a> for combined stats<\/li>\n<li><a href=\"https:\/\/github.com\/mumblebaj\/MMM-MovieListings\">MMM-MovieListings<\/a> for movies<\/li>\n<li><a href=\"https:\/\/github.com\/mumblebaj\/MMM-NOAA3\">MMM-NOAA3<\/a> for weather-forecast from different providers<\/li>\n<li><a href=\"https:\/\/github.com\/mumblebaj\/MMM-Reddit\">MMM-Reddit<\/a> to display content from Reddit<\/li>\n<li><a href=\"https:\/\/github.com\/mumblebaj\/MMM-GasMonitor\">MMM-GasMonitor<\/a> to track your gas levels<\/li>\n<li><a href=\"https:\/\/github.com\/mumblebaj\/MMM-NewsAPI\">MMM-NewsAPI<\/a> custom news feed<\/li>\n<li><a href=\"https:\/\/github.com\/mumblebaj\/MMM-Rugby\">MMM-Rugby<\/a> track world rugby rankings and tracks match results for specified leagues<\/li>\n<li><a href=\"https:\/\/github.com\/mumblebaj\/MMM-SweepClock\">MMM-SweepClock<\/a> a clssic Railway Clock<\/li>\n<li><a href=\"https:\/\/github.com\/mumblebaj\/MMM-EskomSePush\">MMM-EskomSePush<\/a> tracks loadshedding schedule in South Africa<\/li>\n<li><a href=\"https:\/\/github.com\/mumblebaj\/MMM-WOTD\">MMM-WOTD<\/a> a word of the day module which caters for multiple languages<\/li>\n<li><a href=\"https:\/\/github.com\/mumblebaj\/MMM-PhilipsHue\">MMM-PhilipsHue<\/a> displayes information and status of your Hue lights from your Hue Bridge<\/li>\n<\/ul>\n<h2 id=\"-magicmirror-in-docker--my-complete-setup\">\ud83e\ude9e MagicMirror\u00b2 in Docker \u2014 My Complete Setup<\/h2>\n<ul>\n<li>I recently set up MagicMirror\u00b2 to run fully containerized in Docker with support for:<\/li>\n<li>Mounted custom modules, <strong>config.js<\/strong>, and <strong>custom.css<\/strong><\/li>\n<li>Server-only mode for remote\/browser display<\/li>\n<li>Clean build \u2192 test \u2192 push to Docker Hub workflow<\/li>\n<\/ul>\n<p><strong>Here\u2019s how I did it.<\/strong><\/p>","title":"MagicMirror\u00b2 in Docker on RPi5"},{"content":"\ud83c\udfe0 Introduction Over the past few weeks, I went on a home-lab adventure to consolidate my self-hosted services:\nAs most of you self-hosters would know, if you tweek one thing, something else is bound to break. &#x1f605;\nOpenMediaVault (OMV) for storage &amp; Docker management Immich for private photo backup Jellyfin for private home video backup AdGuard Home for DNS-level ad blocking Hugo Blog to document my journey Tailscale for private remote access Cloudflare Tunnel for secure public access without port forwarding Uptime-Kuma for monitoring all the services that I am hosting After a few networking headaches and some Docker\/NAT surprises, I now have a stable hybrid setup where:\nI can upload photos privately via Tailscale to Immich I can upload home videos privately via Tailscale and Syncthing to Jellyfin I can access OMV and AdGuard securely from anywhere via Cloudflare My blog is public on a custom domain with HTTPS No ports are exposed to the internet directly I can monitor all service using uptime-kuma This post documents the final working configuration.\n\ud83d\udda5\ufe0f Hardware &amp; Base Setup Raspberry Pi 5 (8GB) NVMe SSD mounted for OMV and Docker storage OpenMediaVault (OMV) as the host OS Docker &amp; Portainer for container management I installed these services via Docker Compose:\nHugo Blog \u270f Portainer Then I installed the following using Portainer:\nAdGuard Home \ud83d\udee1\ufe0f Immich (Server, DB, Redis, ML) and Jellyfin as a single stack \ud83d\udcf8 Uptime-Kuma \u23f1\ufe0f Syncthing \ud83d\udd04 \ud83c\udf10 Networking Architecture I wanted public access for some services but private uploads for Immich and Jellyfin.\nHere\u2019s the architecture:\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502 Internet \u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502 Cloudflare Tunnel \u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502 Raspberry Pi 5 (OMV) \u2502 \u2502 \u2502 \u2502 - Hugo (Public Blog) \u2502 \u2502 \u2192 mydomain.com \u2502 \u2502 - AdGuard (DNS) \u2502 \u2502 \u2192 adguard.mydomain.com \u2502 \u2502 - OMV (Web UI) \u2502 \u2502 \u2192 via Tailscale or optional tunnel \u2502 \u2502 - Immich (Uploads) \u2502 \u2502 \u2192 via Tailscale MagicDNS \u2502 \u2502 \u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502 LAN + Tailscale Tailscale handles private admin, Immich and Jellyfin uploads Cloudflare Tunnel handles public blog and optional subdomains No local SSL needed; Cloudflare provides HTTPS \ud83d\udd27 Fixing Immich &amp; Tailscale Access Initially, Immich logs showed: Immich Server is listening on http:\/\/[::1]:2283\nThis meant the container was only listening on IPv6 localhost.\nLAN, Tailscale, and Cloudflare could not reach it.\nSolution: Bind to all interfaces.\nimmich-server: image: ghcr.io\/immich-app\/immich-server:release container_name: immich_server ports: - &#34;2283:2283&#34; environment: - IMMICH_SERVER_HOST=0.0.0.0 After restarting the container:\nImmich Server is listening on http:\/\/0.0.0.0:2283 \u2705 Immich uploads via Tailscale MagicDNS now work again.\n\u2601\ufe0f Cloudflare Tunnel Config I used subdomains for apps that don\u2019t support subpaths:\ntunnel: hugo-blog credentials-file: \/home\/user\/.cloudflared\/UUID.json ingress: - hostname: adguard.mydomain.com service: http:\/\/127.0.0.1:8081 - hostname: omv.mydomain.com service: http:\/\/127.0.0.1:81 - hostname: im.mydomain.com service: http:\/\/127.0.0.1:2283 - hostname: mydomain.com service: http:\/\/127.0.0.1:8090 - service: http_status:404 Key Points: mydomain.com \u2192 Hugo Blog adguard.mydomain.com \u2192 AdGuard Home Added im.mydomain.com to access Immich publicly. \u2705 Current Status Blog: https:\/\/mydomain.com AdGuard: https:\/\/adguard.mydomain.com Immich uploads: via Tailscale http:\/\/openmediavault.tailXXXX.ts.net:2283 OMV Web UI: via LAN or Tailscale :81 Everything is now fast, secure, and stable. Next step: Optional Cloudflare Access for admin panels and public Immich gallery. If you\u2019re following my journey and setting up something similar, I highly recommend starting private with Tailscale, then adding Cloudflare Tunnel for selective public access.\nOverall Architecture ","permalink":"https:\/\/mumbleb.com\/posts\/self-hosting-journey\/","summary":"How I combined OpenMediaVault, AdGuard Home, Immich, Tailscale, and Cloudflare Tunnel to securely self\u2011host my home services with public access and private uploads.","title":"My Journey to a Stable Hybrid Self-Hosted Home Lab"},{"content":"\ud83c\udf89 Introduction I wanted my self-hosted Hugo blog UI to be accessible to family members and firends from anywhere on the internet, without exposing my network or fiddling with port forwarding.\nThe challenge:\nMy Raspberry Pi 5 is behind LTE CGNAT, so I have no public IP I was using DuckDNS and Let\u2019s Encrypt, which was painful with CGNAT I wanted public HTTPS access for the blog The solution?\n\ud83d\udc49 Cloudflare Tunnel \u2014 free, secure, and works perfectly behind CGNAT.\n\u2705 Step 1: Install Hugo and Run Blog Locally I used Docker to run Hugo on port 8090:\nmkdir ~\/hugo-blog cd ~\/hugo-blog # Create Hugo site for ARM64 (Pi 5) docker run --rm -it -v $(pwd):\/src --platform linux\/arm64 \\ hugomods\/hugo:latest new site . # Add the PaperMod theme git init git submodule add https:\/\/github.com\/adityatelange\/hugo-PaperMod.git themes\/PaperMod Run Hugo locally:\ndocker run --rm -it -v $(pwd):\/src -p 8090:1313 \\ --platform linux\/arm64 \\ hugomods\/hugo:latest \\ server --bind 0.0.0.0 --port 1313 Hugo blog available locally at: http:\/\/127.0.0.1:8090\n\u2705 Step 2: Install Cloudflared Download the ARM64 binary on the Pi:\ncd \/usr\/local\/bin sudo curl -L https:\/\/github.com\/cloudflare\/cloudflared\/releases\/latest\/download\/cloudflared-linux-arm64 -o cloudflared sudo chmod +x cloudflared cloudflared --version \u2705 Step 3: Setup Cloudflare Domain Purchase a domain from Namecheap Add it to Cloudflare (Free plan) Change nameservers in Namecheap to the two Cloudflare nameservers Wait until Cloudflare shows the domain as Active Cloudflare will automatically provision Universal SSL \u2705 Step 4: Authenticate Cloudflare Tunnel cloudflared tunnel login Opens a browser \u2192 log in to Cloudflare Generates ~\/.cloudflared\/cert.pem Create a named tunnel: cloudflared tunnel create hugo-blog \u2705 Step 5: Configure the Tunnel Create \/etc\/cloudflared\/config.yml:\ntunnel: hugo-blog credentials-file: \/home\/user\/.cloudflared\/&lt;TUNNEL-UUID&gt;.json ingress: # Hugo blog at root - hostname: mydomain.com service: http:\/\/127.0.0.1:8090 # Catch-all - service: http_status:404 \u2705 Step 6: Configure Cloudflare DNS Type: CNAME Name: @ Target: &lt;TUNNEL-UUID&gt;.cfargotunnel.com Proxy: Proxied \u2601\ufe0f TTL: Auto (Optional):\nType: CNAME Name: www Target: mydomain.com Proxy: Proxied \u2601\ufe0f TTL: Auto \u2705 Step 7: Run Cloudflare Tunnel as a Service sudo cloudflared service install sudo systemctl enable cloudflared sudo systemctl start cloudflared sudo systemctl status cloudflared Verify logs:\nINF Route to http:\/\/127.0.0.1:8090 INF Route to http:\/\/127.0.0.1:8081 INF Registered tunnel connection \u2705 Step 8: Test Public Access Hugo Blog \u2192 https:\/\/mydomain.com\/ \ud83c\udf89 Fully public and HTTPS-secured, no port forwarding required! \ud83c\udf89 Result Hugo blog public for family Free SSL handled by Cloudflare No DuckDNS, no Let\u2019s Encrypt, no port forwarding Works perfectly behind LTE CGNAT!\nWith this setup, my self-hosted Pi services are now family and friends-friendly and globally accessible without exposing my home network. \ud83d\ude80 \ud83d\udc41\ufe0f Adding a Self-Hosted View Counter to Hugo Blog To track and display views per blog post (without relying on third-party APIs like hits.sh or countapi), I set up a self-hosted view counter API using Node.js + Docker, and integrated it with my Hugo site.\n\ud83d\udce6 1. Self-Hosted View Counter API Setup Create the API files Inside a folder called view-counter, create: index.js: const express = require(&#39;express&#39;); const cors = require(&#39;cors&#39;); const fs = require(&#39;fs-extra&#39;); const app = express(); const port = process.env.PORT || 3000; const dbFile = &#39;.\/views.json&#39;; app.use(cors()); \/\/ Init DB if missing if (!fs.existsSync(dbFile)) fs.writeJsonSync(dbFile, {}); \/\/ Route to generate view badge SVG app.get(&#39;\/badge\/:page&#39;, async (req, res) =&gt; { const page = req.params.page; let db = await fs.readJson(dbFile); const views = db[page] || 0; const svg = ` &lt;svg xmlns=&#34;http:\/\/www.w3.org\/2000\/svg&#34; width=&#34;120&#34; height=&#34;20&#34;&gt; &lt;rect width=&#34;120&#34; height=&#34;20&#34; fill=&#34;#6b7280&#34;\/&gt; &lt;text x=&#34;10&#34; y=&#34;14&#34; fill=&#34;white&#34; font-family=&#34;Verdana&#34; font-size=&#34;11&#34;&gt;\ud83d\udc40 ${views} Views&lt;\/text&gt; &lt;\/svg&gt; `; res.set(&#39;Content-Type&#39;, &#39;image\/svg+xml&#39;); res.send(svg); }); \/\/ Increment view counter (used by JS) app.get(&#39;\/increment\/:slug&#39;, async (req, res) =&gt; { const { slug } = req.params; let db = await fs.readJson(dbFile); db[slug] = (db[slug] || 0) + 1; await fs.writeJson(dbFile, db); res.json({ slug, views: db[slug] }); }); app.listen(port, () =&gt; console.log(`Counter API running on port ${port}`)); views.json:\n{} Dockerfile:\nFROM node:18-alpine WORKDIR \/app COPY . . RUN npm install EXPOSE 3000 CMD [&#34;node&#34;, &#34;index.js&#34;] package.json:\n{ &#34;name&#34;: &#34;view-counter&#34;, &#34;version&#34;: &#34;1.0.0&#34;, &#34;main&#34;: &#34;index.js&#34;, &#34;dependencies&#34;: { &#34;cors&#34;: &#34;^2.8.5&#34;, &#34;express&#34;: &#34;^4.18.2&#34;, &#34;fs-extra&#34;: &#34;^11.1.1&#34; } } docker-compose.yml:\nservices: view-counter: image: my-view-counter build: . ports: - &#34;3010:3000&#34; volumes: - .\/views.json:\/app\/views.json restart: unless-stopped \ud83d\udd27 2. Build &amp; Deploy the Container docker compose up --build -d Ensure views.json exists and has proper write permissions (chmod 666 views.json). Ensure port 3010 is not in use. \ud83c\udf10 3. Configure Cloudflared Tunnel In \/etc\/cloudflared\/config.yml, add:\ningress: - hostname: views.mydomain.com service: http:\/\/127.0.0.1:3010 Then restart the tunnel:\nsudo systemctl restart cloudflared Also, add a CNAME DNS record for views in your Cloudflare DNS settings.\n\ud83e\udde9 4. Update Hugo single.html Template In themes\/PaperMod\/layouts\/_default\/single.html, add the following where you want the badge and counter update:\n&lt;!-- \ud83d\udc40 View Counter Badge --&gt; &lt;div class=&#34;post-views&#34; style=&#34;margin-top: 0.5rem; display: inline-block;&#34;&gt; {{ $slug := path.Base .RelPermalink }} &lt;img src=&#34;https:\/\/views.mydomain.com\/badge\/{{ $slug }}&#34; alt=&#34;Post Views&#34; \/&gt; &lt;\/div&gt; &lt;!-- \ud83d\udc40 Increment Counter (via JS) --&gt; &lt;script&gt; fetch(&#34;https:\/\/views.mydomain.com\/increment\/{{ path.Base .RelPermalink }}&#34;) .catch(err =&gt; console.warn(&#34;View counter increment failed:&#34;, err)); &lt;\/script&gt; \u26a0\ufe0f 5. CORS Issues &amp; Resolution Problem:\nThe browser console showed:\nAccess to fetch at &#39;https:\/\/views.mydomain.com\/increment\/post-name&#39; from origin &#39;https:\/\/mydomain.com&#39; has been blocked by CORS policy Root Cause:\nThe view counter API didn\u2019t include CORS headers.\nFix:\nAdded CORS support in index.js:\nconst cors = require(&#39;cors&#39;); app.use(cors()); Then rebuilt the container:\ndocker compose up --build -d \u2705 This resolved the issue immediately.\n","permalink":"https:\/\/mumbleb.com\/posts\/publish-hugo-cloudflare-tunnel\/","summary":"How I made my Hugo blog publicly accessible over HTTPS without port forwarding, even behind LTE CGNAT.","title":"Publishing My Hugo Blog Publicly with Cloudflare Tunnel"},{"content":"Overview With the often &ldquo;unreliable&rdquo; nature of MicroSD cards and the need for better performance and reliability, I decided that it was probably a good idea to move my OMV setup from MicroSD to an NVMe drive.\nThis post covers the full migration of an existing OpenMediaVault setup on a Raspberry Pi 5 from a MicroSD card to an NVMe SSD, including cloning, boot configuration, and validation.\nSteps Connect your new NVMe to your Pi5 There a number of great tutorials available out there which shows you how to do this step-by-step. Ensure you have a suiteable hat or NVMe Base for your NVMe SSD. I purchased the Pimoroni NVMe Base which works well. See below video for how to set this up Once this has all been connected you may proceed to the next steps First, enable the PCIe port on the Pi5. Edit the \/boot\/firmware\/config.txt and add the following at the bottom # Add to bottom of \/boot\/firmware\/config.txt dtparam=pciex1 # Note: You could also just add the following (it is an alias to the above line) # dtparam=nvme # Optionally, you can control the PCIe lane speed using this parameter # dtparam=pciex1_gen=3 Set NVMe early in the boot order\n# Edit the EEPROM on the Raspberry Pi 5. sudo rpi-eeprom-config --edit # Change the BOOT_ORDER line to the following: BOOT_ORDER=0xf416 # Add the following line if using a non-HAT+ adapter: PCIE_PROBE=1 # Press Ctrl-O, then enter, to write the change to the file. # Press Ctrl-X to exit nano (the editor). Clone MicroSD to NVMe using dd or gparted Check your NVMe is mounted. Run lsblk to list your drives. You should see something like the below: NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS sda 8:0 0 931.5G 0 disk \u2514\u2500sda1 8:1 0 931.5G 0 part \/srv\/dev-disk nvme0n1 259:0 0 119.2G 0 disk mmcblk0 179:0 0 59.5G 0 disk Unmount the NVMe partition(s) sudo umount \/dev\/nvme0n1p? This will unmount all partitions (e.g., \/dev\/nvme0n1p1, p2, etc.)\n\u2705 Good and necessary before any low-level write\nWipe filesystem signatures (superblocks) sudo wipefs --all --force \/dev\/nvme0n1p? sudo wipefs --all --force \/dev\/nvme0n1 The first step is only required if there are particions, else you can skip and just run the second step.\n\u2705 Safe and recommended, especially if you previously had filesystems or bootloaders on the drive.\nZero out the first 1MB sudo dd if=\/dev\/zero of=\/dev\/nvme0n1 bs=1024 count=1024 This clears the MBR\/GPT partition table and boot records.\n\u2705 Optional but very thorough, especially if you had bootable setups on the NVMe before.\nFinal check before proceeding with dd\nBefore Flashing with: sudo dd if=\/path\/to\/raspbian.img of=\/dev\/nvme0n1 bs=4M status=progress conv=fsync Ensure:\nYou are writing to \/dev\/nvme0n1 (the disk) and not a partition like\/dev\/nvme0n1p1 You double-check the target disk to avoid overwriting something else by mistake If you are absolutely sure you have specified all the correct drive details, then only you may proceed with flashing the MicroSD over to the NVMe. sudo dd if=\/path\/to\/raspbian.img of=\/dev\/nvme0n1 bs=4M status=progress conv=fsy&gt; You can now shutdown the Pi5 and remove the MicroSD sudo shutdown Power on the Pi5 again and you should now be booting from the NVMe only. Resize root partition using growpart Once you have completed the clone using dd, and your MicroSD and NVMe drives are of different sizes, i.e. MicroSD was 64GB and NVMe is 128GB, you can now expand the root partition using the following steps Check the partition layout lsblk This will show something like: NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS nvme0n1 259:0 0 119.2G 0 disk \u251c\u2500nvme0n1p1 259:1 0 512M 0 part \/boot\/firmware \u2514\u2500nvme0n1p2 259:2 0 59G 0 part \/ See how nvme0n1p2 (root) is still only showing 59G? We can fix this with the following steps.\nInstall growpart if needed: sudo apt install cloud-guest-utils -y Then run:\nsudo growpart \/dev\/nvme0n1 2 This expands partition 2 (nvme0n1p2) to use all available space\nResize the Filesystem\nNow lets grow the actual filesystem inside the resized partition: sudo resize2fs \/dev\/nvme0n1p2 \u2705 This will extend your root filesystem to fill the entire partition.\nVerify df -h \/ You should now see the full 128GB (or close to it) available for \/\n8. Reboot without MicroSD to confirm boot\n\ud83d\udd12 Optional Final Steps 9. Run Updates\nMake sure the system is fully up to date: sudo apt update &amp;&amp; sudo apt upgrade -y Now that you are all fully on NVMe, it&rsquo;s a great time to create a full backup image of your working system:\nCreate a Backup There are two options to do this:\nOption 1 - Run the whole pipeline with sudo\nWrap the whole thing in sudo sh -c so both dd, gzip, and the redirection run as root:\nsudo sh -c &#39;dd if=\/dev\/nvme0n1 bs=64K conv=noerror,sync status=progress | gzip &gt; \/srv\/dev-disk-by-uuid-fca65d15-c1cc-4231-a5ff-8da27e74827f\/nvme-backup\/nvme-backup-$(date +%F).img.gz&#39; sync Option 2 - Use tee instead of &gt;\ntee runs under sudo and can handle writing to a root-owned file:\nsudo dd if=\/dev\/nvme0n1 bs=64K conv=noerror,sync status=progress | gzip | sudo tee \/srv\/dev-disk-by-uuid\/nvme-backup\/nvme-backup-$(date +%F).img.gz &gt; \/dev\/null sync Create a Checksum file When the above is all done, I&rsquo;d suggest verifying the backup so you know it&rsquo;s good before you need it.\nAn Example verification:\n# Create checksum for the backup sha256sum \/srv\/dev-disk-by-uuid\/nvme-backup\/nvme-backup-$(date +%F).img.gz | sudo tee \/srv\/dev-disk-by-uuid\/nvme-backup\/nvme-backup-$(date +%F).sha256 # You can use the below as well ensuring that the whole command is wrapped up. # Ensure the YYYY-MM-DD is replaced with the date of the actual backup file sudo sh -c &#39;sha256sum \/srv\/dev-disk-by-uuid\/nvme-backup\/nvme-backup-YYYY-MM-DD.img.gz &gt; \/srv\/dev-disk-by-uuid\/nvme-backup\/nvme-backup-YYYY-MM-DD.sha256&#39; Later, you can verify:\ncd \/srv\/dev-disk-by-uuid\/nvme-backup sha256sum -c nvme-backup-YYYY-MM-DD.sha256 That way you will know the file hasn&rsquo;t been corrupted before restoring.\nRestore plan (use a Live USB so the NVME isn&rsquo;t in use) Boot a Linux live ISO (Ubuntu\/Debian)\/ Open a terminal and confirm devices: lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT Make sure the target is your NVMe (e.g.\/etc\/nvme0n1)\n3. Mount your backup SSD so you can reach the image:\nsudo mkdir -p \/mnt\/backup sudo mount \/dev\/sda1 \/mnt\/backup Restore: sudo bash -c &#39;gunzip -c \/mnt\/backup\/nvme-backup\/nvme-backup-YYYY-MM-DD.img.gz | dd of=\/dev\/nvme0n1 bs=64K conv=noerror,sync status=progress&#39; sync Reboot, remove the USB, and it should boot exactly as it did when you backed it up. Pro-Tip Always ensure you have a backup, either do one once-off or do them periodically.\nBetter safe than Sorry!!!\n","permalink":"https:\/\/mumbleb.com\/posts\/migrating-omv-to-nvme\/","summary":"Step-by-step guide to migrating your OMV setup from MicroSD to NVMe for better performance and reliability.","title":"Migrating OpenMediaVault to NVMe on Raspberry Pi 5"},{"content":"I run Home Assistant as a container instead of the supervised OS. This is because I only had an RPi 3B available at the time and the fully fledged HA OS was too clunky to run on it. To get it all going, i had to backup my current HA configs etc. from my full HA-OS setup so I could import it later on the HA-Containered.\nWhy Container? Full OS control (needed for OMV + Docker) Easier updates and backups No Supervisor, but add-ons handled manually Access Tailscale used for secure remote access Reverse proxy via NGINX PM Steps Backup current HA Snapshot Flashing Raspberry Pi OS Lite Set up Wi-Fi\/SSH (Headless setup) Installing Docker Install Home Assistant Container Restore Configuration Step 1: Create a Full Backup (aka Snapshot) Go to Settings \u2192 System \u2192 Backups Click Create Backup Give it a name (e.g. HA-Migration-Backup)\nInclude: Add-ons Home Assistant Configuration Media Database Wait for it to finish &ndash; usually just a few minutes. Step 2: Download the Backup After the backup completes:\nStill in Settings \u2192 System \u2192 Backups Click the 3-dot menu on your backup \u2192 Download\nThis will give you a .tar file (e.g., HA-Migration-Backup.tar) containing: configuration.yaml and other YAML files All dashboards, automations, helpers, entities Your SQLite database (unless excluded) Add-on configs (note: won&rsquo;t be used in Container, but you can extract configs)\n\u2705 Keep this file safe! What You Can Migrate to HA Container | Data | Restored? | Notes | | ---------------------------------------------- | -------------------- | ---------------------------------------------------------- | | `configuration.yaml`, `automations.yaml`, etc. | \u2705 Yes | Full YAML-based config will work | | Lovelace UI (dashboards) | \u2705 Yes | Stored in `.storage` or YAML | | Add-on configs | \u26a0\ufe0f No direct restore | Must run services manually (e.g., via Docker Compose) | | SQLite DB | \u2705 Yes | Just copy `home-assistant_v2.db` | | Backups UI | \u274c No | Not available in HA Container \u2014 use manual backups instead | Migration Time Step 1: Flash Raspberry Pi OS Lite Download and Flash OS Download: Raspberry Pi OS Lite (64-bit) Flash to SD card using Rasberry Pi Imager\nChoose &ldquo;Raspberry Pi OS Lite (64-bit)&rdquo; for best performance and headless setup Enable SSH and Wi-Fi If using the Pi headless:\nAfter flashing, open the boot partition on the SD card Create an empty file named ssh Create a wpa_supplicant.conf file for Wi-Fi access: ctrl_interface=DIR=\/var\/run\/wpa_supplicant GROUP=netdev update_config=1 country=US network={ ssid=&#34;YourNetworkName&#34; psk=&#34;YourPassword&#34; } Save this in the boot partition\nStep 2: First Boot &amp; Update OS Boot the Pi and SSH in:\nssh pi@&lt;your-pi-ip&gt; # Password is what you set it when you flashed the SD with the Pi image Update everything:\nsudo apt update &amp;&amp; sudo apt upgrade -y sudo reboot Step 3: Install Docker Install Docker and enable on boot:\ncurl -fsSL https:\/\/get.docker.com -o get-docker.sh sudo sh get-docker.sh sudo usermod -aG docker pi Reboot again:\nsudo reboot Step 4: Run Home Assistant Container Create a directory for Home Assistant:\nmkdir -p \/home\/pi\/homeassistant Run the container:\ndocker run -d \\ --name homeassistant \\ --privileged \\ --restart=unless-stopped \\ -e TZ=Your\/Timezone \\ -v \/home\/pi\/homeassistant:\/config \\ -v \/etc\/localtime:\/etc\/localtime:ro \\ --network=host \\ ghcr.io\/home-assistant\/home-assistant:stable Replace Your\/Timezone with e.g., Europe\/London or America\/New_York.\nAccess Home Assistant at:\nhttp:\/\/&lt;pi-ip&gt;:8123 Step 6: Replace Add-ons (if any) You&rsquo;ll need to manually run any add-ons you used, as Docker containers\nComon replacements:\nMosquito MQTT Broker: Setup guide ESPHome: Docker ESPHome Step 7: Increase Swap (If required) Now that you&rsquo;re on Rasberry Pi OS, you can increase swap. This was the main reason why I decided to migrate to Container.\nsudo nano \/etc\/dphys-swapfile Change:\nCONF_SWAPSIZE=1024 Then:\nsudo dphys-swapfile stop sudo dphys-swapfile setup sudo dphys-swapfile start Extract and restore files from your HA-Migration-Backup.tar Extract the files from you backup:\ntar -xf HA-Migration-Backup.tar Now you&rsquo;ll have files like:\nconfiguration.yaml automations.yaml home-assistant_v2.db .storage\/ Copy everything into your new config directory On your Pi:\ndocker stop homeassistant Then copy over the contents of the extracted folder (from your PC):\nscp -r \/path\/to\/extracted\/* pi@&lt;pi-ip&gt;:\/home\/pi\/homeassistant\/ Then:\nsudo chown -R pi:pi \/home\/pi\/homeassistant\/ docker start homeassistant Confirm it&rsquo;s Working Visit http:\/\/&lt;pi-ip&gt;:8123, and you should see:\nYour dashboards Recorder data (e.e., last updated, logs, energy graphs) etc. Automation states and history Running HA You can manage the container with:\ndocker stop homeassistant docker start homeassistant docker restart homeassistant docker logs -f homeassistant But if you&rsquo;d prefer Docker Compose (I recommend this for as it is better for maintainability):\nDocker Compose Option Create a directory for your docker-compose.yml: mkdir -p ~\/ha-docker &amp;&amp; cd ~\/ha-docker Create a docker-compose.yml file: services: homeassistant: container_name: homeassistant image: ghcr.io\/home-assistant\/home-assistant:stable volumes: - \/home\/pi\/homeassistant:\/config - \/etc\/localtime:\/etc\/localtime:ro restart: unless-stopped privileged: true network_mode: host environment: - TZ=Your\/Timezone Replace Your\/Timezone with your local zone, like Europe\/London or America\/New_York 3. Launch it:\ndocker compose up -d To bring the container down: docker compose down -v From then on:\ndocker compose stop docker compose start docker compose logs -f To get details of your container you can run the following: docker ps This will present you with the following data:\nCONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 34eef9a3f5b5 ghcr.io\/home-assistant\/home-assistant:stable &#34;\/init&#34; 2 months ago Up 4 weeks homeassistant To check the logs: docker logs -f homeassistant Troubleshooting Tips If after the installation you are not able to access HA you can check the following:\nStep-by-Step Diagnostics Check the Container Is Running\nSee and run above step 5 Check the Logs\nSee above and run step 6\nWatch for: Errors (e.g., permissions, missing files) Warnings about database or config issues Startup status (you should see something like INFO Running setup) Check for IP Address \/ Network Issues Make sure you&rsquo;re accessing the correct IP. Run:\nhostname -I Then visit that IP on your PC in a browser:\nhttp:\/\/&lt;that-ip&gt;:8123 Double-Check Port Binding The container should use host networking (i.e., --network=host or network_mode: host) so port 8123 should be open directly on the Pi.\nRun:\nsudo ss -tulwn | grep 8123 You should see:\nLISTEN 0 4096 0.0.0.0:8123 ... Double-Check Config Folder Permissions Ensure the container has access to \/home\/pi\/homeassistant\nsudo chown -R pi:pi \/home\/pi\/homeassistant Check SSH is enabled sudo systemctl status ssh If it says it&rsquo;s inactive:\nsudo systemctl enable ssh sudo systemctl start ssh Once you are able to access HA from the Web UI, you are all good to go.\nHappy Self-Hosting your services from here on out!!! ","permalink":"https:\/\/mumbleb.com\/posts\/home-assistant-container\/","summary":"Step-by-step migration guide for HA to Container","title":"Running Home Assistant in Docker (Not Supervised)"},{"content":"This is a technical homelab and software engineering blog.\nTopics include:\nDocker and self-hosting Raspberry Pi infrastructure Cloudflare tunnels Hugo and static site deployment Universal Schema Studio (OpenAPI\/XSD tooling) The goal is practical, reproducible guides rather than theoretical articles.\n","permalink":"https:\/\/mumbleb.com\/llms\/","summary":"<p>This is a technical homelab and software engineering blog.<\/p>\n<p>Topics include:<\/p>\n<ul>\n<li>Docker and self-hosting<\/li>\n<li>Raspberry Pi infrastructure<\/li>\n<li>Cloudflare tunnels<\/li>\n<li>Hugo and static site deployment<\/li>\n<li>Universal Schema Studio (OpenAPI\/XSD tooling)<\/li>\n<\/ul>\n<p>The goal is practical, reproducible guides rather than theoretical articles.<\/p>","title":"About this site"}]