<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Becher Hilal</title>
    <description>The latest articles on DEV Community by Becher Hilal (@bash-thedev).</description>
    <link>https://dev.to/bash-thedev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3855404%2Fabbef9d5-ab88-480c-bcf7-f632bd47c587.JPG</url>
      <title>DEV Community: Becher Hilal</title>
      <link>https://dev.to/bash-thedev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bash-thedev"/>
    <language>en</language>
    <item>
      <title>Why I Let a Machine Judge My Code</title>
      <dc:creator>Becher Hilal</dc:creator>
      <pubDate>Wed, 15 Apr 2026 13:07:57 +0000</pubDate>
      <link>https://dev.to/bash-thedev/why-i-let-a-machine-judge-my-code-42ca</link>
      <guid>https://dev.to/bash-thedev/why-i-let-a-machine-judge-my-code-42ca</guid>
      <description>&lt;p&gt;There's a moment in every growing codebase where you realize you can no longer hold all of it in your head. For me, it was when I opened a file I'd written three weeks earlier and couldn't immediately follow the control flow. Not because the code was wrong. Because it had grown past the point where any single person could casually review it all with the same attention.&lt;/p&gt;

&lt;p&gt;I run 5 Python services, a React frontend, and a growing collection of utility scripts. No team. No pull request reviewers besides myself. The codebase grows weekly. And I decided that trying to maintain consistency through discipline alone is a losing strategy at this scale. The machine needs to enforce the standards I care about, so my attention goes to the decisions that actually need a human brain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pipeline
&lt;/h2&gt;

&lt;p&gt;Three layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Guidelines file    →    Linter config    →    Pre-commit hook
(sets expectations)     (checks the code)     (blocks the commit)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The guidelines file defines the standards: naming conventions, SQL patterns, pitfalls specific to this codebase. The linter config translates those standards into automated checks. The pre-commit hook makes them non-negotiable. Code that doesn't pass doesn't enter the repository. Not warned. Blocked.&lt;/p&gt;

&lt;p&gt;This isn't about catching catastrophic bugs. It's about preventing the slow accumulation of inconsistency that turns a clean codebase into one you dread opening. Individually, an unused import is harmless. A hundred of them spread across fifty files is how a project starts to feel unmaintained.&lt;/p&gt;

&lt;h2&gt;
  
  
  Documentation vs Enforcement
&lt;/h2&gt;

&lt;p&gt;There's an interesting insight from the recent Claude Code source leak. Anthropic's internal tooling moved rules out of documentation files and into enforced hooks. Their reasoning: documentation competes for attention. The more guidelines you write, the more they blend into the background. Hooks are executed by the system. They don't care whether anyone read the documentation.&lt;/p&gt;

&lt;p&gt;My experience matches. The guidelines file says "use &lt;code&gt;$1, $2, $3&lt;/code&gt; for SQL placeholders." That convention gets followed most of the time. The linter catches it every time. "Most" vs "every" is the difference between a suggestion and an enforcement. Both have a place, but I know which one I trust when I'm not actively watching.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Interesting Rules
&lt;/h2&gt;

&lt;p&gt;I run 23 Ruff rule prefixes. The basics (formatting, import ordering, dead imports, stray &lt;code&gt;print()&lt;/code&gt; calls) are table stakes. They keep the codebase clean but they're not worth writing about individually. Here's what actually gets interesting.&lt;/p&gt;

&lt;h3&gt;
  
  
  SQL Injection Detection
&lt;/h3&gt;

&lt;p&gt;My API layer uses raw asyncpg with parameterized queries. No ORM. That means every database query is a hand-written SQL string. The safe way looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM transactions WHERE category = $1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;$1&lt;/code&gt; is a placeholder. The database receives the query and the variable separately, so it always treats &lt;code&gt;category&lt;/code&gt; as data, never as executable SQL. Even if someone passes &lt;code&gt;'; DROP TABLE transactions; --&lt;/code&gt; as the value, nothing bad happens. It's just a weird category name that matches zero rows.&lt;/p&gt;

&lt;p&gt;The dangerous version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM transactions WHERE category = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pastes the variable directly into the SQL string. If the input is malicious, the database executes it as code. Classic SQL injection. The linter flags this pattern and blocks the commit.&lt;/p&gt;

&lt;p&gt;The complication: because I write raw SQL strings (not an ORM building them), the linter also flags my &lt;em&gt;safe&lt;/em&gt; queries as "hardcoded SQL." I have to explicitly tell it that SQL strings are intentional in this codebase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;ignore&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s"&gt;"S608"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c"&gt;# asyncpg uses $1 params, raw SQL strings are intentional&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every ignore has a documented reason. When I'm reviewing this config months from now, I need to know whether each exception was a deliberate decision or a shortcut.&lt;/p&gt;

&lt;h3&gt;
  
  
  Complexity Scoring
&lt;/h3&gt;

&lt;p&gt;McCabe complexity, max of 10. Roughly 10 independent code paths through a function. When I introduced the linter to the existing codebase, some older functions exceeded this threshold. That's normal when you retrofit standards onto code that was already running. The policy: existing code that exceeds the limit gets an exemption. New code doesn't. The exemption list is meant to shrink over time as I refactor, not grow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[tool.ruff.lint.mccabe]&lt;/span&gt;
&lt;span class="py"&gt;max-complexity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The complexity check is the one that's saved me the most time long-term. A function with a score of 12+ is a function where fixing a bug in one branch risks breaking three others. Catching that before it lands means refactoring when the context is fresh, not six months later when I've forgotten why the branches exist.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Async Concurrency Trap
&lt;/h3&gt;

&lt;p&gt;This pattern looks concurrent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's not. It awaits each call one after another, sequentially. If &lt;code&gt;process()&lt;/code&gt; takes 100ms and you have 20 items, that's 2 seconds. The concurrent version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same 20 items, but they all run at the same time. Total time: ~100ms instead of 2 seconds.&lt;/p&gt;

&lt;p&gt;The ASYNC rule set flags the sequential pattern. The linter doesn't auto-fix this one since the fix changes execution behavior, not just formatting. It blocks the commit with an explanation, and you decide how to restructure.&lt;/p&gt;

&lt;p&gt;In a codebase with 70+ async API routes, this pattern showing up undetected in a few hot paths would quietly degrade response times without any obvious cause.&lt;/p&gt;

&lt;h3&gt;
  
  
  Timezone Awareness
&lt;/h3&gt;

&lt;p&gt;Every &lt;code&gt;datetime.now()&lt;/code&gt; call without timezone info is a future debugging session. In a system that handles Dutch business hours, UTC timestamps from external APIs, and scheduled tasks that need to run at specific local times, a naive datetime silently produces the wrong result in any calculation that crosses a timezone boundary.&lt;/p&gt;

&lt;p&gt;The DTZ rules force &lt;code&gt;datetime.now(UTC)&lt;/code&gt; everywhere. It's the kind of rule that feels pedantic until you spend an afternoon figuring out why a scheduled task ran an hour early because of a daylight saving time transition.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pre-Commit Hook
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;repos&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/astral-sh/ruff-pre-commit&lt;/span&gt;
    &lt;span class="na"&gt;rev&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v0.9.10&lt;/span&gt;
    &lt;span class="na"&gt;hooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ruff&lt;/span&gt;
        &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;--fix&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ruff-format&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every commit runs Ruff. The &lt;code&gt;--fix&lt;/code&gt; flag auto-resolves what it can: import sorting, formatting, simple simplifications. Anything it can't auto-fix blocks the commit until you handle it manually. The linter tells you exactly what's wrong and where.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Doesn't Catch
&lt;/h2&gt;

&lt;p&gt;Linters understand structure, not intent. They won't tell you a function returns the wrong result. They won't tell you a query is technically valid SQL but returns stale data. They won't tell you an API endpoint works but exposes data it shouldn't.&lt;/p&gt;

&lt;p&gt;For that you need tests, thoughtful design, and the kind of review that requires understanding what the code is &lt;em&gt;supposed&lt;/em&gt; to do. The linter handles the mechanical layer: formatting, dead code, complexity, security anti-patterns, timezone correctness. That's maybe 80% of the issues that make a codebase worse over time. Automating that 80% means your limited review time goes to the 20% that actually needs a human.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Is Going
&lt;/h2&gt;

