<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>DevLogs on Tiberiu Petre - Software Engineer</title><link>https://petretiberiu.dev/categories/devlogs/</link><description>Recent content in DevLogs on Tiberiu Petre - Software Engineer. Currently exploring backend/systems/infrastructure roles — get in touch: https://petretiberiu.dev/contact/</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Wed, 26 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://petretiberiu.dev/categories/devlogs/index.xml" rel="self" type="application/rss+xml"/><item><title>Sovereign AI Nexus, Part 3: Making It Actually Talk</title><link>https://petretiberiu.dev/posts/sovereign-ai-nexus-03-making-it-talk/</link><pubDate>Wed, 26 Aug 2026 00:00:00 +0000</pubDate><guid>https://petretiberiu.dev/posts/sovereign-ai-nexus-03-making-it-talk/</guid><description>&lt;p&gt;Part 2 was about the scaffold breaking in four different ways before it worked. This one is about the day the app actually started talking back and about three more bugs, quieter than the ones before.&lt;/p&gt;
&lt;h2 id="postgres-and-a-client-that-shouldnt-be-reborn-every-request"&gt;Postgres, and a client that shouldn&amp;rsquo;t be reborn every request&lt;/h2&gt;
&lt;p&gt;Wiring up Postgres was the easy part: a table for &lt;code&gt;exchanges&lt;/code&gt; (prompt, response, timestamp) and a &lt;code&gt;DatabaseClient&lt;/code&gt; wrapping a SQLAlchemy connection. The first version created a new client and a new connection on every single request. It worked, but it&amp;rsquo;s the wrong shape: a real app should open one connection when it starts and reuse it, not pay connection overhead on every prompt.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Part 2 was about the scaffold breaking in four different ways before it worked. This one is about the day the app actually started talking back and about three more bugs, quieter than the ones before.</p>
<h2 id="postgres-and-a-client-that-shouldnt-be-reborn-every-request">Postgres, and a client that shouldn&rsquo;t be reborn every request</h2>
<p>Wiring up Postgres was the easy part: a table for <code>exchanges</code> (prompt, response, timestamp) and a <code>DatabaseClient</code> wrapping a SQLAlchemy connection. The first version created a new client and a new connection on every single request. It worked, but it&rsquo;s the wrong shape: a real app should open one connection when it starts and reuse it, not pay connection overhead on every prompt.</p>
<p>The fix uses FastAPI&rsquo;s <code>lifespan</code> context manager: the client is created once, as a global, when the app starts, and closed once, when it stops. It&rsquo;s a small thing, but the difference between &ldquo;works in a demo&rdquo; and &ldquo;works under real traffic.&rdquo; is very important.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>db_client <span style="color:#f92672">=</span> DatabaseClient()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@asynccontextmanager</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">lifespan</span>(app: FastAPI):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">yield</span>
</span></span><span style="display:flex;"><span>    db_client<span style="color:#f92672">.</span>close()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>app <span style="color:#f92672">=</span> FastAPI(lifespan<span style="color:#f92672">=</span>lifespan)
</span></span></code></pre></div><p>One more bug here, easy to miss: the Postgres container&rsquo;s credentials were hardcoded directly in <code>docker-compose.yml</code> (<code>admin</code>/<code>example</code>), while the backend correctly read them from <code>.env</code>. They happened to match, so everything worked right up until someone changes <code>.env</code> and the two sides silently disagree. This was fixed by making the database service read the same environment variables as the backend.</p>
<h2 id="teaching-the-backend-to-delegate">Teaching the backend to delegate</h2>
<p>The interesting part: how does a Python backend running in a container actually talk to Claude Code?</p>
<p>The obvious-looking answer is to mount the host&rsquo;s <code>~/.claude</code> directory into the container but it turns out to be the wrong one. The real mechanism, already proven in a different part of this same infrastructure (a local AI agent I run for other things), is simpler: <code>claude setup-token</code> generates a long-lived OAuth token once and the CLI picks it up from a <code>CLAUDE_CODE_OAUTH_TOKEN</code> environment variable. No mounted credentials directory, no interactive login inside the container.</p>
<p>The rest is a normal Docker build: <code>claude</code> installed via npm (Node&rsquo;s official Alpine build, chosen specifically to avoid repeating a glibc-vs-musl binary compatibility trap I&rsquo;d already hit once with a different tool), a persistent named volume for the CLI&rsquo;s own config so it doesn&rsquo;t reset on every restart, and a minimal, empty working directory. For now, there is no need for a real project, since the backend is using Claude Code as a text-generation engine here, not as a coding agent that needs to touch real files.</p>
<p>Delegation itself is a subprocess call, deliberately async so one slow request doesn&rsquo;t block every other one:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>proc <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> asyncio<span style="color:#f92672">.</span>create_subprocess_exec(
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;claude&#34;</span>, <span style="color:#e6db74">&#34;-p&#34;</span>, full_prompt,
</span></span><span style="display:flex;"><span>    cwd<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;/workspace&#34;</span>,
</span></span><span style="display:flex;"><span>    stdout<span style="color:#f92672">=</span>asyncio<span style="color:#f92672">.</span>subprocess<span style="color:#f92672">.</span>PIPE,
</span></span><span style="display:flex;"><span>    stderr<span style="color:#f92672">=</span>asyncio<span style="color:#f92672">.</span>subprocess<span style="color:#f92672">.</span>PIPE,
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>stdout, stderr <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> proc<span style="color:#f92672">.</span>communicate()
</span></span></code></pre></div><p>It worked on the first real test. Measured round-trip for a simple prompt: about 3.6 seconds for a full CLI process starting up. Worth knowing before this becomes the path for anything latency-sensitive.</p>
<h2 id="the-bugs-that-dont-announce-themselves">The bugs that don&rsquo;t announce themselves</h2>
<p>The endpoint needed conversation history. I&rsquo;ve decided to get the last N exchanges and fed them back in as context so the model isn&rsquo;t starting cold every time. This is where the quiet bugs live.</p>
<p><strong>Bug one, the loud one.</strong> The query to fetch recent exchanges was written as:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> (prompt, response, created_at) <span style="color:#66d9ef">FROM</span> exchanges ...
</span></span></code></pre></div><p>Those parentheses around the column list look harmless. My wrong assumption was to prevent SQL Injection attacks without knowing Postgres reads that as <em>one</em> composite column, not three separate ones. The first test request worked fine, because there was no history yet to fetch. The second one crashed:</p>
<pre tabindex="0"><code>AttributeError: Could not locate column in row for column &#39;prompt&#39;
</code></pre><p>A one-word explanation, once you see it: no parentheses, three real columns.</p>
<p><strong>Bug two, the silent one.</strong> The timestamp was meant to be captured the moment a request arrives right before it goes anywhere near the LLM. The code looked right:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>response <span style="color:#f92672">=</span> ChatResponse(
</span></span><span style="display:flex;"><span>    prompt<span style="color:#f92672">=</span>msg<span style="color:#f92672">.</span>prompt,
</span></span><span style="display:flex;"><span>    response<span style="color:#f92672">=</span><span style="color:#66d9ef">await</span> call_llm(msg<span style="color:#f92672">.</span>prompt),   <span style="color:#75715e"># ~3.6s</span>
</span></span><span style="display:flex;"><span>    created_at<span style="color:#f92672">=</span>datetime<span style="color:#f92672">.</span>now(timezone<span style="color:#f92672">.</span>utc),
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p>But&hellip; Python evaluates keyword arguments in the order they&rsquo;re written, not by name. The <code>created_at</code> argument is the last one on the page, so it&rsquo;s the last thing evaluated after the multi-second LLM call already finished. The timestamp was quietly measuring the wrong moment. Fixed by capturing it as its own variable, first line of the function, before anything else runs.</p>
<p><strong>Bug three, the one that ate its own fix.</strong> Recent-history queries naturally come back newest-first. Fed straight into a prompt, that reads backwards to the model: the last message first and the first message last. The fix is to reverse the list in Python before building the context, except the first attempt changed the SQL to sort oldest-first <em>and</em> added the Python-side reverse. Confirmed by literally asking the model to recite the conversation back in order and it gave the order correctly, 1 through 4.</p>
<p>None of these three would show up in a five-minute demo. Two of them only show up once there&rsquo;s enough data or enough history for the wrong behavior to matter. It is exactly the kind of bug that&rsquo;s cheap to fix now and expensive to debug later, in production, with a confused conversation to untangle.</p>
<h2 id="whats-next">What&rsquo;s next</h2>
<p>Backend and database talk to each other, and the backend talks to Claude Code. What&rsquo;s still missing is a way for a person to talk to any of it. Task 4 is the chat UI. No deadline on when that happens; it happens when it happens.</p>
<hr>
<p>→ <a href="/series/sovereign-ai-nexus/">Full build series</a> · <a href="/projects/sovereign-ai-nexus/">Project page</a></p>
]]></content:encoded></item><item><title>Sovereign AI Nexus, Part 4: Shipping v1</title><link>https://petretiberiu.dev/posts/sovereign-ai-nexus-04-shipping-v1/</link><pubDate>Wed, 26 Aug 2026 00:00:00 +0000</pubDate><guid>https://petretiberiu.dev/posts/sovereign-ai-nexus-04-shipping-v1/</guid><description>&lt;p&gt;Part 3 ended with the backend able to talk to Claude Code, but no way for a person to talk to any of it. This one covers the rest of the distance to v1: a UI, a Docker Compose stack that actually works end to end, a way to see and delete conversation history, and lastly, a test suite, built specifically so the next change doesn&amp;rsquo;t need a live browser to verify.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Part 3 ended with the backend able to talk to Claude Code, but no way for a person to talk to any of it. This one covers the rest of the distance to v1: a UI, a Docker Compose stack that actually works end to end, a way to see and delete conversation history, and lastly, a test suite, built specifically so the next change doesn&rsquo;t need a live browser to verify.</p>
<h2 id="a-ui-and-a-bug-that-only-shows-up-in-markdown">A UI, and a bug that only shows up in markdown</h2>
<p>The chat UI itself is unremarkable in the good way: a controlled input, Enter to send, Shift+Enter for a new line, disabled while a response is pending. The more interesting part is what happens to the response once it arrives. Claude Code&rsquo;s replies come back as markdown and rendering that as plain text loses all of it. Using the <code>react-markdown</code> plus <code>remark-gfm</code> modules fixed that in about ten lines; user messages stay plain text, since they&rsquo;re not meant to be formatted.</p>
<h2 id="the-bug-that-only-exists-in-the-built-version">The bug that only exists in the built version</h2>
<p>Docker Compose was already running backend, frontend, and Postgres separately in dev (<code>pnpm dev</code>, <code>docker compose up</code> for the rest). The actual test: a full <code>docker compose up</code> from nothing, then a real browser hitting <code>:3000</code> is where things fell apart, in two layers.</p>
<p>The first layer was the one I expected: no CORS headers on the backend, so a browser calling <code>:8000</code> directly from <code>:3000</code> gets blocked. The fix was small: use the <code>CORSMiddleware</code> module scoped to a <code>FRONTEND_ORIGIN</code> environment variable. CORS stands for Cross-Origin Resource Sharing and is a security feature used by web browsers to control how a website on one domain can request and access resources from a different domain. It was chosen over a reverse proxy specifically because this app still isn&rsquo;t exposed publicly; a proxy earns its complexity once that changes, not before.</p>
<p>The second layer was quieter, and CORS headers wouldn&rsquo;t have fixed it. The frontend&rsquo;s <code>fetch('/chat')</code> and <code>fetch('/history')</code> calls use relative paths. In <code>pnpm dev</code>, Vite&rsquo;s own proxy rewrites those transparently to <code>:8000</code> so it looked correct. But the Docker frontend serves a static production build (<code>serve -s dist</code>), with no proxy layer at all. A relative <code>/history</code> request there just hits the frontend&rsquo;s own static server, finds no matching file, and falls through to <code>index.html</code>. This caused the app&rsquo;s own shell to be silently returned in place of JSON. No error, no crash, just the wrong response shape. It surfaced by literally running <code>curl :3000/history</code> and noticing HTML come back where JSON should have been. This is the kind of gap that only shows up when you run the real, built artifact, not when you re-read the code that produced it.</p>
<p>The fix: bake the backend&rsquo;s real origin into the static build itself as a <code>VITE_API_BASE_URL</code> build argument threaded through <code>frontend/Dockerfile</code> and <code>docker-compose.yml</code>. Vite inlines <code>VITE_*</code> variables at build time, so the compiled JS calls the backend directly, origin and all, with no proxy required.</p>
<h2 id="conversation-history-for-real-this-time">Conversation history, for real this time</h2>
<p>The <code>exchanges</code> table always had an <code>id</code> column but nothing before now had ever selected it. Adding it to the response model was the easy part; three endpoints followed naturally: <code>GET /history</code> for the full conversation, <code>DELETE /history/{id}</code> for a single exchange, and <code>DELETE /history</code> for everything.</p>
<p>The UI treats a prompt/response pair as the actual unit of storage, which makes it the natural unit of deletion too so history loads on page mount (a refresh won&rsquo;t lose the conversation in this way), each exchange gets its own delete control, and clearing everything sits behind an explicit confirm/cancel step, since there&rsquo;s no undo once it&rsquo;s gone.</p>
<h2 id="tests-so-the-browser-stops-being-the-test">Tests, so the browser stops being the test</h2>
<p>Every one of the bugs above and the two from Part 3 were caught by hand: reading a stack trace, running <code>curl</code>, opening a real browser. That works, but it doesn&rsquo;t scale, and it doesn&rsquo;t repeat automatically. The last piece of v1 was a test suite built specifically so a future change can be verified without a browser at all.</p>
<p>The one real decision here was the backend&rsquo;s test database: a disposable Postgres, not SQLite. It would have been faster to fake it but the raw SQL in this codebase is Postgres-specific, and two of the bugs already found (the composite-column <code>SELECT</code> and the timestamp-ordering one) are exactly the kind that a friendlier substitute database could paper over instead of catching. Testing against the real thing paid for itself immediately: writing the fixtures surfaced a genuine deadlock where the app&rsquo;s read methods never closed their implicit transaction, which was harmless in a long-lived production connection but left a lock held just long enough to hang the next test&rsquo;s cleanup. Something SQLite would never have shown at all. This could&rsquo;ve been prevented by explictly calling SQLAlchemy&rsquo;s own <code>.commit()</code> on the connection after a query was called.</p>
<p>The two regression tests for Part 3&rsquo;s bugs weren&rsquo;t just written to check current behavior. This was done so each one can be verified by literally reintroducing the original bug, watching the test fail, then reverting it. The frontend side is smaller in comparison: Vitest and React Testing Library, <code>fetch</code> mocked, covering send-and-render, per-exchange delete, and the clear-all confirmation gate.</p>
<h2 id="where-it-stands">Where it stands</h2>
<p>The v1 is done: the chat, the persisted and deletable history, a Docker Compose stack that survives a clean checkout, and a test suite that means the next change doesn&rsquo;t have to be verified by hand. What comes after this is a larger, deliberately undated idea. Turning this into something closer to a content system than a chat demo and it stays exactly that: available whenever there&rsquo;s real appetite for it, not scheduled. This would include a deliberate stage for automatic deployment of the app over to Kubernetes, using a lightwight version of it called K3s.</p>
<hr>
<p><strong><a href="/series/sovereign-ai-nexus/">All posts in this series →</a></strong> · <strong><a href="/projects/sovereign-ai-nexus/">Project page →</a></strong></p>
]]></content:encoded></item><item><title>Sovereign AI Nexus, Part 1: Starting Small, On Purpose</title><link>https://petretiberiu.dev/posts/sovereign-ai-nexus-01-starting-small/</link><pubDate>Thu, 20 Aug 2026 00:00:00 +0000</pubDate><guid>https://petretiberiu.dev/posts/sovereign-ai-nexus-01-starting-small/</guid><description>&lt;p&gt;I&amp;rsquo;ve had portfolio project ideas for Backend and AI Engineering before — written at different times, each answering the same question differently: do I position myself for a specific role, or build what I find interesting and let the résumé follow?&lt;/p&gt;
&lt;p&gt;None of them got built. Not because the ideas were bad — the architectures were actually fairly detailed — but because each one tried to prove too much at once: a backend split across multiple languages &amp;ldquo;to show I know them all,&amp;rdquo; telemetry, observability, multi-agent orchestration, all in the same first version. The kind of scope that feels good on paper and turns overwhelming right when you&amp;rsquo;d need to write the first line of code.&lt;/p&gt;</description><content:encoded><![CDATA[<p>I&rsquo;ve had portfolio project ideas for Backend and AI Engineering before — written at different times, each answering the same question differently: do I position myself for a specific role, or build what I find interesting and let the résumé follow?</p>
<p>None of them got built. Not because the ideas were bad — the architectures were actually fairly detailed — but because each one tried to prove too much at once: a backend split across multiple languages &ldquo;to show I know them all,&rdquo; telemetry, observability, multi-agent orchestration, all in the same first version. The kind of scope that feels good on paper and turns overwhelming right when you&rsquo;d need to write the first line of code.</p>
<p>So this time I&rsquo;m starting differently: <strong>Sovereign AI Nexus</strong> is a very small application — a Python/FastAPI backend, a PostgreSQL database, a React/TypeScript frontend, and exactly one working flow. You send a prompt, you get back a response generated by an AI model, rendered as a text artifact. That&rsquo;s it. No conversation history, no authentication, no agent orchestration. All real ideas, just not part of the first version.</p>
<p>The kind of app isn&rsquo;t new to me. I&rsquo;m essentially building a minimal, self-hosted version of the AI dashboards I already use daily (Claude Code, other agent tooling) — a conversation, a panel of generated artifacts. The difference is that this time it&rsquo;s mine, from the infrastructure up.</p>
<p>What I did keep from the two earlier project ideas that got closest to actually being built:</p>
<ul>
<li>Docker Compose from the first task, not as an afterthought — every service in its own container, with hot-reload, verified working before the real code starts.</li>
<li>Secret hygiene from day one — any API key stays strictly server-side, never in an environment variable that could accidentally end up in the bundle shipped to the browser.</li>
<li>And, maybe most importantly: no pressure to build this for a specific role. It&rsquo;s a project that demonstrates real interest in Backend and AI Platform Engineering.</li>
</ul>
<p>Next up is starting the small tasks: a backend that runs, a table that persists, an endpoint that actually responds to a prompt. None of it has a deadline. The one thing in this whole process that doesn&rsquo;t get to change its mind is publishing this post.</p>
<hr>
<p>→ <a href="/series/sovereign-ai-nexus/">Full build series</a> · <a href="/projects/sovereign-ai-nexus/">Project page</a></p>
]]></content:encoded></item><item><title>Sovereign AI Nexus, Part 2: What Broke While Scaffolding</title><link>https://petretiberiu.dev/posts/sovereign-ai-nexus-02-scaffolding/</link><pubDate>Thu, 20 Aug 2026 00:00:00 +0000</pubDate><guid>https://petretiberiu.dev/posts/sovereign-ai-nexus-02-scaffolding/</guid><description>&lt;p&gt;The first post in this series was about the decision — why I&amp;rsquo;m starting small, why the earlier project ideas never got built. This one is about the day I actually wrote code, and about what broke along the way — because almost nothing worked on the first try, and I think that part is more interesting than if it had gone perfectly.&lt;/p&gt;
&lt;h2 id="the-scaffold-itself"&gt;The scaffold itself&lt;/h2&gt;
&lt;p&gt;For the backend I used FastAPI and uvicorn, managed with &lt;code&gt;uv&lt;/code&gt; — fast, and about as simple as it gets to wire up: a &lt;code&gt;@app.get(path=&amp;quot;/&amp;quot;)&lt;/code&gt; decorator over the handler function. For the frontend, React and TypeScript, scaffolded with Vite.&lt;/p&gt;</description><content:encoded><![CDATA[<p>The first post in this series was about the decision — why I&rsquo;m starting small, why the earlier project ideas never got built. This one is about the day I actually wrote code, and about what broke along the way — because almost nothing worked on the first try, and I think that part is more interesting than if it had gone perfectly.</p>
<h2 id="the-scaffold-itself">The scaffold itself</h2>
<p>For the backend I used FastAPI and uvicorn, managed with <code>uv</code> — fast, and about as simple as it gets to wire up: a <code>@app.get(path=&quot;/&quot;)</code> decorator over the handler function. For the frontend, React and TypeScript, scaffolded with Vite.</p>
<p>Each service becomes its own image via its own Dockerfile. The project orchestrates both containers through a <code>docker-compose.yml</code> at the root. Nothing unusual so far.</p>
<h2 id="the-reload-that-refused-to-work">The reload that refused to work</h2>
<p>The first thing I tested was backend hot-reload — edit a local file, expect to see the change without a rebuild. Nothing happened. I added <code>--reload</code> to uvicorn. Still nothing.</p>
<p>The real cause: the volume in <code>docker-compose.yml</code> mounted the host code to <code>/code/app</code> inside the container, but uvicorn was importing the app from <code>core.main:app</code> — i.e. from <code>/code/core</code>, a completely different path. My edits were landing in a directory nobody was reading. Simple fix once found: the mount has to target the exact import path, not a generic convention copied from a tutorial.</p>
<h2 id="pnpm-add--g-and-a-path-that-doesnt-exist"><code>pnpm add -g</code> and a PATH that doesn&rsquo;t exist</h2>
<p>On the frontend, the production build needed to serve static files through a package called <code>serve</code>, installed globally (<code>pnpm add -g serve</code>). The error: pnpm&rsquo;s global bin directory wasn&rsquo;t on <code>PATH</code> — and it had no way to be, since <code>pnpm setup</code> (which would normally fix this) edits a <code>.bashrc</code>, and a Docker <code>RUN</code> step has no interactive shell to read it.</p>
<p>The cleaner fix wasn&rsquo;t patching PATH — it was avoiding the global install entirely: add <code>serve</code> as a normal project dependency and run it through <code>pnpm exec serve</code>, which resolves the binary from <code>node_modules/.bin</code> with nothing global involved.</p>
<h2 id="a-mount-that-broke-exactly-what-it-was-meant-to-help">A mount that broke exactly what it was meant to help</h2>
<p>After fixing reload on the backend, I tried the same trick on the frontend — mount the whole codebase over <code>/app</code>. Result: a 404 on every request. The reason: the frontend container runs the already-built app (<code>serve -s dist</code>), not a dev server watching for changes. The mount replaced the entire <code>/app</code> from the image — including <code>dist/</code>, built at image-build time — with the host directory, which had no <code>dist/</code> at all.</p>
<p>The lesson: not every container benefits from a volume mount. For real frontend iteration, the right answer isn&rsquo;t Docker — it&rsquo;s running <code>pnpm dev</code> directly. Vite already has native HMR, faster than anything I could build through a container.</p>
<h2 id="kaniko-is-not-buildkit">Kaniko is not BuildKit</h2>
<p>The most subtle bug came from CI, not local development. I used <code>uv</code>&rsquo;s own official Docker recipe — <code>RUN --mount=type=bind,source=uv.lock,...</code> — which worked perfectly locally. On Jenkins, the build failed with &ldquo;No pyproject.toml found,&rdquo; an error that made no sense at first glance.</p>
<p>The cause, found straight from the Kaniko build log: <code>RUN --mount</code> is BuildKit-specific syntax, and Kaniko — the actual builder behind this CI — ignores it entirely, silently. No unknown-syntax error, it just runs the command without the mounts it was promised. The files never landed where <code>uv sync</code> was looking for them. The fix was a plain <code>COPY</code> — less elegant than the official recipe, but portable across builders, not just my machine.</p>
<p>On top of that: a <code>uv.lock</code> that had ended up gitignored by mistake, from a template meant for libraries rather than applications. A lockfile should always be committed for an application, exactly like a <code>package-lock.json</code>.</p>
<h2 id="cicd-one-image-per-service-one-ssh-key-per-repo">CI/CD: one image per service, one SSH key per repo</h2>
<p>For the build side, I went with a single <code>Jenkinsfile</code> with separate stages per service (backend, frontend) rather than separate Jenkins jobs — a build failure in one service doesn&rsquo;t block the other, without paying the setup cost of a whole new job.</p>
<p>The more interesting part was mirroring to GitHub. I wanted a single SSH key, with its blast radius scoped per-repo via GitHub Deploy Keys, rather than a new token to manage for every mirrored project. Gitea&rsquo;s native Push Mirror doesn&rsquo;t support SSH — confirmed directly from source, not just from failed attempts. The fix: the push happens from a dedicated Jenkins stage, right after checkout, using the key as a Jenkins credential — independent of whether the Docker build succeeds, so syncing to GitHub never depends on anything else.</p>
<h2 id="whats-next">What&rsquo;s next</h2>
<p>The next real step is Postgres — one table, one SQLAlchemy model, nothing more. No deadline attached; it happens when it happens.</p>
<hr>
<p>→ <a href="/series/sovereign-ai-nexus/">Full build series</a> · <a href="/projects/sovereign-ai-nexus/">Project page</a></p>
]]></content:encoded></item></channel></rss>