&lt;p&gt;Right now, the gap in the industry isn't AI code generation. That works well enough. The gap is the control layer around it. Experienced developers set up linters, pre-commit hooks, and CI gates because they've learned from years of debugging what happens when standards aren't enforced. They know why parameterized queries matter because they've seen an injection. They know why complexity limits matter because they've maintained a 300-line function.&lt;/p&gt;

&lt;p&gt;A newer generation of developers is building with powerful tools from day one, but without the scar tissue that makes you instinctively set up guardrails. The industry is going to need better defaults, better tooling, and better education around code quality enforcement. Not because new developers are worse. Because the tools are fast enough that the consequences of shipping without guardrails arrive faster too.&lt;/p&gt;

&lt;p&gt;Mechanical enforcement isn't a substitute for skill. It's what lets skill scale.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part of "1 Dev, 22 Containers," a series about building an AI office management system on consumer hardware.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Find me on &lt;a href="https://github.com/The-Bash" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>codenewbie</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Open-Sourced My Ollama Logging Proxy</title>
      <dc:creator>Becher Hilal</dc:creator>
      <pubDate>Thu, 09 Apr 2026 13:51:43 +0000</pubDate>
      <link>https://dev.to/bash-thedev/i-open-sourced-my-ollama-logging-proxy-i3p</link>
      <guid>https://dev.to/bash-thedev/i-open-sourced-my-ollama-logging-proxy-i3p</guid>
      <description>&lt;p&gt;When you use a cloud LLM API, you get usage data for free. Token counts, latency, cost per request, all tracked and queryable. When you run Ollama locally, you get a response and nothing else. No logs, no token counts, no way to tell which of your services is eating all the inference time.&lt;/p&gt;

&lt;p&gt;I had 5 services and a workflow engine all hitting the same Ollama instance on a Mac mini with 24GB of RAM. I couldn't tell which service was the heaviest consumer, whether my model-swapping strategy was working, or if specific workflows were making redundant calls. I was flying blind.&lt;/p&gt;

&lt;p&gt;So I built a transparent proxy that sits between my services and Ollama, logs every inference call with full token counts and timing, and streams responses through without adding any latency. Then I open-sourced it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/The-Bash/ollama-log-proxy" rel="noopener noreferrer"&gt;ollama-log-proxy on GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Concept
&lt;/h2&gt;

&lt;p&gt;Point your apps at the proxy (port 11433) instead of Ollama directly (port 11434). The proxy forwards everything transparently. For inference calls, it records what model was used, how many tokens were consumed, how long it took, and which service made the request. Health checks and model listing get passed through without logging.&lt;/p&gt;

&lt;p&gt;Response streaming works exactly as it does with Ollama directly. The proxy never buffers a full response before forwarding. Clients see tokens appearing in real time during long generations. The token counting happens from a copy of the stream after the response completes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Data Actually Told Me
&lt;/h2&gt;

&lt;p&gt;The reason I'm writing about this isn't the proxy itself. It's what I learned once I had visibility into my LLM traffic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model swap overhead is measurable.&lt;/strong&gt; I run two models on a machine with only enough memory for one at a time. They swap via a 10-second keep-alive timeout. The proxy showed me that the first request after a model swap is consistently 3-4x slower than subsequent requests. That's the cold-start penalty of loading a 9GB model into memory. Before the proxy, I assumed model swapping was "fast enough." Now I have actual numbers and can make informed decisions about keep-alive timing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One workflow was consuming 40% of all inference.&lt;/strong&gt; The email classification pipeline was the heaviest consumer by a wide margin. Seeing the actual numbers made it the obvious first target for optimization. I restructured the batching to reduce total calls, and the overall daily inference time dropped noticeably. Without per-caller breakdown, I would have been optimizing the wrong things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embedding calls are almost free.&lt;/strong&gt; My embedding model returns in under 100ms consistently. I had been conservative about batching embeddings because I assumed they were competing for resources with the larger models. They weren't. The proxy data gave me confidence to run embeddings more aggressively.&lt;/p&gt;

&lt;p&gt;None of this was visible before. I was making assumptions about my own infrastructure that turned out to be wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's In the Repo
&lt;/h2&gt;

&lt;p&gt;The tool is designed to be useful with zero configuration (install, run, point your apps at it, logs go to SQLite) but flexible enough for production setups. It ships with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;4 storage backends&lt;/strong&gt;: SQLite (default), PostgreSQL, JSONL files, or stdout&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A built-in dashboard&lt;/strong&gt;: vanilla HTML and Chart.js, no npm, no build step&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prometheus metrics&lt;/strong&gt;: request counts, token totals, and duration histograms, ready for Grafana&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A pre-built Grafana dashboard&lt;/strong&gt;: included in the repo with a Docker Compose file that wires everything together&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An extensible backend protocol&lt;/strong&gt;: implement three Python methods and you can send logs anywhere&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The README has the full setup guide, CLI reference, and Docker instructions. I won't repeat all of that here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Open-Sourced It
&lt;/h2&gt;

&lt;p&gt;This started as an internal tool. A single Python script that logged Ollama calls to a PostgreSQL table. It grew into something more general because the problem isn't specific to my setup. Anyone running Ollama for real workloads (not just chatting with a model in a terminal) eventually needs to know what's happening at the request level.&lt;/p&gt;

&lt;p&gt;The local LLM ecosystem has great tools for running models and great tools for building applications on top of them. The observability layer in between is mostly missing. This is one piece of that.&lt;/p&gt;

&lt;p&gt;If you run Ollama and you've ever wondered "which of my services is actually using the most tokens," give it a try. The README walks through everything from a zero-config SQLite setup to a full Docker + Grafana stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/The-Bash/ollama-log-proxy" rel="noopener noreferrer"&gt;github.com/The-Bash/ollama-log-proxy&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>python</category>
      <category>aiops</category>
    </item>
    <item>
      <title>I Open-Sourced My Ollama Logging Proxy</title>
      <dc:creator>Becher Hilal</dc:creator>
      <pubDate>Thu, 09 Apr 2026 13:51:43 +0000</pubDate>
      <link>https://dev.to/bash-thedev/i-open-sourced-my-ollama-logging-proxy-e88</link>
      <guid>https://dev.to/bash-thedev/i-open-sourced-my-ollama-logging-proxy-e88</guid>
      <description>&lt;p&gt;When you use a cloud LLM API, you get usage data for free. Token counts, latency, cost per request, all tracked and queryable. When you run Ollama locally, you get a response and nothing else. No logs, no token counts, no way to tell which of your services is eating all the inference time.&lt;/p&gt;

&lt;p&gt;I had 5 services and a workflow engine all hitting the same Ollama instance on a Mac mini with 24GB of RAM. I couldn't tell which service was the heaviest consumer, whether my model-swapping strategy was working, or if specific workflows were making redundant calls. I was flying blind.&lt;/p&gt;

&lt;p&gt;So I built a transparent proxy that sits between my services and Ollama, logs every inference call with full token counts and timing, and streams responses through without adding any latency. Then I open-sourced it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/The-Bash/ollama-log-proxy" rel="noopener noreferrer"&gt;ollama-log-proxy on GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Concept
&lt;/h2&gt;

&lt;p&gt;Point your apps at the proxy (port 11433) instead of Ollama directly (port 11434). The proxy forwards everything transparently. For inference calls, it records what model was used, how many tokens were consumed, how long it took, and which service made the request. Health checks and model listing get passed through without logging.&lt;/p&gt;

&lt;p&gt;Response streaming works exactly as it does with Ollama directly. The proxy never buffers a full response before forwarding. Clients see tokens appearing in real time during long generations. The token counting happens from a copy of the stream after the response completes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Data Actually Told Me
&lt;/h2&gt;

&lt;p&gt;The reason I'm writing about this isn't the proxy itself. It's what I learned once I had visibility into my LLM traffic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model swap overhead is measurable.&lt;/strong&gt; I run two models on a machine with only enough memory for one at a time. They swap via a 10-second keep-alive timeout. The proxy showed me that the first request after a model swap is consistently 3-4x slower than subsequent requests. That's the cold-start penalty of loading a 9GB model into memory. Before the proxy, I assumed model swapping was "fast enough." Now I have actual numbers and can make informed decisions about keep-alive timing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One workflow was consuming 40% of all inference.&lt;/strong&gt; The email classification pipeline was the heaviest consumer by a wide margin. Seeing the actual numbers made it the obvious first target for optimization. I restructured the batching to reduce total calls, and the overall daily inference time dropped noticeably. Without per-caller breakdown, I would have been optimizing the wrong things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embedding calls are almost free.&lt;/strong&gt; My embedding model returns in under 100ms consistently. I had been conservative about batching embeddings because I assumed they were competing for resources with the larger models. They weren't. The proxy data gave me confidence to run embeddings more aggressively.&lt;/p&gt;

&lt;p&gt;None of this was visible before. I was making assumptions about my own infrastructure that turned out to be wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's In the Repo
&lt;/h2&gt;

&lt;p&gt;The tool is designed to be useful with zero configuration (install, run, point your apps at it, logs go to SQLite) but flexible enough for production setups. It ships with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;4 storage backends&lt;/strong&gt;: SQLite (default), PostgreSQL, JSONL files, or stdout&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A built-in dashboard&lt;/strong&gt;: vanilla HTML and Chart.js, no npm, no build step&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prometheus metrics&lt;/strong&gt;: request counts, token totals, and duration histograms, ready for Grafana&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A pre-built Grafana dashboard&lt;/strong&gt;: included in the repo with a Docker Compose file that wires everything together&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An extensible backend protocol&lt;/strong&gt;: implement three Python methods and you can send logs anywhere&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The README has the full setup guide, CLI reference, and Docker instructions. I won't repeat all of that here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Open-Sourced It
&lt;/h2&gt;

&lt;p&gt;This started as an internal tool. A single Python script that logged Ollama calls to a PostgreSQL table. It grew into something more general because the problem isn't specific to my setup. Anyone running Ollama for real workloads (not just chatting with a model in a terminal) eventually needs to know what's happening at the request level.&lt;/p&gt;

&lt;p&gt;The local LLM ecosystem has great tools for running models and great tools for building applications on top of them. The observability layer in between is mostly missing. This is one piece of that.&lt;/p&gt;

&lt;p&gt;If you run Ollama and you've ever wondered "which of my services is actually using the most tokens," give it a try. The README walks through everything from a zero-config SQLite setup to a full Docker + Grafana stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/The-Bash/ollama-log-proxy" rel="noopener noreferrer"&gt;github.com/The-Bash/ollama-log-proxy&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>python</category>
      <category>aiops</category>
    </item>
    <item>
      <title>'I'll Add the Migration Later'... The Lies I Told Myself</title>
      <dc:creator>Becher Hilal</dc:creator>
      <pubDate>Wed, 08 Apr 2026 13:32:05 +0000</pubDate>
      <link>https://dev.to/bash-thedev/ill-add-the-migration-later-the-lies-i-told-myself-2ij6</link>
      <guid>https://dev.to/bash-thedev/ill-add-the-migration-later-the-lies-i-told-myself-2ij6</guid>
      <description>&lt;p&gt;I built a 127-table PostgreSQL database over three months. For the first week, every schema change was tracked properly. Numbered SQL files, a migration table with checksums, clean and reproducible. Then development velocity picked up, I started prioritizing feature delivery over process, and the tracking fell behind.&lt;/p&gt;

&lt;p&gt;By the time I audited the state, I had 19 tracked migrations from week 1 and over 100 untracked schema changes after that. No rollback capability. No record of what was applied when. No way to reproduce the database from scratch.&lt;/p&gt;

&lt;p&gt;This post is about what that cost me, how I recovered, and why migration discipline matters more when you're moving fast, not less.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Tracking Fell Behind
&lt;/h2&gt;

&lt;p&gt;Week 1 was clean. Custom migration system, numbered SQL files, SHA-256 checksums:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;migrations/
  000_schema_migrations.sql
  001_init.sql
  002_init-finance.sql
  003_init-memory.sql
  ...
  018_add-gocardless-columns.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the pace changed. I was running multiple development sessions in parallel, each one producing features that needed schema changes. The quick path was deploying SQL directly to the running database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh docker-host &lt;span class="s2"&gt;"docker exec -i postgres psql -U user -d db"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="s2"&gt;"
ALTER TABLE email_messages ADD COLUMN knowledge_extracted_at TIMESTAMPTZ;
CREATE INDEX idx_emails_knowledge ON email_messages (knowledge_extracted_at);
"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SQL was always written properly. The change itself was correct. What I skipped was registering it in the migration system. Every time, the reasoning was the same: the feature is urgent, the schema change is simple, I'll add the migration file when things slow down.&lt;/p&gt;

&lt;p&gt;Things never slowed down. Over 11 weeks, the live database grew to 127 tables while the migration history still described 60.&lt;/p&gt;

&lt;p&gt;The situation got more complicated with parallel development. Two sessions both needed to add columns to the same table. Both changes deployed fine because they were different columns. But neither was tracked. A week later, a new session read the schema files from git, assumed those columns didn't exist, and wrote code that conflicted with the live state.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Cost Me
&lt;/h2&gt;

&lt;p&gt;The most expensive incident: I deployed a schema change while a classification workflow was processing a batch of emails. The workflow had already classified a bunch of entries and marked them as "in progress," but hadn't written the results yet. When the schema changed, the final INSERT failed. Those emails were now stuck. The pipeline wouldn't reprocess them because they were flagged as in-progress, but the results never landed. Manual recovery took a couple of hours.&lt;/p&gt;

&lt;p&gt;The fix was simple once I found the stuck records. But the root cause wasn't the schema change itself. It was that I had no process for coordinating database changes with running workflows. No migration file meant no review step, no "is anything depending on this column right now" check before executing.&lt;/p&gt;

&lt;p&gt;The ongoing friction was worse than any single incident. New development sessions would reference schema files from git that were weeks out of date. Code would target columns that existed in production but not in the tracked schema, or vice versa. When something looked wrong in the data, there was no migration history to tell me what changed and when.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Recovery: Baseline and Move Forward
&lt;/h2&gt;

&lt;p&gt;I couldn't retroactively track 100+ changes. The pragmatic choice was to accept that weeks 2-12 were untracked, snapshot the current state, and build proper tracking from that point forward.&lt;/p&gt;

&lt;p&gt;I evaluated migration tools against my actual workflow. I write raw SQL with asyncpg, no ORM, so Alembic (which generates migrations from SQLAlchemy models) wasn't a fit. Flyway needs a JVM runtime. I went with &lt;strong&gt;dbmate&lt;/strong&gt;: a single binary, plain SQL migration files with up/down sections, built-in state tracking, and rollback support. It matched how I already write database code without adding framework dependencies.&lt;/p&gt;

&lt;p&gt;The baseline process:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Snapshot the live schema.&lt;/strong&gt; &lt;code&gt;pg_dump --schema-only&lt;/code&gt; exported the full database structure: 9,756 lines covering all 127 tables, indexes, constraints, and functions.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Convert it to a dbmate migration.&lt;/strong&gt; Added dbmate's &lt;code&gt;-- migrate:up&lt;/code&gt; and &lt;code&gt;-- migrate:down&lt;/code&gt; markers so the tool recognizes the file format.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Register it as already applied.&lt;/strong&gt; Created dbmate's tracking table and inserted the baseline version, so dbmate knows "this is the starting point, don't try to run it."&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Preserved the old history.&lt;/strong&gt; Renamed the original migration table to &lt;code&gt;schema_migrations_legacy&lt;/code&gt; rather than dropping it, in case I ever need to reference the week 1 records.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After this, &lt;code&gt;dbmate status&lt;/code&gt; showed 1 applied, 0 pending. Clean slate. The 9,756-line baseline is the single source of truth for the database structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The New Process
&lt;/h2&gt;

&lt;p&gt;Every schema change now follows three rules:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The migration file gets created before the change is applied.&lt;/strong&gt; Not after. The act of writing the migration IS the review step. It forces me to think about what I'm changing, whether anything depends on it, and how to reverse it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every migration has a rollback section.&lt;/strong&gt; Even if the rollback is just "drop the column I added." This is what the &lt;code&gt;-- migrate:up&lt;/code&gt; and &lt;code&gt;-- migrate:down&lt;/code&gt; format gives you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- migrate:up&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;scheduler_tasks&lt;/span&gt;
    &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;retry_count&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;scheduler_tasks&lt;/span&gt;
    &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- migrate:down&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;scheduler_tasks&lt;/span&gt;
    &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;retry_count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;scheduler_tasks&lt;/span&gt;
    &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;IF NOT EXISTS&lt;/code&gt; / &lt;code&gt;IF EXISTS&lt;/code&gt; pattern makes migrations idempotent. If something interrupts the process halfway, I can re-run safely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The migration commits with the feature code.&lt;/strong&gt; Same commit, same PR. &lt;code&gt;git log&lt;/code&gt; always links a schema change to the feature that needed it. No more guessing when a column was added or why.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Tell Someone Starting a Similar Project
&lt;/h2&gt;

&lt;p&gt;Track your schema changes from day one. Even if it's just numbered SQL files in a directory. The tooling doesn't matter as much as the habit. A migration system you actually use beats a sophisticated one you skip when you're in a hurry.&lt;/p&gt;

&lt;p&gt;If you're already behind, don't try to retroactively reconstruct history. Snapshot your current state with &lt;code&gt;pg_dump&lt;/code&gt;, declare it the baseline, and track forward. Accepting that some history is lost is better than pretending it's tracked when it isn't.&lt;/p&gt;

&lt;p&gt;If you use AI coding tools for development, add migration creation to your workflow instructions. These tools generate schema changes fast and won't create migration files unless you explicitly ask them to. The more automated your development, the more important it is that the tracking step is built into the process rather than treated as a separate manual step.&lt;/p&gt;

&lt;p&gt;And coordinate schema changes with your running services. A database migration that's technically correct can still cause problems if it modifies something that active workflows depend on. The migration file is the natural place to document those dependencies, even if it's just a comment at the top: "this column is used by the email classification pipeline, deploy the updated pipeline code first."&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is Part 4 of "One Developer, 22 Containers." The series covers building an AI office management system on consumer hardware, the choices, the trade-offs, and the things that broke along the way.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Find me on &lt;a href="https://github.com/The-Bash" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>devops</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Why I Switched from ChromaDB to ElasticSearch (and What I Miss)</title>
      <dc:creator>Becher Hilal</dc:creator>
      <pubDate>Mon, 06 Apr 2026 12:31:48 +0000</pubDate>
      <link>https://dev.to/bash-thedev/why-i-switched-from-chromadb-to-elasticsearch-and-what-i-miss-2kbl</link>
      <guid>https://dev.to/bash-thedev/why-i-switched-from-chromadb-to-elasticsearch-and-what-i-miss-2kbl</guid>
      <description>&lt;p&gt;My AI system had been extracting knowledge from emails for weeks. Thousands of facts, entities, patterns, all sitting in PostgreSQL. The problem was finding any of it. The brain was using hardcoded SQL filters like &lt;code&gt;WHERE category = 'infrastructure'&lt;/code&gt; to pull context before making decisions. If a fact about hosting costs was categorized under "billing," the brain would never see it when reasoning about infrastructure.&lt;/p&gt;

&lt;p&gt;I needed to search by meaning, not by label. But I also needed to search by exact match. An invoice number like "TDS-2026-003" has no semantic meaning. You can't find it with vector search. You need both approaches working together, in one query.&lt;/p&gt;

&lt;p&gt;That's what led me to Elasticsearch, and that's what this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Vector-Only Search
&lt;/h2&gt;

&lt;p&gt;ChromaDB had been in my Docker Compose file since the early days. The plan was a full RAG pipeline: embed documents, store vectors, retrieve relevant chunks. In practice, ChromaDB sat mostly idle while the knowledge base grew faster than expected. By the time I circled back to search, the requirements had changed.&lt;/p&gt;

&lt;p&gt;The knowledge base isn't just natural language. It contains invoice reference codes, domain names, specific dates, and contract numbers. All things that have no semantic meaning. When you vector-search for "TDS-2026-003," the embedding model encodes it into a 768-dimensional space and looks for nearby vectors. But an arbitrary reference code has no meaningful position in that space. The vector is vaguely related to "invoices" but won't reliably surface the one document containing that exact string.&lt;/p&gt;

&lt;p&gt;This isn't a ChromaDB problem. It's the nature of pure vector search. Any system that only does similarity matching will struggle with exact lookups.&lt;/p&gt;

&lt;p&gt;The requirement that forced the decision: the brain needs to find "TDS-2026-003" (exact keyword match) AND "hosting expenses" (semantic similarity) through the same search system. Ideally in the same query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not pgvector
&lt;/h2&gt;

&lt;p&gt;I was already running PostgreSQL with 95+ tables. Adding pgvector would mean no extra container, no extra service to maintain. It was the obvious first choice.&lt;/p&gt;

&lt;p&gt;The problem is combining results. pgvector gives you vector similarity. PostgreSQL's built-in &lt;code&gt;tsvector&lt;/code&gt; gives you full-text search. But merging the two result sets into a single ranked list, where a document that scores well on both keyword match and semantic similarity ranks higher than one that only matches on one, requires building your own scoring logic. You're essentially building a search engine inside your database.&lt;/p&gt;

&lt;p&gt;Elasticsearch has a built-in algorithm for this called Reciprocal Rank Fusion (RRF). It takes the ranking from BM25 (keyword matching) and the ranking from kNN (vector similarity) and combines them mathematically. A document that appears near the top of both lists gets a higher combined score than one that only appears in one. No custom scoring logic, no manual result merging.&lt;/p&gt;

&lt;p&gt;For simpler use cases where you only need vector similarity or only need keyword search but not both combined with principled ranking, pgvector is the better choice. Less infrastructure, less complexity, and it lives inside a database you're already running.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Migration
&lt;/h2&gt;

&lt;p&gt;Setting up Elasticsearch itself was straightforward. Single node, no cluster, security disabled (behind Tailscale, no public access):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;elasticsearch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;discovery.type=single-node&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;xpack.security.enabled=false&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ES_JAVA_OPTS=-Xms4g -Xmx4g&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;xpack.ml.enabled=false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Disabling the ML features saved about 500MB of heap. I don't use Elasticsearch's built-in ML since all inference runs through Ollama.&lt;/p&gt;

&lt;p&gt;The index design followed a pattern I'd already established in PostgreSQL: separate data domains. Facts, entities, patterns, emails, transactions, and contracts each got their own index. Each domain has different fields and different query needs. Cramming everything into one giant index would mean sparse fields everywhere and confusing relevance scoring.&lt;/p&gt;

&lt;p&gt;Every index follows the same hybrid field pattern. The primary searchable text is stored twice: once as a &lt;code&gt;text&lt;/code&gt; field for BM25 keyword matching, and once as a &lt;code&gt;dense_vector&lt;/code&gt; field for kNN similarity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Example: the facts index
&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fact&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;analyzer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;standard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fact_vector&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dense_vector&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dims&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;768&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;similarity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cosine&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The embedding model is nomic-embed-text running on the Mac mini through Ollama. 768 dimensions, decent multilingual support, which matters for a business that receives emails in Dutch, English, and Arabic. Each text gets truncated to 500 characters before embedding, which keeps throughput consistent and captures most of the semantic signal.&lt;/p&gt;

&lt;p&gt;One thing I learned during indexing: enriching the text before embedding makes a big difference. "Hetzner" alone produces a generic vector. "Hetzner (company) cloud hosting hetzner.com" produces a much more useful one that captures what the entity actually is. Same approach for emails (subject + body snippet) and transactions (counterparty + description).&lt;/p&gt;

&lt;h2&gt;
  
  
  What Hybrid Search Looks Like
&lt;/h2&gt;

&lt;p&gt;The core function combines BM25 and kNN in a single Elasticsearch query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;hybrid_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;text_field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vector_field&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;INDEX_FIELDS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;query_vector&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;embed_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;es_query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;should&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;match&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;text_field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;
                &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;knn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;field&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;vector_field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query_vector&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;query_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;num_candidates&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rank&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rrf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;window_size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rank_constant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;excludes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;vector_field&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;# ... execute and return hits
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;rank.rrf&lt;/code&gt; does the heavy lifting. For each document, it computes a score based on where it ranks in both the keyword results and the vector results. A document that ranks high in both lists gets a combined score higher than one that only appears in one. The &lt;code&gt;rank_constant&lt;/code&gt; of 20 controls how much weight goes to top-ranked results versus the rest.&lt;/p&gt;

&lt;p&gt;In practice, this means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Searching for &lt;strong&gt;"TDS-2026-003"&lt;/strong&gt;: BM25 finds the exact document. The kNN results are vaguely invoice-related noise. RRF correctly puts the exact match at #1.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Searching for &lt;strong&gt;"hosting expenses"&lt;/strong&gt;: BM25 might find documents with those literal words. kNN finds "server rental charges," "VPS monthly payment," "cloud infrastructure billing." Conceptually identical, zero shared keywords. RRF combines both, giving you broader and more useful results than either approach alone.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Searching for &lt;strong&gt;"Hetzner invoice"&lt;/strong&gt;: BM25 catches the exact name "Hetzner." kNN catches hosting-invoice-related concepts. Documents specifically about Hetzner invoices appear in both result sets and rank highest.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Keeping It In Sync
&lt;/h2&gt;

&lt;p&gt;New knowledge gets extracted from emails continuously. Those new facts need to end up in Elasticsearch. Rather than syncing inline, which would add latency to the extraction pipeline, I added an &lt;code&gt;es_indexed&lt;/code&gt; boolean column to each table with a trigger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;fn_mark_es_unindexed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;es_indexed&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;FALSE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Fires on INSERT or content UPDATE, not metadata changes&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;trg_facts_es_sync&lt;/span&gt;
    &lt;span class="k"&gt;BEFORE&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;OF&lt;/span&gt; &lt;span class="n"&gt;fact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;memory_facts&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;EACH&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;fn_mark_es_unindexed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A workflow polls every few minutes for rows where &lt;code&gt;es_indexed = FALSE&lt;/code&gt;, embeds them, indexes them to Elasticsearch, and flips the flag. Partial indexes on the boolean column make the "find unindexed rows" query fast since the index only contains rows that actually need syncing.&lt;/p&gt;

&lt;p&gt;It's not real-time, but for my use case a few minutes of delay between extraction and searchability is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trade-Offs
&lt;/h2&gt;

&lt;p&gt;Search actually works now. The brain can find relevant context regardless of how facts were categorized. Exact matches and semantic matches in one query. Structured filtering by category, confidence score, or date range combined with free-text search.&lt;/p&gt;

&lt;p&gt;The cost: 4.5GB of RAM on a machine already running 20 containers. ChromaDB used 53MB. That's roughly 85x more memory. Elasticsearch is a JVM application and it shows. The heap allocation alone is 4GB, and you can't really go lower for production use.&lt;/p&gt;

&lt;p&gt;The query DSL is also significantly more verbose than ChromaDB's Python API. ChromaDB is &lt;code&gt;collection.query(query_texts=["hosting"], n_results=10)&lt;/code&gt;. The Elasticsearch equivalent is the nested JSON you saw above. I wrapped it in helper functions so the rest of the codebase doesn't have to deal with it, but the learning curve is real.&lt;/p&gt;

&lt;p&gt;Would I do it again? Yes, because hybrid search solved a real problem I couldn't solve any other way. But I wouldn't recommend Elasticsearch for every project that needs search. If you only need vector similarity, ChromaDB or pgvector are simpler and lighter. Elasticsearch earns its 4GB when you need keyword matching and semantic search working together in the same query.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is Part 3 of "One Developer, 22 Containers." The series covers building an AI office management system on consumer hardware, the choices, the trade-offs, and the things that broke along the way.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Find me on &lt;a href="https://github.com/The-Bash" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>elasticsearch</category>
      <category>ai</category>
      <category>database</category>
      <category>search</category>
    </item>
    <item>
      <title>The Stack Nobody Recommended</title>
      <dc:creator>Becher Hilal</dc:creator>
      <pubDate>Sun, 05 Apr 2026 11:41:13 +0000</pubDate>
      <link>https://dev.to/bash-thedev/the-stack-nobody-recommended-3gja</link>
      <guid>https://dev.to/bash-thedev/the-stack-nobody-recommended-3gja</guid>
      <description>&lt;p&gt;The most common question I got after publishing &lt;a href="https://dev.to/bash-thedev/why-i-run-22-docker-services-at-home-23cj"&gt;Part 1&lt;/a&gt; was some variation of "why did you pick X instead of Y?" So this post is about that. Every major technology choice, what I actually considered, where I was right, and where I got lucky.&lt;/p&gt;

&lt;p&gt;I'll be upfront: some of these were informed decisions. Some were "I already know this tool, and I need to move fast." Both are valid, but they lead to different trade-offs down the line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Backend: FastAPI
&lt;/h2&gt;

&lt;p&gt;I come from JavaScript and TypeScript. Years of React on the frontend, Express and Fastify on the backend. When I decided this project would be Python, because that's where the AI/ML ecosystem lives, I needed something that didn't feel foreign.&lt;/p&gt;

&lt;p&gt;FastAPI clicked immediately. The async/await model, the decorator-based routing, and type hints that actually do something. It felt like writing Fastify in Python. That familiarity wasn't the whole reason, but I'd be lying if I said it wasn't a factor.&lt;/p&gt;

&lt;p&gt;The technical reasons held up though. The system handles concurrent webhook callbacks from n8n, real-time polling from the React dashboard, and persistent asyncpg connections to PostgreSQL. All of that is async I/O, and FastAPI was built around that pattern. Django's async support exists now, but it still feels like it was added after the fact rather than designed in.&lt;/p&gt;

&lt;p&gt;I also deliberately avoided using an ORM. Every query in the system is hand-written SQL through asyncpg. With 95+ tables across 9 domains, I wanted to see exactly what was hitting the database. No magic, no N+1 surprises, no migration framework generating SQL I haven't read.&lt;/p&gt;

&lt;p&gt;The price I paid for skipping Django? No free admin panel; I built a React dashboard from scratch, which took weeks. No built-in migration system, I manage schema changes with raw SQL files piped through SSH into Docker, which has bitten me more than once (shell quoting across SSH → Docker → psql mangles complex statements). And a thinner plugin ecosystem when I need something that Django has had for 20 years.&lt;/p&gt;

&lt;p&gt;If you're building a web app with user accounts, admin panels, and forms,  just use Django. FastAPI makes sense when your backend is an API layer coordinating between services, which is my situation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Database: PostgreSQL
&lt;/h2&gt;

&lt;p&gt;This wasn't a difficult decision. My data is deeply relational, transactions link to bank accounts, email classifications reference messages, knowledge facts get reinforced across multiple sources, scheduler tasks reference agents that reference models. Trying to do this in MongoDB would mean denormalizing everything, embedding documents within documents, and handling consistency manually.&lt;/p&gt;

&lt;p&gt;But PostgreSQL gives me things beyond just relational storage that turned out to be critical.&lt;/p&gt;

&lt;p&gt;LISTEN/NOTIFY replaced what would normally require a message queue. When an email gets classified, a trigger fires a notification. The brain service catches it in milliseconds via asyncpg and reacts. No Kafka, no RabbitMQ; just a built-in feature that's been in Postgres for years:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;notify_email_classified&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="n"&gt;PERFORM&lt;/span&gt; &lt;span class="n"&gt;pg_notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'email_classified'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;json_build_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'category'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'urgency'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;urgency&lt;/span&gt;
        &lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At my scale (maybe 50-100 events per hour), this is more than enough. Adding Kafka would mean another container, another config to maintain, and another thing that can go wrong at 3am. I'll add it when I actually need it.&lt;/p&gt;

&lt;p&gt;CHECK constraints turned out to be one of the best decisions in the whole project. The database enforces what categories the AI is allowed to output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;category&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'billing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'shipping'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'subscription'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'employment'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'legal'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'marketing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'personal'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'automated'&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LLMs ignore your instructions sometimes. The extractor once invented a category that wasn't in the allowed list, and the INSERT failed. That's exactly what should happen: a loud failure is infinitely better than silently polluting your data with invalid categories.&lt;/p&gt;

&lt;p&gt;I also use window functions and interval queries for rate limiting, cooldowns, and circuit breakers. All things you'd normally reach for Redis to do. One fewer container in the stack.&lt;/p&gt;

&lt;p&gt;Where MongoDB would win: truly document-shaped data with variable schemas. CMS content, user profiles with heterogeneous fields, and event logs with different payloads. My data isn't any of those things.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Workflow Engine: n8n
&lt;/h2&gt;

&lt;p&gt;This is the decision I have the most complicated feelings about.&lt;/p&gt;

&lt;p&gt;n8n is a self-hosted visual workflow editor. You wire together triggers, HTTP requests, database queries, and code nodes. For my email pipelines, being able to see the flow as a diagram is genuinely valuable. When something breaks, I can see exactly which step failed and what data it had.&lt;/p&gt;

&lt;p&gt;The self-hosting angle ruled out Zapier and Make immediately. My workflows process email bodies and financial data. That doesn't go through a third party. And n8n's code nodes let me drop JavaScript directly into a workflow step, which is how I build the complex JSON payloads for Ollama calls.&lt;/p&gt;

&lt;p&gt;But n8n has caused more production incidents than any other component in the system. Scheduled workflows that overlap because n8n doesn't prevent concurrent executions by default. I had to build a database-level guard to check whether a previous run was still in progress. The API silently truncates long SQL queries without any error. Code nodes run in a sandboxed V8 isolate where &lt;code&gt;process.env&lt;/code&gt; doesn't exist (you need &lt;code&gt;$env&lt;/code&gt; instead), and building JSON in HTTP Request expressions is fragile enough that complex payloads should always go through a Code node first.&lt;/p&gt;

&lt;p&gt;None of these are dealbreakers individually. But collectively, n8n demands a level of defensive programming that I didn't expect from a workflow tool. Every workflow that involves an LLM call now has a stacking check, every SQL query gets verified after deployment, and I've learned to build payloads in Code nodes instead of expression fields.&lt;/p&gt;

&lt;p&gt;If your workflows are mostly code with minimal visual benefit, write Python scripts with a scheduler. The visual editor is n8n's actual advantage. If you don't need it, you're adding complexity for nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local LLM Serving: Ollama
&lt;/h2&gt;

&lt;p&gt;Ollama won on simplicity and nothing else. Install it, &lt;code&gt;ollama pull qwen3:14b&lt;/code&gt;, and there's a model serving API on &lt;code&gt;localhost:11434&lt;/code&gt;. No CUDA configuration, no Python environment management, no Docker GPU passthrough headaches.&lt;/p&gt;

&lt;p&gt;Switching between models is changing one string in the request payload. The API is consistent across every model (&lt;code&gt;/api/chat&lt;/code&gt;, &lt;code&gt;/api/generate&lt;/code&gt;, &lt;code&gt;/api/embed&lt;/code&gt;), which makes the routing logic in my system trivial.&lt;/p&gt;

&lt;p&gt;What I gave up: vLLM offers tensor parallelism, continuous batching, and quantization control that Ollama hides behind its abstraction. For a platform serving many concurrent users, vLLM is the right choice. For a single-user system running one model at a time on a Mac mini, Ollama's defaults are fine, and the setup time difference is measured in hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Communication: Mattermost (For Now)
&lt;/h2&gt;

&lt;p&gt;I need human-in-the-loop approval for every consequential action. The system posts to a chat with context and Approve/Reject buttons. I click, a webhook fires, and the workflow continues.&lt;/p&gt;

&lt;p&gt;I picked Mattermost because it's open source, self-hosted, and has interactive message attachments. That was the full evaluation. It wasn't strategic. iIt was "this runs in Docker and has buttons."&lt;/p&gt;

&lt;p&gt;It works. But I'm planning to migrate to Rocket.Chat. I want voice interaction with the assistant eventually, and Mattermost's audio calling is limited. Rocket.Chat also has more mature mobile apps, which matters because the whole point of HITL is approving actions when I'm not at my desk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Networking: Tailscale
&lt;/h2&gt;

&lt;p&gt;Three machines need to talk to each other. Tailscale gives each one a stable IP that works regardless of the physical network. No port forwarding, no dynamic DNS, no opening ports on my router. Setup took about 10 minutes.&lt;/p&gt;

&lt;p&gt;I could have configured WireGuard manually for the same encryption and performance, but then I'd be managing key rotation, endpoint configs, and NAT traversal myself. For a three-node network, Tailscale's convenience is worth it.&lt;/p&gt;

&lt;p&gt;One thing people ask: why not Cloudflare Tunnels? Because they solve a different problem. Cloudflare Tunnels expose services to the internet through Cloudflare's network. My services don't need to be on the internet; they need to talk to each other privately. Mesh VPN, not reverse proxy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Search: Elasticsearch (Added Later)
&lt;/h2&gt;

&lt;p&gt;I didn't start with Elasticsearch. I started with ChromaDB because it's lighter, runs in Docker, has a simple Python API, and is good enough for basic vector search.&lt;/p&gt;

&lt;p&gt;The problem showed up when the knowledge base grew. I had thousands of facts, entities, and patterns, and I needed to search by meaning &lt;em&gt;and&lt;/em&gt; by exact keywords in the same query. ChromaDB handles vectors. PostgreSQL handles keywords. But running two searches across two systems and merging results is fragile and slow.&lt;/p&gt;

&lt;p&gt;Elasticsearch does both natively (BM25 for exact keyword matching, kNN for vector similarity) in a single query. That's what made me migrate. The trade-off is 4GB of heap memory on a machine that was already tight. For smaller datasets or pure vector search, ChromaDB or pgvector are lighter options.&lt;/p&gt;

&lt;p&gt;I'll cover the migration in a dedicated post.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;Deployment. Right now, I deploy by SSH-ing into a Windows host and running PowerShell commands. No CI/CD, no GitHub Actions. It works because I'm the only developer, but it's the first thing that would break if anyone else needed to contribute.&lt;/p&gt;

&lt;p&gt;If I started over, Linux from day one and a basic GitHub Actions pipeline; push to main, build container, deploy. Not Kubernetes, not Terraform. Just automating the 90-second script I currently run manually.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is Part 2 of "&lt;a href="https://dev.to/bash-thedev/series/38113"&gt;One Developer, 22 Containers&lt;/a&gt;". Next up: migrating from ChromaDB to Elasticsearch, and why hybrid search changed how my AI system finds information.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you've made similar choices — or different ones — I'd love to hear about it in the comments. Find me on &lt;a href="https://github.com/The-Bash" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>fastapi</category>
      <category>postgres</category>
      <category>docker</category>
    </item>
    <item>
      <title>Why I Run 22 Docker Services at Home</title>
      <dc:creator>Becher Hilal</dc:creator>
      <pubDate>Sat, 04 Apr 2026 21:39:46 +0000</pubDate>
      <link>https://dev.to/bash-thedev/why-i-run-22-docker-services-at-home-23cj</link>
      <guid>https://dev.to/bash-thedev/why-i-run-22-docker-services-at-home-23cj</guid>
      <description>&lt;p&gt;Somewhere in my living room, a 2018 gaming PC is running 22 Docker containers, processing 15,000 emails through a local LLM, and managing the finances of a real business. It was never supposed to do any of this.&lt;/p&gt;

&lt;p&gt;I run a one-person software consultancy in the Netherlands; web development, 3D printing, and consulting. Last year, I started building an AI system to help me manage it all. Eight specialized agents handling email triage, financial tracking, infrastructure monitoring, and scheduling. Every piece of inference runs locally. No cloud APIs touching my private data.&lt;/p&gt;

&lt;p&gt;This post covers the hardware, what it actually costs, and what I'd do differently if I started over.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup: Three Machines, One Mesh Network
&lt;/h2&gt;

&lt;p&gt;The entire system runs on three machines connected via &lt;a href="https://tailscale.com/" rel="noopener noreferrer"&gt;Tailscale&lt;/a&gt; mesh VPN:&lt;/p&gt;

&lt;h4&gt;
  
  
  docker-host
&lt;/h4&gt;

&lt;p&gt;A PC I assembled from leftover parts. Over the years, as I upgraded my main gaming machine, the old CPUs, RAM sticks, and motherboards piled up. Eventually, I had enough to build a second computer. It now runs 22+ Docker containers 24/7.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CPU: AMD Ryzen 5 2600X (6 cores, 12 threads)&lt;/li&gt;
&lt;li&gt;RAM: 32GB DDR4 (two 16GB kits — more on this later)&lt;/li&gt;
&lt;li&gt;GPU: NVIDIA GTX 1060 3GB — useless for inference (3GB VRAM), but the Ryzen 5 2600X has no integrated graphics. Without this card, there's no display output. It exists purely to give the machine a screen.&lt;/li&gt;
&lt;li&gt;OS: Windows 11 with Docker Desktop — I still use this machine as a Windows PC occasionally, which is the honest reason it hasn't been wiped to Linux yet&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  inference
&lt;/h4&gt;

&lt;p&gt;A Mac mini M4, bought specifically for local LLM inference.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chip: Apple M4, 10-core CPU, 10-core GPU&lt;/li&gt;
&lt;li&gt;RAM: 24GB unified memory (~17GB available for models after OS and services)&lt;/li&gt;
&lt;li&gt;Role: Ollama model serving, plus Proton Mail Bridge (which requires a GUI; no headless mode exists)&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  edge-vps
&lt;/h4&gt;

&lt;p&gt;A Hostinger VPS, ~€5/month. Runs Nginx Proxy Manager and Uptime Kuma. Exists for one reason: if my home network dies, this is the canary that tells me about it. You can't monitor your own availability from inside the thing that's failing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Local-First: It Started With the Subscriptions
&lt;/h2&gt;

&lt;p&gt;Before I built any of this, I was paying for Claude Pro, GPT Pro, Perplexity Pro, and Google AI. Four separate subscriptions. Each gave me partial access to models through their own interfaces, each with its own limitations on what I could integrate, and each getting a copy of whatever I fed into it.&lt;/p&gt;

&lt;p&gt;My system handles emails, bank transactions, client contracts, delivery tracking, and tax preparation, basically the complete operational picture of my business, in one database. That's the kind of data I don't want leaving my network.&lt;/p&gt;

&lt;p&gt;It's not that I think cloud providers are malicious. It's that I don't want to be in a position where I have to &lt;em&gt;trust&lt;/em&gt; their data handling with everything my business runs on. So the guardrails are explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cloud_llm_boundary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hard_rule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NO cloud LLM usage by any agent without explicit human permission."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"prohibited_data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"Email content — body, subject, sender, recipient, attachments"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"Financial data — transactions, invoices, account numbers, balances"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"Client information — names, contacts, project details, contracts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"Personal data — addresses, phone numbers, government identifiers"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"Infrastructure — credentials, API keys, internal hostnames, IPs"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"exceptions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Development and debugging only, never with production data."&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every piece of production inference runs on Ollama on the Mac mini. Zero tokens leave the house for private data processing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cost Math
&lt;/h2&gt;

&lt;p&gt;This is the part that convinced me the approach was sustainable:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Local Cost&lt;/th&gt;
&lt;th&gt;Cloud Equivalent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LLM inference&lt;/td&gt;
&lt;td&gt;€0 (electricity)&lt;/td&gt;
&lt;td&gt;€100-500/mo (API usage at similar volume)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;€0 (Docker)&lt;/td&gt;
&lt;td&gt;€50-200/mo (managed Postgres)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Elasticsearch&lt;/td&gt;
&lt;td&gt;€0 (Docker)&lt;/td&gt;
&lt;td&gt;€100-300/mo (Elastic Cloud)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n8n&lt;/td&gt;
&lt;td&gt;€0 (self-hosted)&lt;/td&gt;
&lt;td&gt;€24-200/mo (n8n cloud)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mattermost&lt;/td&gt;
&lt;td&gt;€0 (self-hosted)&lt;/td&gt;
&lt;td&gt;€0-50/mo (limited free tier)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monitoring&lt;/td&gt;
&lt;td&gt;€5/mo (VPS)&lt;/td&gt;
&lt;td&gt;€20-50/mo (Datadog, etc.)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~€5/mo&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;€300-1,300/mo&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;But electricity is real. The Ryzen 5 2600X idles around 65W, the Mac mini M4 around 5-7W (rising to ~30W during inference). Call it 100W average for the whole setup. At Dutch electricity prices (~€0.25/kWh), that's about &lt;strong&gt;€25-30/month&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real total: ~€35/month&lt;/strong&gt; versus a minimum of €300/month in cloud services. And I went from four AI subscriptions down to one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Actually Running: 22 Containers
&lt;/h2&gt;

&lt;p&gt;The VPS runs separately with just 2 containers: Nginx Proxy Manager for webhook ingress and Uptime Kuma for external monitoring. Everything else is on docker-host.&lt;/p&gt;

&lt;h3&gt;
  
  
  The RAM Reality
&lt;/h3&gt;

&lt;p&gt;Here's the part nobody shows you in tutorials: how 32GB actually gets divided:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Windows 11 OS overhead:              ~4 GB
Elasticsearch (Java heap):            4 GB  (-Xms4g -Xmx4g)
n8n (Node.js):                      ~4-6 GB typical usage
PostgreSQL:                          ~1 GB
Mattermost:                         ~0.5 GB
7x Python services:                  ~2 GB total
Other containers:                    ~1 GB
Docker engine overhead:              ~1 GB
─────────────────────────────────────────
Total:                              ~18-20 GB typical, ~30 GB under load
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The n8n allocation deserves explanation. It's configured with &lt;code&gt;NODE_OPTIONS=--max-old-space-size=16384&lt;/code&gt;, a 16GB ceiling. That sounds aggressive, but without it, Node.js defaults to a much lower heap limit. When a workflow processes a batch of large email bodies through an LLM and the responses come back as multi-kilobyte JSON objects, memory spikes fast. If the heap limit is too low, Node's garbage collector starts running constantly, trying to free memory instead of doing actual work. Eventually, the process crashes with an out-of-memory error. The high ceiling gives it room to breathe. In practice, n8n uses 4-6GB.&lt;/p&gt;

&lt;p&gt;The real constraint isn't peak usage; it's that everything competes for the same memory bus. When Elasticsearch is indexing, n8n is running 16 workflows, and PostgreSQL is handling a complex CTE query simultaneously... things slow down. Nothing crashes, it just slows down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ollama on the Mac Mini: The Inference Layer
&lt;/h2&gt;

&lt;p&gt;The M4's unified memory architecture is genuinely excellent for LLM inference. Unlike discrete GPUs, where you're limited by VRAM (my GTX 1060's 3GB is useless for anything beyond tiny models), the M4 can use its full 24GB for model weights. The memory bandwidth (120 GB/s) is lower than a high-end GPU, but for a 14B parameter model, it's more than enough.&lt;/p&gt;

&lt;p&gt;I run a tiered model strategy:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Use Case&lt;/th&gt;
&lt;th&gt;Speed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Classification&lt;/td&gt;
&lt;td&gt;qwen2.5:14b&lt;/td&gt;
&lt;td&gt;~9 GB&lt;/td&gt;
&lt;td&gt;Email triage, transaction categorization&lt;/td&gt;
&lt;td&gt;1-2s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reasoning&lt;/td&gt;
&lt;td&gt;qwen3:14b&lt;/td&gt;
&lt;td&gt;~9.3 GB&lt;/td&gt;
&lt;td&gt;Judgment calls, tool use, knowledge extraction&lt;/td&gt;
&lt;td&gt;1.5-3s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two more tiers are planned but not yet running locally:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Generation (qwen3:32b)&lt;/strong&gt; — for client-facing content where quality matters. Needs a GPU with more VRAM than what I currently have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vision (llama3.2-vision:11b)&lt;/strong&gt; — for screenshot comparison and 3D print quality inspection. Planned for when the system matures enough to need it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With ~17GB available for models, I can only run one at a time. The keep-alive is set to 10 seconds; models unload quickly to free RAM for the next one. The flow looks like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Classification batch starts → qwen2.5:14b loads (~4 second cold start)&lt;/li&gt;
&lt;li&gt;Processes 10-50 emails → model stays warm&lt;/li&gt;
&lt;li&gt;Batch finishes → 10 seconds idle → model unloads&lt;/li&gt;
&lt;li&gt;Brain needs to reason → qwen3:14b loads&lt;/li&gt;
&lt;li&gt;Brain finishes → unloads&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This works because classification and reasoning don't overlap much. The classifier runs on a schedule; the brain runs on events. The 4-second cold start is acceptable. If I had 48GB of unified memory, I'd keep both warm permanently, but the M4 with 24GB was the sweet spot for price/performance.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Logging Proxy
&lt;/h3&gt;

&lt;p&gt;One of the more useful things I built is an HTTP proxy that sits between all consumers and Ollama:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Proxy sits between all services and Ollama
# Every inference call gets logged to PostgreSQL
&lt;/span&gt;&lt;span class="n"&gt;INFERENCE_ENDPOINTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/generate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/embed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;POLL_ENDPOINTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/tags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/ps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every inference request gets logged with full token counts, latency, and caller info. The logging happens in a daemon thread, so it doesn't block the response. This means I can query the usage table to see exactly which service is consuming the most tokens, what the average latency is, and which workflows are the heaviest users.&lt;/p&gt;

&lt;p&gt;All containers talk to the proxy. They never hit Ollama directly. This gives me a single point of observability for all LLM traffic across the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Machines Find Each Other
&lt;/h2&gt;

&lt;p&gt;Tailscale gives each machine a stable IP that works regardless of the physical network. No port forwarding. No dynamic DNS. No opening ports on the home router.&lt;/p&gt;

&lt;p&gt;Docker containers on the docker-host reach the inference server's Ollama through the Tailscale IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml (simplified, IPs redacted)&lt;/span&gt;
&lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;OLLAMA_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://&amp;lt;inference-tailscale-ip&amp;gt;:11433&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql://user:pass@postgres:5432/db&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Services on the same Docker host use Docker service names (e.g., &lt;code&gt;http://postgres:5432&lt;/code&gt;). Cross-machine communication goes through Tailscale IPs.&lt;/p&gt;

&lt;p&gt;I also run CoreDNS inside Docker for internal subdomain routing, friendly names like &lt;code&gt;dashboard.internal&lt;/code&gt;, &lt;code&gt;api.internal&lt;/code&gt;, all resolving to Tailscale IPs within the mesh only. One thing worth knowing if you set this up: CoreDNS in authoritative mode doesn't fall through to external DNS for missing records; it returns NXDOMAIN. So every new internal subdomain needs to be added to the zone file, or it simply won't resolve.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Memory Mystery
&lt;/h2&gt;

&lt;p&gt;The 32GB of DDR4 in docker-host is two 16GB kits of Corsair Vengeance RGB Pro, rated at 3200MHz. Same model number, same batch number; one kit bought in 2018, one in 2022. They should be as compatible as two kits can physically be.&lt;/p&gt;

&lt;p&gt;They aren't. I've set XMP to 3200MHz multiple times. With the original single kit, I even ran a stable overclock at 3600MHz. But since adding the second kit, the profile either fails to apply or reverts to JEDEC default 2133MHz after some time. No error, no BSOD,  just silently drops back.&lt;/p&gt;

&lt;p&gt;So right now, 32GB of 3200MHz-rated memory is running at 2133MHz. That's roughly 33% of the memory bandwidth sitting unused. Every container, every query, every Docker layer pull. All running at two-thirds speed on the memory bus.&lt;/p&gt;

&lt;p&gt;I haven't fully diagnosed whether it's a subtle timing incompatibility between the kits, a motherboard limitation with four DIMMs populated, or something else entirely. It's on the list, but it's the kind of issue that requires dedicated downtime to troubleshoot properly, and downtime means taking 22 containers offline.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Change If I Started Over
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Linux instead of Windows on docker-host.&lt;/strong&gt; Docker on Windows works, but it adds friction everywhere. My deploy script runs PowerShell commands over SSH (&lt;code&gt;Remove-Item -Recurse -Force&lt;/code&gt; instead of &lt;code&gt;rm -rf&lt;/code&gt;). I once corrupted a CoreDNS zone file because PowerShell's &lt;code&gt;-replace&lt;/code&gt; treats &lt;code&gt;\n&lt;/code&gt; as literal text instead of a newline. Linux would eliminate an entire category of issues.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A dedicated, purpose-built server.&lt;/strong&gt; The current machine has three problems: it's not built for this job, it's not efficient at this job, and it has competing use cases.&lt;/p&gt;

&lt;p&gt;The docker-host is also my occasional Windows machine (I still use it for things that need Windows). That means I can't wipe it to Linux, and it means the machine is pulling double duty when it should be dedicated infrastructure. In an ideal setup, Docker lives on its own box that I never touch except to SSH into.&lt;/p&gt;

&lt;p&gt;The hardware itself is wasteful for a container host. The Ryzen 5 2600X pulls 95W TDP. Those 12 threads are genuinely useful when n8n, PostgreSQL, and Elasticsearch all spike at once, but most of the time, containers are waiting on I/O, not burning CPU. An Intel i5-12500T at 35W would handle the same workload. Then there's the GTX 1060 drawing 120W under load for absolutely nothing; it's only installed because the Ryzen has no integrated graphics. And the 650W PSU is running at maybe 20% load, which is the least efficient part of its power curve. The whole machine is basically optimized for gaming, not for sitting in a corner running Docker.&lt;/p&gt;

&lt;p&gt;My ideal replacement: something like a &lt;strong&gt;Dell OptiPlex 3080 Micro&lt;/strong&gt; — small form factor, Intel with integrated graphics (no discrete GPU needed), 16GB RAM (expandable), designed for 24/7 operation, near-silent. These go for reasonable prices secondhand, though RAM pricing makes anything above 16GB expensive. It wouldn't match the Ryzen's raw multi-threaded output, but for a Docker host that's mostly waiting on I/O and network, it doesn't need to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;48GB on the Mac mini.&lt;/strong&gt; The 24GB M4 is good, but being limited to one model at a time creates a scheduling bottleneck. With 48GB I could keep the classifier and the reasoning model warm simultaneously and cut out the cold-start latency entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start with Elasticsearch earlier.&lt;/strong&gt; I started with ChromaDB for vector search because it's lighter. But once I needed hybrid search (keyword + semantic in the same query), I had to migrate anyway. If your data has both structured metadata and unstructured text (and you know you'll need to search both), start with something that handles both natively. That said, if you only need vector similarity for a smaller dataset, ChromaDB or &lt;a href="https://github.com/pgvector/pgvector" rel="noopener noreferrer"&gt;pgvector&lt;/a&gt; will save you 2GB of RAM and a lot of query DSL.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Control Argument
&lt;/h2&gt;

&lt;p&gt;Beyond cost and privacy, there's a third reason I run local-first: &lt;strong&gt;I own the upgrade timeline.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I decide when to update Postgres. When Elasticsearch changes licensing, it doesn't affect my running instance. When n8n raises cloud pricing, it doesn't matter. When a model provider deprecates an API version, my workflows keep running.&lt;/p&gt;

&lt;p&gt;I've been bitten by the alternative. I originally planned to use a specific open banking provider for transaction imports. They closed to new signups months after I started planning around them. Because my architecture is local-first, switching to a different provider was a contained change, one API integration, not a full re-architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is This For You?
&lt;/h2&gt;

&lt;p&gt;Honest answer: probably not, if you're building a side project or a startup MVP. The setup cost in time is real. Docker Compose files don't write themselves, Tailscale needs configuring, and you'll spend a weekend debugging why a Python service can't reach Elasticsearch through Docker's bridge network.&lt;/p&gt;

&lt;p&gt;If your data is genuinely sensitive and you have ongoing infrastructure needs, and you don't mind being your own sysadmin, it's worth considering. If you need to scale past what consumer hardware handles, or you have a team that needs managed infrastructure, or you'd rather write application code than debug Docker networking at midnight, stick with cloud services. There's no shame in that — it's a legitimate trade-off.&lt;/p&gt;

&lt;p&gt;For me, €35/month, zero data leaving the house, and full control over every component is worth being my own sysadmin, DBA, and on-call engineer. For a solo operation, that math works.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is Part 1 of "&lt;a href="https://dev.to/bash-thedev/series/38113"&gt;One Developer, 22 Containers&lt;/a&gt;" — a series about building an AI office management system on consumer hardware. Next up: the technology decisions behind every major component, what I considered, and what I'd pick differently today.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you're building something similar or have questions about any of the stack, I'd love to hear about it in the comments. You can also find me on &lt;a href="https://github.com/The-Bash" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>selfhosted</category>
      <category>ai</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
